diff --git a/.claude/skills/axiom-accessibility-diag/.openskills.json b/.claude/skills/axiom-accessibility-diag/.openskills.json new file mode 100644 index 0000000..df63d03 --- /dev/null +++ b/.claude/skills/axiom-accessibility-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-accessibility-diag", + "installedAt": "2026-04-12T08:05:39.269Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-accessibility-diag/SKILL.md b/.claude/skills/axiom-accessibility-diag/SKILL.md new file mode 100644 index 0000000..477ad29 --- /dev/null +++ b/.claude/skills/axiom-accessibility-diag/SKILL.md @@ -0,0 +1,989 @@ +--- +name: axiom-accessibility-diag +description: Use when fixing VoiceOver issues, Dynamic Type violations, color contrast failures, touch target problems, keyboard navigation gaps, or Reduce Motion support - comprehensive accessibility diagnostics with WCAG compliance, Accessibility Inspector workflows, and App Store Review preparation for iOS/macOS +license: MIT +metadata: + version: "1.0.0" +--- + +# Accessibility Diagnostics + +## Overview + +Systematic accessibility diagnosis and remediation for iOS/macOS apps. Covers the 7 most common accessibility issues that cause App Store rejections and user complaints. + +**Core principle** Accessibility is not optional. iOS apps must support VoiceOver, Dynamic Type, and sufficient color contrast to pass App Store Review. Users with disabilities depend on these features. + +## When to Use This Skill + +- Fixing VoiceOver navigation issues (missing labels, wrong element order) +- Supporting Dynamic Type (text scaling for vision disabilities) +- Meeting color contrast requirements (WCAG AA/AAA) +- Fixing touch target size violations (< 44x44pt) +- Adding keyboard navigation (iPadOS/macOS) +- Supporting Reduce Motion (vestibular disorders) +- Preparing for App Store Review accessibility requirements +- Responding to user complaints about accessibility + +## The 7 Critical Accessibility Issues + +### 1. VoiceOver Labels & Hints (CRITICAL - App Store Rejection) + +**Problem** Missing or generic accessibility labels prevent VoiceOver users from understanding UI purpose. + +**WCAG** 4.1.2 Name, Role, Value (Level A) + +#### Common violations +```swift +// ❌ WRONG - No label (VoiceOver says "Button") +Button(action: addToCart) { + Image(systemName: "cart.badge.plus") +} + +// ❌ WRONG - Generic label +.accessibilityLabel("Button") + +// ❌ WRONG - Reads implementation details +.accessibilityLabel("cart.badge.plus") // VoiceOver: "cart dot badge dot plus" + +// ✅ CORRECT - Descriptive label +Button(action: addToCart) { + Image(systemName: "cart.badge.plus") +} +.accessibilityLabel("Add to cart") + +// ✅ CORRECT - With hint for complex actions +.accessibilityLabel("Add to cart") +.accessibilityHint("Double-tap to add this item to your shopping cart") +``` + +#### When to use hints +- Action is not obvious from label ("Add to cart" is obvious, no hint needed) +- Multi-step interaction ("Swipe right to confirm, left to cancel") +- State change ("Double-tap to toggle notifications on or off") + +#### Decorative elements +```swift +// ✅ CORRECT - Hide decorative images from VoiceOver +Image("decorative-pattern") + .accessibilityHidden(true) + +// ✅ CORRECT - Combine multiple elements into one label +HStack { + Image(systemName: "star.fill") + Text("4.5") + Text("(234 reviews)") +} +.accessibilityElement(children: .combine) +.accessibilityLabel("Rating: 4.5 stars from 234 reviews") +``` + +#### Testing +- Enable VoiceOver: Cmd+F5 (simulator) or triple-click side button (device) +- Navigate: Swipe right/left to move between elements +- Listen: Does VoiceOver announce purpose clearly? +- Check order: Does navigation order match visual layout? + +--- + +### 2. Dynamic Type Support (HIGH - User Experience) + +**Problem** Fixed font sizes prevent users with vision disabilities from reading text. + +**WCAG** 1.4.4 Resize Text (Level AA - support 200% scaling without loss of content/functionality) + +#### Common violations +```swift +// ❌ WRONG - Fixed size, won't scale +Text("Price: $19.99") + .font(.system(size: 17)) + +UILabel().font = UIFont.systemFont(ofSize: 17) + +// ❌ WRONG - Custom font without scaling +Text("Headline") + .font(Font.custom("CustomFont", size: 24)) + +// ✅ CORRECT - SwiftUI semantic styles (auto-scales) +Text("Price: $19.99") + .font(.body) + +Text("Headline") + .font(.headline) + +// ✅ CORRECT - UIKit semantic styles +label.font = UIFont.preferredFont(forTextStyle: .body) + +// ✅ CORRECT - Custom font with scaling +let customFont = UIFont(name: "CustomFont", size: 24)! +label.font = UIFontMetrics.default.scaledFont(for: customFont) +label.adjustsFontForContentSizeCategory = true +``` + +#### Custom sizes that scale with Dynamic Type +```swift +// ❌ WRONG - Fixed size, won't scale +Text("Price: $19.99") + .font(.system(size: 17)) + +// ⚠️ ACCEPTABLE - Custom font without scaling (accessibility violation) +Text("Headline") + .font(Font.custom("CustomFont", size: 24)) + +// ✅ GOOD - Custom size that scales with Dynamic Type +Text("Large Title") + .font(.system(size: 60).relativeTo(.largeTitle)) + +Text("Custom Headline") + .font(.system(size: 24).relativeTo(.title2)) + +// ✅ BEST - Use semantic styles when possible +Text("Headline") + .font(.headline) +``` + +**How `relativeTo:` works** +- Base size: Your exact pixel size (24pt, 60pt, etc.) +- Scales with: The text style you specify (`.title2`, `.largeTitle`, etc.) +- Result: When user increases text size in Settings, your custom size grows proportionally + +**Example** +- `.title2` base: ~22pt → Your custom: 24pt (1.09x larger) +- User increases to "Extra Large" text +- `.title2` grows to ~28pt → Your custom grows to ~30.5pt (maintains 1.09x ratio) + +**Fix hierarchy (best to worst)** +1. **Best**: Use semantic styles (`.title`, `.body`, `.caption`) +2. **Good**: Use `.system(size:).relativeTo()` for required custom sizes +3. **Acceptable**: Custom font with `.dynamicTypeSize()` modifier +4. **Unacceptable**: Fixed sizes that never scale + +#### SwiftUI text styles +- `.largeTitle` - 34pt (scales to 44pt at accessibility sizes) +- `.title` - 28pt +- `.title2` - 22pt +- `.title3` - 20pt +- `.headline` - 17pt semibold +- `.body` - 17pt (default) +- `.callout` - 16pt +- `.subheadline` - 15pt +- `.footnote` - 13pt +- `.caption` - 12pt +- `.caption2` - 11pt + +#### Layout considerations +```swift +// ❌ WRONG - Fixed frame breaks with large text +Text("Long product description...") + .font(.body) + .frame(height: 50) // Clips at large text sizes + +// ✅ CORRECT - Flexible frame +Text("Long product description...") + .font(.body) + .lineLimit(nil) // Allow multiple lines + .fixedSize(horizontal: false, vertical: true) + +// ✅ CORRECT - Stack rearranges at large sizes +HStack { + Text("Label:") + Text("Value") +} +.dynamicTypeSize(...DynamicTypeSize.xxxLarge) // Limit maximum size if needed +``` + +#### Testing +1. Xcode Preview: Environment override + ```swift + .environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge) + ``` + +2. Simulator: Settings → Accessibility → Display & Text Size → Larger Text → Drag to maximum + +3. Device: Settings → Accessibility → Display & Text Size → Larger Text + +4. Check: Does text remain readable? Does layout adapt? Is any text clipped? + +--- + +### 3. Color Contrast (HIGH - Vision Disabilities) + +**Problem** Low contrast text is unreadable for users with vision disabilities or in bright sunlight. + +#### WCAG +- **1.4.3 Contrast (Minimum)** — Level AA + - Normal text (< 18pt): 4.5:1 contrast ratio + - Large text (≥ 18pt or ≥ 14pt bold): 3:1 contrast ratio +- **1.4.6 Contrast (Enhanced)** — Level AAA + - Normal text: 7:1 contrast ratio + - Large text: 4.5:1 contrast ratio + +#### Common violations +```swift +// ❌ WRONG - Low contrast (1.8:1 - fails WCAG) +Text("Warning") + .foregroundColor(.yellow) // on white background + +// ❌ WRONG - Low contrast in dark mode +Text("Info") + .foregroundColor(.gray) // on black background + +// ✅ CORRECT - High contrast (7:1+ passes AAA) +Text("Warning") + .foregroundColor(.orange) // or .red + +// ✅ CORRECT - System colors adapt to light/dark mode +Text("Info") + .foregroundColor(.primary) // Black in light mode, white in dark + +Text("Secondary") + .foregroundColor(.secondary) // Automatic high contrast +``` + +#### Differentiate Without Color +```swift +// ❌ WRONG - Color alone indicates status +Circle() + .fill(isAvailable ? .green : .red) + +// ✅ CORRECT - Color + icon/text +HStack { + Image(systemName: isAvailable ? "checkmark.circle.fill" : "xmark.circle.fill") + Text(isAvailable ? "Available" : "Unavailable") +} +.foregroundColor(isAvailable ? .green : .red) + +// ✅ CORRECT - Respect system preference +if UIAccessibility.shouldDifferentiateWithoutColor { + // Use patterns, icons, or text instead of color alone +} +``` + +#### Testing +1. Use Color Contrast Analyzer tool (free download) +2. Screenshot your UI, measure text vs background +3. Check both light and dark mode +4. Settings → Accessibility → Display & Text Size → Increase Contrast (test with this ON) + +#### Quick reference +- Black (#000000) on White (#FFFFFF): 21:1 ✅ AAA +- Dark Gray (#595959) on White: 7:1 ✅ AAA +- Medium Gray (#767676) on White: 4.5:1 ✅ AA +- Light Gray (#959595) on White: 2.8:1 ❌ Fails + +--- + +### 4. Touch Target Sizes (MEDIUM - Motor Disabilities) + +**Problem** Small tap targets are difficult or impossible for users with motor disabilities. + +**WCAG** 2.5.5 Target Size (Level AAA - 44x44pt minimum) + +**Apple HIG** 44x44pt minimum for all tappable elements + +#### Common violations +```swift +// ❌ WRONG - Too small (24x24pt) +Button("×") { + dismiss() +} +.frame(width: 24, height: 24) + +// ❌ WRONG - Small icon without padding +Image(systemName: "heart") + .font(.system(size: 16)) + .onTapGesture { } + +// ✅ CORRECT - Minimum 44x44pt +Button("×") { + dismiss() +} +.frame(minWidth: 44, minHeight: 44) + +// ✅ CORRECT - Larger icon or padding +Image(systemName: "heart") + .font(.system(size: 24)) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) // Expand tap area + .onTapGesture { } + +// ✅ CORRECT - UIKit button with edge insets +button.contentEdgeInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) +// Total size: icon size + insets ≥ 44x44pt +``` + +#### Spacing between targets +```swift +// ❌ WRONG - Targets too close (hard to tap accurately) +HStack(spacing: 4) { + Button("Edit") { } + Button("Delete") { } +} + +// ✅ CORRECT - Adequate spacing (8pt minimum, 12pt better) +HStack(spacing: 12) { + Button("Edit") { } + Button("Delete") { } +} +``` + +#### Testing +1. Accessibility Inspector: Xcode → Open Developer Tool → Accessibility Inspector +2. Select "Audit" tab → Run audit → Check for "Small Text" and "Hit Region" warnings +3. Manual: Tap with one finger (not stylus) — can you hit it reliably without mistakes? + +--- + +### 5. Keyboard Navigation (MEDIUM - iPadOS/macOS) + +**Problem** Users who cannot use touch/mouse cannot navigate app. + +**WCAG** 2.1.1 Keyboard (Level A - all functionality available via keyboard) + +#### Common violations +```swift +// ❌ WRONG - Custom gesture without keyboard alternative +.onTapGesture { + showDetails() +} +// No way to trigger with keyboard + +// ✅ CORRECT - Button provides keyboard support automatically +Button("Show Details") { + showDetails() +} +.keyboardShortcut("d", modifiers: .command) // Optional shortcut + +// ✅ CORRECT - Custom control with focus support +struct CustomButton: View { + @FocusState private var isFocused: Bool + + var body: some View { + Text("Custom") + .focusable() + .focused($isFocused) + .onKeyPress(.return) { + action() + return .handled + } + } +} +``` + +#### Focus management +```swift +// ✅ CORRECT - Set initial focus +.focusSection() // Group related controls +.defaultFocus($focus, .constant(true)) // Set default + +// ✅ CORRECT - Move focus after action +@FocusState private var focusedField: Field? + +Button("Next") { + focusedField = .next +} +``` + +#### Testing (iPadOS/macOS) +1. Connect keyboard to iPad or use Mac +2. Press Tab - does focus move to interactive elements? +3. Press Space/Return - does focused element activate? +4. Check custom controls have visible focus indicator +5. Can you reach all functionality without mouse/touch? + +--- + +### 6. Reduce Motion Support (MEDIUM - Vestibular Disorders) + +**Problem** Animations cause discomfort, nausea, or seizures for users with vestibular disorders. + +**WCAG** 2.3.3 Animation from Interactions (Level AAA - motion animation can be disabled) + +#### Common violations +```swift +// ❌ WRONG - Always animates (can cause nausea) +.onAppear { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + scale = 1.0 + } +} + +// ❌ WRONG - Parallax scrolling without opt-out +ScrollView { + GeometryReader { geo in + Image("hero") + .offset(y: geo.frame(in: .global).minY * 0.5) // Parallax + } +} + +// ✅ CORRECT - Respect Reduce Motion preference +.onAppear { + if UIAccessibility.isReduceMotionEnabled { + scale = 1.0 // Instant + } else { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + scale = 1.0 + } + } +} + +// ✅ CORRECT - Simpler animation or cross-fade +if UIAccessibility.isReduceMotionEnabled { + // Cross-fade or instant change + withAnimation(.linear(duration: 0.2)) { + showView = true + } +} else { + // Complex spring animation + withAnimation(.spring()) { + showView = true + } +} +``` + +#### SwiftUI modifier +```swift +// ✅ CORRECT - Automatic support +.animation(.spring(), value: isExpanded) +.transaction { transaction in + if UIAccessibility.isReduceMotionEnabled { + transaction.animation = nil // Disable animation + } +} +``` + +#### Testing +1. Settings → Accessibility → Motion → Reduce Motion (toggle ON) +2. Navigate app - are animations reduced or eliminated? +3. Test: Transitions, scrolling effects, parallax, particle effects +4. Video autoplay should also respect this preference + +--- + +### 7. Common Violations (HIGH - App Store Review) + +#### Images Without Labels + +```swift +// ❌ WRONG - Informative image without label +Image("product-photo") + +// ✅ CORRECT - Informative image with label +Image("product-photo") + .accessibilityLabel("Red sneakers with white laces") + +// ✅ CORRECT - Decorative image hidden +Image("background-pattern") + .accessibilityHidden(true) +``` + +#### Buttons With Wrong Traits + +```swift +// ❌ WRONG - Custom button without button trait +Text("Submit") + .onTapGesture { + submit() + } +// VoiceOver announces as "Submit, text" not "Submit, button" + +// ✅ CORRECT - Use Button for button-like controls +Button("Submit") { + submit() +} +// VoiceOver announces as "Submit, button" + +// ✅ CORRECT - Custom control with correct trait +Text("Submit") + .accessibilityAddTraits(.isButton) + .onTapGesture { + submit() + } +``` + +#### Inaccessible Custom Controls + +```swift +// ❌ WRONG - Custom slider without accessibility support +struct CustomSlider: View { + @Binding var value: Double + + var body: some View { + // Drag gesture only, no VoiceOver support + GeometryReader { geo in + // ... + } + .gesture(DragGesture()...) + } +} + +// ✅ CORRECT - Custom slider with accessibility actions +struct CustomSlider: View { + @Binding var value: Double + + var body: some View { + GeometryReader { geo in + // ... + } + .gesture(DragGesture()...) + .accessibilityElement() + .accessibilityLabel("Volume") + .accessibilityValue("\(Int(value))%") + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: + value = min(value + 10, 100) + case .decrement: + value = max(value - 10, 0) + @unknown default: + break + } + } + } +} +``` + +#### Missing State Announcements + +```swift +// ❌ WRONG - State change without announcement +Button("Toggle") { + isOn.toggle() +} + +// ✅ CORRECT - State change with announcement +Button("Toggle") { + isOn.toggle() + UIAccessibility.post( + notification: .announcement, + argument: isOn ? "Enabled" : "Disabled" + ) +} + +// ✅ CORRECT - Automatic state with accessibilityValue +Button("Toggle") { + isOn.toggle() +} +.accessibilityValue(isOn ? "Enabled" : "Disabled") +``` + +## 8. Assistive Access Support (iOS 17+ — Cognitive Disabilities) + +**Problem** App is unavailable or broken in Assistive Access mode, excluding users with cognitive disabilities who rely on a simplified system experience. + +Assistive Access is a system-wide mode (Settings > Accessibility > Assistive Access) that replaces the standard iOS UI with large controls, simplified navigation, and reduced cognitive load. Apps that don't opt in are hidden from users in this mode. + +#### Symptom: App missing from Assistive Access home screen + +Your app doesn't appear under "Optimized Apps" in Assistive Access settings. + +```xml + +UISupportsAssistiveAccess + +``` + +This makes the app available and launches it full screen in Assistive Access mode. Without this key, users in Assistive Access mode cannot access your app at all. + +#### Symptom: Standard UI too complex for Assistive Access users + +Your app launches in Assistive Access but shows the full standard interface, overwhelming users who need simplified controls. + +```swift +// ✅ FIX - Provide a dedicated Assistive Access scene +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() // Standard UI + } + + AssistiveAccess { + AssistiveAccessContentView() // Simplified UI + } + } +} +``` + +The `AssistiveAccess` scene type provides a separate entry point. When the system is in Assistive Access mode, it uses this scene instead of the standard `WindowGroup`. Native SwiftUI controls inside this scene automatically adopt the Assistive Access visual style (large buttons, prominent navigation, grid/row layout). + +#### Symptom: App already designed for cognitive accessibility but displays in reduced frame + +If your app is already purpose-built for users with cognitive disabilities (e.g., AAC apps), it may appear in a reduced frame rather than full screen. + +```xml + +UISupportsFullScreenInAssistiveAccess + +``` + +This displays your app identically to its standard appearance, bypassing the Assistive Access frame. + +#### Detecting Assistive Access at runtime + +```swift +struct MyView: View { + @Environment(\.accessibilityAssistiveAccessEnabled) var assistiveAccessEnabled + + var body: some View { + if assistiveAccessEnabled { + // Simplified content + } else { + // Standard content + } + } +} +``` + +#### UIKit implementation + +For UIKit apps, use the `.windowAssistiveAccessApplication` scene session role in your `UISceneConfiguration` to route to a dedicated scene delegate for the Assistive Access experience. + +#### Design principles for Assistive Access scenes + +- **Distill to core functionality** — One or two essential features, not the full app +- **Large, prominent controls** — Ample spacing, no hidden gestures or timed interactions +- **Multiple representations** — Pair text with icons; use visual alternatives +- **Step-by-step navigation** — Clear back buttons, consistent patterns +- **Safe interactions** — Remove irreversible actions; confirm destructive ones + +#### Adding navigation icons + +```swift +NavigationStack { + MyView() + .navigationTitle("My Feature") + .assistiveAccessNavigationIcon(systemImage: "star.fill") +} +``` + +#### Testing + +1. **Device** — Enable Assistive Access in Settings > Accessibility > Assistive Access, verify app appears in "Optimized Apps", test the full user flow +2. **Accessibility Inspector** — Run audit on the Assistive Access scene for label, contrast, and hit region issues + +--- + +## Accessibility Inspector Workflow + +### 1. Launch Accessibility Inspector + +Xcode → Open Developer Tool → Accessibility Inspector + +### 2. Select Target + +- Dropdown: Choose running simulator or connected device +- Target: Select your app + +### 3. Inspection Mode + +- Click "Inspection Pointer" button (crosshair icon) +- Hover over UI elements to see: + - Label, Value, Hint, Traits + - Frame, Path + - Actions available + - Parent/child hierarchy + +### 4. Run Audit + +- Click "Audit" tab +- Click "Run Audit" button +- Review findings: + - **Contrast** — Color contrast issues + - **Hit Region** — Touch target size issues + - **Clipped Text** — Text truncation with Dynamic Type + - **Element Description** — Missing labels/hints + - **Traits** — Wrong accessibility traits + +### 5. Fix and Re-Test + +- Click each finding for details +- Fix in code +- Re-run audit to verify + +## VoiceOver Testing Checklist + +### Enable VoiceOver +- **Simulator** Cmd+F5 or Settings → Accessibility → VoiceOver +- **Device** Triple-click side button (if enabled in Settings) + +### Navigation Testing +1. ☐ Swipe right/left - moves logically through UI elements +2. ☐ Each element announces purpose clearly +3. ☐ No unlabeled elements (except decorative) +4. ☐ Heading navigation works (swipe up/down with 2 fingers) +5. ☐ Container navigation works (swipe left/right with 3 fingers) + +### Interaction Testing +1. ☐ Double-tap activates buttons +2. ☐ Swipe up/down adjusts sliders/pickers (with `.accessibilityAdjustableAction`) +3. ☐ Custom gestures have VoiceOver equivalents +4. ☐ Text fields announce keyboard type +5. ☐ State changes are announced + +### Content Testing +1. ☐ Images have descriptive labels or are hidden +2. ☐ Error messages are announced +3. ☐ Loading states are announced +4. ☐ Modal sheets announce role +5. ☐ Alerts announce automatically + +## App Store Review Preparation + +### Required Accessibility Features (iOS) + +1. **VoiceOver Support** + - All UI elements must have labels + - Navigation must be logical + - All actions must be performable + +2. **Dynamic Type** + - Text must scale from -3 to +12 sizes + - Layout must adapt without clipping + +3. **Sufficient Contrast** + - Minimum 4.5:1 for normal text + - Minimum 3:1 for large text (≥18pt) + +### App Store Connect Metadata + +When submitting: +1. Accessibility → Select features your app supports: + - ☑ VoiceOver + - ☑ Dynamic Type + - ☑ Increased Contrast + - ☑ Reduce Motion (if supported) + +2. Test Notes: Document accessibility testing + ``` + Accessibility Testing Completed: + - VoiceOver: All screens tested with VoiceOver enabled + - Dynamic Type: Tested at all size categories + - Color Contrast: Verified 4.5:1 minimum contrast + - Touch Targets: All buttons minimum 44x44pt + - Reduce Motion: Animations respect user preference + ``` + +### Common Rejection Reasons + +1. **"App is not fully functional with VoiceOver"** + - Missing labels on images/buttons + - Unlabeled custom controls + - Actions not performable with VoiceOver + +2. **"Text is not readable at all Dynamic Type sizes"** + - Fixed font sizes + - Text clipping at large sizes + - Layout breaks at accessibility sizes + +3. **"Insufficient color contrast"** + - Text fails 4.5:1 ratio + - UI elements fail 3:1 ratio + - Color-only indicators + +--- + +## Design Review Pressure: Defending Accessibility Requirements + +### The Problem + +Under design review pressure, you'll face requests to: +- "Those VoiceOver labels make the code messy - can we skip them?" +- "Dynamic Type breaks our carefully designed layout - let's lock font sizes" +- "The high contrast requirement ruins our brand aesthetic" +- "44pt touch targets are too big - make them smaller for a cleaner look" + +These sound like reasonable design preferences. **But they violate App Store requirements and exclude 15% of users.** Your job: defend using App Store guidelines and legal requirements, not opinion. + +### Red Flags — Designer Requests That Violate Accessibility + +If you hear ANY of these, **STOP and reference this skill**: + +- ❌ **"Skip VoiceOver labels on icon-only buttons"** – App Store rejection (Guideline 2.5.1) +- ❌ **"Use fixed 14pt font for compact design"** – Excludes users with vision disabilities +- ❌ **"3:1 contrast ratio is fine"** – Fails WCAG AA for text (needs 4.5:1) +- ❌ **"Make buttons 36x36pt for clean aesthetic"** – Fails touch target requirement (44x44pt minimum) +- ❌ **"Disable Dynamic Type in this screen"** – App Store rejection risk +- ❌ **"Color-code without labels (red=error, green=success)"** – Excludes colorblind users (8% of men) + +### How to Push Back Professionally + +#### Step 1: Show the Guideline + +``` +"I want to support this design direction, but let me show you Apple's App Store +Review Guideline 2.5.1: + +'Apps should support accessibility features such as VoiceOver and Dynamic Type. +Failure to include sufficient accessibility features may result in rejection.' + +Here's what we need for approval: +1. VoiceOver labels on all interactive elements +2. Dynamic Type support (can't lock font sizes) +3. 4.5:1 contrast ratio for text, 3:1 for UI +4. 44x44pt minimum touch targets + +Let me show where our design currently falls short..." +``` + +#### Step 2: Demonstrate the Risk + +Open the app with accessibility features enabled: +- **VoiceOver** (Cmd+F5): Show buttons announcing "Button" instead of purpose +- **Largest Text Size**: Show layout breaking or text clipping +- **Color Contrast Analyzer**: Show failing contrast ratios +- **Touch target overlay**: Show targets < 44pt + +#### Reference +- App Store Review Guideline 2.5.1 +- WCAG 2.1 Level AA (industry standard) +- ADA compliance requirements (legal risk in US) + +#### Step 3: Offer Compromise + +``` +"I can achieve your aesthetic goals while meeting accessibility requirements: + +1. VoiceOver labels: Add them programmatically (invisible in UI, required for approval) +2. Dynamic Type: Use layout techniques that adapt (examples from Apple HIG) +3. Contrast: Adjust colors slightly to meet 4.5:1 (I'll show options that preserve brand) +4. Touch targets: Expand hit areas programmatically (visual size stays the same) + +These changes won't affect the visual design you're seeing, but they're required +for App Store approval and legal compliance." +``` + +#### Step 4: Document the Decision + +If overruled (designer insists on violations): + +``` +Slack message to PM + designer: + +"Design review decided to proceed with: +- Fixed font sizes (disabling Dynamic Type) +- 38x38pt buttons (below 44pt requirement) +- 3.8:1 text contrast (below 4.5:1 requirement) + +Important: These changes violate App Store Review Guideline 2.5.1 and WCAG AA. +This creates three risks: + +1. App Store rejection during review (adds 1-2 week delay) +2. ADA compliance issues if user files complaint (legal risk) +3. 15% of potential users unable to use app effectively + +I'm flagging this proactively so we can prepare a response plan if rejected." +``` + +#### Why this works +- You're not questioning their design taste +- You're raising App Store rejection risk (business impact) +- You're citing specific guidelines (not opinion) +- You're offering solutions that preserve visual design +- You're documenting the decision (protects you post-rejection) + +### Real-World Example: App Store Rejection (48-Hour Resubmit Window) + +#### Scenario +- 48 hours until resubmit deadline after rejection +- Apple cited: "2.5.1 - Insufficient VoiceOver support" +- Designer says: "Just add generic labels quickly" +- PM watching the meeting, wants fastest fix + +#### What to do + +```swift +// ❌ WRONG - Generic labels (will fail re-review) +Button(action: addToCart) { + Image(systemName: "cart.badge.plus") +} +.accessibilityLabel("Button") // Apple will reject again + +// ✅ CORRECT - Descriptive labels (passes review) +Button(action: addToCart) { + Image(systemName: "cart.badge.plus") +} +.accessibilityLabel("Add to cart") +.accessibilityHint("Double-tap to add this item to your shopping cart") +``` + +#### In the meeting, demonstrate +1. Enable VoiceOver (Cmd+F5) +2. Show "Button" announcement (generic - fails) +3. Show "Add to cart" announcement (descriptive - passes) +4. Reference Apple's rejection message: "Elements must have descriptive labels" + +**Time estimate** 2-4 hours to audit all interactive elements and add proper labels. + +#### Result +- Honest time estimate prevents second rejection +- Proper labels pass Apple review +- Resubmit accepted within 48 hours + +### When to Accept the Design Decision (Even If You Disagree) + +Sometimes designers have valid reasons to override accessibility guidelines. Accept if: + +- [ ] They understand the App Store rejection risk +- [ ] They're willing to delay launch if rejected +- [ ] You document the decision in writing +- [ ] They commit to fixing if rejected + +#### Document in Slack + +``` +"Design review decided to proceed with [specific violations]. + +We understand this creates: +- App Store rejection risk (Guideline 2.5.1) +- Potential 1-2 week delay if rejected +- Need to audit and fix all instances if rejected + +Monitoring plan: +- Submit for review with current design +- If rejected, implement proper accessibility (estimated 2-4 hours) +- Have accessibility-compliant version ready as backup" +``` + +This protects both of you and shows you're not blocking - just de-risking. + +--- + +## WCAG Compliance Levels + +### Level A (Minimum — Required for App Store) +- 1.1.1 Non-text Content — Images have text alternatives +- 2.1.1 Keyboard — All functionality via keyboard (iPadOS/macOS) +- 4.1.2 Name, Role, Value — Elements have accessible names + +### Level AA (Standard — Recommended) +- 1.4.3 Contrast (Minimum) — 4.5:1 text, 3:1 UI +- 1.4.4 Resize Text — Support 200% text scaling +- 1.4.5 Images of Text — Use real text when possible + +### Level AAA (Enhanced — Best Practice) +- 1.4.6 Contrast (Enhanced) — 7:1 text, 4.5:1 UI +- 2.3.3 Animation from Interactions — Reduce Motion support +- 2.5.5 Target Size - 44x44pt minimum targets + +**Goal** Meet Level AA for all content, Level AAA where feasible. + +## Quick Command Reference + +After making fixes: + +```bash +# Quick scan for new issues +/axiom:audit-accessibility + +# Deep diagnosis for specific issues +/skill axiom:accessibility-diag +``` + +## Resources + +**Docs**: /accessibility/voiceover, /uikit/uifont/scaling_fonts_automatically + +--- + +**Remember** Accessibility is not a feature, it's a requirement. 15% of users have some form of disability. Making your app accessible isn't just the right thing to do - it expands your user base and improves the experience for everyone. diff --git a/.claude/skills/axiom-accessibility-diag/agents/openai.yaml b/.claude/skills/axiom-accessibility-diag/agents/openai.yaml new file mode 100644 index 0000000..7776dbb --- /dev/null +++ b/.claude/skills/axiom-accessibility-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Accessibility Diagnostics" + short_description: "Fixing VoiceOver issues, Dynamic Type violations, color contrast failures, touch target problems, keyboard navigation..." diff --git a/.claude/skills/axiom-alarmkit-ref/.openskills.json b/.claude/skills/axiom-alarmkit-ref/.openskills.json new file mode 100644 index 0000000..6b30d61 --- /dev/null +++ b/.claude/skills/axiom-alarmkit-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-alarmkit-ref", + "installedAt": "2026-04-12T08:05:41.287Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-alarmkit-ref/SKILL.md b/.claude/skills/axiom-alarmkit-ref/SKILL.md new file mode 100644 index 0000000..7bb91ed --- /dev/null +++ b/.claude/skills/axiom-alarmkit-ref/SKILL.md @@ -0,0 +1,502 @@ +--- +name: axiom-alarmkit-ref +description: Use when implementing alarm functionality, scheduling wake alarms, or integrating AlarmKit with Live Activities. Covers AlarmKit authorization, alarm configuration, SwiftUI views, and Live Activity integration. +license: MIT +--- + +# AlarmKit Reference + +Complete API reference for AlarmKit, Apple's framework for scheduling alarms and countdown timers with system-level alerting, Dynamic Island integration, and focus/silent mode override. + +## Overview + +AlarmKit lets apps create alarms and timers that behave like the built-in Clock app -- they override Do Not Disturb, appear in the Dynamic Island, and show on the Lock Screen. The framework handles scheduling, snooze, pause/resume, and UI presentation through a small set of types centered on `AlarmManager`. + +## System Requirements + +- **iOS 26+** (AlarmKit introduced in iOS 26) +- **Widget Extension** required for Live Activity / Dynamic Island presentation +- **Physical device** recommended for alarm sound and notification testing + +--- + +## Part 1: Key Components + +### AlarmManager + +Singleton entry point for all alarm operations. + +```swift +import AlarmKit + +let manager = AlarmManager.shared +``` + +All scheduling, cancellation, and observation flows through this shared instance. + +### Alarm + +Describes an alarm that can alert once or on a repeating schedule. + +```swift +struct Alarm { + var id: UUID + var schedule: Schedule? + var countdownDuration: CountdownDuration? + var state: AlarmState +} +``` + +### AlarmPresentation + +Content for the alarm UI across three states -- alerting, counting down, and paused. + +```swift +struct AlarmPresentation { + var alert: Alert // Required: shown when alarm fires + var countdown: Countdown? // Optional: shown during countdown + var paused: Paused? // Optional: shown when paused +} +``` + +### AlarmAttributes + +Generic container pairing presentation with app-specific metadata and tint color. Used to configure the Live Activity widget. + +```swift +struct AlarmAttributes { + var presentation: AlarmPresentation + var metadata: Metadata + var tintColor: Color +} +``` + +### AlarmMetadata + +Protocol for app-specific data attached to an alarm. Conform an empty struct for minimal usage, or add properties for richer UI. + +```swift +struct RecipeMetadata: AlarmMetadata { + let recipeName: String + let cookingStep: String +} +``` + +--- + +## Part 2: Authorization + +Apps must request permission before scheduling alarms. Add `NSAlarmKitUsageDescription` to Info.plist. + +### Requesting Authorization + +```swift +func requestAlarmAuthorization() async -> Bool { + do { + let state = try await AlarmManager.shared.requestAuthorization() + return state == .authorized + } catch { + print("Authorization error: \(error)") + return false + } +} +``` + +### Checking Current State + +Use `authorizationState` (not `authorizationStatus`) to read the current value: + +```swift +let state = await AlarmManager.shared.authorizationState +// .authorized | .denied | .notDetermined +``` + +### Observing Authorization Changes + +```swift +for await authState in AlarmManager.shared.authorizationUpdates { + switch authState { + case .authorized: enableAlarmUI() + case .denied: showPermissionPrompt() + case .notDetermined: break + @unknown default: break + } +} +``` + +--- + +## Part 3: Scheduling Alarms + +Every alarm requires a `UUID`, an `AlarmManager.AlarmConfiguration`, and a call to `schedule(id:configuration:)`. + +### One-Time Alarm + +```swift +let id = UUID() +let time = Alarm.Schedule.Relative.Time(hour: 7, minute: 30) +let schedule = Alarm.Schedule.relative(.init( + time: time, + repeats: .never +)) + +let alert = AlarmPresentation.Alert( + title: "Wake Up", + stopButton: .stopButton, + secondaryButton: .snoozeButton, + secondaryButtonBehavior: .countdown +) + +struct EmptyMetadata: AlarmMetadata {} +let config = AlarmManager.AlarmConfiguration( + countdownDuration: nil, + schedule: schedule, + attributes: AlarmAttributes( + presentation: AlarmPresentation(alert: alert), + metadata: EmptyMetadata(), + tintColor: .blue + ), + sound: .default +) + +let alarm = try await AlarmManager.shared.schedule(id: id, configuration: config) +``` + +### Repeating Alarm + +Use `.weekly(Array(weekdays))` for specific days: + +```swift +let time = Alarm.Schedule.Relative.Time(hour: 6, minute: 0) +let schedule = Alarm.Schedule.relative(.init( + time: time, + repeats: .weekly([.monday, .tuesday, .wednesday, .thursday, .friday]) +)) +``` + +### Countdown Timer + +Set `schedule: nil` and provide `countdownDuration` with a `preAlert` interval: + +```swift +let countdown = Alarm.CountdownDuration( + preAlert: 300, // 5 minutes + postAlert: 10 // Optional post-alert snooze window +) + +let config = AlarmManager.AlarmConfiguration( + countdownDuration: countdown, + schedule: nil, + attributes: attributes, + sound: .default +) +``` + +Timers support pause/resume and show a countdown presentation when `AlarmPresentation.countdown` is provided. + +### Snooze Configuration + +Snooze uses `CountdownDuration.postAlert` combined with a `.snoozeButton` secondary action: + +```swift +let alert = AlarmPresentation.Alert( + title: "Alarm", + stopButton: .stopButton, + secondaryButton: .snoozeButton, + secondaryButtonBehavior: .countdown // Starts post-alert countdown +) + +let countdownDuration = Alarm.CountdownDuration( + preAlert: nil, + postAlert: 9 * 60 // 9-minute snooze +) +``` + +--- + +## Part 4: Customizing Alarm UI + +### Alert Presentation + +The alert state is shown when the alarm fires. The stop button is required; secondary button is optional. + +```swift +// Minimal +let basic = AlarmPresentation.Alert( + title: "Alarm", + stopButton: .stopButton +) + +// With custom button labels +let custom = AlarmPresentation.Alert( + title: "Medication Reminder", + stopButton: AlarmButton(label: "Taken"), + secondaryButton: AlarmButton(label: "Remind Later"), + secondaryButtonBehavior: .countdown +) + +// With open-app action +let openApp = AlarmPresentation.Alert( + title: "Workout Time", + stopButton: .stopButton, + secondaryButton: .openAppButton, + secondaryButtonBehavior: .custom +) +``` + +### Countdown Presentation + +Shown while a timer counts down. Only relevant for alarms with `countdownDuration.preAlert`. + +```swift +let countdown = AlarmPresentation.Countdown( + title: "Timer Running", + pauseButton: .pauseButton +) +``` + +### Paused Presentation + +Shown when a countdown timer is paused. + +```swift +let paused = AlarmPresentation.Paused( + title: "Timer Paused", + resumeButton: .resumeButton +) +``` + +### Full Three-State Presentation + +Combine all three for a complete timer experience: + +```swift +let presentation = AlarmPresentation( + alert: AlarmPresentation.Alert( + title: "Timer Complete", + stopButton: .stopButton, + secondaryButton: .repeatButton, + secondaryButtonBehavior: .countdown + ), + countdown: AlarmPresentation.Countdown( + title: "Cooking Timer", + pauseButton: .pauseButton + ), + paused: AlarmPresentation.Paused( + title: "Timer Paused", + resumeButton: .resumeButton + ) +) +``` + +--- + +## Part 5: Managing Alarms + +### Retrieve All Alarms + +```swift +let alarms = try AlarmManager.shared.alarms +``` + +### Pause / Resume + +```swift +try await AlarmManager.shared.pause(id: alarmID) +try await AlarmManager.shared.resume(id: alarmID) +``` + +### Cancel + +```swift +try await AlarmManager.shared.cancel(id: alarmID) +``` + +### Observe Alarm Updates + +Use `alarmUpdates` to keep UI in sync. An alarm absent from the emitted array is no longer scheduled. + +```swift +for await alarms in AlarmManager.shared.alarmUpdates { + self.alarms = alarms +} +``` + +--- + +## Part 6: Live Activity Integration + +AlarmKit alarms appear in the Dynamic Island and Lock Screen through `ActivityConfiguration`. Add a Widget Extension target and implement the widget using `AlarmAttributes`. + +```swift +struct AlarmWidgetView: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: AlarmAttributes.self) { context in + // Lock Screen presentation + VStack { + Text(context.attributes.presentation.alert.title) + if context.state.mode == .countdown { + Text( + timerInterval: context.state.countdownEndDate + .timeIntervalSinceNow, + countsDown: true + ) + .bold() + } + } + .padding() + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Text(context.attributes.presentation.alert.title) + } + DynamicIslandExpandedRegion(.trailing) { + if context.state.mode == .countdown { + Text( + timerInterval: context.state.countdownEndDate + .timeIntervalSinceNow, + countsDown: true + ) + } + } + } compactLeading: { + Image(systemName: "alarm") + } compactTrailing: { + if context.state.mode == .countdown { + Text( + timerInterval: context.state.countdownEndDate + .timeIntervalSinceNow, + countsDown: true + ) + } + } minimal: { + Image(systemName: "alarm") + } + } + } +} +``` + +--- + +## Part 7: SwiftUI Integration + +### ViewModel Pattern with @Observable + +```swift +import AlarmKit + +@Observable +class AlarmViewModel { + var alarms: [Alarm] = [] + private let manager = AlarmManager.shared + + func requestAuthorization() { + Task { + _ = try? await manager.requestAuthorization() + } + } + + func loadAndObserve() { + Task { + alarms = (try? manager.alarms) ?? [] + for await updated in manager.alarmUpdates { + alarms = updated + } + } + } + + func addAlarm(hour: Int, minute: Int, weekdays: Set) { + Task { + let time = Alarm.Schedule.Relative.Time(hour: hour, minute: minute) + let schedule = Alarm.Schedule.relative(.init( + time: time, + repeats: weekdays.isEmpty ? .never : .weekly(Array(weekdays)) + )) + + let alert = AlarmPresentation.Alert( + title: "Alarm", + stopButton: .stopButton, + secondaryButton: .snoozeButton, + secondaryButtonBehavior: .countdown + ) + + struct EmptyMetadata: AlarmMetadata {} + let config = AlarmManager.AlarmConfiguration( + countdownDuration: Alarm.CountdownDuration( + preAlert: nil, postAlert: 9 * 60 + ), + schedule: schedule, + attributes: AlarmAttributes( + presentation: AlarmPresentation(alert: alert), + metadata: EmptyMetadata(), + tintColor: .blue + ), + sound: .default + ) + + _ = try? await manager.schedule(id: UUID(), configuration: config) + } + } + + func cancel(id: UUID) { + Task { try? await manager.cancel(id: id) } + } + + func togglePause(id: UUID, isPaused: Bool) { + Task { + if isPaused { + try? await manager.resume(id: id) + } else { + try? await manager.pause(id: id) + } + } + } +} +``` + +### Alarm List View + +```swift +struct AlarmListView: View { + @State private var viewModel = AlarmViewModel() + + var body: some View { + NavigationStack { + List(viewModel.alarms, id: \.id) { alarm in + AlarmRow(alarm: alarm, viewModel: viewModel) + } + .navigationTitle("Alarms") + .onAppear { + viewModel.requestAuthorization() + viewModel.loadAndObserve() + } + } + } +} +``` + +--- + +## Part 8: Best Practices + +| Practice | Detail | +|----------|--------| +| Request authorization early | On first launch or first alarm creation attempt | +| Handle denial gracefully | Guide users to Settings if permission was denied | +| Persist alarm UUIDs | Store IDs to manage alarms across app launches | +| Implement widget extension | Required for countdown/Dynamic Island presentation | +| Use `alarmUpdates` | Keep UI in sync; don't poll or cache stale state | +| Test on physical device | Alarm sounds, notifications, and Live Activities require real hardware | +| Respect system limits | There is a system-imposed cap on alarms per app | +| Use `authorizationState` | Not `authorizationStatus` -- the correct property name is `authorizationState` | + +--- + +## Resources + +**WWDC**: 2025-230 + +**Docs**: /alarmkit, /alarmkit/alarmmanager, /alarmkit/alarm, /alarmkit/alarmpresentation, /alarmkit/alarmattributes + +**Skills**: axiom-extensions-widgets-ref, axiom-swiftui-26-ref diff --git a/.claude/skills/axiom-alarmkit-ref/agents/openai.yaml b/.claude/skills/axiom-alarmkit-ref/agents/openai.yaml new file mode 100644 index 0000000..17dd502 --- /dev/null +++ b/.claude/skills/axiom-alarmkit-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "AlarmKit Reference" + short_description: "Implementing alarm functionality, scheduling wake alarms, or integrating AlarmKit with Live Activities" diff --git a/.claude/skills/axiom-analyze-crash/.openskills.json b/.claude/skills/axiom-analyze-crash/.openskills.json new file mode 100644 index 0000000..9b892e0 --- /dev/null +++ b/.claude/skills/axiom-analyze-crash/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-analyze-crash", + "installedAt": "2026-04-12T08:05:41.288Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-analyze-crash/SKILL.md b/.claude/skills/axiom-analyze-crash/SKILL.md new file mode 100644 index 0000000..c0c79b6 --- /dev/null +++ b/.claude/skills/axiom-analyze-crash/SKILL.md @@ -0,0 +1,343 @@ +--- +name: axiom-analyze-crash +description: Use when the user has a crash log (. +license: MIT +disable-model-invocation: true +--- + + +> **Note:** This audit may use Bash commands to run builds, tests, or CLI tools. +# Crash Analyzer Agent + +You are an expert at analyzing iOS/macOS crash reports programmatically. + +## Core Principle + +**Understand the crash before writing any fix.** 15 minutes of proper analysis prevents hours of misdirected debugging. + +## Your Mission + +When the user provides a crash log: +1. Parse the crash report (JSON .ips or text format) +2. Extract key fields (exception, crashed thread, frames) +3. Check symbolication status +4. Categorize by crash pattern +5. Generate actionable analysis with specific next steps + +## Input Handling + +### Crash Log Sources + +Users may provide crashes via: +- **Pasted text** — Full crash report in the conversation +- **File path** — `~/Library/Logs/DiagnosticReports/MyApp.ips` +- **Xcode export** — Copied from Organizer + +### File Locations + +```bash +# macOS crash logs +~/Library/Logs/DiagnosticReports/*.ips + +# iOS Simulator crash logs (same location) +~/Library/Logs/DiagnosticReports/*.ips + +# Device crash logs (after sync) +~/Library/Logs/CrashReporter/MobileDevice// +``` + +## Crash Report Formats + +### Modern Format (.ips - JSON) + +```json +{"app_name":"MyApp","timestamp":"2026-01-09 06:55:45.00 -0800",...} +{ + "exception": {"codes":"0x0000000000000001, 0x00000001024eef1c","type":"EXC_BREAKPOINT","signal":"SIGTRAP"}, + "faultingThread": 0, + "threads": [ + { + "triggered": true, + "frames": [ + {"imageOffset":257820,"symbol":"functionName","symbolLocation":222832,"imageIndex":0}, + ... + ] + } + ], + "usedImages": [ + {"uuid":"4c4c44ef-5555-3144-a1b5-0562264d518f","path":"/path/to/binary","name":"MyApp"} + ] +} +``` + +### Legacy Format (.crash - Text) + +``` +Exception Type: EXC_BAD_ACCESS (SIGSEGV) +Exception Codes: KERN_INVALID_ADDRESS at 0x0000000000000010 + +Thread 0 Crashed: +0 MyApp 0x100abc123 functionName + 45 +1 MyApp 0x100abc456 callerFunction + 123 +``` + +## Parsing Workflow + +### Step 1: Detect Format + +```bash +# Check if file is JSON (.ips) or text (.crash) +if head -1 "$CRASH_FILE" | grep -q "^{"; then + echo "JSON format (.ips)" +else + echo "Text format (.crash)" +fi +``` + +### Step 2: Extract Key Fields (JSON) + +For .ips files, extract: + +```bash +# Parse with jq (if available) or grep/sed + +# App info (first line is separate JSON) +head -1 "$CRASH_FILE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'App: {d[\"app_name\"]} {d.get(\"app_version\",\"\")} ({d.get(\"build_version\",\"\")})')" + +# Exception type +grep -o '"type":"[^"]*"' "$CRASH_FILE" | head -1 + +# Exception codes +grep -o '"codes":"[^"]*"' "$CRASH_FILE" | head -1 + +# Faulting thread +grep -o '"faultingThread":[0-9]*' "$CRASH_FILE" +``` + +### Step 3: Check Symbolication Status + +**Symbolicated** — Frames have `symbol` field with function names: +```json +{"symbol":"MyViewController.viewDidLoad()","symbolLocation":45} +``` + +**Unsymbolicated** — Frames only have offsets: +```json +{"imageOffset":257820,"symbolLocation":0} +``` + +**Partially symbolicated** — System frames have names, app frames don't + +### Step 4: Extract Crashed Thread Frames + +```bash +# For JSON, extract frames from faulting thread +# Look for thread with "triggered": true +``` + +## Exception Type Reference + +| Exception | Signal | Common Cause | +|-----------|--------|--------------| +| `EXC_BAD_ACCESS` | `SIGSEGV` | Null pointer, deallocated object, array out of bounds | +| `EXC_BAD_ACCESS` | `SIGBUS` | Misaligned memory access | +| `EXC_BREAKPOINT` | `SIGTRAP` | Swift runtime error, `fatalError()`, assertion | +| `EXC_CRASH` | `SIGABRT` | Uncaught exception, `abort()` called | +| `EXC_CRASH` | `SIGKILL` | System killed app (watchdog, jetsam) | +| `EXC_RESOURCE` | — | Exceeded resource limit (CPU, memory, wakeups) | + +### Special Exception Codes + +| Code | Name | Meaning | +|------|------|---------| +| `0x8badf00d` | "ate bad food" | Watchdog timeout (main thread blocked) | +| `0xdead10cc` | "deadlock" | Deadlock detected | +| `0xc00010ff` | "cool off" | Thermal event (device too hot) | +| `0xbaadca11` | "bad call" | Invalid function call | +| `KERN_INVALID_ADDRESS` | — | Null pointer or invalid memory | +| `KERN_PROTECTION_FAILURE` | — | Memory protection violation | + +## Crash Pattern Categories + +### Category 1: Null Pointer / Bad Access + +**Indicators:** +- `EXC_BAD_ACCESS` with `KERN_INVALID_ADDRESS` +- Address near `0x0` (e.g., `0x10`, `0x20`) = nil dereference +- Address large but valid-looking = deallocated object + +**Analysis:** +``` +Crash at address 0x0000000000000010 +↓ +Low address (< 0x1000) indicates nil + offset +↓ +Likely: Force-unwrapped optional or accessing property on nil +``` + +**Actionable steps:** +1. Find the crash line in code +2. Identify which variable could be nil +3. Add `guard let` or `if let` protection +4. Add logging to track when this becomes nil + +### Category 2: Swift Runtime Error + +**Indicators:** +- `EXC_BREAKPOINT` with `SIGTRAP` +- Frame contains `swift_runtime_` or assertion functions +- Application Specific Information has error message + +**Analysis:** +``` +EXC_BREAKPOINT + SIGTRAP +↓ +Swift runtime intentionally stopped execution +↓ +Look for: fatalError(), precondition failure, array bounds, force cast +``` + +**Actionable steps:** +1. Check Application Specific Information for error message +2. Search code for `fatalError`, `!`, `as!` at crash location +3. Replace force operations with safe alternatives + +### Category 3: Watchdog Timeout + +**Indicators:** +- Exception code `0x8badf00d` +- `EXC_CRASH` with `SIGKILL` +- Termination reason mentions "watchdog" + +**Analysis:** +``` +0x8badf00d = "ate bad food" +↓ +Main thread was blocked for too long +↓ +System killed app to maintain responsiveness +``` + +**Time limits:** +- App launch: ~20 seconds +- Background task: ~10 seconds +- Scene transition: ~5 seconds + +**Actionable steps:** +1. Identify blocking operation on main thread +2. Look for synchronous network/file I/O +3. Move heavy work to background queue +4. Add timeout handling + +### Category 4: Memory Pressure (Jetsam) + +**Indicators:** +- `EXC_RESOURCE` or jetsam report +- Termination reason: "memory limit exceeded" +- High `pageOuts` value + +**Actionable steps:** +1. Profile with Instruments → Allocations +2. Check for unbounded caches +3. Implement memory warnings handling +4. Use `autoreleasepool` for batch operations + +### Category 5: Uncaught Exception + +**Indicators:** +- `EXC_CRASH` with `SIGABRT` +- NSException info in crash report +- `objc_exception_throw` in stack + +**Actionable steps:** +1. Read NSException reason in crash report +2. Common: NSInvalidArgumentException, NSRangeException +3. Add try-catch or input validation + +## Output Format + +```markdown +## Crash Analysis Report + +### Summary +- **App**: [name] [version] ([build]) +- **Crash Time**: [timestamp] +- **OS**: [version] +- **Device**: [model] + +### Exception +- **Type**: [EXC_TYPE] ([SIGNAL]) +- **Codes**: [codes or special code name] +- **Category**: [pattern category from above] + +### Symbolication Status +- [✅ Fully symbolicated / ⚠️ Partially symbolicated / ❌ Not symbolicated] +- [If not symbolicated: Instructions to fix] + +### Crashed Thread (Thread [N]) +``` +Frame 0: [function or address] ← Crash location +Frame 1: [function or address] +Frame 2: [function or address] +... +``` + +### Analysis +[Interpretation of what happened based on pattern matching] + +### Root Cause Hypothesis +[Most likely cause based on evidence] + +### Actionable Steps +1. [Specific step with code location if known] +2. [Investigation step] +3. [Fix recommendation] + +### If Unsymbolicated +```bash +# Find dSYM for UUID: [uuid] +mdfind "com_apple_xcode_dsym_uuids == [UUID]" + +# Symbolicate address manually +xcrun atos -arch arm64 -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -l [load_address] [crash_address] +``` +``` + +## Symbolication Commands + +When crash is not symbolicated, provide these commands: + +```bash +# Find dSYM by UUID (from crash report's usedImages) +mdfind "com_apple_xcode_dsym_uuids == YOUR-UUID-HERE" + +# If dSYM not found, check Archives +ls ~/Library/Developer/Xcode/Archives/ + +# Symbolicate a single address +xcrun atos -arch arm64 \ + -o /path/to/MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \ + -l 0x100000000 \ + 0x0000000100abc123 + +# Batch symbolicate from file +xcrun atos -arch arm64 \ + -o /path/to/MyApp.dSYM/Contents/Resources/DWARF/MyApp \ + -l 0x100000000 \ + -f addresses.txt +``` + +## When to Escalate + +Report to user and stop if: +- Crash log is truncated or corrupted +- Format is unrecognized +- Critical information is missing (no exception type, no threads) +- Multiple unrelated issues in single crash (unusual) + +## Related + +- `axiom-testflight-triage` — Full TestFlight workflow including Organizer +- `axiom-memory-debugging` — For memory-related crashes +- `axiom-swift-concurrency` — For concurrency-related crashes +- `axiom-xcode-debugging` — For build/environment issues diff --git a/.claude/skills/axiom-analyze-crash/agents/openai.yaml b/.claude/skills/axiom-analyze-crash/agents/openai.yaml new file mode 100644 index 0000000..3243c7f --- /dev/null +++ b/.claude/skills/axiom-analyze-crash/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Analyze Crash" + short_description: "The user has a crash log (." diff --git a/.claude/skills/axiom-analyze-swift-performance/.openskills.json b/.claude/skills/axiom-analyze-swift-performance/.openskills.json new file mode 100644 index 0000000..03eba64 --- /dev/null +++ b/.claude/skills/axiom-analyze-swift-performance/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-analyze-swift-performance", + "installedAt": "2026-04-12T08:05:41.289Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-analyze-swift-performance/SKILL.md b/.claude/skills/axiom-analyze-swift-performance/SKILL.md new file mode 100644 index 0000000..78cf2fe --- /dev/null +++ b/.claude/skills/axiom-analyze-swift-performance/SKILL.md @@ -0,0 +1,258 @@ +--- +name: axiom-analyze-swift-performance +description: Use when the user mentions Swift performance audit, code optimization, or performance review. +license: MIT +disable-model-invocation: true +--- +# Swift Performance Analyzer Agent + +You are an expert at detecting Swift performance issues — both known anti-patterns AND context-dependent overhead that only matters in hot paths, tight loops, and high-frequency call sites. + +## Your Mission + +Run a comprehensive Swift performance audit using 5 phases: map allocation hotspots and type characteristics, detect known anti-patterns, reason about context-dependent performance, correlate compound issues, and score performance health. Report all issues with: +- File:line references +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Fix recommendations with code examples + +**Note**: This agent checks Swift-level performance (ARC, copies, generics, actors). For SwiftUI-specific performance (view bodies, lazy loading), use `swiftui-performance-analyzer`. + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +Also skip SwiftUI view files (files with `struct.*: View`) — use `swiftui-performance-analyzer` for those. + +## Phase 1: Map Allocation Hotspots + +Before grepping for anti-patterns, build a mental model of where performance matters most. + +### Step 1: Identify Type Characteristics + +``` +Glob: **/*.swift (excluding test/vendor/view paths) +Grep for: + - `struct ` declarations — value types (check size: count stored properties) + - `class ` declarations — reference types (ARC-managed) + - `actor ` declarations — actor-isolated types + - `enum ` with associated values — potentially large value types + - `any ` — existential types (witness table overhead) + - `some ` — opaque types (specialized, efficient) +``` + +### Step 2: Identify Hot Paths + +``` +Grep for: + - `for `, `while `, `forEach` — loops (potential hot paths) + - `func.*(_ .*:` — functions with value-type parameters (copy candidates) + - `await ` inside loops — actor hop overhead + - `.append(`, `.reserveCapacity` — collection growth patterns + - `weak var`, `[weak self]` — ARC overhead points +``` + +### Step 3: Identify Performance-Sensitive Code + +Read 2-3 key files (data processing, networking layer, model layer) to understand: +- What are the large value types? (structs with arrays, many properties) +- Where are the tight loops? (data processing, parsing, rendering) +- What's the actor boundary pattern? (fine-grained vs coarse-grained) +- Is there generic code that could benefit from specialization? + +### Output + +Write a brief **Performance Hotspot Map** (8-10 lines) summarizing: +- Large value types identified (structs with >5 properties or containing collections) +- Hot path locations (tight loops, data processing, parsing) +- Actor boundary pattern (fine-grained calls vs batched) +- Generic/existential usage pattern +- ARC-heavy areas (many weak references, closure captures) + +Present this map in the output before proceeding. + +## Phase 2: Detect Known Anti-Patterns + +Run all 8 existing detection patterns. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification. + +### 1. Unnecessary Copies (HIGH) + +**Pattern**: Large structs passed by value without ownership annotations +**Search**: Structs with >5 stored properties or containing Array/Dictionary — check functions that take them as parameters without `borrowing`, `consuming`, or `inout`. For custom COW types, check for missing `isKnownUniquelyReferenced` before mutation. +**Issue**: Expensive implicit copies on every function call; COW types without uniqueness check copy on every mutation +**Fix**: Use `borrowing` for read-only, `consuming` for ownership transfer; add `isKnownUniquelyReferenced` guard in COW mutating methods +**Note**: Only flag for large types. Small structs (2-3 fields, no collections) are fine by value. + +### 2. Excessive ARC Traffic (CRITICAL) + +**Pattern**: Unnecessary weak references, gratuitous self captures +**Search**: `weak var` where child lifetime < parent lifetime (unowned would work); `[weak self]` that immediately `guard let self` with no early return; closure captures of entire `self` when only one property is needed +**Issue**: Atomic operations for weak ~2x slower than unowned; full self captures retain unnecessarily +**Fix**: Use `unowned` when lifetime guarantees exist; capture specific properties + +### 3. Unspecialized Generics (HIGH) + +**Pattern**: Existential types where concrete or opaque types would work +**Search**: `any ` in function signatures, property types, and collections (`[any Protocol]`); generic functions in hot paths without `@_specialize` hints for common concrete types +**Issue**: Witness table overhead, heap allocation for existential containers, ~10x slower than specialized +**Fix**: Use `some` instead of `any` where possible; use generic constraints instead of existential collections; add `@_specialize(where T == ConcreteType)` for hot-path generics called with few concrete types + +### 4. Collection Inefficiencies (MEDIUM) + +**Pattern**: Missing capacity reservation, suboptimal collection types +**Search**: Loops with `.append(` without prior `reserveCapacity`; `Array` that could be `ContiguousArray` (no ObjC interop); `for element in array` where `array.lazy.filter` would short-circuit; `func hash(into` with expensive computations (string concatenation, nested hashing) +**Issue**: Multiple reallocations, NSArray bridging, unnecessary full iteration, expensive hash functions in hot-path dictionaries +**Fix**: Reserve capacity, use ContiguousArray for pure Swift, use lazy for short-circuit, optimize `hash(into:)` implementations + +### 5. Actor Isolation Overhead (HIGH) + +**Pattern**: Fine-grained actor calls in loops, async without suspension +**Search**: `await actorMethod()` inside `for`/`while` loops; `async func` that contains no `await`; actor methods accessing only immutable state (could be `nonisolated`) +**Issue**: Each actor hop costs ~100μs; async overhead for operations that never suspend +**Fix**: Batch actor operations, remove unnecessary async, mark immutable access as nonisolated, use `@concurrent` (Swift 6.2+) for CPU work that should run off the actor + +### 6. Large Value Types (MEDIUM) + +**Pattern**: Structs with collections or many properties passed by value +**Search**: Structs containing `var.*: \[`, `var.*: Dictionary`, `var.*: Set` — structs with Array/Dictionary/Set as stored properties +**Issue**: COW copy-on-write semantics mean sharing is cheap, but mutation triggers full copy +**Fix**: Use `borrowing`/`consuming`, or switch to class for frequently-mutated large types + +### 7. Inlining Issues (LOW) + +**Pattern**: Large functions marked @inlinable, or hot small functions without it +**Search**: `@inlinable` on functions — read and check line count (>20 lines is too large); small utility functions in public module APIs without `@inlinable`; `@usableFromInline` without corresponding `@inlinable` consumer (orphaned annotation) +**Issue**: Large inlined functions cause code bloat; missing inlining on hot paths misses optimization; orphaned `@usableFromInline` indicates dead code or incomplete optimization +**Fix**: Inline only small (<10 lines) frequently called functions; remove orphaned `@usableFromInline` or add the missing `@inlinable` wrapper + +### 8. Memory Layout Problems (MEDIUM) + +**Pattern**: Structs with poor field ordering +**Search**: Structs with alternating small/large fields (e.g., `var flag: Bool` then `var value: Int64` then `var active: Bool`) +**Issue**: Padding waste, poor cache utilization +**Fix**: Order fields largest to smallest + +## Phase 3: Reason About Context-Dependent Performance + +Using the Performance Hotspot Map from Phase 1 and your domain knowledge, check for issues that depend on *where* the code runs — not just *what* the code does. + +| Question | What it detects | Why it matters | +|----------|----------------|----------------| +| Are any of the Phase 2 patterns inside tight loops or data processing pipelines? | Anti-patterns amplified by iteration | An unnecessary copy in a one-shot function costs microseconds; the same copy in a loop processing 10K items costs milliseconds | +| Are there actor calls inside loops that could be batched into a single call? | Unbatched actor access | 100 individual actor hops at 100μs each = 10ms; one batched call = 100μs total | +| Are there large structs mutated inside loops (triggering COW copy per iteration)? | COW thrashing | Each mutation of a shared-reference struct triggers a full copy — in a loop, this is N copies | +| Do generic functions in hot paths get called with only 1-2 concrete types? | Missed specialization opportunity | The compiler may not specialize across module boundaries without hints | +| Are there closures created inside loops that capture class references? | Per-iteration ARC traffic | Each closure capture increments/decrements reference counts — N iterations = 2N atomic ops | +| Are `any` protocol types used in collections that are iterated frequently? | Existential overhead in hot path | Each element access goes through witness table — 10x slower than concrete type access | +| Are there functions marked async that are called in synchronous contexts via Task {}? | Unnecessary async overhead | Task creation + context switch for code that could run synchronously | + +For each finding, explain the context that makes it a performance problem. Require evidence from the Phase 1 map — don't flag a large struct copy in a one-shot initialization function. + +## Phase 4: Cross-Reference Findings + +When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations: + +| Finding A | + Finding B | = Compound | Severity | +|-----------|------------|-----------|----------| +| Large struct copy | Inside tight loop | N copies per iteration | CRITICAL | +| Actor hop in loop | No batching alternative | 100μs × N per loop iteration | CRITICAL | +| `any` protocol collection | Iterated in hot path | Witness table lookup per element per iteration | CRITICAL | +| Weak self capture | In closure created per-loop-iteration | 2N atomic ops per loop | HIGH | +| Missing reserveCapacity | Loop appends >100 items | ~14 reallocations for 10K items | HIGH | +| Async function | Never awaits internally | Unnecessary Task overhead on every call | HIGH | +| Large struct mutation | Shared reference (COW) | Full copy on each mutation | HIGH | +| Unspecialized generic | Called from only 1-2 concrete types | Missed optimization in performance-critical code | MEDIUM | + +Also note overlaps with other auditors: +- Actor hop overhead → compound with concurrency-auditor (isolation correctness) +- Closure captures → compound with memory-auditor (retain cycles) +- Collection operations in view body → compound with swiftui-performance-analyzer +- Weak/unowned in delegate pattern → compound with memory-auditor + +## Phase 5: Swift Performance Health Score + +Calculate and present a health score: + +```markdown +## Performance Health Score + +| Metric | Value | +|--------|-------| +| Value type efficiency | N large structs, M with ownership annotations (Z%) | +| ARC discipline | N weak references, M appropriate (Z% correct weak/unowned) | +| Generic specialization | N `any` usages, M that could be `some` or concrete (Z% specialized) | +| Collection efficiency | N append loops, M with reserveCapacity (Z%) | +| Actor efficiency | N actor calls in loops, M batched (Z%) | +| Hot path cleanliness | N hot paths identified, M free of amplified anti-patterns (Z%) | +| **Health** | **OPTIMIZED / OVERHEAD / BOTTLENECKED** | +``` + +Scoring: +- **OPTIMIZED**: No CRITICAL issues, hot paths free of amplified anti-patterns, >80% appropriate ownership/ARC, no `any` in hot paths +- **OVERHEAD**: No CRITICAL issues in hot paths, but some unnecessary copies, missing reserveCapacity, or gratuitous ARC traffic +- **BOTTLENECKED**: Any CRITICAL issues in hot paths, or actor hops in tight loops, or large struct copies in iteration + +## Output Format + +```markdown +# Swift Performance Audit Results + +## Performance Hotspot Map +[8-10 line summary from Phase 1] + +## Summary +- CRITICAL: [N] issues +- HIGH: [N] issues +- MEDIUM: [N] issues +- LOW: [N] issues +- Phase 2 (anti-pattern detection): [N] issues +- Phase 3 (context reasoning): [N] issues +- Phase 4 (compound findings): [N] issues + +## Performance Health Score +[Phase 5 table] + +## Issues by Severity + +### [SEVERITY] [Category]: [Description] +**File**: path/to/file.swift:line +**Phase**: [2: Detection | 3: Context | 4: Compound] +**Context**: [hot path / one-shot / loop body — from Phase 1 map] +**Issue**: What's wrong or suboptimal +**Impact**: Estimated cost (e.g., "~100μs × N iterations") +**Fix**: Code example showing the fix +**Cross-Auditor Notes**: [if overlapping with another auditor] + +## Quick Wins +1. [Highest impact, easiest fix] +2. [Second highest impact] +3. [Third highest impact] + +## Recommendations +1. [Immediate actions — CRITICAL fixes in hot paths] +2. [Short-term — HIGH fixes (ARC, generics, collections)] +3. [Long-term — architectural improvements from Phase 3 findings] +4. [Verification — profile with Instruments Time Profiler after fixes] +``` + +## Output Limits + +If >50 issues in one category: Show top 10, provide total count, list top 3 files +If >100 total issues: Summarize by category, show only CRITICAL/HIGH details + +## False Positives (Not Issues) + +- Small structs (2-3 fields, no collections) passed by value — copy is cheaper than indirection +- `weak var delegate` that is genuinely optional (delegate may be deallocated first) +- `any Protocol` in cold paths (configuration, setup, one-shot initialization) +- Arrays that grow to <100 items without reserveCapacity +- `async func` that wraps a single `await` call (legitimate async wrapper) +- ContiguousArray not used when ObjC bridging is needed +- @inlinable absent on internal (non-public) functions +- Large structs that are created once and never copied (stored in @State, let binding) + +## Related + +For Instruments workflows: `axiom-swift-performance` skill +For SwiftUI-specific performance: `swiftui-performance-analyzer` agent +For memory lifecycle issues: `axiom-memory-debugging` skill +For actor isolation patterns: `axiom-swift-concurrency` skill diff --git a/.claude/skills/axiom-analyze-swift-performance/agents/openai.yaml b/.claude/skills/axiom-analyze-swift-performance/agents/openai.yaml new file mode 100644 index 0000000..b0c463e --- /dev/null +++ b/.claude/skills/axiom-analyze-swift-performance/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Analyze Swift Performance" + short_description: "The user mentions Swift performance audit, code optimization, or performance review." diff --git a/.claude/skills/axiom-analyze-swiftui-performance/.openskills.json b/.claude/skills/axiom-analyze-swiftui-performance/.openskills.json new file mode 100644 index 0000000..3b7e6a1 --- /dev/null +++ b/.claude/skills/axiom-analyze-swiftui-performance/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-analyze-swiftui-performance", + "installedAt": "2026-04-12T08:05:41.290Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-analyze-swiftui-performance/SKILL.md b/.claude/skills/axiom-analyze-swiftui-performance/SKILL.md new file mode 100644 index 0000000..b9a2f91 --- /dev/null +++ b/.claude/skills/axiom-analyze-swiftui-performance/SKILL.md @@ -0,0 +1,258 @@ +--- +name: axiom-analyze-swiftui-performance +description: Use when the user mentions SwiftUI performance, janky scrolling, slow animations, or view update issues. +license: MIT +disable-model-invocation: true +--- +# SwiftUI Performance Analyzer Agent + +You are an expert at detecting SwiftUI performance issues — both known anti-patterns AND context-dependent performance problems that cause frame drops, janky scrolling, and poor responsiveness. + +## Your Mission + +Run a comprehensive SwiftUI performance audit using 5 phases: map the view hierarchy and rendering contexts, detect known anti-patterns, reason about context-dependent performance, correlate compound issues, and score performance health. Report all issues with: +- File:line references +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Fix recommendations with code examples + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Phase 1: Map View Hierarchy and Rendering Contexts + +Before grepping for anti-patterns, build a mental model of where performance matters most. + +### Step 1: Identify Scrolling Contexts + +``` +Glob: **/*.swift (excluding test/vendor paths) +Grep for: + - `List`, `LazyVStack`, `LazyHStack`, `LazyVGrid`, `LazyHGrid` — lazy containers + - `ScrollView` — scroll containers + - `ForEach` — repeated content + - `TabView` with `.tabViewStyle(.page)` — paged scrolling +``` + +### Step 2: Identify View Body Complexity + +``` +Grep for: + - `var body: some View` — all view body definitions + - `DateFormatter()`, `NumberFormatter()` — formatter creation + - `Data(contentsOf:`, `String(contentsOf:` — file I/O + - `UIImage(`, `CIFilter`, `UIGraphicsBeginImageContext` — image processing + - `.contains(`, `.filter(`, `.first(where:` — collection operations +``` + +### Step 3: Identify Update Triggers + +Read 3-5 key view files (especially those in scrolling contexts) to understand: +- What @State/@Binding/@Observable values trigger body re-evaluation? +- Are there high-frequency update sources? (scroll offset, gesture state, timers) +- How deep is the view hierarchy in scrolling cells? + +### Output + +Write a brief **Performance Context Map** (8-10 lines) summarizing: +- Scrolling contexts and their cell complexity +- View body hotspots (files with formatters, I/O, image processing) +- High-frequency update sources +- Observable/state dependency chains + +Present this map in the output before proceeding. + +## Phase 2: Detect Known Anti-Patterns + +Run all 10 existing detection patterns. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — especially verify the code is actually in a view body, not in `.task` or a background context. + +### 1. File I/O in View Body (CRITICAL) + +**Pattern**: Synchronous file reads in view body +**Search**: `Data(contentsOf:` or `String(contentsOf:` — verify near `var body` +**Issue**: Blocks main thread, guaranteed frame drops, potential ANR +**Fix**: Use `.task` with async loading, store in @State + +### 2. Expensive Formatters in View Body (CRITICAL) + +**Pattern**: DateFormatter(), NumberFormatter() created in view body +**Search**: `DateFormatter()` or `NumberFormatter()` in files with `var body` — verify not `static let` +**Issue**: ~1-2ms each, 100 rows = 100-200ms wasted per update +**Fix**: Move to `static let` or @Observable model + +### 3. Image Processing in View Body (HIGH) + +**Pattern**: Image resizing, filtering, transformation in view body +**Search**: `.resized`, `.thumbnail`, `UIGraphicsBeginImageContext`, `CIFilter` — verify near `var body`, not in `.task` +**Issue**: CPU-intensive work causes stuttering during scrolling +**Fix**: Process in background with `.task`, cache thumbnails + +### 4. Whole-Collection Dependencies (HIGH) + +**Pattern**: Collection operations that depend on entire collection in view body +**Search**: `.contains(`, `.first(where:`, `.filter(` — verify near `var body` +**Issue**: View updates when ANY item changes, not just relevant items +**Fix**: Use Set for O(1) lookups (breaks collection dependency) +**Note**: Sets are OK (O(1)), small collections OK (<10 items) + +### 5. Missing Lazy Loading (MEDIUM) + +**Pattern**: Non-lazy containers with many items +**Search**: `VStack` or `HStack` followed by `ForEach` — verify not already `LazyVStack`/`LazyHStack` +**Issue**: All views created immediately, high memory, slow initial load +**Fix**: Use LazyVStack/LazyHStack for long lists +**Note**: VStack with <20 items is fine + +### 6. Frequently Changing Environment Values (MEDIUM) + +**Pattern**: Environment values that change every frame passed to deep hierarchies +**Search**: `.environment(` with scroll offset, gesture state, or timer-driven values +**Issue**: All child views update on every change +**Fix**: Pass values directly to views that need them, not via environment + +### 7. Missing View Identity (MEDIUM) + +**Pattern**: ForEach without explicit id on non-Identifiable types +**Search**: `ForEach` without `id:` parameter — verify type isn't Identifiable +**Issue**: SwiftUI can't track views efficiently, recreates all on change +**Fix**: Use `ForEach(items, id: \.id)` or conform to Identifiable + +### 8. Navigation Performance (HIGH) + +**Pattern**: NavigationPath recreation or large models in navigation state +**Search**: `NavigationPath()` — verify near `var body` (recreated each update); `.navigationDestination` passing full model objects +**Issue**: Navigation hierarchy rebuilds unnecessarily, memory pressure +**Fix**: Use stable `@State` for path, pass IDs not full models + +### 9. Timer/Observer Leaks in Views (MEDIUM) + +**Pattern**: Timers or observers in views without cleanup +**Search**: `Timer.` in files with `struct.*: View` — check for `.onDisappear` cleanup +**Issue**: Memory leaks, cumulative performance degradation +**Fix**: Add `.onDisappear { timer?.invalidate() }` + +### 10. Old ObservableObject Pattern (LOW) + +**Pattern**: ObservableObject + @Published instead of @Observable (iOS 17+) +**Search**: `ObservableObject`, `@Published` +**Issue**: More allocations, less efficient updates (whole-object invalidation vs property-level) +**Fix**: Migrate to `@Observable` macro + +## Phase 3: Reason About Context-Dependent Performance + +Using the Performance Context Map from Phase 1 and your domain knowledge, check for issues that depend on *where* the code runs — not just *what* the code does. + +| Question | What it detects | Why it matters | +|----------|----------------|----------------| +| Are any of the Phase 2 patterns inside scrolling cell views (List row, LazyVStack item)? | Anti-patterns amplified by scrolling | A formatter in a settings screen costs 1-2ms; the same formatter in a List cell costs 1-2ms × visible rows × scroll velocity | +| Do views inside ForEach/List access @Observable properties that change frequently? | Unnecessary cell rebuilds | One property change on the model rebuilds every cell that reads any property on that model | +| Are there views that create child views conditionally based on data that changes often? | Structural identity thrashing | if/else toggling between views destroys and recreates instead of updating | +| Do any scrolling views have deep view hierarchies (>5 levels of nesting)? | Deep hierarchy in hot path | SwiftUI diffing cost scales with tree depth — deep cells in fast scrolling = dropped frames | +| Are there GeometryReader usages inside scrolling cells? | GeometryReader in hot path | GeometryReader forces two layout passes — acceptable in static views, expensive in scrolling | +| Is there image loading (AsyncImage, .task with image) inside List/ForEach without caching? | Uncached image loading in scrolling | Images re-fetched on every scroll-into-view without caching | +| Are there @State properties initialized with expensive expressions? | Expensive state initialization | @State initializers run once per view identity — but with identity thrashing, they run repeatedly | + +For each finding, explain the context that makes it a performance problem. Require evidence from the Phase 1 map — don't flag a formatter in a single-instance settings view the same as one in a scrolling cell. + +## Phase 4: Cross-Reference Findings + +When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations: + +| Finding A | + Finding B | = Compound | Severity | +|-----------|------------|-----------|----------| +| Formatter in view body | Inside List/ForEach cell | N× per-frame cost during scrolling | CRITICAL | +| File I/O in view body | Inside scrolling context | Main thread blocked per cell | CRITICAL | +| Whole-collection dependency | Large dataset (>100 items) | Every mutation rebuilds entire list | CRITICAL | +| Image processing in body | No caching + scrolling context | Re-processed on every scroll-into-view | CRITICAL | +| Missing lazy loading | >100 items in ForEach | All 100+ views created at once | HIGH | +| GeometryReader in cell | Deep view hierarchy | Double layout pass on deep tree per cell | HIGH | +| Frequent environment change | Many child views | Entire subtree invalidated per frame | HIGH | +| NavigationPath recreation | In view body | Navigation hierarchy rebuilt every update | HIGH | + +Also note overlaps with other auditors: +- Timer/observer leaks → compound with memory-auditor +- @MainActor missing on view model → compound with concurrency-auditor +- Image processing → compound with energy-auditor (GPU/CPU drain) + +## Phase 5: SwiftUI Performance Health Score + +Calculate and present a health score: + +```markdown +## Performance Health Score + +| Metric | Value | +|--------|-------| +| View body purity | N view files scanned, M with expensive operations in body (Z%) | +| Scrolling cell safety | N scrolling contexts, M with clean cells (Z%) | +| Lazy container usage | N long-list contexts, M using lazy containers (Z%) | +| Collection efficiency | N collection operations in bodies, M using Set/efficient lookups (Z%) | +| Observable efficiency | N @Observable, M ObservableObject (migration %) | +| **Health** | **SMOOTH / JANKY / BROKEN** | +``` + +Scoring: +- **SMOOTH**: No CRITICAL issues, all scrolling cells clean, >90% lazy container usage, no expensive operations in view bodies +- **JANKY**: No CRITICAL issues in scrolling contexts, but some expensive operations in bodies or missing lazy loading +- **BROKEN**: Any CRITICAL issues in scrolling contexts, or file I/O in view body, or formatters in List cells + +## Output Format + +```markdown +# SwiftUI Performance Audit Results + +## Performance Context Map +[8-10 line summary from Phase 1] + +## Summary +- CRITICAL: [N] issues +- HIGH: [N] issues +- MEDIUM: [N] issues +- LOW: [N] issues +- Phase 2 (anti-pattern detection): [N] issues +- Phase 3 (context reasoning): [N] issues +- Phase 4 (compound findings): [N] issues + +## Performance Health Score +[Phase 5 table] + +## Issues by Severity + +### [SEVERITY] [Category]: [Description] +**File**: path/to/file.swift:line +**Phase**: [2: Detection | 3: Context | 4: Compound] +**Context**: [scrolling cell / static view / navigation — from Phase 1 map] +**Issue**: What's wrong or suboptimal +**Impact**: What users experience (frame drops, jank, slow load) +**Fix**: Code example showing the fix +**Cross-Auditor Notes**: [if overlapping with another auditor] + +## Recommendations +1. [Immediate actions — CRITICAL fixes in scrolling contexts] +2. [Short-term — HIGH fixes (navigation, collection dependencies)] +3. [Long-term — architectural improvements from Phase 3 findings] +4. [Verification — profile with Instruments SwiftUI template after fixes] +``` + +## Output Limits + +If >50 issues in one category: Show top 10, provide total count, list top 3 files +If >100 total issues: Summarize by category, show only CRITICAL/HIGH details + +## False Positives (Not Issues) + +- Formatters in @Observable classes or `static let` +- Small collections (<10 items) with .contains() +- Sets with .contains() (O(1) lookup) +- VStack with few items (<20) +- Image processing in `.task` or background queue +- File I/O in `.task` or async contexts +- ForEach on Identifiable types (automatic identity) +- GeometryReader in non-scrolling, single-instance views +- ObservableObject in iOS 16-only targets + +## Related + +For SwiftUI Instruments workflows: `axiom-swiftui-performance` skill +For view update debugging: `axiom-swiftui-debugging` skill +For memory lifecycle issues: `axiom-memory-debugging` skill diff --git a/.claude/skills/axiom-analyze-swiftui-performance/agents/openai.yaml b/.claude/skills/axiom-analyze-swiftui-performance/agents/openai.yaml new file mode 100644 index 0000000..65c6878 --- /dev/null +++ b/.claude/skills/axiom-analyze-swiftui-performance/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Analyze SwiftUI Performance" + short_description: "The user mentions SwiftUI performance, janky scrolling, slow animations, or view update issues." diff --git a/.claude/skills/axiom-analyze-test-failures/.openskills.json b/.claude/skills/axiom-analyze-test-failures/.openskills.json new file mode 100644 index 0000000..159caa2 --- /dev/null +++ b/.claude/skills/axiom-analyze-test-failures/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-analyze-test-failures", + "installedAt": "2026-04-12T08:05:41.291Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-analyze-test-failures/SKILL.md b/.claude/skills/axiom-analyze-test-failures/SKILL.md new file mode 100644 index 0000000..466958f --- /dev/null +++ b/.claude/skills/axiom-analyze-test-failures/SKILL.md @@ -0,0 +1,379 @@ +--- +name: axiom-analyze-test-failures +description: Use when the user mentions flaky tests, tests that pass locally but fail in CI, race conditions in tests, or needs to diagnose WHY a specific test fails. +license: MIT +disable-model-invocation: true +--- +# Test Failure Analyzer Agent + +You are an expert at diagnosing WHY tests fail, especially intermittent/flaky failures in Swift Testing. + +## Your Mission + +Analyze the codebase to find patterns that cause flaky tests, focusing on: +- Swift Testing async patterns (missing `confirmation`, wrong waits) +- Swift 6 concurrency issues (`@MainActor` missing) +- Parallel execution races (shared state, missing `.serialized`) +- Timing-dependent assertions + +Report findings with: +- File:line references +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Root cause explanation +- Fix with code example + +## Files to Scan + +Include: `*Tests.swift`, `*Test.swift`, `**/*Tests/*.swift` +Skip: `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Flaky Test Patterns (iOS 18+ / Swift Testing Focus) + +### Pattern 1: Missing `await confirmation` (CRITICAL) + +**Issue**: Async work without proper waiting +**Why flaky**: Test completes before async callback fires +**Detection**: Closures/callbacks without `confirmation {}` + +```swift +// ❌ FLAKY - Test may complete before callback +@Test func fetchData() async { + var result: Data? + service.fetch { data in + result = data // May not run before assertion + } + #expect(result != nil) // FAILS intermittently +} + +// ✅ CORRECT - Waits for callback +@Test func fetchData() async { + await confirmation { confirm in + service.fetch { data in + #expect(data != nil) + confirm() + } + } +} +``` + +### Pattern 2: `@MainActor` Missing on UI Tests (CRITICAL) + +**Issue**: Swift 6 requires explicit actor isolation +**Why flaky**: Data races when accessing @MainActor types +**Detection**: Tests accessing UI types without @MainActor + +```swift +// ❌ FLAKY - Data race accessing MainActor ViewModel +@Test func viewModelUpdates() async { + let vm = ContentViewModel() // @MainActor type + vm.load() // Data race! +} + +// ✅ CORRECT - Proper isolation +@Test @MainActor func viewModelUpdates() async { + let vm = ContentViewModel() + await vm.load() +} +``` + +### Pattern 3: Shared Mutable State in `@Suite` (HIGH) + +**Issue**: Static/class vars shared across parallel tests +**Why flaky**: Tests pass individually, fail together +**Detection**: `static var` in test suites + +```swift +// ❌ FLAKY - Parallel tests mutate shared state +@Suite struct CacheTests { + static var sharedCache: [String: Data] = [:] // Shared! + + @Test func storeItem() { + Self.sharedCache["key"] = Data() // Race condition + } +} + +// ✅ CORRECT - Instance property, fresh per test +@Suite struct CacheTests { + var cache: [String: Data] = [:] // Fresh per test + + @Test func storeItem() { + cache["key"] = Data() + } +} +``` + +### Pattern 4: `Task.sleep` in Assertions (MEDIUM) + +**Issue**: Arbitrary waits for async completion +**Why flaky**: CI has variable timing +**Detection**: `Task.sleep` or `try await Task.sleep` in tests + +```swift +// ❌ FLAKY - Timing-dependent +@Test func loadData() async throws { + viewModel.startLoading() + try await Task.sleep(for: .seconds(2)) // May not be enough + #expect(viewModel.isLoaded) +} + +// ✅ CORRECT - Condition-based waiting +@Test func loadData() async { + await confirmation { confirm in + viewModel.$isLoaded + .filter { $0 } + .sink { _ in confirm() } + .store(in: &cancellables) + viewModel.startLoading() + } +} +``` + +### Pattern 5: Missing `.serialized` Trait (MEDIUM) + +**Issue**: Tests with shared resources run in parallel +**Why flaky**: Order-dependent or resource-contention failures +**Detection**: Tests accessing singletons/files without `.serialized` + +```swift +// ❌ FLAKY - Parallel tests compete for singleton +@Suite struct DatabaseTests { + @Test func writeData() { Database.shared.write("a") } + @Test func readData() { _ = Database.shared.read() } +} + +// ✅ CORRECT - Force serial execution +@Suite(.serialized) struct DatabaseTests { + @Test func writeData() { Database.shared.write("a") } + @Test func readData() { _ = Database.shared.read() } +} +``` + +### Pattern 6: `#expect` with Date Comparisons (LOW) + +**Issue**: Date assertions drift across timezones/DST +**Why flaky**: Passes in one timezone, fails in CI (UTC) +**Detection**: `#expect` with `Date()` or date comparisons + +```swift +// ❌ FLAKY - Timezone-dependent +@Test func expirationDate() { + let item = CacheItem() + #expect(item.expiresAt > Date()) // May fail near midnight +} + +// ✅ CORRECT - Use fixed dates or tolerances +@Test func expirationDate() { + let now = Date() + let item = CacheItem(createdAt: now) + #expect(item.expiresAt.timeIntervalSince(now) > 3600) +} +``` + +## Audit Process + +### Step 1: Find All Test Files + +Use Glob: `**/*Tests.swift`, `**/*Test.swift` + +### Step 2: Search for Flaky Patterns + +**Pattern 1 - Missing confirmation**: +``` +Grep: \.sink\s*\{|completion\s*:|\.fetch\s*\{ +# Then verify no surrounding confirmation {} +``` + +**Pattern 2 - Missing @MainActor**: +``` +Grep: @Test\s+func|@Test\s+@MainActor +# Check tests that access @MainActor types +``` + +**Pattern 3 - Shared mutable state**: +``` +Grep: static var.*=|class var.*= +# In files matching *Tests.swift +``` + +**Pattern 4 - Task.sleep in tests**: +``` +Grep: Task\.sleep|try await Task\.sleep +``` + +**Pattern 5 - Missing .serialized**: +``` +Grep: @Suite\s+struct|@Suite\s*\( +# Check for Database, FileManager, UserDefaults access +``` + +**Pattern 6 - Date assertions**: +``` +Grep: #expect.*Date\(\)|#expect.*\.date +``` + +### Step 3: Read Context and Verify + +For each match: +1. Read surrounding context (20 lines) +2. Verify it's a real issue (not false positive) +3. Check if fix is already present + +## Output Format + +```markdown +# Test Failure Analysis Results + +## Summary +- **CRITICAL Issues**: [count] (Will cause intermittent failures) +- **HIGH Issues**: [count] (Likely flaky in parallel execution) +- **MEDIUM Issues**: [count] (May cause timing issues) +- **LOW Issues**: [count] (Edge case failures) + +## Flakiness Risk Score: HIGH / MEDIUM / LOW + +## CRITICAL Issues + +### Missing `await confirmation` +- `Tests/NetworkTests.swift:45` + ```swift + @Test func fetchUser() async { + var user: User? + api.fetchUser { user = $0 } + #expect(user != nil) // FLAKY! + } + ``` + - **Root cause**: Test completes before async callback + - **Fix**: + ```swift + @Test func fetchUser() async { + await confirmation { confirm in + api.fetchUser { user in + #expect(user != nil) + confirm() + } + } + } + ``` + +### Missing `@MainActor` +- `Tests/ViewModelTests.swift:23` + ```swift + @Test func updateUI() async { + let vm = MainActorViewModel() // Data race + } + ``` + - **Root cause**: Accessing @MainActor type without isolation + - **Fix**: Add `@MainActor` to test function + +## HIGH Issues + +### Shared Mutable State +- `Tests/CacheTests.swift:12` - `static var testCache` + - **Root cause**: Parallel tests mutate same collection + - **Fix**: Use instance property instead of static + +## MEDIUM Issues + +### Missing `.serialized` Trait +- `Tests/DatabaseTests.swift` - Suite accesses shared database + - **Root cause**: Parallel writes cause constraint violations + - **Fix**: Add `.serialized` trait to `@Suite` + +## Verification Steps + +After fixes, verify with: + +```bash +# Run tests multiple times to detect flakiness +swift test --parallel --num-workers 8 + +# Run specific test repeatedly +swift test --filter "TestName" --iterations 100 + +# Xcode: Edit Scheme → Test → Options → "Repeat Until Failure" +``` + +## Swift Testing Best Practices + +| Pattern | Use When | +|---------|----------| +| `confirmation {}` | Any callback/closure-based async | +| `@MainActor` | Test accesses UI types | +| `.serialized` | Tests share singleton/file/database | +| Instance properties | Any test data that changes | +``` + +## Severity Definitions + +**CRITICAL**: Will definitely cause intermittent failures +- Missing `confirmation` for async callbacks +- Missing `@MainActor` for UI tests + +**HIGH**: Likely to cause parallel execution failures +- Shared mutable state (`static var`) +- Order-dependent tests + +**MEDIUM**: May cause timing-related failures +- `Task.sleep` for waiting +- Missing `.serialized` for shared resources + +**LOW**: Edge case failures +- Date/timezone assertions +- Locale-dependent comparisons + +## False Positives to Avoid + +**Not issues**: +- `static let` constants (immutable is fine) +- `confirmation` already present +- Tests marked with `.serialized` +- `@MainActor` already present +- One-time setup in `static var` that's read-only + +**Verify before reporting**: +- Read surrounding context +- Check for `confirmation {}` wrapper +- Check for trait annotations + +## XCTest Flaky Patterns (Legacy) + +For XCTest code, also check: + +### XCTestExpectation Issues +```swift +// ❌ FLAKY - Timeout too short for CI +wait(for: [expectation], timeout: 1.0) + +// ✅ BETTER - Generous timeout +wait(for: [expectation], timeout: 10.0) +``` + +### Missing waitForExistence +```swift +// ❌ FLAKY - Element may not exist yet +XCTAssertTrue(app.buttons["Submit"].exists) + +// ✅ CORRECT - Wait for element +XCTAssertTrue(app.buttons["Submit"].waitForExistence(timeout: 5)) +``` + +## When No Issues Found + +Report: +```markdown +# Test Failure Analysis Results + +## Summary +No flaky test patterns detected. + +## Verified +- ✅ Async tests use `confirmation` properly +- ✅ UI tests have `@MainActor` isolation +- ✅ No shared mutable state in suites +- ✅ No timing-dependent assertions + +## Recommendations +- Run tests with `--iterations 100` to verify stability +- Enable parallel testing to expose hidden races +- Use Xcode's "Repeat Until Failure" for suspect tests +``` diff --git a/.claude/skills/axiom-analyze-test-failures/agents/openai.yaml b/.claude/skills/axiom-analyze-test-failures/agents/openai.yaml new file mode 100644 index 0000000..63ae37b --- /dev/null +++ b/.claude/skills/axiom-analyze-test-failures/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Analyze Test Failures" + short_description: "The user mentions flaky tests, tests that pass locally but fail in CI, race conditions in tests, or needs to diagnose..." diff --git a/.claude/skills/axiom-app-attest/.openskills.json b/.claude/skills/axiom-app-attest/.openskills.json new file mode 100644 index 0000000..6119785 --- /dev/null +++ b/.claude/skills/axiom-app-attest/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-app-attest", + "installedAt": "2026-04-12T08:05:42.610Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-app-attest/SKILL.md b/.claude/skills/axiom-app-attest/SKILL.md new file mode 100644 index 0000000..1c8ac50 --- /dev/null +++ b/.claude/skills/axiom-app-attest/SKILL.md @@ -0,0 +1,335 @@ +--- +name: axiom-app-attest +description: Use when implementing app integrity verification, preventing fraud with DCAppAttestService, validating requests from legitimate app instances, using DeviceCheck for promotional abuse prevention, or needing server-side attestation/assertion validation. Covers key generation, attestation, assertion, rollout strategy, and risk metrics. +license: MIT +--- + +# App Attest + +Device-backed app integrity verification for fraud prevention. Proves three things to your server: the request came from a genuine Apple device, running your genuine app, with an untampered payload. + +## When to Use This Skill + +Use when you need to: +- Verify requests come from legitimate app instances (not modified/cloned apps) +- Prevent fraud in purchases, promotions, or competitive features +- Implement DCAppAttestService attestation or assertion flows +- Handle DeviceCheck 2-bit per-device state for promotional abuse +- Build server-side validation for attestation objects or assertion signatures +- Plan a gradual App Attest rollout for a large install base + +## Example Prompts + +"How do I verify my app hasn't been tampered with?" +"DCAppAttestService attestKey keeps failing with serverUnavailable" +"How do I prevent users from claiming a free trial multiple times?" +"What's the difference between attestation and assertion?" +"How do I validate an attestation object on my server?" +"isSupported returns false — should I block the user?" +"We have 2M DAU, how do I roll out App Attest safely?" +"How do I detect if someone is creating fake app instances?" + +## Red Flags + +Signs you're headed for trouble: + +- **Validating app integrity on-device** — Modified apps control the runtime. Any local check can be patched out. Verification MUST happen server-side. +- **Not guarding with isSupported** — DCAppAttestService crashes on unsupported devices. Always check before calling any API. +- **Blocking users when isSupported returns false** — Some legitimate devices return false. Treat as risk signal, not hard block. +- **Reusing keys across multiple users on same device** — One key per user per device. Shared keys break account-level trust association. +- **Enabling App Attest for all users at once** — `attestKey` calls Apple's servers. At scale, rate limiting causes failures. Gradual rollout required (WWDC 2021-10244). +- **Using assertions for every API call** — Cryptographic cost per call. Reserve for sensitive operations (purchases, account changes), not routine fetches. +- **Discarding key on serverUnavailable error** — Transient Apple server issue. Retry with same key. Only discard on other errors. +- **Skipping counter validation on server** — Counter must be ever-increasing. Without this, replay attacks succeed. + +## Three Properties Verified + +App Attest proves three things about each request: + +| Property | What It Proves | How | +|----------|---------------|-----| +| Genuine device | Request comes from real Apple hardware | Hardware-backed key in Secure Enclave | +| Genuine app | Your app binary, unmodified | App identity hash in attestation | +| Untampered payload | Request data hasn't been altered | Digest signing in assertions | + +**Privacy design**: Anonymous. No hardware identifiers. Keys don't survive reinstall/migration/restore. Apple can't correlate across apps or users. + +## Key Generation + +```swift +import DeviceCheck + +func generateAppAttestKey(for userId: String) async throws -> String { + let service = DCAppAttestService.shared + + guard service.isSupported else { + // NOT an error — use as risk signal, not blocker + reportUnattestedDevice() + throw AppAttestError.unsupported + } + + let keyId = try await service.generateKey() + // Cache persistently — one key per user per device + UserDefaults.standard.set(keyId, forKey: "appAttestKeyId_\(userId)") + return keyId +} +``` + +**Key lifecycle**: One key per user per device. Cache `keyId` persistently (Keychain or UserDefaults). Keys don't survive reinstall, migration, or restore. App Clips share identity with full app. Generate new key on sign-out. + +## Attestation Flow + +Attestation registers the key with Apple and your server. Happens once per key. + +```dot +digraph attestation { + "Server issues\nchallenge" [shape=ellipse]; + "SHA256 hash\nchallenge" [shape=box]; + "attestKey API\n(Apple servers)" [shape=box]; + "Send attestation\nto your server" [shape=box]; + "Server validates\ncertificate chain" [shape=box]; + "Store public key\n+ key association" [shape=doublecircle]; + + "Error?" [shape=diamond]; + "serverUnavailable?" [shape=diamond]; + "Retry same key" [shape=box]; + "Discard key\ngenerate new" [shape=box]; + + "Server issues\nchallenge" -> "SHA256 hash\nchallenge"; + "SHA256 hash\nchallenge" -> "attestKey API\n(Apple servers)"; + "attestKey API\n(Apple servers)" -> "Error?" ; + "Error?" -> "Send attestation\nto your server" [label="success"]; + "Error?" -> "serverUnavailable?" [label="error"]; + "serverUnavailable?" -> "Retry same key" [label="yes"]; + "serverUnavailable?" -> "Discard key\ngenerate new" [label="no"]; + "Send attestation\nto your server" -> "Server validates\ncertificate chain"; + "Server validates\ncertificate chain" -> "Store public key\n+ key association"; +} +``` + +```swift +func attestKey(userId: String) async throws { + guard let keyId = storedKeyId(for: userId) else { + throw AppAttestError.noKey + } + + // 1. Get one-time challenge from YOUR server (minimum 16 bytes) + let challenge = try await server.fetchAttestationChallenge() + + // 2. Hash the challenge + let hash = Data(SHA256.hash(data: challenge)) + + // 3. Request attestation from Apple + do { + let attestation = try await service.attestKey(keyId, clientDataHash: hash) + // 4. Send attestation object to YOUR server for validation + try await server.verifyAttestation(attestation, keyId: keyId, challenge: challenge) + } catch DCError.serverUnavailable { + // Transient — retry with SAME key later + scheduleAttestationRetry(keyId: keyId, userId: userId) + } catch { + // Other error — key is compromised or invalid + // Discard and generate a new key + clearStoredKey(for: userId) + try await generateAndAttestNewKey(userId: userId) + } +} +``` + +**Challenge requirements**: Server-generated, single-use, minimum 16 bytes, short-lived (expire after minutes, not hours). + +## Assertion Flow + +Assertions prove ongoing request integrity. No Apple server involvement — on-device only. + +```swift +func assertRequest(payload: Data, userId: String) async throws -> Data { + guard let keyId = storedKeyId(for: userId) else { + throw AppAttestError.noKey + } + + // Hash the payload you want to protect + let hash = Data(SHA256.hash(data: payload)) + + // Generate assertion (on-device, no network) + let assertion = try await service.generateAssertion(keyId, clientDataHash: hash) + + // Send assertion + original payload to server + // Server verifies signature and checks counter + return assertion +} +``` + +**When to assert**: Reserve for moments that cost you money or trust if faked. + +| Assert | Don't Assert | +|--------|-------------| +| In-app purchases | Content fetches | +| Account changes (email, password) | Read-only API calls | +| Competitive actions (leaderboard scores) | Analytics events | +| Promotional claims (free trial) | UI configuration | +| Reward redemptions | Search queries | + +**Performance**: Secure Enclave operations. Fast enough for individual actions, expensive on every request. + +## Server-Side Validation + +Your server does the actual trust verification. The app only generates cryptographic material. + +### Attestation Validation (once per key) + +1. **Certificate chain** — Verify roots to Apple's App Attest root CA (Apple Private PKI) +2. **Nonce** — Recompute SHA256(challenge || clientDataHash), match against credential certificate +3. **App identity hash** — SHA256(teamId + "." + bundleId) must match your app +4. **Counter** — Store initial value (assertions increment from here) +5. **Key association** — Extract and store public key, associate with user account + +### Assertion Validation (per sensitive request) + +1. **Signature** — Verify using stored public key from attestation +2. **App identity hash** — Must match attestation's hash (prevents cross-app replay) +3. **Counter** — Must be strictly greater than last seen value (replay protection) +4. **Client data hash** — Recompute from request payload, must match what was signed + +**Counter is critical**: Without strictly-increasing counter validation, replay attacks succeed indefinitely. + +## Rollout Strategy + +From WWDC 2021-10244: `attestKey` makes a network call to Apple's servers. Apple rate-limits these calls per app. + +| Install Base | Recommended Ramp Time | +|-------------|----------------------| +| <100K DAU | Days | +| ~1M DAU | ~1 day gradual ramp | +| ~100M DAU | Weeks | +| ~1B DAU | 1+ month gradual ramp | + +### Gradual Enablement Pattern + +```swift +func shouldEnableAppAttest(userId: String) -> Bool { + guard DCAppAttestService.shared.isSupported else { return false } + // Server controls rollout percentage — start at 1%, ramp daily + return server.isAppAttestEnabled(for: userId) +} +``` + +**Rollout process**: Start at 1%. Monitor attestation success rate. If above 95%, double daily. If rate limiting errors spike, pause. Treat unattested requests as lower-trust during rollout (additional fraud signals), not blocked. + +## DeviceCheck Integration + +DeviceCheck stores 2 bits of state per device on Apple's servers. Different purpose from App Attest. + +| Feature | App Attest | DeviceCheck | +|---------|-----------|-------------| +| Purpose | Verify app integrity | Track per-device state | +| Survives reinstall | No | Yes (tied to hardware) | +| Apple servers | Attestation only | Every query | + +### Promotional Fraud Prevention + +```swift +import DeviceCheck + +func checkTrialEligibility() async throws -> Bool { + guard DCDevice.current.isSupported else { return true } + + let token = try await DCDevice.current.generateToken() + // Server calls Apple: POST https://api.devicecheck.apple.com/v1/query_two_bits + let state = try await server.queryDeviceState(token: token) + return !state.bit0 // bit0 = has claimed trial +} + +func markTrialClaimed() async throws { + let token = try await DCDevice.current.generateToken() + // Server calls Apple: POST https://api.devicecheck.apple.com/v1/update_two_bits + try await server.updateDeviceState(token: token, bit0: true) +} +``` + +**2 bits, your rules**: Apple stores bits + timestamp. Semantics are yours (e.g., bit0=trial claimed, bit1=abuse flagged). Reset on your schedule. Shared across all apps from the same developer team — coordinate meaning across your portfolio. + +## Risk Metric Service + +After attestation, redeem the receipt with Apple to get risk metrics: + +**Server-side**: POST receipt to `https://data.appattest.apple.com/v1/attestationData` (use `data-development.appattest.apple.com` for sandbox). Response includes approximate key count for the device. + +**How to use**: Most devices have 1-3 keys. High key counts signal an attacker creating many fake identities. Redeem periodically (Apple rate-limits), establish a baseline for your app, and combine with other fraud signals (velocity, behavioral analysis). + +## Anti-Rationalization Table + +| Rationalization | Why It Fails | What To Do Instead | +|----------------|-------------|-------------------| +| "We'll validate integrity on-device" | Modified apps control the runtime and can patch out any local check | All validation on your server. Device only generates crypto material. | +| "isSupported is always true on modern devices" | Some configurations and enterprise MDM setups return false | Always guard. Handle false as risk signal, not crash. | +| "One key per device is enough" | Multi-user devices need per-user keys for accurate account association | One key per user per device. New key on sign-out. | +| "We'll enable App Attest for everyone on launch day" | Apple rate-limits attestKey calls. Large install bases will see widespread failures. | Server-controlled gradual rollout. Monitor success rate. | +| "Assert every API call for maximum security" | Secure Enclave operations have real cost. Assertion latency on every request degrades UX. | Assert sensitive operations only. Use session tokens for routine calls. | +| "serverUnavailable means the key is bad" | It's a transient Apple server issue. Discarding the key forces re-attestation unnecessarily. | Retry with same key. Only discard on non-transient errors. | +| "We don't need counter validation" | Without strictly-increasing counters, replay attacks succeed indefinitely. | Store counter server-side. Reject assertions with counter <= last seen. | +| "DeviceCheck replaces App Attest" | DeviceCheck is 2-bit state storage, not integrity verification. Different threat models. | Use both: App Attest for integrity, DeviceCheck for per-device flags. | + +## Pressure Scenarios + +### Scenario 1: "Block users who fail attestation" + +**Pressure**: "If they can't attest, they're probably running a modified app. Block them." + +**Reality**: `isSupported` returns false on legitimate devices (older hardware, enterprise MDM, simulator). During rollout, most users simply haven't been enrolled yet. Blocking = blocking real customers. + +**Correct action**: Trust tiers on server. Attested = high trust. Unattested = lower trust with additional fraud signals. Never hard-block on attestation failure alone. + +**Push-back template**: "Some legitimate devices return isSupported=false. Let's use attestation as one signal in a risk score — high trust for attested, additional checks for unattested." + +### Scenario 2: "Enable App Attest for everyone at once" + +**Pressure**: "We've been building this for weeks. Ship it to everyone." + +**Reality**: `attestKey` calls Apple's servers. Apple rate-limits per app. At 5M DAU, flipping the switch causes a thundering herd — mass failures, error floods, confused users. WWDC 2021-10244 explicitly recommends gradual rollout. + +**Correct action**: Server-controlled rollout starting at 1%. At 5M DAU, expect ~1 week to full rollout. + +**Push-back template**: "Apple rate-limits attestKey calls — their WWDC session recommends gradual rollout. I'll set up server-side percentage control starting at 1%, ramping to 100% over about a week." + +## Checklist + +Before shipping App Attest: + +**Key Generation**: +- [ ] `isSupported` checked before any DCAppAttestService call +- [ ] Graceful handling when `isSupported` returns false (risk signal, not block) +- [ ] Key ID cached persistently per user +- [ ] One key per user per device (not shared) + +**Attestation**: +- [ ] Challenge from server is single-use, minimum 16 bytes, short-lived +- [ ] `serverUnavailable` retries with same key +- [ ] Other errors discard key and generate new +- [ ] Attestation object sent to server for validation (not validated on-device) + +**Assertion**: +- [ ] Used only for sensitive operations (not every API call) +- [ ] Payload hash covers the actual request data being protected +- [ ] Server validates signature with stored public key +- [ ] Server validates counter is strictly increasing + +**Server**: +- [ ] Certificate chain validated against Apple's App Attest root CA +- [ ] App identity hash (teamId + bundleId) verified +- [ ] Counter stored and checked for strict increase +- [ ] Public key associated with user account + +**Rollout**: +- [ ] Server-controlled percentage (not client-side) +- [ ] Gradual ramp with monitoring +- [ ] Unattested users handled gracefully (lower trust, not blocked) +- [ ] Rollback plan if attestation success rate drops + +## Resources + +**WWDC**: 2021-10244 + +**Docs**: /devicecheck, /devicecheck/establishing-your-app-s-integrity, /devicecheck/validating-apps-that-connect-to-your-server + +**Skills**: axiom-cryptokit \ No newline at end of file diff --git a/.claude/skills/axiom-app-attest/agents/openai.yaml b/.claude/skills/axiom-app-attest/agents/openai.yaml new file mode 100644 index 0000000..8c93230 --- /dev/null +++ b/.claude/skills/axiom-app-attest/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "App Attest" + short_description: "Implementing app integrity verification, preventing fraud with DCAppAttestService, validating requests from legitimat..." diff --git a/.claude/skills/axiom-app-composition/.openskills.json b/.claude/skills/axiom-app-composition/.openskills.json new file mode 100644 index 0000000..d09e194 --- /dev/null +++ b/.claude/skills/axiom-app-composition/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-app-composition", + "installedAt": "2026-04-12T08:05:43.422Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-app-composition/SKILL.md b/.claude/skills/axiom-app-composition/SKILL.md new file mode 100644 index 0000000..fef8500 --- /dev/null +++ b/.claude/skills/axiom-app-composition/SKILL.md @@ -0,0 +1,1462 @@ +--- +name: axiom-app-composition +description: Use when structuring app entry points, managing authentication flows, switching root views, handling scene lifecycle, or asking 'how do I structure my @main', 'where does auth state live', 'how do I prevent screen flicker on launch', 'when should I modularize' - app-level composition patterns for iOS 26+ +license: MIT +compatibility: iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, axiom-visionOS 26+. Xcode 26+ +metadata: + version: "1.0" +--- + +# App Composition + +## When to Use This Skill + +Use this skill when: +- Structuring your @main entry point and root view +- Managing authentication state (login → onboarding → main) +- Switching between app-level states without flicker +- Handling scene lifecycle events (scenePhase) +- Restoring app state after termination +- Deciding when to split into feature modules +- Coordinating between multiple windows (iPad, axiom-visionOS) + +## Example Prompts + +| What You Might Ask | Why This Skill Helps | +|--------------------|----------------------| +| "How do I switch between login and main screens?" | AppStateController pattern with validated transitions | +| "My app flickers when switching from splash to main" | Flicker prevention with animation coordination | +| "Where should auth state live?" | App-level state machine, not scattered booleans | +| "How do I handle app going to background?" | scenePhase lifecycle patterns | +| "When should I split my app into modules?" | Decision tree based on codebase size and team | +| "How do I restore state after app is killed?" | SceneStorage and state validation patterns | + +## Quick Decision Tree + +``` +What app-level architecture question are you solving? +│ +├─ How do I manage app states (loading, auth, main)? +│ └─ Part 1: App-Level State Machines +│ - Enum-based state with validated transitions +│ - AppStateController pattern +│ - Prevents "boolean soup" anti-pattern +│ +├─ How do I structure @main and root view switching? +│ └─ Part 2: Root View Switching Patterns +│ - Delegate to AppStateController (no logic in @main) +│ - Flicker prevention with animation +│ - Coordinator integration +│ +├─ How do I handle scene lifecycle? +│ └─ Part 3: Scene Lifecycle Integration +│ - scenePhase for session validation +│ - SceneStorage for restoration +│ - Multi-window coordination +│ +├─ When should I modularize? +│ └─ Part 4: Feature Module Basics +│ - Decision tree by size/team +│ - Module boundaries and DI +│ - Navigation coordination +│ +└─ What mistakes should I avoid? + └─ Part 5: Anti-Patterns + Part 6: Pressure Scenarios + - Boolean-based state + - Logic in @main + - Missing restoration validation +``` + +--- + +# Part 1: App-Level State Machines + +## Core Principle + +> "Apps have discrete states. Model them explicitly with enums, not scattered booleans." + +Every non-trivial app has distinct states: loading, unauthenticated, onboarding, authenticated, error recovery. These states should be: +1. **Explicit** — An enum, not multiple booleans +2. **Validated** — Transitions are checked and logged +3. **Centralized** — One source of truth +4. **Observable** — Views react to state changes + +## The Boolean Soup Problem + +```swift +// ❌ Boolean soup — impossible to validate, prone to invalid states +class AppState { + var isLoading = true + var isLoggedIn = false + var hasCompletedOnboarding = false + var hasError = false + var user: User? + + // What if isLoading && isLoggedIn && hasError are all true? + // Invalid state, but nothing prevents it +} +``` + +**Problems** +- No compile-time guarantee of valid states +- Easy to forget to update one boolean +- Testing requires checking all combinations +- Race conditions create impossible states + +## The AppStateController Pattern + +### Step 1: Define Explicit States + +```swift +enum AppState: Equatable { + case loading + case unauthenticated + case onboarding(OnboardingStep) + case authenticated(User) + case error(AppError) +} + +enum OnboardingStep: Equatable { + case welcome + case permissions + case profileSetup + case complete +} + +enum AppError: Equatable { + case networkUnavailable + case sessionExpired + case maintenanceMode +} +``` + +### Step 2: Create the Controller + +```swift +@Observable +@MainActor +class AppStateController { + private(set) var state: AppState = .loading + + // MARK: - State Transitions + + func transition(to newState: AppState) { + guard isValidTransition(from: state, to: newState) else { + assertionFailure("Invalid transition: \(state) → \(newState)") + logInvalidTransition(from: state, to: newState) + return + } + + let oldState = state + state = newState + logTransition(from: oldState, to: newState) + } + + // MARK: - Validation + + private func isValidTransition(from: AppState, to: AppState) -> Bool { + switch (from, to) { + // From loading + case (.loading, .unauthenticated): return true + case (.loading, .authenticated): return true + case (.loading, .error): return true + + // From unauthenticated + case (.unauthenticated, .onboarding): return true + case (.unauthenticated, .authenticated): return true + case (.unauthenticated, .error): return true + + // From onboarding + case (.onboarding, .onboarding): return true // Step changes + case (.onboarding, .authenticated): return true + case (.onboarding, .unauthenticated): return true // Cancelled + + // From authenticated + case (.authenticated, .unauthenticated): return true // Logout + case (.authenticated, .error): return true + + // From error + case (.error, .loading): return true // Retry + case (.error, .unauthenticated): return true + + default: return false + } + } + + // MARK: - Logging + + private func logTransition(from: AppState, to: AppState) { + #if DEBUG + print("AppState: \(from) → \(to)") + #endif + } + + private func logInvalidTransition(from: AppState, to: AppState) { + // Log to analytics for debugging + Analytics.log("InvalidStateTransition", properties: [ + "from": String(describing: from), + "to": String(describing: to) + ]) + } +} +``` + +### Step 3: Initialize from Storage + +```swift +extension AppStateController { + func initialize() async { + // Check for stored session + if let session = await SessionStorage.loadSession() { + // Validate session is still valid + do { + let user = try await AuthService.validateSession(session) + transition(to: .authenticated(user)) + } catch { + // Session expired or invalid + await SessionStorage.clearSession() + transition(to: .unauthenticated) + } + } else { + transition(to: .unauthenticated) + } + } +} +``` + +## State Machine Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ .loading │ +└────────────┬───────────────┬────────────────┬───────────────┘ + │ │ │ + ▼ ▼ ▼ + .unauthenticated .authenticated .error + │ │ │ + ▼ │ │ + .onboarding ─────────►│◄───────────────┘ + │ │ + └───────────────┘ +``` + +## Testing State Machines + +```swift +@Test func testValidTransitions() async { + let controller = AppStateController() + + // Loading → Unauthenticated (valid) + controller.transition(to: .unauthenticated) + #expect(controller.state == .unauthenticated) + + // Unauthenticated → Authenticated (valid) + let user = User(id: "1", name: "Test") + controller.transition(to: .authenticated(user)) + #expect(controller.state == .authenticated(user)) +} + +@Test func testInvalidTransitionRejected() async { + let controller = AppStateController() + + // Loading → Onboarding (invalid — must go through unauthenticated) + controller.transition(to: .onboarding(.welcome)) + #expect(controller.state == .loading) // Unchanged +} + +@Test func testSessionExpiredTransition() async { + let controller = AppStateController() + let user = User(id: "1", name: "Test") + controller.transition(to: .authenticated(user)) + + // Authenticated → Error (session expired) + controller.transition(to: .error(.sessionExpired)) + #expect(controller.state == .error(.sessionExpired)) + + // Error → Unauthenticated (force re-login) + controller.transition(to: .unauthenticated) + #expect(controller.state == .unauthenticated) +} +``` + +## The State-as-Bridge Pattern (WWDC 2025/266) + +From WWDC 2025's "Explore concurrency in SwiftUI": + +> "Find the boundaries between UI code that requires time-sensitive changes, and long-running async logic." + +The key insight: **synchronous state changes drive UI** (for animations), **async code lives in the model** (testable without SwiftUI), and **state bridges the two**. + +```swift +// ✅ State-as-Bridge: UI triggers state, model does async work +struct ColorExtractorView: View { + @State private var model = ColorExtractor() + + var body: some View { + Button("Extract Colors") { + // ✅ Synchronous state change triggers animation + withAnimation { model.isExtracting = true } + + // Async work happens in Task + Task { + await model.extractColors() + + // ✅ Synchronous state change ends animation + withAnimation { model.isExtracting = false } + } + } + .scaleEffect(model.isExtracting ? 1.5 : 1.0) + } +} + +@Observable +class ColorExtractor { + var isExtracting = false + var colors: [Color] = [] + + func extractColors() async { + // Heavy computation happens here, testable without SwiftUI + let extracted = await heavyComputation() + colors = extracted + } +} +``` + +**Why this matters for app composition** +- App-level state changes (loading → authenticated) should be **synchronous** +- Heavy work (session validation, data loading) should be **async in the model** +- This separation makes state machines testable without SwiftUI imports + +--- + +# Part 2: Root View Switching Patterns + +## Core Principle + +> "The @main entry point should be a thin shell. All logic belongs in AppStateController." + +## The Clean @main Pattern + +```swift +@main +struct MyApp: App { + @State private var appState = AppStateController() + + var body: some Scene { + WindowGroup { + RootView() + .environment(appState) + .task { + await appState.initialize() + } + } + } +} +``` + +**What @main does** +- Creates AppStateController +- Injects it via environment +- Triggers initialization + +**What @main does NOT do** +- Business logic +- Auth checks +- Conditional rendering +- Navigation decisions + +## RootView: The State Switch + +```swift +struct RootView: View { + @Environment(AppStateController.self) private var appState + + var body: some View { + Group { + switch appState.state { + case .loading: + LaunchView() + case .unauthenticated: + AuthenticationFlow() + case .onboarding(let step): + OnboardingFlow(step: step) + case .authenticated(let user): + MainTabView(user: user) + case .error(let error): + ErrorRecoveryView(error: error) + } + } + } +} +``` + +## Testability Benefit + +The thin-shell pattern enables up to 60x faster tests. When app logic lives in a Swift Package instead of the app target, tests run with `swift test` (~0.4s) vs `xcodebuild test` (~25s) — no simulator, no app launch. + +| Component | Location | Tested With | +|-----------|----------|-------------| +| Business logic, models, services | Swift Package (`MyAppCore`) | `swift test` (0.4s) | +| Root view composition | App target (thin shell) | `xcodebuild test` (25s) | + +See `axiom-swift-testing` Strategy 1 for the complete package extraction walkthrough. + +## Preventing Flicker During Transitions + +### Problem: Flash of Wrong Content + +When app state changes, you might see a flash of the old screen before the new one appears. This happens when: +- State changes before view is ready +- No transition animation +- Loading state too short to perceive + +### Solution: Animated Transitions + +```swift +struct RootView: View { + @Environment(AppStateController.self) private var appState + + var body: some View { + ZStack { + switch appState.state { + case .loading: + LaunchView() + .transition(.opacity) + case .unauthenticated: + AuthenticationFlow() + .transition(.opacity) + case .onboarding(let step): + OnboardingFlow(step: step) + .transition(.opacity) + case .authenticated(let user): + MainTabView(user: user) + .transition(.opacity) + case .error(let error): + ErrorRecoveryView(error: error) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.3), value: appState.state) + } +} +``` + +### Minimum Loading Duration + +For a polished experience, ensure the loading screen is visible long enough: + +```swift +extension AppStateController { + func initialize() async { + let startTime = Date() + + // Do actual initialization + await performInitialization() + + // Ensure minimum display time for loading screen + let elapsed = Date().timeIntervalSince(startTime) + let minimumDuration: TimeInterval = 0.5 + if elapsed < minimumDuration { + try? await Task.sleep(for: .seconds(minimumDuration - elapsed)) + } + } +} +``` + +## Coordinator Integration + +If using coordinators, integrate them at the root level: + +```swift +struct RootView: View { + @Environment(AppStateController.self) private var appState + @State private var authCoordinator = AuthCoordinator() + @State private var mainCoordinator = MainCoordinator() + + var body: some View { + Group { + switch appState.state { + case .loading: + LaunchView() + case .unauthenticated, .onboarding: + AuthenticationFlow() + .environment(authCoordinator) + case .authenticated(let user): + MainTabView(user: user) + .environment(mainCoordinator) + case .error(let error): + ErrorRecoveryView(error: error) + } + } + .animation(.easeInOut(duration: 0.3), value: appState.state) + } +} +``` + +--- + +# Part 3: Scene Lifecycle Integration + +## Core Principle + +> "Scene lifecycle events are app-wide concerns handled centrally, not scattered across features." + +## Understanding ScenePhase (Apple Documentation) + +ScenePhase indicates a scene's operational state. How you interpret the value depends on **where it's read**. + +**Read from a View** → Returns the phase of the enclosing scene +**Read from App** → Returns an **aggregate** value reflecting all scenes + +| Phase | Description | +|-------|-------------| +| `.active` | Scene is in the foreground and interactive | +| `.inactive` | Scene is in the foreground but should pause work | +| `.background` | Scene isn't visible; app may terminate soon | + +**Critical insight from Apple docs** When reading at the App level, `.active` means *any* scene is active, and `.background` means *all* scenes are in background. + +## scenePhase Handling + +```swift +@main +struct MyApp: App { + @State private var appState = AppStateController() + @Environment(\.scenePhase) private var scenePhase + + var body: some Scene { + WindowGroup { + RootView() + .environment(appState) + .task { + await appState.initialize() + } + } + .onChange(of: scenePhase) { oldPhase, newPhase in + handleScenePhaseChange(from: oldPhase, to: newPhase) + } + } + + private func handleScenePhaseChange(from: ScenePhase, to: ScenePhase) { + switch to { + case .active: + // App became active — validate session, refresh data + Task { + await appState.validateSession() + await appState.refreshIfNeeded() + } + + case .inactive: + // App about to go inactive — save state + appState.prepareForBackground() + + case .background: + // App in background — release resources + appState.releaseResources() + + @unknown default: + break + } + } +} +``` + +## Session Validation on Active + +```swift +extension AppStateController { + func validateSession() async { + guard case .authenticated(let user) = state else { return } + + do { + // Check if token is still valid + let isValid = try await AuthService.validateToken(user.token) + if !isValid { + transition(to: .error(.sessionExpired)) + } + } catch { + // Network error — keep authenticated but show warning + // Don't immediately log out on transient network issues + } + } + + func prepareForBackground() { + // Save any pending data + // Cancel non-essential network requests + // Prepare for potential termination + } + + func releaseResources() { + // Release cached images + // Stop location updates if not essential + // Reduce memory footprint + } +} +``` + +## SceneStorage for State Restoration + +From Apple documentation: SceneStorage provides automatic state restoration. The system manages saving and restoring on your behalf. + +**Key constraints** +- Keep data lightweight (not full models) +- Each Scene has its own storage (not shared) +- Data destroyed when scene is explicitly destroyed + +```swift +struct MainTabView: View { + @SceneStorage("selectedTab") private var selectedTab = 0 + @SceneStorage("lastViewedItemID") private var lastViewedItemID: String? + + var body: some View { + TabView(selection: $selectedTab) { + HomeTab() + .tag(0) + SearchTab() + .tag(1) + ProfileTab() + .tag(2) + } + .onAppear { + if let itemID = lastViewedItemID { + // Restore to last viewed item + navigateToItem(itemID) + } + } + } +} +``` + +### Navigation State Restoration (WWDC 2022/10054) + +For complex navigation, use a Codable NavigationModel: + +```swift +// Encapsulate navigation state with Codable conformance +class NavigationModel: ObservableObject, Codable { + @Published var selectedCategory: Category? + @Published var recipePath: [Recipe] = [] + + enum CodingKeys: String, CodingKey { + case selectedCategory + case recipePathIds + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory) + // Store only IDs, not full models + try container.encode(recipePath.map(\.id), forKey: .recipePathIds) + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.selectedCategory = try container.decodeIfPresent( + Category.self, forKey: .selectedCategory) + + let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds) + // compactMap discards deleted items gracefully + self.recipePath = recipePathIds.compactMap { DataModel.shared[$0] } + } + + var jsonData: Data? { + get { try? JSONEncoder().encode(self) } + set { + guard let data = newValue, + let model = try? JSONDecoder().decode(NavigationModel.self, from: data) + else { return } + self.selectedCategory = model.selectedCategory + self.recipePath = model.recipePath + } + } +} + +// Use with SceneStorage +struct ContentView: View { + @StateObject private var navModel = NavigationModel() + @SceneStorage("navigation") private var data: Data? + + var body: some View { + NavigationSplitView { /* ... */ } + .task { + if let data = data { + navModel.jsonData = data + } + for await _ in navModel.objectWillChangeSequence { + data = navModel.jsonData + } + } + } +} +``` + +**Key patterns from WWDC** +- Store **IDs only**, not full model objects +- Use `compactMap` to handle deleted items gracefully +- Save on every `objectWillChange` for real-time persistence + +### Validating Restored State + +Never trust restored state blindly: + +```swift +struct DetailView: View { + @SceneStorage("detailItemID") private var restoredItemID: String? + @State private var item: Item? + + var body: some View { + Group { + if let item { + ItemContent(item: item) + } else { + ProgressView() + } + } + .task { + if let itemID = restoredItemID { + // Validate item still exists + item = await ItemService.fetch(itemID) + if item == nil { + // Item was deleted — clear restoration + restoredItemID = nil + } + } + } + } +} +``` + +## Multi-Window Coordination (iPad, axiom-visionOS) + +From Apple documentation: Every window in a WindowGroup maintains **independent state**. The system allocates new storage for @State and @StateObject for each window. + +```swift +@main +struct MyApp: App { + @State private var appState = AppStateController() + + var body: some Scene { + // Primary window + WindowGroup { + MainView() + .environment(appState) + } + + // Data-presenting window (iPad) + // Prefer lightweight data (IDs, not full models) + WindowGroup("Detail", id: "detail", for: Item.ID.self) { $itemID in + if let itemID { + DetailView(itemID: itemID) + .environment(appState) + } + } + + #if os(visionOS) + // Immersive space + ImmersiveSpace(id: "immersive") { + ImmersiveView() + .environment(appState) + } + #endif + } +} +``` + +**Key behaviors from Apple docs** +- If a window with the same value already exists, the system **brings it to front** instead of opening a new one +- SwiftUI persists the binding value for state restoration +- Use unique identifier strings for each window group + +### Opening Additional Windows + +```swift +struct ItemRow: View { + let item: Item + @Environment(\.openWindow) private var openWindow + + var body: some View { + Button(item.title) { + // Open in new window on iPad + // Use ID to match window group, value to pass data + openWindow(id: "detail", value: item.id) + } + } +} +``` + +### Dismissing Windows Programmatically + +```swift +struct DetailView: View { + var itemID: Item.ID? + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack { + // ... + Button("Done") { + dismiss() // Closes this window + } + } + } +} +``` + +--- + +# Part 4: Feature Module Basics + +## Core Principle + +> "Split into modules when features have clear boundaries. Not before." + +Premature modularization creates overhead. Late modularization creates pain. Use this decision tree. + +## When to Modularize Decision Tree + +``` +Should I extract this feature into a module? +│ +├─ Is the codebase under 5,000 lines with 1-2 developers? +│ └─ NO modularization needed yet +│ Single target is fine, revisit at 10,000 lines +│ +├─ Is the codebase 5,000-20,000 lines with 3+ developers? +│ └─ CONSIDER modularization +│ Look for natural boundaries +│ +├─ Is the codebase over 20,000 lines? +│ └─ MODULARIZE for build times +│ Parallel compilation essential +│ +├─ Could this feature be used in multiple apps? +│ └─ EXTRACT to reusable module +│ Shared authentication, analytics, axiom-networking +│ +├─ Do multiple developers work on this feature daily? +│ └─ EXTRACT for merge conflict reduction +│ Isolated codebases = parallel work +│ +└─ Does the feature have clear input/output boundaries? + ├─ YES → Good candidate for module + └─ NO → Refactor boundaries first, then extract +``` + +## Module Boundary Pattern + +### Define a Public API + +```swift +// FeatureModule/Sources/FeatureModule/FeatureAPI.swift + +/// Public interface for the feature module +public protocol FeatureAPI { + /// Show the feature's main view + @MainActor + func makeMainView() -> AnyView + + /// Handle deep link into feature + @MainActor + func handleDeepLink(_ url: URL) -> Bool +} + +/// Factory to create feature with dependencies +public struct FeatureFactory { + public static func create( + analytics: AnalyticsProtocol, + networking: NetworkingProtocol + ) -> FeatureAPI { + FeatureImplementation( + analytics: analytics, + networking: axiom-networking + ) + } +} +``` + +### Internal Implementation + +```swift +// FeatureModule/Sources/FeatureModule/Internal/FeatureImplementation.swift + +internal class FeatureImplementation: FeatureAPI { + private let analytics: AnalyticsProtocol + private let networking: NetworkingProtocol + + internal init( + analytics: AnalyticsProtocol, + networking: NetworkingProtocol + ) { + self.analytics = analytics + self.networking = networking + } + + @MainActor + public func makeMainView() -> AnyView { + AnyView(FeatureMainView(viewModel: makeViewModel())) + } + + public func handleDeepLink(_ url: URL) -> Bool { + // Handle feature-specific deep links + return false + } + + private func makeViewModel() -> FeatureViewModel { + FeatureViewModel(analytics: analytics, axiom-networking: axiom-networking) + } +} +``` + +### Use in Main App + +```swift +// MainApp/Sources/App/AppDependencies.swift + +@Observable +class AppDependencies { + let analytics: AnalyticsProtocol + let networking: NetworkingProtocol + + // Lazy-created feature modules + lazy var profileFeature: FeatureAPI = { + ProfileFeatureFactory.create( + analytics: analytics, + networking: axiom-networking + ) + }() + + lazy var settingsFeature: FeatureAPI = { + SettingsFeatureFactory.create( + analytics: analytics, + networking: axiom-networking + ) + }() +} + +// MainApp/Sources/App/MainTabView.swift + +struct MainTabView: View { + @Environment(AppDependencies.self) private var dependencies + + var body: some View { + TabView { + dependencies.profileFeature.makeMainView() + .tabItem { Label("Profile", systemImage: "person") } + + dependencies.settingsFeature.makeMainView() + .tabItem { Label("Settings", systemImage: "gear") } + } + } +} +``` + +## Navigation Coordination Between Modules + +Features should not know about each other directly: + +```swift +// ❌ Feature knows about other features +struct ProfileView: View { + func showSettings() { + // ProfileView imports SettingsFeature — circular dependency risk + NavigationLink(value: SettingsDestination()) + } +} + +// ✅ Feature delegates navigation to coordinator +struct ProfileView: View { + let onShowSettings: () -> Void + + func showSettings() { + onShowSettings() // ProfileView doesn't know what happens + } +} + +// Coordinator wires features together +class MainCoordinator { + func showSettings(from profile: ProfileFeatureAPI) { + // Coordinator knows about both features + navigationPath.append(SettingsRoute()) + } +} +``` + +## Module Folder Structure + +``` +MyApp/ +├── App/ # Main app target +│ ├── MyApp.swift # @main entry point +│ ├── AppDependencies.swift # Dependency container +│ ├── AppStateController.swift # App state machine +│ └── Coordinators/ # Navigation coordinators +│ +├── Packages/ +│ ├── Core/ # Shared utilities +│ │ ├── Networking/ +│ │ ├── Analytics/ +│ │ └── Design/ # Design system +│ │ +│ ├── Features/ # Feature modules +│ │ ├── Profile/ +│ │ ├── Settings/ +│ │ └── Onboarding/ +│ │ +│ └── Domain/ # Business logic +│ ├── Models/ +│ └── Services/ +``` + +--- + +# Part 5: Anti-Patterns + +## Anti-Pattern 1: Boolean-Based State + +```swift +// ❌ Boolean soup — impossible to validate +class AppState { + var isLoading = true + var isLoggedIn = false + var hasCompletedOnboarding = false + var hasError = false +} + +// What if isLoading && isLoggedIn && hasError are all true? +``` + +**Fix** Use enum-based state (Part 1) + +```swift +// ✅ Explicit states — compiler prevents invalid combinations +enum AppState { + case loading + case unauthenticated + case onboarding(OnboardingStep) + case authenticated(User) + case error(AppError) +} +``` + +## Anti-Pattern 2: Logic in @main + +```swift +// ❌ Business logic in App entry point +@main +struct MyApp: App { + @State private var user: User? + @State private var isLoading = true + + var body: some Scene { + WindowGroup { + if isLoading { + LoadingView() + } else if let user { + MainView(user: user) + } else { + LoginView(onLogin: { self.user = $0 }) + } + } + .task { + user = await AuthService.getCurrentUser() + isLoading = false + } + } +} +``` + +**Problems** +- @main becomes bloated with logic +- Hard to test without launching app +- State scattered across multiple @State + +**Fix** Delegate to AppStateController (Part 2) + +```swift +// ✅ @main is a thin shell +@main +struct MyApp: App { + @State private var appState = AppStateController() + + var body: some Scene { + WindowGroup { + RootView() + .environment(appState) + .task { await appState.initialize() } + } + } +} +``` + +## Anti-Pattern 3: Missing State Validation on Restore + +```swift +// ❌ Trusts restored state blindly +.onAppear { + if let savedState = SceneStorage.appState { + appState.state = savedState // Token might be expired! + } +} +``` + +**Problems** +- Session could have expired +- User could have been logged out on another device +- Data could have been deleted + +**Fix** Validate before applying (Part 3) + +```swift +// ✅ Validates restored state +.task { + if let savedSession = await SessionStorage.loadSession() { + do { + let user = try await AuthService.validateSession(savedSession) + appState.transition(to: .authenticated(user)) + } catch { + // Session invalid — force re-login + await SessionStorage.clearSession() + appState.transition(to: .unauthenticated) + } + } +} +``` + +## Anti-Pattern 4: Navigation Logic Scattered Across Features + +```swift +// ❌ Every feature knows about every other feature +struct ProfileView: View { + @Environment(\.navigationPath) private var path + + func showSettings() { + path.append(SettingsDestination()) // ProfileView imports Settings + } + + func showOrderHistory() { + path.append(OrderHistoryDestination()) // ProfileView imports Orders + } +} +``` + +**Problems** +- Circular dependencies +- Hard to test navigation +- Changes ripple across modules + +**Fix** Delegate to coordinator (Part 4) + +```swift +// ✅ Feature delegates navigation decisions +struct ProfileView: View { + let onShowSettings: () -> Void + let onShowOrderHistory: () -> Void + + // ProfileView doesn't know what these do +} +``` + +## Anti-Pattern 5: God Coordinator + +```swift +// ❌ Single coordinator knows all features +class AppCoordinator { + func showProfile() { } + func showSettings() { } + func showOnboarding() { } + func showPayment() { } + func showChat() { } + func showOrderHistory() { } + func showNotifications() { } + // ... 50 more methods +} +``` + +**Problems** +- Massive file that everyone touches +- Merge conflicts +- Single point of failure + +**Fix** Scoped coordinators + +```swift +// ✅ Scoped coordinators for each domain +class AuthCoordinator { } // Login, signup, forgot password +class MainCoordinator { } // Tab navigation, main flows +class SettingsCoordinator { } // Settings navigation tree +class OrderCoordinator { } // Order flow, history, details +``` + +--- + +# Part 5b: UIKit Integration (Incremental Adoption) + +> For comprehensive bridging patterns (UIViewRepresentable, UIViewControllerRepresentable, UIHostingConfiguration, coordinators, lifecycle, gotchas), see `/skill axiom-uikit-bridging`. This section covers app-level integration strategy only. + +## When This Applies + +Most production iOS apps have existing UIKit code. Rewriting everything in SwiftUI is rarely practical. Use these patterns for incremental adoption. + +## UIHostingController — SwiftUI Inside UIKit + +Embed SwiftUI views in an existing UIKit navigation hierarchy: + +```swift +// Present a SwiftUI view from a UIKit view controller +let settingsView = SettingsView(store: store) +let hostingController = UIHostingController(rootView: settingsView) +navigationController?.pushViewController(hostingController, animated: true) +``` + +**Key rules**: +- `UIHostingController` owns the SwiftUI view's lifecycle — don't store the root view separately +- Use `sizingOptions: .intrinsicContentSize` when embedding as a child for correct Auto Layout sizing +- For sheets: `hostingController.modalPresentationStyle = .pageSheet` works naturally +- SwiftUI environment doesn't bridge automatically — inject dependencies through the root view's initializer + +## UIViewControllerRepresentable — UIKit Inside SwiftUI + +Wrap existing UIKit view controllers for use in SwiftUI: + +```swift +struct DocumentPickerView: UIViewControllerRepresentable { + @Binding var selectedURL: URL? + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.pdf]) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) { } + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + class Coordinator: NSObject, UIDocumentPickerDelegate { + let parent: DocumentPickerView + init(_ parent: DocumentPickerView) { self.parent = parent } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + parent.selectedURL = urls.first + } + } +} +``` + +**When to use**: Camera UI, document pickers, mail compose, any UIKit controller without a SwiftUI equivalent. + +## AppDelegate + SwiftUI @main + +Bridge `UIApplicationDelegate` callbacks into a SwiftUI app: + +```swift +@main +struct MyApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + RootView() + } + } +} + +class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Push notification registration, third-party SDK init, etc. + return true + } + + func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + // Forward to push notification service + } +} +``` + +**When to use**: Push notifications, third-party SDKs requiring AppDelegate, background URL sessions, Handoff. + +## Migration Priority + +When incrementally adopting SwiftUI in a UIKit app: + +1. **Leaf screens first** — Settings, About, detail views (no navigation complexity) +2. **New features in SwiftUI** — Don't rewrite, but build new screens in SwiftUI +3. **Shared components** — Build reusable SwiftUI components, wrap in `UIHostingController` +4. **Navigation last** — Don't mix `UINavigationController` with `NavigationStack` in the same flow; migrate entire navigation subtrees + +**Don't**: Replace `UINavigationController` with `NavigationStack` for half the app. Either a flow is fully SwiftUI navigation or fully UIKit navigation. + +--- + +# Part 6: Pressure Scenarios + +## Scenario 1: "Just hardcode the root for now" + +### The Pressure + +> "We only have one flow right now. Just show MainView directly, we'll add auth later." + +### Red Flags +- "We'll add X later" → Tech debt that compounds +- "It's just one flow" → Flows multiply +- "Keep it simple" → Simplicity now, complexity later + +### Time Cost Comparison + +| Option | Initial | When Adding Auth | Total | +|--------|---------|------------------|-------| +| Hardcode MainView | 0 min | 2-4 hours refactor | 2-4 hours | +| AppStateController | 30 min | 30 min add state | 1 hour | + +### Push-Back Script + +> "The AppStateController pattern takes 30 minutes now. When we add auth later — and we will — it'll take another 30 minutes to add the state. Hardcoding now saves 0 minutes because we'll spend 2-4 hours refactoring when we need auth. Let's invest 30 minutes now." + +### What to Do + +1. Create minimal AppStateController with two states: + ```swift + enum AppState { + case loading + case ready + } + ``` + +2. When auth is needed, add states: + ```swift + enum AppState { + case loading + case unauthenticated // Added + case authenticated(User) // Added + } + ``` + +3. Total effort: 1 hour instead of 4 hours + +--- + +## Scenario 2: "We don't need modules yet" + +### The Pressure + +> "Let's keep everything in one target. Modules are over-engineering." + +### Decision Framework + +| Codebase | Team | Recommendation | +|----------|------|----------------| +| < 5,000 lines | 1-2 devs | Single target is fine | +| 5,000-20,000 lines | 3+ devs | Consider modules | +| > 20,000 lines | Any | Modules essential | + +### Push-Back Script + +> "I agree modules add overhead. Let's use this decision tree: We have [X] lines and [Y] developers. Based on that, we [should/shouldn't] modularize yet. If we hit [threshold], we'll revisit. Sound good?" + +### What to Do + +1. Check codebase size: `find . -name "*.swift" | xargs wc -l` +2. If under threshold, document decision and threshold for revisit +3. If over threshold, identify natural boundaries first + +--- + +## Scenario 3: "Navigation is too complex to test" + +### The Pressure + +> "Testing navigation state is too hard. Let's just do manual QA." + +### Why This Fails + +- Navigation bugs are #1 "works on my machine" cause +- Deep linking requires automated verification +- State restoration needs regression testing +- Manual QA misses edge cases + +### Solution: Test the State Machine + +```swift +// ✅ Test navigation state without UI +@Test func testLoginCompletesOnboarding() async { + let controller = AppStateController() + controller.transition(to: .unauthenticated) + + // Simulate login + await controller.handleLogin(user: mockUser) + + // First-time user goes to onboarding + #expect(controller.state == .onboarding(.welcome)) +} + +@Test func testDeepLinkWhileUnauthenticated() async { + let controller = AppStateController() + controller.transition(to: .unauthenticated) + + // Deep link to order + let handled = controller.handleDeepLink(URL(string: "app://order/123")!) + + // Should not navigate — requires auth + #expect(handled == false) + #expect(controller.state == .unauthenticated) +} +``` + +### Push-Back Script + +> "Navigation is complex, which is exactly why we need automated tests. The AppStateController pattern lets us test state transitions without launching the UI. We can verify deep linking, auth flows, and restoration in seconds. Manual QA can't catch all the combinations." + +--- + +# Part 7: Code Review Checklist + +## App State + +- [ ] App state is an enum, not booleans +- [ ] All valid states are explicitly defined +- [ ] State transitions are validated in `isValidTransition` +- [ ] Invalid transitions are caught (assertion in debug, logged in prod) +- [ ] State changes are logged for debugging + +## Root View + +- [ ] @main delegates to AppStateController +- [ ] No business logic in @main +- [ ] RootView switches on single source of truth +- [ ] Transitions are animated (no flicker) +- [ ] Loading state has minimum display duration + +## Scene Lifecycle + +- [ ] scenePhase changes handled in one place +- [ ] Session validated on .active (not blindly trusted) +- [ ] Resources released on .background +- [ ] SceneStorage used for tab selection / navigation state +- [ ] Restored state validated before applying + +## Module Boundaries + +- [ ] Features have public API protocols +- [ ] No circular dependencies between modules +- [ ] Navigation delegates to coordinators +- [ ] Dependencies injected, not singletons +- [ ] Module decision documented (why split / not split) + +## Testing + +- [ ] State transitions tested without UI +- [ ] Invalid transitions tested +- [ ] Deep link handling tested +- [ ] Restoration validation tested + +--- + +## Resources + +**WWDC**: 2025-266, 2024-10150, 2023-10149, 2025-256, 2022-10054 + +**Docs**: /swiftui/scenephase, /swiftui/scene, /swiftui/scenestorage, /swiftui/windowgroup, /observation/observable() + +**Skills**: axiom-swiftui-architecture, axiom-swiftui-nav, axiom-swift-concurrency diff --git a/.claude/skills/axiom-app-composition/agents/openai.yaml b/.claude/skills/axiom-app-composition/agents/openai.yaml new file mode 100644 index 0000000..a7f618f --- /dev/null +++ b/.claude/skills/axiom-app-composition/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "App Composition" + short_description: "Structuring app entry points, managing authentication flows, switching root views, handling scene lifecycle, or askin..." diff --git a/.claude/skills/axiom-app-discoverability/.openskills.json b/.claude/skills/axiom-app-discoverability/.openskills.json new file mode 100644 index 0000000..0072276 --- /dev/null +++ b/.claude/skills/axiom-app-discoverability/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-app-discoverability", + "installedAt": "2026-04-12T08:05:44.178Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-app-discoverability/SKILL.md b/.claude/skills/axiom-app-discoverability/SKILL.md new file mode 100644 index 0000000..426eac5 --- /dev/null +++ b/.claude/skills/axiom-app-discoverability/SKILL.md @@ -0,0 +1,542 @@ +--- +name: axiom-app-discoverability +description: Use when making app surface in Spotlight search, Siri suggestions, or system experiences - covers the 6-step strategy combining App Intents, App Shortcuts, Core Spotlight, and NSUserActivity to feed the system metadata for iOS 16+ +license: MIT +metadata: + version: "1.0.0" +--- + +# App Discoverability + +## Overview + +**Core principle** Feed the system metadata across multiple APIs, let the system decide when to surface your app. + +iOS surfaces apps in Spotlight, Siri suggestions, and system experiences based on metadata you provide through App Intents, App Shortcuts, Core Spotlight, and NSUserActivity. The system learns from actual usage and boosts frequently-used actions. No single API is sufficient—comprehensive discoverability requires a multi-API strategy. + +**Key insight** iOS boosts shortcuts and activities that users actually invoke. If nobody uses an intent, the system hides it. Provide clear, action-oriented metadata and the system does the heavy lifting. + +--- + +## When to Use This Skill + +Use this skill when: +- Making your app appear in Spotlight search results +- Enabling Siri to suggest your app in relevant contexts +- Adding app actions to Action Button (iPhone/Apple Watch Ultra) +- Making app content discoverable system-wide +- Planning discoverability architecture before implementation +- Troubleshooting "why isn't my app being suggested?" + +Do NOT use this skill when: +- You need detailed API reference (use app-intents-ref, axiom-app-shortcuts-ref, axiom-core-spotlight-ref) +- You're implementing a specific API (use the reference skills) +- You just want to add a single App Intent (use app-intents-ref) + +--- + +## The 6-Step Discoverability Strategy + +This is a proven strategy from developers who've implemented discoverability across multiple production apps. **Implementation time: One evening for minimal viable discoverability.** + +### Step 1: Add App Intents + +App Intents power Spotlight search, Siri requests, and Shortcut suggestions. **Without AppIntents, your app will never surface meaningfully.** + +```swift +struct OrderCoffeeIntent: AppIntent { + static var title: LocalizedStringResource = "Order Coffee" + static var description = IntentDescription("Orders coffee for pickup") + + @Parameter(title: "Coffee Type") + var coffeeType: CoffeeType + + @Parameter(title: "Size") + var size: CoffeeSize + + func perform() async throws -> some IntentResult { + try await CoffeeService.shared.order(type: coffeeType, size: size) + return .result(dialog: "Your \(size) \(coffeeType) is ordered") + } +} +``` + +**Why this matters** App Intents are the foundation. Everything else builds on them. + +See: **app-intents-ref** for complete API reference + +--- + +### Step 2: Add App Shortcuts with Suggested Phrases + +App Shortcuts make your intents **instantly available** after install. No configuration required. + +```swift +struct CoffeeAppShortcuts: AppShortcutsProvider { + @AppShortcutsBuilder + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: OrderCoffeeIntent(), + phrases: [ + "Order coffee in \(.applicationName)", + "Get my usual coffee from \(.applicationName)" + ], + shortTitle: "Order Coffee", + systemImageName: "cup.and.saucer.fill" + ) + } + + static var shortcutTileColor: ShortcutTileColor = .tangerine +} +``` + +**Why this matters** Without App Shortcuts, users must manually configure shortcuts. With them, your actions appear immediately in Siri, Spotlight, Action Button, and Control Center. + +**Critical** Use `suggestedPhrase` patterns—this increases the chance that the system proposes them in Spotlight action suggestions and Siri's carousel. + +See: **app-shortcuts-ref** for phrase patterns and best practices + +--- + +### Step 3: Expose Searchable Content via Core Spotlight + +Index content that matters. **The system will surface items that match user queries.** + +```swift +import CoreSpotlight +import UniformTypeIdentifiers + +func indexOrder(_ order: Order) { + let attributes = CSSearchableItemAttributeSet(contentType: .item) + attributes.title = order.coffeeName + attributes.contentDescription = "Order from \(order.date.formatted())" + attributes.keywords = ["coffee", "order", order.coffeeName] + + let item = CSSearchableItem( + uniqueIdentifier: order.id.uuidString, + domainIdentifier: "orders", + attributeSet: attributes + ) + + CSSearchableIndex.default().indexSearchableItems([item]) { error in + if let error = error { + print("Indexing error: \(error)") + } + } +} +``` + +**Why this matters** Core Spotlight makes your app's content searchable. When users search for "latte" in Spotlight, your app's orders appear. + +**Index only what matters** Don't index everything. Focus on user-facing content (orders, documents, notes, etc.). + +See: **core-spotlight-ref** for batching, deletion patterns, and best practices + +--- + +### Step 4: Use NSUserActivity for High-Value Screens + +Mark important screens as eligible for search and prediction. + +```swift +func viewOrder(_ order: Order) { + let activity = NSUserActivity(activityType: "com.coffeeapp.viewOrder") + activity.title = order.coffeeName + activity.isEligibleForSearch = true + activity.isEligibleForPrediction = true + activity.persistentIdentifier = order.id.uuidString + + // Connect to App Intents + activity.appEntityIdentifier = order.id.uuidString + + // Provide rich metadata + let attributes = CSSearchableItemAttributeSet(contentType: .item) + attributes.contentDescription = "Your \(order.coffeeName) order" + attributes.thumbnailData = order.imageData + activity.contentAttributeSet = attributes + + activity.becomeCurrent() + + // In your view controller or SwiftUI view + self.userActivity = activity +} +``` + +**Why this matters** The system learns which screens users visit frequently and suggests them proactively. Lock screen widgets, Siri suggestions, and Spotlight all benefit. + +**Critical** Only mark screens that users would want to return to. Not settings, not onboarding, not error states. + +See: **core-spotlight-ref** for eligibility patterns and activity continuation + +--- + +### Step 5: Provide Correct Intent Metadata + +Clear descriptions and titles are critical because **Spotlight displays them directly.** + +#### ❌ DON'T: Generic or unclear +```swift +static var title: LocalizedStringResource = "Do Thing" +static var description = IntentDescription("Performs action") +``` + +#### ✅ DO: Specific, action-oriented +```swift +static var title: LocalizedStringResource = "Order Coffee" +static var description = IntentDescription("Orders coffee for pickup") +``` + +**Parameter summaries must be natural language:** + +```swift +static var parameterSummary: some ParameterSummary { + Summary("Order \(\.$size) \(\.$coffeeType)") +} +// Siri: "Order large latte" +``` + +**Why this matters** Poor metadata means users won't understand what your intent does. Clear metadata = higher usage = system boosts it. + +--- + +### Step 6: Usage-Based Boosting + +**The system boosts shortcuts and activities that users actually invoke. If nobody uses an intent, the system hides it.** + +This is automatic—you don't control it. What you control: +1. **Discoverability** — Make it easy to find (Steps 1-5) +2. **Utility** — Make it worth using (design good intents) +3. **Promotion** — Show users available shortcuts (SiriTipView) + +```swift +// Promote your shortcuts in-app +SiriTipView(intent: OrderCoffeeIntent(), isVisible: $showTip) + .siriTipViewStyle(.dark) +``` + +**Why this matters** Even perfect metadata won't help if users don't know shortcuts exist. Educate users in your app's UI. + +See: **app-shortcuts-ref** for SiriTipView and ShortcutsLink patterns + +--- + +## Decision Tree: Which API for Which Use Case + +``` +┌─ Need to expose app functionality? ────────────────────────────────┐ +│ │ +│ ┌─ YES → App Intents (AppIntent protocol) │ +│ │ └─ Want instant availability without user setup? │ +│ │ └─ YES → App Shortcuts (AppShortcutsProvider) │ +│ │ │ +│ └─ NO → Exposing app CONTENT (not actions)? │ +│ │ │ +│ ├─ User-initiated activity (viewing screen)? │ +│ │ └─ YES → NSUserActivity with isEligibleForSearch │ +│ │ │ +│ └─ Indexing all content (documents, orders, notes)? │ +│ └─ YES → Core Spotlight (CSSearchableItem) │ +│ │ +│ ┌─ Already using App Intents? │ +│ │ └─ Want automatic Spotlight search for entities? │ +│ │ └─ YES → IndexedEntity protocol │ +│ │ │ +│ └─ Want to connect screen to App Intent entity? │ +│ └─ YES → NSUserActivity.appEntityIdentifier │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### Quick Reference Table + +| Use Case | API | Example | +|----------|-----|---------| +| Expose action to Siri/Shortcuts | `AppIntent` | "Order coffee" | +| Make action available instantly | `AppShortcut` | Appear in Spotlight immediately | +| Index all app content | `CSSearchableItem` | All coffee orders searchable | +| Mark current screen important | `NSUserActivity` | User viewing order detail | +| Auto-generate Find actions | `IndexedEntity` | "Find orders where..." | +| Link screen to App Intent | `appEntityIdentifier` | Deep link to specific order | + +--- + +## Quick Implementation Pattern ("One Evening" Approach) + +For minimal viable discoverability: + +### 1. Define 1-3 Core App Intents (30 minutes) +```swift +// Your app's most valuable actions +struct OrderCoffeeIntent: AppIntent { /* ... */ } +struct ReorderLastIntent: AppIntent { /* ... */ } +struct ViewOrdersIntent: AppIntent { /* ... */ } +``` + +### 2. Create AppShortcutsProvider (15 minutes) +```swift +struct CoffeeAppShortcuts: AppShortcutsProvider { + @AppShortcutsBuilder + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: OrderCoffeeIntent(), + phrases: ["Order coffee in \(.applicationName)"], + shortTitle: "Order", + systemImageName: "cup.and.saucer.fill" + ) + // Add 2-3 more shortcuts + } +} +``` + +### 3. Index Top-Level Content (30 minutes) +```swift +// Index most recent/important content only +func indexRecentOrders() { + let recentOrders = try await OrderService.shared.recent(limit: 20) + let items = recentOrders.map { createSearchableItem(from: $0) } + CSSearchableIndex.default().indexSearchableItems(items) +} +``` + +### 4. Add NSUserActivity to Detail Screens (30 minutes) +```swift +// In your detail view controllers/views +let activity = NSUserActivity(activityType: "com.app.viewOrder") +activity.isEligibleForSearch = true +activity.becomeCurrent() +self.userActivity = activity +``` + +### 5. Test in Spotlight and Shortcuts (15 minutes) +- Open Shortcuts app → Search for your app → Verify shortcuts appear +- Search Spotlight → Search for your content → Verify results +- Invoke Siri → "Order coffee in [YourApp]" → Verify works + +**Total time: ~2 hours** for basic discoverability + +--- + +## Batch Indexing for Large Content Libraries + +When indexing 1,000+ items, index in batches to avoid launch slowdowns: + +```swift +func indexAllContent() async { + let allItems = try await ContentService.shared.all() + let batchSize = 100 + + for batch in stride(from: 0, to: allItems.count, by: batchSize) { + let slice = Array(allItems[batch.. [VisualSearchResult] { + // Match visual input to app entities + } +} + +// Each entity type needs an OpenIntent +struct OpenLandmarkIntent: OpenIntent { /* ... */ } +struct OpenCollectionIntent: OpenIntent { /* ... */ } +``` + +### Onscreen Entities + +Associate app entities with visible content so users can ask Siri or ChatGPT about what's on screen: + +```swift +struct LandmarkDetailView: View { + let landmark: LandmarkEntity + + var body: some View { + Group { /* View content */ } + .userActivity("com.landmarks.ViewingLandmark") { activity in + activity.title = "Viewing \(landmark.name)" + activity.appEntityIdentifier = EntityIdentifier(for: landmark) + } + } +} +``` + +--- + +## Core Concepts + +### The Three Building Blocks + +**1. AppIntent** — Executable actions with parameters +```swift +struct OrderSoupIntent: AppIntent { + static var title: LocalizedStringResource = "Order Soup" + static var description: IntentDescription = "Orders soup from the restaurant" + + @Parameter(title: "Soup") + var soup: SoupEntity + + @Parameter(title: "Quantity") + var quantity: Int? + + func perform() async throws -> some IntentResult { + guard let quantity = quantity, quantity < 10 else { + throw $quantity.needsValue("Please specify how many soups") + } + + try await OrderService.shared.order(soup: soup, quantity: quantity) + return .result() + } +} +``` + +**2. AppEntity** — Objects users interact with +```swift +struct SoupEntity: AppEntity { + var id: String + var name: String + var price: Decimal + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Soup" + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)", subtitle: "$\(price)") + } + + static var defaultQuery = SoupQuery() +} +``` + +**3. AppEnum** — Enumeration types for parameters +```swift +enum SoupSize: String, AppEnum { + case small + case medium + case large + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Size" + static var caseDisplayRepresentations: [SoupSize: DisplayRepresentation] = [ + .small: "Small (8 oz)", + .medium: "Medium (12 oz)", + .large: "Large (16 oz)" + ] +} +``` + +--- + +## AppIntent: Defining Actions + +### Essential Properties + +```swift +struct SendMessageIntent: AppIntent { + // REQUIRED: Short verb-noun phrase + static var title: LocalizedStringResource = "Send Message" + + // REQUIRED: Purpose explanation + static var description: IntentDescription = "Sends a message to a contact" + + // OPTIONAL: Discovery in Shortcuts/Spotlight + static var isDiscoverable: Bool = true + + // OPTIONAL: Launch app when run + static var openAppWhenRun: Bool = false + + // OPTIONAL: Authentication requirement + static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication +} +``` + +### Parameter Declaration + +```swift +struct BookAppointmentIntent: AppIntent { + // Required parameter (non-optional) + @Parameter(title: "Service") + var service: ServiceEntity + + // Optional parameter + @Parameter(title: "Preferred Date") + var preferredDate: Date? + + // Parameter with requestValueDialog for disambiguation + @Parameter(title: "Location", + requestValueDialog: "Which location would you like to visit?") + var location: LocationEntity + + // Parameter with default value + @Parameter(title: "Duration") + var duration: Int = 60 +} +``` + +### Parameter Summary (Siri Phrasing) + +```swift +struct OrderIntent: AppIntent { + @Parameter(title: "Item") + var item: MenuItem + + @Parameter(title: "Quantity") + var quantity: Int + + static var parameterSummary: some ParameterSummary { + Summary("Order \(\.$quantity) \(\.$item)") { + \.$quantity + \.$item + } + } +} +// Siri: "Order 2 lattes" +``` + +### The perform() Method + +```swift +func perform() async throws -> some IntentResult { + // 1. Validate parameters + guard quantity > 0 && quantity < 100 else { + throw ValidationError.invalidQuantity + } + + // 2. Execute action + let order = try await orderService.placeOrder( + item: item, + quantity: quantity + ) + + // 3. Donate for learning (optional) + await donation() + + // 4. Return result + return .result( + value: order, + dialog: "Your order for \(quantity) \(item.name) has been placed" + ) +} +``` + +### Error Handling + +```swift +enum OrderError: Error, CustomLocalizedStringResourceConvertible { + case outOfStock(itemName: String) + case paymentFailed + case networkError + + var localizedStringResource: LocalizedStringResource { + switch self { + case .outOfStock(let name): + return "Sorry, \(name) is out of stock" + case .paymentFailed: + return "Payment failed. Please check your payment method" + case .networkError: + return "Network error. Please try again" + } + } +} + +func perform() async throws -> some IntentResult { + if !item.isInStock { + throw OrderError.outOfStock(itemName: item.name) + } + // ... +} +``` + +--- + +## AppEntity: Representing App Content + +### Entity Definition + +```swift +struct BookEntity: AppEntity { + // REQUIRED: Unique, persistent identifier + var id: UUID + + // App data properties + var title: String + var author: String + var coverImageURL: URL? + + // REQUIRED: Type display name + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Book" + + // REQUIRED: Instance display + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(title)", + subtitle: "by \(author)", + image: coverImageURL.map { .init(url: $0) } + ) + } + + // REQUIRED: Query for resolution + static var defaultQuery = BookQuery() +} +``` + +### Exposing Properties + +```swift +struct TaskEntity: AppEntity { + var id: UUID + + @Property(title: "Title") + var title: String + + @Property(title: "Due Date") + var dueDate: Date? + + @Property(title: "Priority") + var priority: TaskPriority + + @Property(title: "Completed") + var isCompleted: Bool + + // Properties exposed to system for filtering/sorting +} +``` + +### Computed and Deferred Properties + +#### @ComputedProperty + +Computed properties that read directly from a source of truth (no stored value): + +```swift +struct SettingsEntity: UniqueAppEntity { + @ComputedProperty + var defaultPlace: PlaceDescriptor { + UserDefaults.standard.defaultPlace + } + + init() { } +} +``` + +#### @DeferredProperty + +Properties that are expensive to calculate, only fetched when explicitly requested: + +```swift +struct LandmarkEntity: IndexedEntity { + @DeferredProperty + var crowdStatus: Int { + get async throws { + await modelData.getCrowdStatus(self) + } + } +} +``` + +### Entity Query + +```swift +struct BookQuery: EntityQuery { + func entities(for identifiers: [UUID]) async throws -> [BookEntity] { + // Fetch entities by IDs + return try await BookService.shared.fetchBooks(ids: identifiers) + } + + func suggestedEntities() async throws -> [BookEntity] { + // Provide suggestions (recent, favorites, etc.) + return try await BookService.shared.recentBooks(limit: 10) + } +} + +// Optional: Enable string-based search +extension BookQuery: EntityStringQuery { + func entities(matching string: String) async throws -> [BookEntity] { + return try await BookService.shared.searchBooks(query: string) + } +} +``` + +### Separating Entities from Models + +#### ❌ DON'T: Modify core data models +```swift +// DON'T make your model conform to AppEntity +struct Book: AppEntity { // Bad - couples model to intents + var id: UUID + var title: String + // ... +} +``` + +#### ✅ DO: Create dedicated entities +```swift +// Your core model +struct Book { + var id: UUID + var title: String + var isbn: String + var pages: Int + // ... lots of internal properties +} + +// Separate entity for intents +struct BookEntity: AppEntity { + var id: UUID + var title: String + var author: String + + // Convert from model + init(from book: Book) { + self.id = book.id + self.title = book.title + self.author = book.author.name + } +} +``` + +--- + +## Authentication & Security + +### Authentication Policies + +```swift +struct ViewAccountIntent: AppIntent { + // No authentication required + static var authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed +} + +struct TransferMoneyIntent: AppIntent { + // Requires user to be logged in + static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication +} + +struct UnlockVaultIntent: AppIntent { + // Requires device unlock (Face ID/Touch ID/passcode) + static var authenticationPolicy: IntentAuthenticationPolicy = .requiresLocalDeviceAuthentication +} +``` + +--- + +## Background vs Foreground Execution + +### Intent Modes + +Use `supportedModes` for granular control over execution context instead of the boolean `openAppWhenRun`: + +```swift +struct GetCrowdStatusIntent: AppIntent { + static let supportedModes: IntentModes = [.background, .foreground(.dynamic)] + + func perform() async throws -> some ReturnsValue & ProvidesDialog { + guard await modelData.isOpen(landmark) else { + return .result(value: 0, dialog: "The landmark is currently closed.") + } + + if systemContext.currentMode.canContinueInForeground { + do { + try await continueInForeground(alwaysConfirm: false) + await navigator.navigateToCrowdStatus(landmark) + } catch { + // Opening app was denied + } + } + + let status = await modelData.getCrowdStatus(landmark) + return .result(value: status, dialog: "Current crowd level: \(status)") + } +} +``` + +#### Available Modes + +| Mode | Behavior | +|------|----------| +| `.background` | Performs entirely in background | +| `.foreground(.immediate)` | App foregrounded before `perform()` runs | +| `.foreground(.dynamic)` | Can request foreground during execution | +| `.foreground(.deferred)` | Background initially, foreground before completion | + +#### Common Combinations + +| Combination | Use When | +|-------------|----------| +| `[.background, .foreground]` | Foreground default, background fallback | +| `[.background, .foreground(.dynamic)]` | Background default, can request foreground | +| `[.background, .foreground(.deferred)]` | Background initially, guaranteed foreground when requested | + +### Continuing in Foreground + +Request foreground transition at runtime when using `.foreground(.dynamic)`: + +```swift +// Normal transition +try await continueInForeground(alwaysConfirm: false) + +// Transition after an error +throw needsToContinueInForegroundError( + IntentDialog("Need to open app to complete this action"), + alwaysConfirm: true +) +``` + +### Background Execution (Legacy) + +```swift +struct QuickToggleIntent: AppIntent { + static var openAppWhenRun: Bool = false // Runs in background + + func perform() async throws -> some IntentResult { + // Executes without opening app + await SettingsService.shared.toggle(setting: .darkMode) + return .result() + } +} +``` + +### Foreground Continuation (Legacy) + +```swift +struct EditDocumentIntent: AppIntent { + @Parameter(title: "Document") + var document: DocumentEntity + + func perform() async throws -> some IntentResult { + // Open app to continue in UI + return .result(opensIntent: OpenDocumentIntent(document: document)) + } +} + +struct OpenDocumentIntent: AppIntent { + static var openAppWhenRun: Bool = true + + @Parameter(title: "Document") + var document: DocumentEntity + + func perform() async throws -> some IntentResult { + // App is now foreground, safe to update UI + await MainActor.run { + DocumentCoordinator.shared.open(document: document) + } + return .result() + } +} +``` + +--- + +## Confirmation Dialogs + +### Requesting Confirmation + +```swift +struct DeleteTaskIntent: AppIntent { + @Parameter(title: "Task") + var task: TaskEntity + + func perform() async throws -> some IntentResult { + // Request confirmation before destructive action + try await requestConfirmation( + result: .result(dialog: "Are you sure you want to delete '\(task.title)'?"), + confirmationActionName: .init(stringLiteral: "Delete") + ) + + // User confirmed, proceed + try await TaskService.shared.delete(task: task) + return .result(dialog: "Task deleted") + } +} +``` + +--- + +## Multiple Choice API + +Request user input with structured options: + +```swift +let options = [ + IntentChoiceOption(title: "Option 1", subtitle: "Description 1"), + IntentChoiceOption(title: "Option 2", subtitle: "Description 2"), + IntentChoiceOption.cancel(title: "Not now") +] + +let choice = try await requestChoice( + between: options, + dialog: IntentDialog("Please select an option") +) + +switch choice.id { +case options[0].id: // Option 1 selected +case options[1].id: // Option 2 selected +default: // Cancelled +} +``` + +--- + +## Interactive Snippets + +### Static Snippets + +Return a SwiftUI view showing the outcome of an intent: + +```swift +func perform() async throws -> some IntentResult { + return .result(view: Text("Order placed!").font(.title)) +} +``` + +### SnippetIntent + +Return interactive snippets with follow-up action buttons: + +```swift +func perform() async throws -> some IntentResult { + let landmark = await findNearestLandmark() + + return .result( + value: landmark, + opensIntent: OpenLandmarkIntent(landmark: landmark), + snippetIntent: LandmarkSnippetIntent(landmark: landmark) + ) +} + +struct LandmarkSnippetIntent: SnippetIntent { + @Parameter var landmark: LandmarkEntity + + var snippet: some View { + VStack { + Text(landmark.name).font(.headline) + Text(landmark.description).font(.body) + + HStack { + Button("Add to Favorites") { /* action */ } + Button("Search Tickets") { /* action */ } + } + } + .padding() + } +} +``` + +--- + +## Swift Package Support + +### AppIntentsPackage + +Include App Intents in Swift Packages and static libraries: + +```swift +// In your framework or dynamic library +public struct LandmarksKitPackage: AppIntentsPackage { } + +// In your app target +struct LandmarksPackage: AppIntentsPackage { + static var includedPackages: [any AppIntentsPackage.Type] { + [LandmarksKitPackage.self] + } +} +``` + +This enables modular intent definitions across package boundaries. The app target aggregates all packages via `includedPackages`. + +--- + +## Apple Intelligence: Use Model Action + +### Overview + +The **Use Model action** in Shortcuts (iOS 18.1+) allows users to incorporate Apple Intelligence models into their automation workflows. Your app's entities can be passed to language models for filtering, transformation, and reasoning. + +**Key capability** Under the hood, the action passes a JSON representation of your entity to the model, so you'll want to make sure to expose any information you want it to be able to reason over, in the entity definition. + +### Three Output Types + +#### 1. Text (AttributedString) +- Models often respond with Rich Text (bold, italic, lists, tables) +- Use `AttributedString` type for text parameters to preserve formatting +- Enables lossless transfer from model to your app + +#### 2. Dictionary +- Structured data extraction from unstructured input +- Useful for parsing PDFs, emails, documents +- Example: Extract vendor, amount, date from invoice + +#### 3. App Entities (Your Types) +- Pass lists of entities to models for filtering/reasoning +- Model receives JSON representation of entities +- Example: "Filter calendar events related to my trip" + +### Exposing Entities to Models + +Models receive a JSON representation of your entities including: + +**1. All exposed properties** (converted to strings) +```swift +struct EventEntity: AppEntity { + var id: UUID + + @Property(title: "Title") + var title: String + + @Property(title: "Start Date") + var startDate: Date + + @Property(title: "End Date") + var endDate: Date + + @Property(title: "Notes") + var notes: String? + + // All @Property values included in JSON for model +} +``` + +**2. Type display representation** (hints what entity represents) +```swift +static var typeDisplayRepresentation: TypeDisplayRepresentation = "Calendar Event" +``` + +**3. Display representation** (title and subtitle) +```swift +var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(title)", + subtitle: "\(startDate.formatted())" + ) +} +``` + +#### Example JSON sent to model +```json +{ + "type": "Calendar Event", + "title": "Team Meeting", + "subtitle": "Jan 15, 2025 at 2:00 PM", + "properties": { + "Title": "Team Meeting", + "Start Date": "2025-01-15T14:00:00Z", + "End Date": "2025-01-15T15:00:00Z", + "Notes": "Discuss Q1 roadmap" + } +} +``` + +### Supporting Rich Text with AttributedString + +**Why it matters** If your app supports Rich Text content, now is the time to make sure your app intents use the attributed string type for text parameters where appropriate. + +#### ❌ DON'T: Use plain String +```swift +struct CreateNoteIntent: AppIntent { + @Parameter(title: "Content") + var content: String // Loses formatting from model +} +``` + +#### ✅ DO: Use AttributedString +```swift +struct CreateNoteIntent: AppIntent { + @Parameter(title: "Content") + var content: AttributedString // Preserves Rich Text + + func perform() async throws -> some IntentResult { + let note = Note(content: content) // Rich Text preserved + try await NoteService.shared.save(note) + return .result() + } +} +``` + +#### Real-world example from WWDC +Bear app's Create Note accepts AttributedString, allowing diary templates from ChatGPT to include: +- Bold headings +- Mood logging tables +- Formatted lists +- All preserved losslessly + +### Automatic Type Conversion + +When Use Model output connects to another action, the runtime automatically converts types: + +#### Example: Boolean for If actions +```swift +// User's shortcut: +// 1. Get notes created today +// 2. For each note: +// - Use Model: "Is this note related to developing features for Shortcuts?" +// - If [model output] = yes: +// - Add to Shortcuts Projects folder +``` + +Instead of returning verbose text like "Yes, this note seems to be about developing features for the Shortcuts app", the model automatically returns a Boolean (`true`/`false`) when connected to an If action. + +#### Explicit output types available +- Text (AttributedString) +- Number +- Boolean +- Dictionary +- Date +- App Entities + +### Follow-Up Feature + +Enable iterative refinement before passing to next action: + +```swift +// User runs shortcut: +// 1. Get recipe from Safari +// 2. Use Model: "Extract ingredients list" +// - Follow Up: enabled +// - User types: "Double the recipe" +// - Model adjusts: 800g flour instead of 400g +// 3. Add to Grocery List in Things app +``` + +#### When to use +- Recipe modifications (scale servings, substitute ingredients) +- Content refinement (adjust tone, length, style) +- Data validation (confirm extracted values before saving) + +--- + +## IndexedEntity: Automatic Find Actions + +### Overview + +**IndexedEntity** dramatically reduces boilerplate by auto-generating Find actions from your Spotlight integration. Instead of manually implementing `EntityQuery` and `EntityPropertyQuery`, adopt IndexedEntity to get: + +- Automatic Find action in Shortcuts +- Property-based filtering +- Search support +- Minimal code required + +### Basic Implementation + +```swift +struct EventEntity: AppEntity, IndexedEntity { + var id: UUID + + // 1. Properties with indexing keys + @Property(title: "Title", indexingKey: \.eventTitle) + var title: String + + @Property(title: "Start Date", indexingKey: \.startDate) + var startDate: Date + + @Property(title: "End Date", indexingKey: \.endDate) + var endDate: Date + + // 2. Custom key for properties without standard Spotlight attribute + @Property(title: "Notes", customIndexingKey: "eventNotes") + var notes: String? + + // Display representation automatically maps to Spotlight + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(title)", + subtitle: "\(startDate.formatted())" + // title → kMDItemTitle + // subtitle → kMDItemDescription + // image → kMDItemContentType (if provided) + ) + } + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event" +} +``` + +### Indexing Key Mapping + +#### Standard Spotlight attribute keys +```swift +// Common Spotlight keys for events +@Property(title: "Title", indexingKey: \.eventTitle) +var title: String + +@Property(title: "Start Date", indexingKey: \.startDate) +var startDate: Date + +@Property(title: "Location", indexingKey: \.eventLocation) +var location: String? +``` + +#### Custom keys for non-standard attributes +```swift +@Property(title: "Notes", customIndexingKey: "eventNotes") +var notes: String? + +@Property(title: "Attendee Count", customIndexingKey: "attendeeCount") +var attendeeCount: Int +``` + +### Auto-Generated Find Action + +With IndexedEntity conformance, users get this Find action automatically: + +#### In Shortcuts app +``` +Find Events where: + - Title contains "Team" + - Start Date is today + - Location is "San Francisco" +``` + +#### Without IndexedEntity, you'd need to manually implement +- `EnumerableEntityQuery` protocol +- `EntityPropertyQuery` protocol +- Property filters for each searchable field +- Search/suggestion logic + +**With IndexedEntity** Just add indexing keys, done! + +### Search Support + +Enable string-based search by implementing `EntityStringQuery`: + +```swift +extension EventEntityQuery: EntityStringQuery { + func entities(matching string: String) async throws -> [EventEntity] { + return try await EventService.shared.search(query: string) + } +} +``` + +Or rely on IndexedEntity + Spotlight for automatic search. + +### Explicit Spotlight Indexing + +For entities that need custom searchable attributes or manual index management: + +```swift +extension LandmarkEntity { + var searchableAttributes: CSSearchableItemAttributeSet { + let attributes = CSSearchableItemAttributeSet() + attributes.title = name + attributes.namedLocation = regionDescription + attributes.keywords = activities + attributes.latitude = NSNumber(value: coordinate.latitude) + attributes.longitude = NSNumber(value: coordinate.longitude) + attributes.supportsNavigation = true + return attributes + } +} + +// Add entities to index +func indexLandmarks() async { + let landmarks = await fetchLandmarks() + try await CSSearchableIndex.default().indexAppEntities(landmarks, priority: .normal) +} + +// Remove from index when deleted +func deleteLandmark(_ landmark: LandmarkEntity) async { + await dataStore.delete(landmark) + try await CSSearchableIndex.default().deleteAppEntities( + identifiedBy: [landmark.id], + ofType: LandmarkEntity.self + ) +} +``` + +### Example: Travel Tracking App + +Apple's sample code (App Intents Travel Tracking App) demonstrates IndexedEntity: + +```swift +struct TripEntity: AppEntity, IndexedEntity { + var id: UUID + + @Property(title: "Name", indexingKey: \.title) + var name: String + + @Property(title: "Start Date", indexingKey: \.startDate) + var startDate: Date + + @Property(title: "End Date", indexingKey: \.endDate) + var endDate: Date + + @Property(title: "Destination", customIndexingKey: "destination") + var destination: String + + // Auto-generated Find Trips action with filters for all properties +} +``` + +--- + +## Spotlight on Mac + +### Overview + +**Spotlight on Mac** (macOS Sequoia+) allows users to run your app's intents directly from system search. Intents that work in Shortcuts automatically work in Spotlight with proper configuration. + +**Key principle** Spotlight is all about running things quickly. To do that, people need to be able to provide all the information your intent needs to run directly in Spotlight. + +### Requirements for Spotlight Visibility + +#### 1. Parameter Summary Must Include All Required Parameters + +The parameter summary, which is what people will see in Spotlight UI, must contain all required parameters that don't have a default value. + +#### ❌ WON'T SHOW in Spotlight +```swift +struct CreateEventIntent: AppIntent { + static var title: LocalizedStringResource = "Create Event" + + @Parameter(title: "Title") + var title: String + + @Parameter(title: "Start Date") + var startDate: Date + + @Parameter(title: "End Date") + var endDate: Date + + @Parameter(title: "Notes") // Required, no default + var notes: String + + static var parameterSummary: some ParameterSummary { + Summary("Create '\(\.$title)' from \(\.$startDate) to \(\.$endDate)") + // Missing 'notes' parameter! + } +} +``` + +#### ✅ WILL SHOW in Spotlight (Option 1: Make optional) +```swift +@Parameter(title: "Notes") +var notes: String? // Optional - can omit from summary +``` + +#### ✅ WILL SHOW in Spotlight (Option 2: Provide default) +```swift +@Parameter(title: "Notes") +var notes: String = "" // Has default - can omit from summary +``` + +#### ✅ WILL SHOW in Spotlight (Option 3: Include in summary) +```swift +static var parameterSummary: some ParameterSummary { + Summary("Create '\(\.$title)' from \(\.$startDate) to \(\.$endDate)") { + \.$notes // All required params included + } +} +``` + +#### 2. Intent Must Not Be Hidden + +Intents hidden from Shortcuts won't appear in Spotlight: + +```swift +// ❌ Hidden from Spotlight +static var isDiscoverable: Bool = false + +// ❌ Hidden from Spotlight +static var assistantOnly: Bool = true + +// ❌ Hidden from Spotlight +// Intent with no perform() method (widget configuration only) +``` + +### Providing Suggestions + +Make parameter filling quick with suggestions: + +#### Option 1: Suggested Entities (Subset of Large List) +```swift +struct EventEntityQuery: EntityQuery { + func entities(for identifiers: [UUID]) async throws -> [EventEntity] { + return try await EventService.shared.fetchEvents(ids: identifiers) + } + + // Provide upcoming events, not all past/present events + func suggestedEntities() async throws -> [EventEntity] { + return try await EventService.shared.upcomingEvents(limit: 10) + } +} +``` + +#### Option 2: All Entities (Small, Bounded List) +```swift +struct TimezoneQuery: EnumerableEntityQuery { + func allEntities() async throws -> [TimezoneEntity] { + // Small list - provide all + return TimezoneEntity.allTimezones + } +} +``` + +**Use suggested entities when** List is large or unbounded (calendar events, notes, contacts) +**Use all entities when** List is small and bounded (timezones, priority levels, categories) + +### On-Screen Content Tagging + +Suggest currently active content: + +```swift +// In your detail view controller +func showEventDetail(_ event: Event) { + let activity = NSUserActivity(activityType: "com.myapp.viewEvent") + activity.persistentIdentifier = event.id.uuidString + + // Spotlight suggests this event for parameters + activity.appEntityIdentifier = event.id.uuidString + + userActivity = activity +} +``` + +For more details on on-screen content tagging, see the "Exploring New Advances in App Intents" session. + +### Search Beyond Suggestions + +**Basic filtering** (automatic): +If you provide suggestions, Spotlight automatically filters them as user types. + +**Deep search** (requires implementation): +For searching beyond suggestions: + +#### Option 1: EntityStringQuery +```swift +extension EventQuery: EntityStringQuery { + func entities(matching string: String) async throws -> [EventEntity] { + return try await EventService.shared.search(query: string) + } +} +``` + +#### Option 2: IndexedEntity +```swift +struct EventEntity: AppEntity, IndexedEntity { + // Spotlight search automatically supported +} +``` + +### Background vs Foreground Intents + +#### Pattern: Paired Intents with opensIntent + +```swift +// Background intent - runs without opening app +struct CreateEventIntent: AppIntent { + static var openAppWhenRun: Bool = false + + @Parameter(title: "Title") + var title: String + + @Parameter(title: "Start Date") + var startDate: Date + + func perform() async throws -> some IntentResult { + let event = try await EventService.shared.createEvent( + title: title, + startDate: startDate + ) + + // Optionally open app to view created event + return .result( + value: EventEntity(from: event), + opensIntent: OpenEventIntent(event: EventEntity(from: event)) + ) + } +} + +// Foreground intent - opens app to specific event +struct OpenEventIntent: AppIntent { + static var openAppWhenRun: Bool = true + + @Parameter(title: "Event") + var event: EventEntity + + func perform() async throws -> some IntentResult { + await MainActor.run { + EventCoordinator.shared.showEvent(id: event.id) + } + return .result() + } +} +``` + +#### User experience +1. User runs "Create Event" in Spotlight (background) +2. Event created without opening app +3. Spotlight shows "Open in App" button (opensIntent) +4. User taps button → App opens to event detail + +### Predictable Intent Protocol + +Enable Spotlight suggestions based on usage patterns: + +```swift +struct OrderCoffeeIntent: AppIntent, PredictableIntent { + static var title: LocalizedStringResource = "Order Coffee" + + @Parameter(title: "Coffee Type") + var coffeeType: CoffeeType + + @Parameter(title: "Size") + var size: CoffeeSize + + func perform() async throws -> some IntentResult { + // Order logic + return .result() + } +} +``` + +Spotlight learns when/how user runs this intent and surfaces suggestions proactively. + +--- + +## Automations on Mac + +### Overview + +**Personal Automations** arrive on macOS (macOS Sequoia+) with Mac-specific triggers: + +#### New Mac Automation Types +- **Folder Automation** — Trigger when files added/removed from folder +- **External Drive Automation** — Trigger when drive connected/disconnected +- Time of Day (from iOS) +- Bluetooth (from iOS) +- And more... + +**Example use case** Invoice processing shortcut runs automatically every time a new invoice is added to ~/Documents/Invoices folder. + +### Automatic Availability + +As long as your intent is available on macOS, they will also be available to use in Shortcuts to run as a part of Automations on Mac. This includes iOS apps that are installable on macOS. + +**No additional code required** — your existing intents work in automations automatically. + +### Platform Support + +```swift +struct ProcessInvoiceIntent: AppIntent { + static var title: LocalizedStringResource = "Process Invoice" + + // Available on macOS automatically + // Also works: iOS apps installed on Mac (Catalyst, Mac Catalyst) + + @Parameter(title: "Invoice") + var invoice: FileEntity + + func perform() async throws -> some IntentResult { + // Extract data, add to spreadsheet, etc. + return .result() + } +} +``` + +### Additional System Integration Points + +With automations, your intents are now accessible from: +- **Siri** — Voice commands +- **Shortcuts app** — Manual workflows +- **Spotlight** — Quick actions +- **Automations** — Triggered workflows +- **Action Button** — Hardware trigger (Apple Watch Ultra) +- **Control Center** — Quick controls +- **Widgets** — Interactive elements +- **Live Activities** — Dynamic Island + +--- + +## Assistant Schemas (Pre-built Intents) + +Apple provides pre-built schemas for common app categories: + +### Books App Example + +```swift +import AppIntents +import BooksIntents + +struct OpenBookIntent: BooksOpenBookIntent { + @Parameter(title: "Book") + var target: BookEntity + + func perform() async throws -> some IntentResult { + await MainActor.run { + BookReader.shared.open(book: target) + } + return .result() + } +} +``` + +### Available Assistant Schemas + +- **BooksIntents** — Navigate pages, open books, play audiobooks, search +- **BrowserIntents** — Bookmark tabs, clear history, manage windows +- **CameraIntents** — Capture modes, device switching, start/stop +- **EmailIntents** — Draft management, reply, forward, archive +- **PhotosIntents** — Album/asset management, editing, filtering +- **PresentationsIntents** — Slide creation, media insertion, playback +- **SpreadsheetsIntents** — Sheet management, content addition +- **DocumentsIntents** — File management, page manipulation, search + +--- + +## Testing & Debugging + +### Testing with Shortcuts App + +1. **Add intent to Shortcuts**: + - Open Shortcuts app + - Tap "+" to create new shortcut + - Search for your app name + - Select your intent + +2. **Test parameter resolution**: + - Fill in parameters + - Run shortcut + - Check Xcode console for logs + +3. **Test with Siri**: + - "Hey Siri, [your intent name]" + - Siri should prompt for parameters + - Verify dialog text and results + +### Xcode Intent Testing + +```swift +// In your app target, not tests +#if DEBUG +extension OrderSoupIntent { + static func testIntent() async throws { + let intent = OrderSoupIntent() + intent.soup = SoupEntity(id: "1", name: "Tomato", price: 8.99) + intent.quantity = 2 + + let result = try await intent.perform() + print("Result: \(result)") + } +} +#endif +``` + +### Common Debugging Issues + +#### Issue 1: Intent not appearing in Shortcuts +```swift +// ❌ Problem: isDiscoverable = false or missing +struct MyIntent: AppIntent { + // Missing isDiscoverable +} + +// ✅ Solution: Make discoverable +struct MyIntent: AppIntent { + static var isDiscoverable: Bool = true +} +``` + +#### Issue 2: Parameter not resolving +```swift +// ❌ Problem: Missing defaultQuery +struct ProductEntity: AppEntity { + var id: String + // Missing defaultQuery +} + +// ✅ Solution: Add query +struct ProductEntity: AppEntity { + var id: String + static var defaultQuery = ProductQuery() +} +``` + +#### Issue 3: Intent crashes in background +```swift +// ❌ Problem: Accessing MainActor from background +func perform() async throws -> some IntentResult { + UIApplication.shared.open(url) // Crash! MainActor only + return .result() +} + +// ✅ Solution: Use MainActor or openAppWhenRun +func perform() async throws -> some IntentResult { + await MainActor.run { + UIApplication.shared.open(url) + } + return .result() +} +``` + +#### Issue 4: Entity query returns empty results +```swift +// ❌ Problem: entities(for:) not implemented +struct BookQuery: EntityQuery { + // Missing entities(for:) implementation +} + +// ✅ Solution: Implement required methods +struct BookQuery: EntityQuery { + func entities(for identifiers: [UUID]) async throws -> [BookEntity] { + return try await BookService.shared.fetchBooks(ids: identifiers) + } + + func suggestedEntities() async throws -> [BookEntity] { + return try await BookService.shared.recentBooks(limit: 10) + } +} +``` + +--- + +## Best Practices + +### 1. Intent Naming + +#### ❌ DON'T: Generic or unclear +```swift +static var title: LocalizedStringResource = "Do Thing" +static var title: LocalizedStringResource = "Process" +``` + +#### ✅ DO: Verb-noun, specific +```swift +static var title: LocalizedStringResource = "Send Message" +static var title: LocalizedStringResource = "Book Appointment" +static var title: LocalizedStringResource = "Start Workout" +``` + +### 2. Parameter Summary + +#### ❌ DON'T: Technical or confusing +```swift +static var parameterSummary: some ParameterSummary { + Summary("Execute \(\.$action) with \(\.$target)") +} +``` + +#### ✅ DO: Natural language +```swift +static var parameterSummary: some ParameterSummary { + Summary("Send \(\.$message) to \(\.$contact)") +} +// Siri: "Send 'Hello' to John" +``` + +### 3. Error Messages + +#### ❌ DON'T: Technical jargon +```swift +throw MyError.validationFailed("Invalid parameter state") +``` + +#### ✅ DO: User-friendly +```swift +throw MyError.outOfStock("Sorry, this item is currently unavailable") +``` + +### 4. Entity Suggestions + +#### ❌ DON'T: Return all entities +```swift +func suggestedEntities() async throws -> [TaskEntity] { + return try await TaskService.shared.allTasks() // Could be thousands! +} +``` + +#### ✅ DO: Limit to recent/relevant +```swift +func suggestedEntities() async throws -> [TaskEntity] { + return try await TaskService.shared.recentTasks(limit: 10) +} +``` + +### 5. Async Operations + +#### ❌ DON'T: Block main thread +```swift +func perform() async throws -> some IntentResult { + let data = URLSession.shared.synchronousDataTask(url) // Blocks! + return .result() +} +``` + +#### ✅ DO: Use async/await +```swift +func perform() async throws -> some IntentResult { + let data = try await URLSession.shared.data(from: url) + return .result() +} +``` + +--- + +## Real-World Examples + +### Example 1: Start Workout Intent + +```swift +struct StartWorkoutIntent: AppIntent { + static var title: LocalizedStringResource = "Start Workout" + static var description: IntentDescription = "Starts a new workout session" + static var openAppWhenRun: Bool = true + + @Parameter(title: "Workout Type") + var workoutType: WorkoutType + + @Parameter(title: "Duration (minutes)") + var duration: Int? + + static var parameterSummary: some ParameterSummary { + Summary("Start \(\.$workoutType)") { + \.$duration + } + } + + func perform() async throws -> some IntentResult { + let workout = Workout( + type: workoutType, + duration: duration.map { TimeInterval($0 * 60) } + ) + + await MainActor.run { + WorkoutCoordinator.shared.start(workout) + } + + return .result( + dialog: "Starting \(workoutType.displayName) workout" + ) + } +} + +enum WorkoutType: String, AppEnum { + case running + case cycling + case swimming + case yoga + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Workout Type" + static var caseDisplayRepresentations: [WorkoutType: DisplayRepresentation] = [ + .running: "Running", + .cycling: "Cycling", + .swimming: "Swimming", + .yoga: "Yoga" + ] + + var displayName: String { + switch self { + case .running: return "running" + case .cycling: return "cycling" + case .swimming: return "swimming" + case .yoga: return "yoga" + } + } +} +``` + +### Example 2: Add Task with Entity Query + +```swift +struct AddTaskIntent: AppIntent { + static var title: LocalizedStringResource = "Add Task" + static var description: IntentDescription = "Creates a new task" + static var isDiscoverable: Bool = true + + @Parameter(title: "Title") + var title: String + + @Parameter(title: "List") + var list: TaskListEntity? + + @Parameter(title: "Due Date") + var dueDate: Date? + + static var parameterSummary: some ParameterSummary { + Summary("Add '\(\.$title)'") { + \.$list + \.$dueDate + } + } + + func perform() async throws -> some IntentResult { + let task = try await TaskService.shared.createTask( + title: title, + list: list?.id, + dueDate: dueDate + ) + + return .result( + value: TaskEntity(from: task), + dialog: "Task '\(title)' added" + ) + } +} + +struct TaskListEntity: AppEntity { + var id: UUID + var name: String + var color: String + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "List" + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(name)", + image: .init(systemName: "list.bullet") + ) + } + + static var defaultQuery = TaskListQuery() +} + +struct TaskListQuery: EntityQuery, EntityStringQuery { + func entities(for identifiers: [UUID]) async throws -> [TaskListEntity] { + return try await TaskService.shared.fetchLists(ids: identifiers) + } + + func suggestedEntities() async throws -> [TaskListEntity] { + // Provide user's favorite lists + return try await TaskService.shared.favoriteLists(limit: 5) + } + + func entities(matching string: String) async throws -> [TaskListEntity] { + return try await TaskService.shared.searchLists(query: string) + } +} +``` + +--- + +## App Intents Checklist + +### Before Submitting to App Store + +- ☐ All intents have clear, localized titles and descriptions +- ☐ Parameter summaries use natural language phrasing +- ☐ Error messages are user-friendly, not technical +- ☐ Authentication policies match data sensitivity +- ☐ Entity queries return reasonable suggestion counts (< 20) +- ☐ Intents marked `isDiscoverable` appear in Shortcuts +- ☐ Destructive actions request confirmation +- ☐ Background intents don't access MainActor +- ☐ Foreground intents set `openAppWhenRun = true` +- ☐ Entity `displayRepresentation` shows meaningful info +- ☐ Tested with Siri voice commands +- ☐ Tested in Shortcuts app +- ☐ Tested with different parameter combinations +- ☐ Verified localization for all supported languages + +--- + +## Resources + +**WWDC**: 2025-244, 2025-275, 2025-260 + +**Docs**: /appintents, /appintents/appintent, /appintents/appentity, /Updates/AppIntents + +**Skills**: axiom-app-shortcuts-ref, axiom-core-spotlight-ref, axiom-app-discoverability + +--- + +**Remember** App Intents are how users interact with your app through Siri, Shortcuts, and system features. Well-designed intents feel like a natural extension of your app's functionality and provide value across Apple's ecosystem. diff --git a/.claude/skills/axiom-app-intents-ref/agents/openai.yaml b/.claude/skills/axiom-app-intents-ref/agents/openai.yaml new file mode 100644 index 0000000..2077ea0 --- /dev/null +++ b/.claude/skills/axiom-app-intents-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "App Intents Reference" + short_description: "Integrating App Intents for Siri, Apple Intelligence, Shortcuts, Spotlight, or system experiences" diff --git a/.claude/skills/axiom-app-shortcuts-ref/.openskills.json b/.claude/skills/axiom-app-shortcuts-ref/.openskills.json new file mode 100644 index 0000000..8944e11 --- /dev/null +++ b/.claude/skills/axiom-app-shortcuts-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-app-shortcuts-ref", + "installedAt": "2026-04-12T08:05:45.553Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-app-shortcuts-ref/SKILL.md b/.claude/skills/axiom-app-shortcuts-ref/SKILL.md new file mode 100644 index 0000000..3159142 --- /dev/null +++ b/.claude/skills/axiom-app-shortcuts-ref/SKILL.md @@ -0,0 +1,828 @@ +--- +name: axiom-app-shortcuts-ref +description: Use when implementing App Shortcuts for instant Siri/Spotlight availability, configuring AppShortcutsProvider, adding suggested phrases, or debugging shortcuts not appearing - covers complete App Shortcuts API for iOS 16+ +license: MIT +metadata: + version: "1.0.0" +--- + +# App Shortcuts Reference + +## Overview + +Comprehensive guide to App Shortcuts framework for making your app's actions instantly available in Siri, Spotlight, Action Button, Control Center, and other system experiences. App Shortcuts are pre-configured App Intents that work immediately after app install—no user setup required. + +**Key distinction** App Intents are the actions; App Shortcuts are the pre-configured "surface" that makes those actions instantly discoverable system-wide. + +--- + +## When to Use This Skill + +Use this skill when: +- Implementing AppShortcutsProvider for your app +- Adding suggested phrases for Siri invocation +- Configuring instant Spotlight availability +- Creating parameterized shortcuts (skip Siri clarification) +- Using NegativeAppShortcutPhrase to prevent false positives (iOS 17+) +- Promoting shortcuts with SiriTipView +- Updating shortcuts dynamically with updateAppShortcutParameters() +- Debugging shortcuts not appearing in Shortcuts app or Spotlight +- Choosing between App Intents and App Shortcuts + +Do NOT use this skill for: +- General App Intents implementation (use app-intents-ref) +- Core Spotlight indexing (use core-spotlight-ref) +- Overall discoverability strategy (use app-discoverability) + +--- + +## Related Skills + +- **app-intents-ref** — Complete App Intents implementation reference +- **app-discoverability** — Strategic guide for making apps discoverable +- **core-spotlight-ref** — Core Spotlight and NSUserActivity integration + +--- + +## App Shortcuts vs App Intents + +| Aspect | App Intent | App Shortcut | +|--------|-----------|--------------| +| **Discovery** | Must be found in Shortcuts app | Instantly available after install | +| **Configuration** | User configures in Shortcuts | Pre-configured by developer | +| **Siri activation** | Requires custom phrase setup | Works immediately with provided phrases | +| **Spotlight** | Requires donation or IndexedEntity | Appears automatically | +| **Action button** | Not directly accessible | Can be assigned immediately | +| **Setup time** | Minutes per user | Zero | + +**When to use App Shortcuts** Every app should provide App Shortcuts for core functionality. They dramatically improve discoverability with zero user effort. + +--- + +## Core Concepts + +### AppShortcutsProvider Protocol + +**Required conformance** Your app must have exactly one type conforming to `AppShortcutsProvider`. + +```swift +struct MyAppShortcuts: AppShortcutsProvider { + // Required: Define your shortcuts + @AppShortcutsBuilder + static var appShortcuts: [AppShortcut] { get } + + // Optional: Branding color + static var shortcutTileColor: ShortcutTileColor { get } + + // Optional: Dynamic updates + static func updateAppShortcutParameters() + + // Optional: Negative phrases (iOS 17+) + static var negativePhrases: [NegativeAppShortcutPhrase] { get } +} +``` + +**Platform support** iOS 16+, iPadOS 16+, macOS 13+, tvOS 16+, watchOS 9+ + +--- + +### AppShortcut Structure + +Associates an `AppIntent` with spoken phrases and metadata. + +```swift +AppShortcut( + intent: StartMeditationIntent(), + phrases: [ + "Start meditation in \(.applicationName)", + "Begin mindfulness with \(.applicationName)" + ], + shortTitle: "Meditate", + systemImageName: "figure.mind.and.body" +) +``` + +**Components:** +- `intent` — The App Intent to execute +- `phrases` — Spoken/typed phrases for Siri/Spotlight +- `shortTitle` — Short label for Shortcuts app tiles +- `systemImageName` — SF Symbol for visual representation + +--- + +### AppShortcutPhrase (Suggested Phrases) + +**String interpolation** Phrases use `\(.applicationName)` to dynamically include your app's name. + +```swift +phrases: [ + "Start meditation in \(.applicationName)", + "Meditate with \(.applicationName)" +] +``` + +**User sees in Siri/Spotlight:** +- "Start meditation in Calm" +- "Meditate with Calm" + +**Why this matters** The system uses these exact phrases to trigger your intent via Siri and show suggestions in Spotlight. + +--- + +### @AppShortcutsBuilder + +Result builder for defining shortcuts array. + +```swift +@AppShortcutsBuilder +static var appShortcuts: [AppShortcut] { + AppShortcut(intent: OrderIntent(), /* ... */) + AppShortcut(intent: ReorderIntent(), /* ... */) + + if UserDefaults.standard.bool(forKey: "premiumUser") { + AppShortcut(intent: CustomizeIntent(), /* ... */) + } +} +``` + +**Result builder features:** +- Conditional shortcuts (if/else) +- Loop-generated shortcuts (for-in) +- Inline array construction + +--- + +## Phrase Template Patterns + +### Basic Phrases (No Parameters) + +```swift +AppShortcut( + intent: StartWorkoutIntent(), + phrases: [ + "Start workout in \(.applicationName)", + "Begin exercise with \(.applicationName)", + "Work out in \(.applicationName)" + ], + shortTitle: "Start Workout", + systemImageName: "figure.run" +) +``` + +**Benefits:** +- Simple, discoverable +- Works for all users +- No parameter ambiguity + +**Use when** Intent has no required parameters or parameters have defaults. + +--- + +### Parameterized Phrases (Skip Clarification) + +Pre-configure intents with specific parameter values to skip Siri's clarification step. + +```swift +// Intent with parameters +struct StartMeditationIntent: AppIntent { + static var title: LocalizedStringResource = "Start Meditation" + + @Parameter(title: "Type") + var meditationType: MeditationType? + + @Parameter(title: "Duration") + var duration: Int? +} + +// Shortcuts with different parameter combinations +@AppShortcutsBuilder +static var appShortcuts: [AppShortcut] { + // Generic version (will ask for parameters) + AppShortcut( + intent: StartMeditationIntent(), + phrases: ["Start meditation in \(.applicationName)"], + shortTitle: "Meditate", + systemImageName: "figure.mind.and.body" + ) + + // Specific versions (skip parameter step) + AppShortcut( + intent: StartMeditationIntent( + meditationType: .mindfulness, + duration: 10 + ), + phrases: [ + "Start quick mindfulness in \(.applicationName)", + "10 minute mindfulness in \(.applicationName)" + ], + shortTitle: "Quick Mindfulness", + systemImageName: "brain.head.profile" + ) + + AppShortcut( + intent: StartMeditationIntent( + meditationType: .sleep, + duration: 20 + ), + phrases: [ + "Start sleep meditation in \(.applicationName)" + ], + shortTitle: "Sleep Meditation", + systemImageName: "moon.stars.fill" + ) +} +``` + +**Benefits:** +- One-phrase completion (no follow-up questions) +- Better user experience for common use cases +- Spotlight shows specific shortcuts + +**Trade-off** More shortcuts = more visual clutter in Shortcuts app. Balance common cases (3-5 shortcuts) vs flexibility (generic shortcut with parameters). + +--- + +## NegativeAppShortcutPhrase (iOS 17+) + +Train the system to NOT invoke your app for certain phrases. + +```swift +struct MeditationAppShortcuts: AppShortcutsProvider { + @AppShortcutsBuilder + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: StartMeditationIntent(), + phrases: ["Start meditation in \(.applicationName)"], + shortTitle: "Meditate", + systemImageName: "figure.mind.and.body" + ) + } + + // Prevent false positives + static var negativePhrases: [NegativeAppShortcutPhrase] { + NegativeAppShortcutPhrases { + "Stop meditation" + "Cancel meditation" + "End session" + } + } +} +``` + +**When to use:** +- Phrases that sound similar to your shortcuts but mean the opposite +- Common phrases users might say that shouldn't trigger your app +- Disambiguation when multiple apps have similar capabilities + +**Platform** iOS 17.0+, iPadOS 17.0+, macOS 14.0+, tvOS 17.0+, watchOS 10.0+ + +--- + +## Discovery UI Components + +### SiriTipView — Promote Shortcuts In-App + +Display the spoken phrase for a shortcut directly in your app's UI. + +```swift +import AppIntents +import SwiftUI + +struct OrderConfirmationView: View { + @State private var showSiriTip = true + + var body: some View { + VStack { + Text("Order confirmed!") + + // Show Siri tip after successful order + SiriTipView(intent: ReorderIntent(), isVisible: $showSiriTip) + .siriTipViewStyle(.dark) + } + } +} +``` + +**Requirements:** +- Intent must be used in an AppShortcut (otherwise shows empty view) +- isVisible binding controls display state + +**Styles:** +- `.automatic` — Adapts to environment +- `.light` — Light background +- `.dark` — Dark background + +**Best practice** Show after users complete actions, suggesting easier ways next time. + +--- + +### ShortcutsLink — Link to Shortcuts App + +Opens your app's page in the Shortcuts app, listing all available shortcuts. + +```swift +import AppIntents +import SwiftUI + +struct SettingsView: View { + var body: some View { + List { + Section("Siri & Shortcuts") { + ShortcutsLink() + // Displays "Shortcuts" with standard link styling + } + } + } +} +``` + +**When to use:** +- Settings screen +- Help/Support section +- Onboarding flow + +**Benefits** Single tap takes users to see all your app's shortcuts, with suggested phrases visible. + +--- + +### ShortcutTileColor — Branding + +Set the color for your shortcuts in the Shortcuts app. + +```swift +struct CoffeeAppShortcuts: AppShortcutsProvider { + static var shortcutTileColor: ShortcutTileColor = .tangerine + + @AppShortcutsBuilder + static var appShortcuts: [AppShortcut] { + // ... + } +} +``` + +**Available colors:** +| Color | Use Case | +|-------|----------| +| `.blue` | Default, professional | +| `.tangerine` | Energy, food/beverage | +| `.purple` | Creative, meditation | +| `.teal` | Health, wellness | +| `.red` | Urgent, important | +| `.pink` | Lifestyle, social | +| `.navy` | Business, finance | +| `.yellow` | Productivity, notes | +| `.lime` | Fitness, outdoor | + +Full list: `.blue`, `.grape`, `.grayBlue`, `.grayBrown`, `.grayGreen`, `.lightBlue`, `.lime`, `.navy`, `.orange`, `.pink`, `.purple`, `.red`, `.tangerine`, `.teal`, `.yellow` + +**Choose color** that matches your app icon or brand identity. + +--- + +## Dynamic Updates + +### updateAppShortcutParameters() + +Call when parameter options change to refresh stored shortcuts. + +```swift +struct MeditationAppShortcuts: AppShortcutsProvider { + @AppShortcutsBuilder + static var appShortcuts: [AppShortcut] { + // Shortcuts can reference dynamic data + for session in MeditationData.favoriteSessions { + AppShortcut( + intent: StartSessionIntent(session: session), + phrases: ["Start \(session.name) in \(.applicationName)"], + shortTitle: session.name, + systemImageName: session.iconName + ) + } + } + + static func updateAppShortcutParameters() { + // Called automatically when needed + // Override only if you need custom behavior + } +} + +// In your app, when data changes +extension MeditationData { + func markAsFavorite(_ session: Session) { + favoriteSessions.append(session) + + // Update App Shortcuts to reflect new data + MeditationAppShortcuts.updateAppShortcutParameters() + } +} +``` + +**When to call:** +- User adds/removes favorites +- Available options change +- App data structure updates + +**Automatic invocation** The system calls this periodically, but you can force updates when you know data changed. + +--- + +## Complete Implementation Example + +### Step 1: Define App Intents + +```swift +import AppIntents + +struct OrderCoffeeIntent: AppIntent { + static var title: LocalizedStringResource = "Order Coffee" + static var description = IntentDescription("Orders coffee for pickup") + + @Parameter(title: "Coffee Type") + var coffeeType: CoffeeType + + @Parameter(title: "Size") + var size: CoffeeSize + + @Parameter(title: "Customizations") + var customizations: String? + + static var parameterSummary: some ParameterSummary { + Summary("Order \(\.$size) \(\.$coffeeType)") { + \.$customizations + } + } + + func perform() async throws -> some IntentResult { + let order = try await CoffeeService.shared.order( + type: coffeeType, + size: size, + customizations: customizations + ) + + return .result( + value: order, + dialog: "Your \(size) \(coffeeType) is ordered for pickup" + ) + } +} + +struct ReorderLastIntent: AppIntent { + static var title: LocalizedStringResource = "Reorder Last Coffee" + static var description = IntentDescription("Reorders your most recent coffee") + static var openAppWhenRun: Bool = false + + func perform() async throws -> some IntentResult { + guard let lastOrder = try await CoffeeService.shared.lastOrder() else { + throw CoffeeError.noRecentOrders + } + + try await CoffeeService.shared.reorder(lastOrder) + + return .result( + dialog: "Reordering your \(lastOrder.coffeeName)" + ) + } +} + +enum CoffeeType: String, AppEnum { + case latte, cappuccino, americano, espresso + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Coffee" + static var caseDisplayRepresentations: [CoffeeType: DisplayRepresentation] = [ + .latte: "Latte", + .cappuccino: "Cappuccino", + .americano: "Americano", + .espresso: "Espresso" + ] +} + +enum CoffeeSize: String, AppEnum { + case small, medium, large + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Size" + static var caseDisplayRepresentations: [CoffeeSize: DisplayRepresentation] = [ + .small: "Small", + .medium: "Medium", + .large: "Large" + ] +} +``` + +--- + +### Step 2: Create AppShortcutsProvider + +```swift +import AppIntents + +struct CoffeeAppShortcuts: AppShortcutsProvider { + + @AppShortcutsBuilder + static var appShortcuts: [AppShortcut] { + // Generic order (will ask for parameters) + AppShortcut( + intent: OrderCoffeeIntent(), + phrases: [ + "Order coffee in \(.applicationName)", + "Get coffee from \(.applicationName)" + ], + shortTitle: "Order", + systemImageName: "cup.and.saucer.fill" + ) + + // Common specific orders (skip parameter step) + AppShortcut( + intent: OrderCoffeeIntent( + coffeeType: .latte, + size: .medium + ), + phrases: [ + "Order my usual from \(.applicationName)", + "Get my regular coffee from \(.applicationName)" + ], + shortTitle: "Usual Order", + systemImageName: "star.fill" + ) + + // Reorder last + AppShortcut( + intent: ReorderLastIntent(), + phrases: [ + "Reorder coffee from \(.applicationName)", + "Order again from \(.applicationName)" + ], + shortTitle: "Reorder", + systemImageName: "arrow.clockwise" + ) + } + + // Branding + static var shortcutTileColor: ShortcutTileColor = .tangerine + + // Prevent false positives (iOS 17+) + static var negativePhrases: [NegativeAppShortcutPhrase] { + NegativeAppShortcutPhrases { + "Cancel coffee order" + "Stop coffee" + } + } +} +``` + +--- + +### Step 3: Promote in UI + +```swift +import SwiftUI +import AppIntents + +struct OrderConfirmationView: View { + @State private var showReorderTip = true + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.green) + + Text("Order Placed!") + .font(.title) + + Text("Your coffee will be ready in 10 minutes") + .foregroundColor(.secondary) + + // Promote reorder shortcut + if showReorderTip { + SiriTipView(intent: ReorderLastIntent(), isVisible: $showReorderTip) + .siriTipViewStyle(.dark) + .padding(.top) + } + + // Link to see all shortcuts + Section { + ShortcutsLink() + } header: { + Text("See all available shortcuts") + .font(.caption) + } + } + .padding() + } +} +``` + +--- + +## Where App Shortcuts Appear + +Once implemented, your App Shortcuts are available in: + +| Location | User Experience | +|----------|-----------------| +| **Siri** | Voice activation with provided phrases | +| **Spotlight** | Search for action or phrase → Instant execution | +| **Shortcuts app** | Pre-populated shortcuts, zero configuration | +| **Action Button** (iPhone 15 Pro) | Assignable to hardware button | +| **Apple Watch Ultra** | Action Button assignment | +| **Control Center** | Add shortcuts as controls | +| **Lock Screen widgets** | Quick actions without unlocking | +| **Apple Pencil Pro** | Squeeze gesture assignment | +| **Focus Filters** | Contextual filtering | + +**Instant availability** All locations work immediately after app install. No user setup required. + +--- + +## Testing & Debugging + +### Verify Shortcuts Appear in Shortcuts App + +1. Build and run your app on device +2. Open Shortcuts app +3. Tap "+" to create new shortcut +4. Search for your app name +5. Verify shortcuts appear with correct titles and icons + +**If shortcuts don't appear:** +- Ensure AppShortcutsProvider is in your main app target +- Check that `isDiscoverable` is true for the AppIntents (default) +- Rebuild and reinstall app +- Check console for AppShortcuts errors + +--- + +### Test Siri Invocation + +1. Invoke Siri +2. Say one of your suggested phrases +3. Verify Siri executes the intent + +**Example**: +- You: "Order coffee in CoffeeApp" +- Siri: "What size and type?" +- You: "Medium latte" +- Siri: "Your medium latte is ordered for pickup" + +**If Siri doesn't recognize phrase:** +- Check phrase includes `\(.applicationName)` +- Verify phrase is in appShortcuts array +- Try simpler phrases (3-6 words ideal) +- Avoid complex grammar or rare words + +--- + +### Test Spotlight Discovery + +1. Swipe down to open Spotlight +2. Type your app name or shortcut phrase +3. Verify shortcut appears in results +4. Tap to execute + +**If shortcut doesn't appear in Spotlight:** +- Wait a few minutes (indexing delay) +- Restart device +- Check System Settings → Siri & Search → [Your App] → Show App in Search + +--- + +### Debug with Console Logs + +```swift +#if DEBUG +struct CoffeeAppShortcuts: AppShortcutsProvider { + @AppShortcutsBuilder + static var appShortcuts: [AppShortcut] { + let shortcuts = [ + AppShortcut(/* ... */), + // ... + ] + + print("📱 Registered \(shortcuts.count) App Shortcuts") + shortcuts.forEach { shortcut in + print(" - \(shortcut.shortTitle)") + } + + return shortcuts + } +} +#endif +``` + +**Check Xcode console** after app launch to verify shortcuts are registered. + +--- + +## Best Practices + +### 1. Phrase Design + +#### ❌ DON'T: Long, complex phrases +```swift +phrases: [ + "I would like to order a coffee from \(.applicationName) please" +] +``` + +#### ✅ DO: Short, natural phrases +```swift +phrases: [ + "Order coffee in \(.applicationName)", + "Get coffee from \(.applicationName)" +] +``` + +**Guidelines:** +- 3-6 words ideal +- Start with verb (Order, Start, Get, Show) +- Include `\(.applicationName)` for disambiguation +- Use natural language users would actually say + +--- + +### 2. Shortcut Quantity + +#### ❌ DON'T: Provide 20+ shortcuts +```swift +// Bad: Overwhelming +AppShortcut for every possible combination +``` + +#### ✅ DO: Focus on 3-5 core actions +```swift +// Good: Focused on common tasks +AppShortcut(intent: OrderIntent(), /* ... */) +AppShortcut(intent: ReorderIntent(), /* ... */) +AppShortcut(intent: ViewOrdersIntent(), /* ... */) +``` + +**Why** Too many shortcuts creates clutter. Focus on high-value, frequently-used actions. + +--- + +### 3. Parameter Combinations + +#### ❌ DON'T: Parameterize every variant +```swift +// Bad: Creates 12 shortcuts (3 sizes × 4 types) +for size in CoffeeSize.allCases { + for type in CoffeeType.allCases { + AppShortcut(intent: OrderIntent(type: type, size: size), /* ... */) + } +} +``` + +#### ✅ DO: Provide generic + top 2-3 common cases +```swift +// Good: Generic + common specific cases +AppShortcut(intent: OrderIntent(), /* ... */) // Generic +AppShortcut(intent: OrderIntent(type: .latte, size: .medium), /* ... */) // Usual +AppShortcut(intent: OrderIntent(type: .espresso, size: .small), /* ... */) // Quick +``` + +--- + +### 4. Short Titles + +#### ❌ DON'T: Verbose or redundant +```swift +shortTitle: "Order Coffee from Coffee App" +``` + +#### ✅ DO: Concise and clear +```swift +shortTitle: "Order" +``` + +**Context** App name already appears in Shortcuts app, so no need to repeat. + +--- + +### 5. System Images + +#### ❌ DON'T: Use custom images +```swift +// Not supported +shortImage: UIImage(named: "custom") +``` + +#### ✅ DO: Use SF Symbols +```swift +systemImageName: "cup.and.saucer.fill" +``` + +**Why** SF Symbols scale properly, support dark mode, and integrate with system UI. + +--- + +## Resources + +**WWDC**: 2022-10170, 2022-10169, 260 + +**Docs**: /appintents/appshortcutsprovider, /appintents/appshortcut, /appintents/app-shortcuts + +**Skills**: axiom-app-intents-ref, axiom-app-discoverability, axiom-core-spotlight-ref + +--- + +**Remember** App Shortcuts make your app's functionality instantly available across iOS. Define 3-5 core shortcuts with natural phrases, promote them in your UI with SiriTipView, and users can invoke them immediately via Siri, Spotlight, Action Button, and more. diff --git a/.claude/skills/axiom-app-shortcuts-ref/agents/openai.yaml b/.claude/skills/axiom-app-shortcuts-ref/agents/openai.yaml new file mode 100644 index 0000000..a09d71b --- /dev/null +++ b/.claude/skills/axiom-app-shortcuts-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "App Shortcuts Reference" + short_description: "Implementing App Shortcuts for instant Siri/Spotlight availability, configuring AppShortcutsProvider, adding suggeste..." diff --git a/.claude/skills/axiom-app-store-connect-ref/.openskills.json b/.claude/skills/axiom-app-store-connect-ref/.openskills.json new file mode 100644 index 0000000..7f42c04 --- /dev/null +++ b/.claude/skills/axiom-app-store-connect-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-app-store-connect-ref", + "installedAt": "2026-04-12T08:05:46.228Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-app-store-connect-ref/SKILL.md b/.claude/skills/axiom-app-store-connect-ref/SKILL.md new file mode 100644 index 0000000..f2443e8 --- /dev/null +++ b/.claude/skills/axiom-app-store-connect-ref/SKILL.md @@ -0,0 +1,347 @@ +--- +name: axiom-app-store-connect-ref +description: Use when navigating App Store Connect to find crash data, read TestFlight feedback, interpret metrics dashboards, or export diagnostic logs. Covers crash-free rates, dSYM symbolication, termination types, MetricKit. +license: MIT +metadata: + version: "1.0.0" +--- + +# App Store Connect Reference + +## Overview + +App Store Connect (ASC) provides crash reports, TestFlight feedback, and performance metrics for your apps. This reference covers how to navigate ASC to find and export crash data for analysis. + +#### ASC vs Xcode Organizer + +| Task | Best Tool | +|------|-----------| +| Quick crash triage during development | Xcode Organizer | +| Team-wide crash visibility | App Store Connect | +| TestFlight feedback with screenshots | App Store Connect | +| Historical metrics and trends | App Store Connect | +| Downloading crash logs for analysis | Either (ASC has better export) | +| Symbolication | Xcode Organizer | + +--- + +## Navigating to Crash Data + +### Path to Crashes + +``` +App Store Connect +└── My Apps + └── [Your App] + └── Analytics + └── Crashes +``` + +**Direct URL pattern:** `https://appstoreconnect.apple.com/analytics/app/[APP_ID]/crashes` + +### Crashes Dashboard Sections + +1. **Filters bar** — Platform, Version, Date Range, Compare +2. **Crash-Free Users graph** — Daily percentage trend line +3. **Crash Count by Version** — Bar chart comparing versions +4. **Top Crash Signatures** — Ranked by share percentage, shows exception type and function name + +### Key Metrics Explained + +| Metric | What It Means | +|--------|---------------| +| **Crash-Free Users** | Percentage of daily active users who didn't experience a crash | +| **Crash Count** | Total number of crash reports received | +| **Crash Rate** | Crashes per 1,000 sessions | +| **Affected Devices** | Number of unique devices that crashed | +| **Crash Signature** | Grouped crashes with same stack trace | + +### Filtering Options + +| Filter | Use Case | +|--------|----------| +| **Platform** | iOS, iPadOS, macOS, watchOS, tvOS | +| **Version** | Drill into specific app versions | +| **Date Range** | Last 7/30/90 days or custom range | +| **Compare** | Compare crash rates between versions | +| **Device** | Filter by iPhone model, iPad, etc. | +| **OS Version** | Find OS-specific crashes | + +--- + +## Viewing Individual Crash Reports + +### Crash Signature Detail + +Each crash signature shows: +- **Header** — Exception type and affected device/crash share counts, first seen date +- **Exception Information** — Type (e.g., EXC_BAD_ACCESS), codes, address +- **Crashed Thread** — Stack frames with binary, function, and offset +- **Distribution** — Breakdown by iOS version and device model + +### Downloading Crash Logs + +1. Click on a crash signature +2. Look for **Download Logs** button (top right) +3. Select format: + - **.ips** (JSON format, iOS 15+) + - **.crash** (text format, legacy) +4. Use `crash-analyzer` agent to parse: `/axiom:analyze-crash` + +--- + +## TestFlight Feedback + +### Path to Feedback + +``` +App Store Connect +└── My Apps + └── [Your App] + └── TestFlight + └── Feedback +``` + +### Feedback Entry Contents + +Each feedback submission includes: + +| Field | Description | +|-------|-------------| +| **Screenshot** | What the tester saw (often most valuable) | +| **Comment** | Tester's description of the issue | +| **App Version** | Exact TestFlight build number | +| **Device Model** | iPhone 15 Pro Max, iPad Air, etc. | +| **OS Version** | iOS 17.2.1, etc. | +| **Battery Level** | Low battery can affect behavior | +| **Available Disk** | Low disk can cause write failures | +| **Network Type** | WiFi vs Cellular | +| **Locale** | Language and region settings | +| **Timestamp** | When submitted | + +### Feedback Filtering + +| Filter | Use Case | +|--------|----------| +| **Build** | Focus on specific TestFlight builds | +| **Date** | Recent feedback first | +| **Has Screenshot** | Find visual issues quickly | + +### Limitation: No Reply + +TestFlight feedback is **one-way**. You cannot respond to testers through ASC. For follow-up: + +- Contact through TestFlight group email +- Add in-app feedback mechanism +- Include your email in TestFlight notes + +--- + +## Metrics Dashboard + +### Path to Metrics + +``` +App Store Connect +└── My Apps + └── [Your App] + └── Analytics + └── Metrics +``` + +### Available Metrics Categories + +| Category | What It Shows | +|----------|---------------| +| **Crashes** | Crash-free users, crash count, top signatures | +| **Hang Rate** | Main thread hangs > 250ms | +| **Disk Writes** | Excessive disk I/O patterns | +| **Launch Time** | App startup performance | +| **Memory** | Peak memory usage, terminations | +| **Battery** | Energy usage during foreground/background | +| **Scrolling** | Scroll hitch rate | + +### Terminations (Non-Crash Kills) + +The Metrics dashboard shows terminations that don't produce crash reports: + +| Termination Type | Cause | +|------------------|-------| +| **Memory Limit** | Jetsam killed app for memory pressure | +| **CPU Limit (Background)** | Exceeded background CPU quota | +| **Launch Timeout** | App took too long to launch | +| **Background Task Timeout** | Background task exceeded time limit | + +### Comparing Versions + +Use the **Compare** filter to see: + +- Did crash rate improve or regress? +- Which version introduced a spike? +- Performance trends over releases + +--- + +## Exporting Data + +### Manual Export + +1. Navigate to Crashes or Metrics +2. Use date range filter to select period +3. Click **Export** (if available) or download individual crash logs + +### App Store Connect API + +For automated export, use the [App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi): + +```bash +# Get crash diagnostic insights +GET /v1/apps/{id}/perfPowerMetrics + +# Authentication requires API key from ASC +# Users and Access → Keys → App Store Connect API +``` + +**API capabilities:** + +| Endpoint | Data | +|----------|------| +| `perfPowerMetrics` | Performance and power metrics | +| `diagnosticSignatures` | Crash signature aggregates | +| `diagnosticLogs` | Individual crash logs | +| `betaTesters` | TestFlight tester info | +| `betaFeedback` | TestFlight feedback entries | + +### MCP-Powered Access + +If **asc-mcp** is configured, you can access ASC data programmatically from Claude Code: + +| Manual ASC Action | asc-mcp Tool | +|-------------------|-------------| +| View crash metrics | `metrics_app_perf`, `metrics_build_diagnostics` | +| Download crash logs | `metrics_get_diagnostic_logs` | +| List TestFlight testers | `builds_get_beta_testers` | +| View app reviews | `reviews_list`, `reviews_stats` | +| Respond to reviews | `reviews_create_response` | +| Check build status | `builds_get_processing_state` | +| Export sales data | `analytics_sales_report` (requires vendor_number) | + +**Setup and workflows**: `/skill axiom-asc-mcp` + +### Xcode Cloud Integration + +If using Xcode Cloud, crash data integrates with CI/CD: + +- View crashes per workflow run +- Compare crash rates between branches +- Automated alerts on crash spikes + +--- + +## Best Practices + +### Daily Monitoring + +1. Check crash-free users percentage +2. Review any new crash signatures +3. Monitor for version-to-version regressions + +### Crash Triage Priority + +| Priority | Criteria | +|----------|----------| +| **P0 - Critical** | >1% of users affected, data loss risk | +| **P1 - High** | >0.5% affected, user-facing impact | +| **P2 - Medium** | <0.5% affected, workaround exists | +| **P3 - Low** | Rare, edge case, no impact | + +### Correlating with Releases + +After each release: + +1. Wait 24-48 hours for crash data to populate +2. Compare crash-free rate to previous version +3. Investigate any new top crash signatures +4. Check TestFlight feedback for user reports + +--- + +## Common Questions + +### Why don't I see crashes in ASC? + +| Cause | Fix | +|-------|-----| +| Too recent | Wait 24 hours for processing | +| No users yet | Need active installs to report | +| User opted out | Requires device analytics sharing | +| Build not distributed | Must be TestFlight or App Store | + +### Why are crashes unsymbolicated? + +ASC crashes should auto-symbolicate if you uploaded dSYMs during distribution. **dSYM files** contain the debug symbols that map memory addresses back to function names and line numbers. + +**Verify dSYMs were uploaded:** +1. Xcode → Window → Organizer → Archives → select build +2. Right-click → "Show in Finder" → right-click `.xcarchive` → "Show Package Contents" +3. Check `dSYMs/` folder contains `.dSYM` bundles + +**Manual symbolication workflow:** +```bash +# 1. Download .ips file from ASC (Crashes → signature → Download Logs) + +# 2. Find the binary UUID from the crash report +grep --after-context=2 "Binary Images" crash.ips +# Look for: 0x100000000 - 0x100ffffff MyApp arm64 + +# 3. Locate matching dSYM on your machine +mdfind "com_apple_xcode_dsym_uuids == " + +# 4. Symbolicate an address +atos -arch arm64 -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \ + -l 0x100000000 0x100045abc +# Output: -[UserManager currentUser] (UserManager.m:42) +``` + +**Common symbolication failures:** + +| Symptom | Cause | Fix | +|---------|-------|-----| +| All addresses unsymbolicated | dSYMs not uploaded | Re-upload from Xcode Organizer | +| Only your code unsymbolicated | dSYM UUID mismatch | Rebuild from same commit | +| System frameworks unsymbolicated | Normal for device-specific | Use `atos` with device support files | +| Bitcode builds unsymbolicated | Apple recompiled binary | Download dSYMs from ASC: Xcode → Organizer → Download Debug Symbols | + +See `crash-analyzer` agent for automated parsing: `/axiom:analyze-crash` + +### ASC vs Organizer: Which stack trace is better? + +Both show the same data, but: +- **Organizer** integrates with Xcode projects (click to jump to code) +- **ASC** better for team-wide visibility and historical trends + +--- + +## Field Diagnostics with MetricKit + +For device-level crash diagnostics, hang call stacks, and custom telemetry beyond ASC's aggregated dashboards, see `axiom-metrickit-ref`. + +**Key difference**: ASC shows aggregated trends for team visibility. MetricKit provides per-device diagnostics you can correlate with your own telemetry. + +--- + +## Related + +**Skills**: axiom-testflight-triage (Xcode Organizer workflows), axiom-asc-mcp (programmatic ASC access via MCP) + +**Agents**: crash-analyzer (automated crash log parsing) + +**Commands**: `/axiom:analyze-crash` + +--- + +## Resources + +**WWDC:** 2020-10076, 2020-10078, 2021-10203, 2021-10258 + +**Docs:** /app-store-connect/api, /xcode/diagnosing-issues-using-crash-reports-and-device-logs diff --git a/.claude/skills/axiom-app-store-connect-ref/agents/openai.yaml b/.claude/skills/axiom-app-store-connect-ref/agents/openai.yaml new file mode 100644 index 0000000..798305a --- /dev/null +++ b/.claude/skills/axiom-app-store-connect-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "App Store Connect Reference" + short_description: "Navigating App Store Connect to find crash data, read TestFlight feedback, interpret metrics dashboards, or export di..." diff --git a/.claude/skills/axiom-app-store-diag/.openskills.json b/.claude/skills/axiom-app-store-diag/.openskills.json new file mode 100644 index 0000000..635674c --- /dev/null +++ b/.claude/skills/axiom-app-store-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-app-store-diag", + "installedAt": "2026-04-12T08:05:46.842Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-app-store-diag/SKILL.md b/.claude/skills/axiom-app-store-diag/SKILL.md new file mode 100644 index 0000000..d3857f0 --- /dev/null +++ b/.claude/skills/axiom-app-store-diag/SKILL.md @@ -0,0 +1,1202 @@ +--- +name: axiom-app-store-diag +description: Use when app is rejected by App Review, submission blocked, or appeal needed - systematic diagnosis from rejection message to fix with guideline-specific remediation patterns and appeal writing +license: MIT +metadata: + version: "1.0.0" +--- + +# App Store Rejection Diagnostics + +## Overview + +Systematic App Store rejection diagnosis and remediation. 9 diagnostic patterns covering the most common rejection categories including technical, metadata, privacy, business, subjective, and safety violations. + +**Core principle** Most App Store rejections fall into well-known categories. Reading the rejection message carefully and mapping to the correct guideline prevents the #1 mistake: fixing the wrong thing and getting rejected again for the same reason. + +Most developers waste 1-2 weeks on rejection cycles because they skim the rejection message, assume the cause, and "fix" something that wasn't the problem. This skill provides systematic diagnosis from rejection message to targeted fix. + +## Red Flags — Suspect Submission Issue + +If you see ANY of these, suspect a submission issue and use this skill: + +- Rejection message cites a specific guideline number +- "Binary Rejected" without clear guideline (technical gate failure) +- Same app rejected multiple times for different reasons +- "Metadata Rejected" (no code change needed) +- Rejection mentions "privacy" or "data collection" +- Rejection mentions "login" or "authentication" +- Reviewer asks for demo account or more information + +- ❌ **FORBIDDEN** "The reviewer is wrong, let's just resubmit" + - Re-read the rejection. App Review is right 95% of the time. + - Resubmitting without changes wastes 3-7 days per cycle. + - If you genuinely disagree, use the appeal process (Pattern 7). + +## Mandatory First Steps + +**ALWAYS do these BEFORE changing any code:** + +1. **Read the FULL rejection message** — Don't skim. Copy the exact text. Note every guideline number cited. +2. **Identify rejection type**: + - "App Rejected" → Guideline violation, code/content fix needed + - "Metadata Rejected" → ASC metadata issue, no build needed + - "Binary Rejected" → Technical gate (SDK, manifest, encryption) + - "Removed from Sale" → Post-approval enforcement +3. **Check the specific guideline** — Look up the exact number in app-store-ref +4. **Screenshot the rejection** — Save for team communication and appeal reference +5. **Check App Review messages in ASC** — Sometimes they ask for information, not reject + +#### What this tells you + +| Rejection Type | What Changed | Next Step | +|---|---|---| +| "App Rejected" + Guideline 2.1 | App crashed or had placeholders | Pattern 1 | +| "Metadata Rejected" | Screenshots or description wrong | Pattern 2 | +| "App Rejected" + Guideline 5.1 | Privacy policy or manifest gaps | Pattern 3 | +| "App Rejected" + Guideline 4.8 | Missing Sign in with Apple | Pattern 4 | +| "App Rejected" + Guideline 3.x | Business/monetization violation | Pattern 5 | +| "Binary Rejected" / no guideline | SDK, signing, or encryption issue | Pattern 6 | +| Reviewer seems incorrect | Genuine misunderstanding | Pattern 7 | +| Guideline 1.x cited | Safety/content issue | Pattern 9 | +| Guideline 4.1-4.3 cited | Design/originality issue | Pattern 8 | + +#### MANDATORY INTERPRETATION + +Before changing ANY code, identify ONE of these: + +1. If "App Rejected" with guideline number → Map to specific pattern (1-5) +2. If "Metadata Rejected" → Fix in ASC, no build required (Pattern 2) +3. If "Binary Rejected" → Technical gate failure (Pattern 6) +4. If multiple guidelines cited → Fix ALL cited issues, not just the first one. Both binary AND metadata can be rejected simultaneously — binary issues need a new build, metadata issues can be fixed in ASC. Fix both before resubmitting. +5. If reviewer asks for information → Reply in ASC before making code changes + +#### If rejection reason is unclear or contradictory +- STOP. Do NOT start fixing code yet +- Reply to App Review in ASC asking for clarification +- Include screenshots or video showing the feature working +- Wait for response before making changes + +## Decision Tree + +``` +App Store rejection? +│ +├─ What does the rejection say? +│ │ +│ ├─ Cites Guideline 2.1? +│ │ ├─ App crashed during review? → Pattern 1 (check crash logs) +│ │ ├─ Placeholder content found? → Pattern 1 (search project) +│ │ ├─ Broken links? → Pattern 1 (verify URLs) +│ │ └─ Missing demo credentials? → Pattern 1 (provide in review notes) +│ │ +│ ├─ Cites Guideline 2.3? +│ │ ├─ Screenshots don't match app? → Pattern 2 (retake screenshots) +│ │ ├─ Description promises missing features? → Pattern 2 (update text) +│ │ └─ Keywords contain trademarks? → Pattern 2 (remove keywords) +│ │ +│ ├─ Cites Guideline 5.1? +│ │ ├─ Privacy policy missing/inaccessible? → Pattern 3 (add/fix policy) +│ │ ├─ Purpose strings missing? → Pattern 3 (add to Info.plist) +│ │ ├─ Privacy manifest incomplete? → Pattern 3 (update PrivacyInfo) +│ │ └─ Tracking without ATT? → Pattern 3 (implement ATT) +│ │ +│ ├─ Cites Guideline 4.8? +│ │ ├─ Third-party login without SIWA? → Pattern 4 (add SIWA) +│ │ ├─ SIWA button hidden or broken? → Pattern 4 (fix prominence) +│ │ └─ Exception applies? → Pattern 4 (verify exemption) +│ │ +│ ├─ Cites Guideline 3.x? +│ │ ├─ Digital content without IAP? → Pattern 5 (implement StoreKit) +│ │ ├─ Subscription issues? → Pattern 5 (fix terms/value) +│ │ └─ Loot box odds not disclosed? → Pattern 5 (add disclosure) +│ │ +│ ├─ "Binary Rejected" / no guideline? +│ │ ├─ Wrong SDK version? → Pattern 6 (update Xcode) +│ │ ├─ Privacy manifest missing? → Pattern 6 (add PrivacyInfo) +│ │ ├─ Encryption not declared? → Pattern 6 (add ITSAppUsesNonExemptEncryption) +│ │ └─ Invalid signing? → Pattern 6 (regenerate provisioning) +│ │ +│ ├─ "I believe the reviewer is wrong"? +│ │ └─ → Pattern 7 (Appeal Process) +│ │ +│ ├─ Cites Guideline 1.x? +│ │ └─ Safety/content issue → Pattern 9 +│ │ +│ └─ Cites Guideline 4.1-4.3? +│ └─ Design/originality issue → Pattern 8 +``` + +## Pattern Selection Rules (MANDATORY) + +Before proceeding to a pattern: + +1. **Copy the exact rejection text** — Word for word, including guideline numbers +2. **Match guideline number to pattern** — Don't guess, map directly +3. **If multiple guidelines cited** — Fix ALL of them before resubmitting +4. **If no guideline number** — Likely Binary Rejected, start with Pattern 6 +5. **If unsure** — Reply to reviewer for clarification first + +#### Apply ONE pattern at a time +- Identify the correct pattern from the rejection message +- Implement the complete fix for that pattern +- If multiple guidelines cited, fix each one before resubmitting +- DO NOT resubmit after fixing only one of multiple cited issues + +#### FORBIDDEN +- Resubmitting without changes hoping for a different reviewer +- Skimming the rejection and guessing the fix +- Fixing only the first cited guideline when multiple are cited +- Arguing emotionally in App Review messages +- Disabling privacy features to avoid Guideline 5.1 + +## Diagnostic Patterns + +### Pattern 1: Guideline 2.1 — App Completeness + +**Time cost** 3-7 days per rejection cycle + +#### Symptom +- Rejection citing "App Completeness" +- Crashes during review +- Placeholder content found +- Broken links (support URL, privacy policy, in-app links) +- Missing demo credentials for login-required apps + +#### Common causes +1. App crashes on reviewer's device (different OS version, different device class) +2. Placeholder text or images visible in any screen +3. Broken links (support URL, privacy policy, in-app links) +4. Missing demo credentials for login-required apps +5. Backend service was down during review window + +#### Diagnosis +```bash +# 1. Check crash logs in App Store Connect +# Xcode Organizer > Crashes > Filter by version + +# 2. Search for placeholder strings +grep -r "Lorem\|TODO\|FIXME\|placeholder\|sample\|test data" \ + --include="*.swift" --include="*.storyboard" --include="*.xib" . + +# 3. Verify all URLs resolve +curl -sI "https://your-support-url.com" | head -1 +curl -sI "https://your-privacy-policy-url.com" | head -1 + +# 4. Test on latest shipping iOS +# Check ASC for specific iOS version reviewer used (noted in rejection) +``` + +#### Fix +```swift +// ❌ WRONG — Demo credentials that expire +// Review Notes: "Login: test@test.com / password123" +// (If this account expires or gets locked, instant rejection) + +// ✅ CORRECT — Permanent demo credentials +// Review Notes: +// "Demo Account: demo@yourapp.com / ReviewDemo2024! +// This account has pre-populated sample data. +// Account will not expire during review period." +``` + +```swift +// ❌ WRONG — Placeholder still in code +Text("Lorem ipsum dolor sit amet") + +// ✅ CORRECT — Real content in every screen +Text("Welcome to YourApp. Get started by creating your first project.") +``` + +#### Verification +- Submit to TestFlight first, test every screen on multiple devices +- Verify ALL URLs load successfully (including privacy policy from within the app) +- Ensure demo credentials work and won't expire +- Test on the specific iOS version mentioned in rejection (check rejection message or ASC Activity → Build → review device info) +- Monitor backend uptime during review window (don't deploy during review) +- Check ASC crash logs (Xcode Organizer → Crashes) for the specific device and OS version the reviewer used + +--- + +### Pattern 2: Guideline 2.3 — Metadata Issues + +**Time cost** 1-3 days (metadata fix, no build needed) + +#### Symptom +- "Metadata Rejected" — no code change required +- Screenshots don't match current app UI +- Description promises features not in the app +- Keywords contain trademarked or competitor names + +#### Common causes +1. Screenshots show old UI or features that no longer exist +2. Description promises features not yet implemented +3. Keywords contain trademarked terms or competitor names +4. App name implies functionality that doesn't exist +5. Category selection doesn't match app's primary function + +#### Diagnosis + +Compare every screenshot to current app UI. Read description word by word — does each claim exist in the app? Check keywords against Apple's trademark list. + +``` +Checklist: +☐ Every screenshot matches current build +☐ Every feature mentioned in description exists and works +☐ No trademarked terms in keywords (e.g., "Instagram", "Uber") +☐ App icon appropriate for all audiences +☐ Age rating matches actual content +☐ Category selection accurate +☐ "What's New" text matches actual changes +``` + +#### Fix + +Update metadata directly in App Store Connect. No new build needed for metadata-only rejections. + +``` +✅ Take fresh screenshots FROM THE SUBMITTED BUILD (not dev build) +✅ Remove any features from description that aren't fully functional +✅ Replace trademarked keywords with generic equivalents + ("photo sharing" not "Instagram-like") +✅ Ensure "What's New" describes changes in this specific version +``` + +#### Verification +- Take screenshots on the exact build version submitted +- Have someone outside the team read the description and verify each claim +- Search keywords for any trademarked terms + +--- + +### Pattern 3: Guideline 5.1 — Privacy Violations + +**Time cost** 3-10 days (code + manifest + policy changes) + +#### Symptom +- Rejection citing privacy policy, data collection, purpose strings, or tracking +- Privacy manifest missing required reason API declarations +- Third-party SDK collects data not disclosed + +#### Common causes +1. Privacy policy missing or not accessible from within the app +2. Privacy policy doesn't match actual data collection +3. Missing purpose strings for permission requests +4. Privacy manifest (PrivacyInfo.xcprivacy) missing required reason API declarations +5. Third-party SDK collects data not disclosed in privacy nutrition labels +6. App tracks users without ATT (App Tracking Transparency) consent + +#### Diagnosis +```swift +// 1. Check: Is privacy policy URL in ASC AND accessible from within the app? +// Both are required. In-app access is commonly missed. + +// 2. Check purpose strings +// ❌ WRONG — Generic purpose string +"NSCameraUsageDescription" = "Camera access needed" + +// ✅ CORRECT — Specific purpose string explaining why +"NSCameraUsageDescription" = "Take photos for your profile picture and upload to your account" + +// 3. Generate privacy report +// Xcode: Product → Archive → Generate Privacy Report +// This shows aggregate data from all frameworks and your code + +// 4. Check privacy manifest +// Verify PrivacyInfo.xcprivacy exists in your app target +// AND in every framework target that uses required reason APIs +``` + +#### Fix + +##### Purpose strings (Info.plist) +```xml + +NSCameraUsageDescription +Take photos for your profile picture and upload to your account + +NSLocationWhenInUseUsageDescription +Show nearby restaurants on the map and calculate delivery distance + +NSPhotoLibraryUsageDescription +Select photos from your library to attach to messages +``` + +##### Privacy manifest (PrivacyInfo.xcprivacy) +```xml + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + +``` + +##### Privacy policy requirements +``` +Your privacy policy MUST specifically list: +☐ What data is collected (every type) +☐ How data is collected (automatically, user-provided) +☐ All uses of collected data +☐ Third-party sharing (who, why) +☐ Data retention period +☐ How users can request deletion +☐ Contact information for privacy inquiries +``` + +##### App Tracking Transparency +```swift +// Required if app tracks users across other companies' apps/websites +import AppTrackingTransparency + +func requestTrackingPermission() { + ATTrackingManager.requestTrackingAuthorization { status in + switch status { + case .authorized: + // Enable tracking (analytics, ad attribution) + break + case .denied, .restricted, .notDetermined: + // Disable ALL tracking + // Remove IDFA access, disable third-party analytics that track + break + @unknown default: + break + } + } +} +``` + +#### Verification +- Generate Privacy Report (Product > Archive > Generate Privacy Report) and verify all APIs declared +- Test privacy policy link from within the app (not just browser) +- Verify every permission request has a specific, honest purpose string +- Audit all third-party SDKs for undisclosed data collection +- Test ATT flow: deny tracking, verify app works correctly without it + +--- + +### Pattern 4: Guideline 4.8 — Missing Sign in with Apple + +**Time cost** 3-7 days (implementation + resubmit) + +#### Symptom +- Rejection citing Guideline 4.8 +- App has third-party login but no Sign in with Apple (SIWA) + +#### Common causes +1. App has Google/Facebook/Twitter login but no SIWA +2. SIWA button exists but doesn't work +3. SIWA not offered at equal prominence (hidden or secondary) +4. SIWA flow doesn't handle credential revocation + +#### Diagnosis + +The rule is simple: If your app uses ANY third-party or social login service, you MUST offer Sign in with Apple as an equivalent option. + +**Exceptions** (SIWA not required): +- Company-internal or employee-only apps +- Education or enterprise apps with existing institutional auth +- Government/tax/banking apps requiring government ID +- Apps that are a client for a specific third-party service (e.g., email client) + +#### Fix +```swift +import AuthenticationServices + +// ✅ CORRECT — SIWA at same prominence as other login options +struct LoginView: View { + var body: some View { + VStack(spacing: 16) { + // Sign in with Apple — MUST be at same visual level + SignInWithAppleButton(.signIn) { request in + request.requestedScopes = [.fullName, .email] + } onCompletion: { result in + switch result { + case .success(let authorization): + handleAuthorization(authorization) + case .failure(let error): + handleError(error) + } + } + .signInWithAppleButtonStyle(.black) + .frame(height: 50) + + // Other login options at same size/prominence + GoogleSignInButton() + .frame(height: 50) + } + } + + func handleAuthorization(_ authorization: ASAuthorization) { + guard let credential = authorization.credential + as? ASAuthorizationAppleIDCredential else { return } + + let userIdentifier = credential.user + let fullName = credential.fullName + let email = credential.email + // Note: fullName and email only provided on FIRST sign-in + // Store them immediately — they won't be provided again + + // Send to your backend for account creation/login + } +} +``` + +```swift +// ✅ Handle credential revocation (required for account deletion support) +func checkCredentialState() { + let provider = ASAuthorizationAppleIDProvider() + provider.getCredentialState(forUserID: storedUserIdentifier) { state, error in + switch state { + case .authorized: + break // User is still signed in + case .revoked: + // User revoked credentials — sign out immediately + signOut() + case .notFound: + // Credential not found — show sign-in + showLogin() + @unknown default: + break + } + } +} +``` + +#### Verification +- SIWA button is visually equal to other login buttons (same size, same screen) +- Full SIWA flow works: sign in, account creation, credential check +- Handle revocation: user can revoke in Settings > Apple ID > Sign-In & Security +- Test account deletion flow (required since June 2022) + +--- + +### Pattern 5: Guideline 3.x — Business/Monetization + +**Time cost** 3-14 days (may require architectural changes) + +#### Symptom +- Rejection citing business guidelines +- IAP requirements not met +- Subscription doesn't provide ongoing value +- External payment for digital content + +#### Common causes +1. Digital content unlocked without IAP (using external payment for in-app features) +2. Subscription doesn't provide ongoing value (one-time content sold as subscription) +3. Loot box or random item purchase odds not disclosed +4. Deceptive subscription flow (dark patterns, misleading free trial) +5. IAP metadata incomplete or not submitted for review + +#### Diagnosis + +The key question: Is any digital content or feature unlocked without Apple IAP? + +``` +Digital goods/features → MUST use Apple IAP + Examples: premium features, virtual currency, ad removal, content + packs, subscription access to digital content + +Physical goods/services → MAY use external payment + Examples: physical merchandise, ride-sharing, food delivery, + person-to-person services + +Certain categories → MAY use external payment (3.1.3 exceptions) + Examples: "reader" apps (Kindle, Netflix, Spotify), one-to-one + real-time services +``` + +#### Fix +```swift +// ❌ WRONG — Unlocking features via external payment +func unlockPremium(receiptFromServer: String) { + // Bypass Apple IAP → rejection + UserDefaults.standard.set(true, forKey: "isPremium") +} + +// ✅ CORRECT — StoreKit 2 for all digital goods +import StoreKit + +func purchasePremium() async throws { + let product = try await Product.products(for: ["com.app.premium"]).first! + let result = try await product.purchase() + + switch result { + case .success(let verification): + let transaction = try checkVerified(verification) + // Unlock feature + await transaction.finish() + case .pending: + // Payment pending (Ask to Buy, etc.) + break + case .userCancelled: + break + @unknown default: + break + } +} +``` + +```swift +// ✅ Loot box disclosure (required if random items for purchase) +struct LootBoxView: View { + var body: some View { + VStack { + Text("Mystery Box — $4.99") + Text("Contents are random. Odds:") + .font(.caption) + + // MUST disclose odds before purchase + VStack(alignment: .leading) { + Text("Common item: 60%") + Text("Rare item: 30%") + Text("Legendary item: 10%") + } + .font(.caption2) + .foregroundStyle(.secondary) + } + } +} +``` + +#### Verification +- ALL digital content/features use Apple IAP (StoreKit 2) +- IAP products submitted and approved in ASC before app submission +- Subscription terms clearly communicated before purchase screen +- Free trial duration and auto-renewal price clearly visible +- Loot box odds disclosed before any purchase +- No external payment links for digital goods (unless "reader" app exception applies) + +--- + +### Pattern 6: Binary Rejected — Technical Gates + +**Time cost** 1-3 days (build configuration fix) + +#### Symptom +- "Binary Rejected" with no specific guideline +- Automated rejection during processing +- Build stuck in "Processing" state + +#### Common causes +1. Built with outdated SDK version (must meet Apple's minimum) +2. Privacy manifest (PrivacyInfo.xcprivacy) missing or invalid +3. Encryption compliance not declared (ITSAppUsesNonExemptEncryption) +4. Invalid signing or provisioning profile +5. Missing required device capabilities in Info.plist +6. App uses private or deprecated APIs +7. App binary too large without on-demand resources + +#### Diagnosis +```bash +# 1. Check Xcode and SDK version +xcodebuild -version +# Must be current or previous major Xcode version + +# 2. Check processing logs in ASC +# App Store Connect → My Apps → [App] → Activity → Build → Processing Log + +# 3. Verify encryption declaration +grep -c "ITSAppUsesNonExemptEncryption" Info.plist +# Must exist and be set to YES or NO + +# 4. Check provisioning +security cms -D -i embedded.mobileprovision 2>/dev/null | head -20 +# Verify not expired + +# 5. Check for private API usage +# Xcode: Product → Archive → Distribute App → Validate App +# This catches most private API issues before submission +``` + +#### Fix +```xml + + +ITSAppUsesNonExemptEncryption + + + +ITSAppUsesNonExemptEncryption + + +``` + +**Encryption decision flow**: +1. Does your app use ONLY standard OS-provided HTTPS (URLSession, Alamofire)? → Set `false`, done +2. Does your app call OpenSSL, libsodium, or custom crypto directly? → Set `true`, upload BIS docs +3. Does your app implement proprietary encryption protocols? → Set `true`, upload BIS docs +4. Unsure? → Run `strings YourApp | grep -i "openssl\|libcrypto\|CCCrypt"` to check + +```bash +# Validate before submitting +# Xcode: Product → Archive → Distribute App → Validate App +# Catches ~80% of binary rejection causes + +# Clean build if signing issues +rm -rf ~/Library/Developer/Xcode/DerivedData +# Re-download provisioning profiles in Xcode Preferences → Accounts +``` + +#### Verification +- Run "Validate App" in Xcode Organizer before submitting +- Verify Xcode version meets Apple's current requirements +- Check PrivacyInfo.xcprivacy exists and is included in the app bundle +- Verify ITSAppUsesNonExemptEncryption key is present +- Ensure provisioning profile is not expired +- Test app on physical device with release configuration + +--- + +### Pattern 7: Appeal Process + +**Time to resolve** 3-14 days + +#### When to use +- You genuinely believe the reviewer misunderstood your app +- You believe the wrong guideline was applied +- Your app complies and you have evidence + +#### When NOT to use +- You disagree with Apple's rules (they won't change for your app) +- You're hoping a different reviewer will approve without changes +- You want to skip implementing a required feature (like SIWA) + +#### Step 1: Reply in App Store Connect first + +Most issues resolve without a formal appeal. Reply to App Review messages in ASC with: +- Specific evidence of compliance +- Screenshots or video demonstrating the feature +- Clear reference to the guideline you believe you comply with + +#### Step 2: If unresolved, submit formal appeal + +URL: developer.apple.com/contact/app-store/?topic=appeal + +#### Appeal writing + +``` +✅ GOOD appeal structure: + +"Our app complies with Guideline [X.Y] because [specific evidence]. + +The reviewer noted: '[quote exact rejection text]' + +However, our app [specific counter-evidence with details]: +1. [Feature X] works as shown in [attached screenshot/video] +2. [Policy Y] is accessible at [URL] and within the app at [screen] +3. [Requirement Z] is implemented as described in [technical detail] + +Attached: [screenshots, screen recording, or documentation] + +We respectfully request re-review of this decision." +``` + +``` +❌ BAD appeal examples: + +"This is unfair. Other apps do the same thing. Please approve." +→ Apple reviews each app independently + +"We've been rejected 3 times and are losing money." +→ Financial pressure is not relevant to guideline compliance + +"The reviewer didn't understand our app." +→ Vague. Show specifically what they missed. + +"We need this approved by Friday for our launch." +→ Deadlines are not App Review's concern +``` + +#### Step 3: Escalate if needed + +If appeal is denied: +1. Request a phone call with App Review (available through appeal process) +2. Contact Apple Developer Relations as last resort +3. Consider whether the app genuinely needs architectural changes + +#### Verification +- Wait for response before making code changes (if appealing) +- Include ONE appeal per rejection (multiple appeals slow the process) +- Respond to any information requests before filing appeal + +--- + +### Pattern 8: Guideline 4.2/4.3 — Minimum Functionality / Spam + +> Patterns 8-9 address subjective rejections where the fix is demonstrating value or compliance, not changing code. + +**Time to resolve** 1-4 weeks (requires app changes, not just metadata) + +#### Rejection messages you'll see + +- **4.2**: "Your app does not include sufficient content and features to be appropriate for the App Store" +- **4.3**: "Your app duplicates the content and functionality of other apps submitted by you or another developer" + +#### What it really means + +- **4.2** (#3 most common rejection): "Your app doesn't do enough to justify existing as a native app" — not enough value beyond what a website provides. +- **4.3** (#2 most common rejection): "Your app looks too similar to another app, or is template-generated." Apple actively fights template-mill spam. + +#### Detection — is this really a 4.2/4.3? + +Before assuming the rejection is valid, check for misclassification: + +```bash +# Is the app a WKWebView wrapper? +grep -rn "WKWebView\|SFSafariViewController\|UIWebView" --include="*.swift" . + +# Does the app use native features at all? +grep -rn "import CoreLocation\|import AVFoundation\|import UserNotifications\|import HealthKit\|import CoreML" --include="*.swift" . + +# Is the codebase template-generated? (common template markers) +grep -rn "powered by\|generated by\|template\|starter kit" --include="*.swift" --include="*.plist" . +``` + +If `WKWebView` is the primary UI and native feature imports are absent, the rejection is likely valid. + +#### Response decision tree + +``` +Is the rejection valid? (be honest) +│ +├─ YES, app is thin / web wrapper +│ ├─ Can you add meaningful native features? → Add them (see Evidence Checklist) +│ └─ Core value IS the web content? → Consider reader app model or PWA instead +│ +├─ PARTIALLY, app has value but reviewer missed it +│ ├─ Did you provide demo credentials? → If not, resubmit with access +│ ├─ Is the value hidden behind onboarding? → Add review notes explaining path to value +│ └─ Does the app need content/data to show value? → Pre-populate sample data for review +│ +└─ NO, app is clearly feature-rich + └─ Appeal with detailed functionality walkthrough (Pattern 7) + Include: feature list, screenshots of each major screen, video demo +``` + +#### Evidence checklist — what satisfies reviewers + +**For 4.2 (Minimum Functionality)** — demonstrate native value: + +``` +Features that prove native value: +☐ Offline functionality (data available without network) +☐ Push notifications with meaningful triggers +☐ Device API usage (camera, location, sensors, HealthKit) +☐ Custom UI beyond web content (native controls, animations, gestures) +☐ Local data persistence and sync +☐ Widget, Live Activity, or Watch companion +☐ Accessibility features (VoiceOver, Dynamic Type) +☐ System integration (Shortcuts, Share Extension, Spotlight) + +Changes that DON'T help: +✗ Adding a splash screen or settings-only screen to a web wrapper +✗ Cosmetic changes (colors/fonts) without functional native features +``` + +**For 4.3 (Spam / Duplicate)** — demonstrate uniqueness: + +``` +How to differentiate: +☐ Unique UI that doesn't match other apps in your catalog +☐ Different target audience with distinct feature set +☐ Separate branding (icon, name, color scheme) +☐ Features that justify a separate app vs. being a mode in an existing app +☐ Distinct App Store description and screenshots +☐ Different primary use case (not just themed variants) + +If you have multiple similar apps: +☐ Consolidate into one app with multiple modes/themes +☐ Remove duplicates before resubmitting +☐ Explain in review notes why apps need to be separate +``` + +#### Communication template + +``` +Resolution Center response (adapt for 4.2 or 4.3): + +"Thank you for your feedback. [Choose one]: + +For 4.2: We have added native features that provide value beyond our +web presence: [list features with device capabilities used]. + +For 4.3: Our app is distinct from [similar app] — different target +audience ([X] vs [Y]), unique features ([list]), separate branding. + +[Attach: screenshots of each major screen, 30-60s video walkthrough] +Demo credentials: [username] / [password]" +``` + +#### Verification + +- Test the app yourself: would YOU download this instead of using the website? +- Compare against your other apps: can you clearly articulate why they're separate? +- Pre-populate demo data so the reviewer sees value immediately +- Record a 30-60 second walkthrough video showing native features in action + +--- + +### Pattern 9: Guideline 1.x — Safety: Content / UGC / Kids + +**Time to resolve** 1-3 weeks (requires moderation infrastructure or content changes) + +#### Rejection messages and what they mean + +- **1.1**: "Your app includes content that many users would find objectionable" — Apple reviews content strictly, including content accessible through your app. +- **1.2**: "Your app enables the display of user-generated content but does not include sufficient mechanisms to report offensive content" — UGC without adequate moderation. Non-negotiable: if users can post anything, you need a complete moderation system. +- **1.3**: "Your Kids category app must comply with COPPA" / "includes third-party analytics not appropriate for Kids" — Kids category has the tightest rules. Any gap is a rejection. + +#### Detection — which sub-guideline? + +``` +Does the app have UGC? +├─ YES → Likely 1.2 (moderation required) +│ ├─ Comments, posts, or forums? → Need reporting + moderation +│ ├─ Photo/video uploads visible to others? → Need content review +│ ├─ User profiles visible to others? → Need profile reporting +│ └─ Chat or messaging? → Need blocking + reporting + content filtering +│ +├─ Is it in the Kids category? +│ └─ YES → Likely 1.3 (strict requirements) +│ ├─ Any third-party SDKs? → Must be certified for kids +│ ├─ Any external links? → Must be gated or removed +│ └─ Any purchases? → Must have parental gate +│ +└─ Does it display third-party or app-provided content? + └─ YES → Likely 1.1 (content standards) + ├─ Web content via WKWebView? → Must filter or restrict + ├─ AI-generated content? → Must moderate outputs + └─ Content from third-party APIs? → Must filter before display +``` + +#### Response decision tree + +**UGC rejection (1.2)**: +``` +What moderation exists? +│ +├─ No moderation at all +│ └─ Implement complete moderation system (see Implementation Checklist) +│ +├─ Reporting exists but insufficient +│ ├─ Missing blocking? → Add user blocking (immediate effect) +│ ├─ No response workflow? → Add 24-hour review commitment +│ └─ No pre-publish review? → Add content queue or ML filtering +│ +└─ Moderation exists but not visible to reviewer + └─ Add review notes explaining moderation flow + demo how to trigger it +``` + +**Kids category rejection (1.3)**: +``` +What triggered the rejection? +│ +├─ Third-party SDKs +│ └─ Remove ALL analytics/ads SDKs not certified for Kids category +│ Common offenders: Firebase Analytics, Facebook SDK, AdMob +│ Allowed: Apple's own frameworks, COPPA-certified SDKs only +│ +├─ External links +│ └─ Remove all links OR gate behind parental verification +│ Includes: "Visit our website", social media links, "More apps" +│ +├─ In-app purchases +│ └─ Add parental gate before ANY purchase flow +│ Gate must require adult knowledge (e.g., "spell this word", math problem) +│ Simple "Are you 18?" button is NOT sufficient +│ +└─ Data collection + └─ Remove ALL data collection not essential to app function + No device IDs, no location, no contact info, no tracking +``` + +#### Implementation checklist — minimum viable moderation (1.2) + +``` +Required for ANY app with UGC: +☐ Report button on every piece of user content (visible, not buried) +☐ Block user functionality (immediate, no delay) +☐ Visible Terms of Use / Community Guidelines (accessible from within app) +☐ Content review workflow (human review queue or ML pre-screening) +☐ 24-hour response commitment for reported content +☐ Ability to remove content and ban users +☐ In-app link to Terms of Use from content creation screens +``` + +#### Kids category requirements (1.3) + +``` +MANDATORY for Kids category: +☐ COPPA compliance (no data collection from children under 13) +☐ No third-party advertising (none, not even "child-safe" networks) +☐ No external links (or gated behind parental verification) +☐ No social features (no chat, no profiles, no friend lists) +☐ Parental gate before any purchase (not a simple age button) +☐ Remove ALL non-COPPA-certified SDKs (Firebase Analytics, Facebook, AdMob, Crashlytics) +☐ No user tracking of any kind +☐ Privacy policy specifically addresses children's data +``` + +#### Communication template + +``` +Resolution Center response (adapt for 1.2 or 1.3): + +"Thank you for your feedback. [Choose one]: + +For 1.2 (UGC): We have implemented moderation: report button on all +content [location], user blocking, [review workflow], Terms of Use at +[URL]. To test: create a post → [...] menu → Report. + +For 1.3 (Kids): Removed [SDKs]. External links [removed/gated]. +Added parental gate for purchases. No user data collected. +Privacy policy updated at [URL]. + +[Attach: screenshots showing moderation/parental gate flow]" +``` + +#### Verification + +- Test the report flow end-to-end: report content, verify it reaches a review queue +- Test the block flow: block a user, verify their content is hidden immediately +- For Kids: remove the app from a test device, reinstall, verify no data persists +- For Kids: run `strings YourApp.app/YourApp | grep -i "firebase\|facebook\|google\|admob"` to catch embedded SDKs +- Have someone unfamiliar with the app try to find the report/block buttons — if they can't find them in 5 seconds, they're not prominent enough + +--- + +## Quick Reference Table + +| Rejection Type | Likely Cause | First Check | Pattern | Typical Fix Time | +|---|---|---|---|---| +| Guideline 2.1 | Crashes/placeholders | Test on device, search placeholders | 1 | 1-3 days | +| Guideline 2.3 | Metadata mismatch | Compare screenshots to app | 2 | 1 day (no build) | +| Guideline 5.1 | Privacy gaps | Check policy + manifest + purpose strings | 3 | 2-5 days | +| Guideline 4.8 | Missing SIWA | Check for third-party login | 4 | 3-5 days | +| Guideline 3.x | Payment method | Review IAP flows | 5 | 3-14 days | +| Binary Rejected | Technical gate | Check SDK, manifest, encryption | 6 | 1-2 days | +| Guideline 1.x | Safety/content/UGC | Check UGC moderation + Kids compliance | 9 | 1-3 weeks | +| Guideline 4.2/4.3 | Thin app/spam | Audit native features + app uniqueness | 8 | 1-4 weeks | + +## Production Crisis Scenario + +### Context: App rejected for 3rd time, different reason each time, launch is tomorrow + +**Situation**: Marketing committed to a launch date. App was rejected for crashes (fixed), then metadata (fixed), now privacy policy "doesn't match actual data collection." + +**Pressure signals**: +- Product team already sent press releases with launch date +- App Store rating will drop if launch delayed +- Manager asking "why wasn't this caught earlier?" +- Temptation to quick-fix only the cited privacy issue + +**Why this happens**: Each review pass goes deeper. First pass catches obvious issues (crashes). Second pass checks metadata. Third pass audits privacy compliance. This is normal, not "the reviewer is picking on you." + +#### Rationalization traps (DO NOT fall into these) + +1. *"Just fix the privacy policy wording and resubmit"* + - The reviewer said "doesn't match actual data collection" + - That means your app collects data you didn't disclose + - A wording change without auditing actual data collection = another rejection + +2. *"The reviewer is being unreasonable, let's appeal"* + - Three rejections for three different valid issues is not unreasonable + - Appealing wastes 3-14 days when you could fix and resubmit in 1-3 days + +3. *"Let's remove the privacy-sensitive features to ship faster"* + - Removing features changes the app, requiring re-review of everything + - May introduce new issues (broken UI, missing functionality) + +4. *"Different reviewer next time might not notice"* + - Reviewers see the rejection history — they check previously cited issues + - Repeat rejections get escalated to senior reviewers + +#### MANDATORY approach + +1. Don't panic. Don't resubmit without a thorough fix. +2. Run the COMPLETE pre-flight checklist — not just the cited issue. +3. Audit all data collection: every SDK, every analytics call, every API request that sends user data. +4. Generate privacy report (Product > Archive > Generate Privacy Report) and cross-reference with privacy policy. +5. Fix privacy policy to specifically list every data type actually collected. +6. Verify all previous rejection issues still fixed (crashes, metadata). +7. Request expedited review at developer.apple.com/contact/app-store/?topic=expedite if genuinely time-critical. +8. Communicate to stakeholders: "Each review fixes more issues. This submission addresses privacy compliance comprehensively." + +#### Time comparison + +| Approach | Time to Approval | +|---|---| +| Quick fix + resubmit | 7-14 more days (likely rejected again) | +| Full audit + thorough fix | 3-5 days (high confidence) | +| Full audit + expedited review | 1-3 days (if granted) | + +#### Professional communication template + +``` +To stakeholders: + +"Root cause: Our third-party analytics SDK collects device identifiers +that weren't disclosed in our privacy policy or nutrition labels. + +Fix: Updated privacy policy, privacy nutrition labels in ASC, and +PrivacyInfo.xcprivacy to accurately reflect all data collection. +Also audited all SDKs for undisclosed collection. + +Timeline: Resubmitting today with expedited review request. +Expected approval: 1-3 business days. + +Prevention: Adding privacy audit to our pre-submission checklist +so future submissions include accurate disclosure from the start." +``` + +--- + +## Common Mistakes + +### 1. Skimming the Rejection Message + +**Problem** Developer reads "Guideline 5.1" and assumes they know the issue without reading the full explanation. + +**Why it fails** Guideline 5.1 covers privacy policy, purpose strings, privacy manifest, tracking, AND data collection disclosure. The rejection message tells you exactly which aspect failed. Guessing the wrong one wastes a full review cycle (3-7 days). + +**Fix**: Copy the FULL rejection text. Highlight every specific requirement mentioned. Map each one to the fix before writing any code. + +### 2. Fixing Only the Cited Issue + +**Problem** Rejection cites Guideline 5.1 (privacy). Developer fixes privacy but doesn't check for other issues. + +**Why it fails** Reviewers find new issues on each pass. First pass catches crashes, second catches metadata, third catches privacy. If you only fix privacy, the fourth pass might find a Guideline 4.8 (SIWA) issue. + +**Fix**: Before every resubmission, run through ALL common rejection patterns (1-6). Fix everything proactively. One thorough submission beats three partial ones. + +### 3. Resubmitting Without Changes + +**Problem** "Maybe a different reviewer will approve it." + +**Why it fails** Reviewers see the rejection history. Unchanged resubmissions get the same result or escalated to senior reviewers. Each wasted cycle costs 3-7 days. + +**Fix**: Always make at least the changes the reviewer requested. If you believe the rejection is wrong, reply in ASC with evidence first. + +### 4. Arguing Emotionally in App Review Messages + +**Problem** "This is unfair! Other apps do this! You're blocking our business!" + +**Why it fails** App Review is a technical compliance review, not a negotiation. Emotional arguments are ignored. Specific evidence of compliance works. + +**Fix**: Be factual, specific, and professional. Quote the guideline. Show screenshots. Provide technical evidence. + +### 5. Ignoring Third-Party SDK Issues + +**Problem** "We don't collect that data — it must be the SDK." + +**Why it fails** Your app is responsible for ALL SDK behavior. If Facebook SDK collects device identifiers, YOUR privacy policy and nutrition labels must disclose it. + +**Fix**: Audit every third-party SDK. Generate Privacy Report to see aggregate data collection. Update privacy policy and nutrition labels to cover all SDK behavior. + +### 6. Deploying Backend Changes During Review + +**Problem** Pushing a backend update that changes API responses while the app is under review. + +**Why it fails** Reviewers may test at any time during the review window. A backend change that breaks the reviewed build = crash during review = Guideline 2.1 rejection. + +**Fix**: Freeze backend during review period. If changes are necessary, ensure backward compatibility with the submitted build. + +### 7. Not Using Expedited Review When Available + +**Problem** Developer doesn't know about or doesn't use expedited review for critical situations. + +**Why it fails** Waiting 3-7 days for standard review when a 1-day expedited review is available for legitimate reasons. + +**Fix**: Request expedited review at developer.apple.com/contact/app-store/?topic=expedite for: critical bug fixes, time-sensitive events, or security patches. Don't abuse it — Apple tracks usage and may deny future requests. + +--- + +## Pre-Submission Checklist + +Run through this BEFORE every App Store submission to prevent rejections: + +``` +App Completeness (2.1): +☐ Tested on latest shipping iOS version on physical device +☐ Tested on at least 2 device sizes (iPhone SE, iPhone Pro Max) +☐ No placeholder text (search: Lorem, TODO, FIXME, placeholder, sample) +☐ All URLs resolve (support URL, privacy policy, in-app links) +☐ Demo credentials provided if login required (non-expiring) +☐ Backend stable and not deploying during review window + +Metadata (2.3): +☐ Screenshots taken from submitted build (not dev build) +☐ Every feature in description exists and works +☐ No trademarked terms in keywords +☐ Age rating matches content +☐ "What's New" text accurate + +Privacy (5.1): +☐ Privacy policy accessible in-app AND via URL in ASC +☐ Privacy policy matches actual data collection +☐ Every permission has specific, honest purpose string +☐ PrivacyInfo.xcprivacy exists and lists all required reason APIs +☐ Privacy Report generated and cross-referenced +☐ ATT implemented if any cross-app tracking +☐ Privacy nutrition labels accurate (including third-party SDKs) + +Sign in with Apple (4.8): +☐ If third-party login exists, SIWA offered at same prominence +☐ SIWA flow works: sign in, account creation, revocation handling +☐ Account deletion supported (required since June 2022) + +Business (3.x): +☐ All digital goods/features use Apple IAP +☐ IAP products approved in ASC before app submission +☐ Subscription terms clear before purchase +☐ Loot box odds disclosed if applicable + +Technical (Binary): +☐ Xcode version meets Apple's current requirements +☐ "Validate App" passes in Xcode Organizer +☐ ITSAppUsesNonExemptEncryption key present +☐ Provisioning profile not expired +☐ Tested with release configuration on device + +Safety & Content (1.x): +☐ If UGC: report, block, and moderation workflow all functional +☐ If Kids category: no non-COPPA SDKs, no external links, parental gate on purchases +☐ AI-generated or third-party content has moderation/filtering + +Design & Originality (4.2/4.3): +☐ App provides native value beyond a website (offline, push, device APIs) +☐ App is distinct from other apps in your catalog +☐ Demo data pre-populated for reviewer if app needs content to show value +``` + +--- + +## Cross-References + +- **app-store-connect-ref** — ASC crash analysis, TestFlight feedback, metrics dashboards +- **privacy-ux** — Privacy manifest implementation details and required reason APIs +- **storekit-ref** — StoreKit 2 IAP/subscription implementation +- **accessibility-diag** — Accessibility compliance (VoiceOver, Dynamic Type, WCAG) +- **ios-build** — Build and signing issues that cause Binary Rejected + +## Resources + +**WWDC**: 2025-328 + +**Docs**: /app-store/review/guidelines, /distribute/app-review, /support/offering-account-deletion-in-your-app, /contact/app-store/?topic=appeal + +**Skills**: app-store-connect-ref, privacy-ux, storekit-ref, accessibility-diag diff --git a/.claude/skills/axiom-app-store-diag/agents/openai.yaml b/.claude/skills/axiom-app-store-diag/agents/openai.yaml new file mode 100644 index 0000000..f2cc648 --- /dev/null +++ b/.claude/skills/axiom-app-store-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "App Store Diagnostics" + short_description: "App is rejected by App Review, submission blocked, or appeal needed" diff --git a/.claude/skills/axiom-app-store-ref/.openskills.json b/.claude/skills/axiom-app-store-ref/.openskills.json new file mode 100644 index 0000000..2b9fc87 --- /dev/null +++ b/.claude/skills/axiom-app-store-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-app-store-ref", + "installedAt": "2026-04-12T08:05:47.497Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-app-store-ref/SKILL.md b/.claude/skills/axiom-app-store-ref/SKILL.md new file mode 100644 index 0000000..cf5d69b --- /dev/null +++ b/.claude/skills/axiom-app-store-ref/SKILL.md @@ -0,0 +1,1052 @@ +--- +name: axiom-app-store-ref +description: Use when looking up ANY App Store metadata field requirement, privacy manifest schema, age rating tier, export compliance decision, EU DSA trader status, IAP review pipeline, or WWDC25 submission change. Covers character limits, screenshot specs, encryption decision tree, account deletion rules. +license: MIT +metadata: + version: "1.0.0" +--- + +# App Store Submission Reference + +## Overview + +Complete reference for every App Store submission requirement: + +- **Part 1** — Required metadata fields (descriptions, screenshots, keywords, App Review info) +- **Part 2** — Privacy requirements (manifest schema, nutrition labels, ATT, Required Reason APIs) +- **Part 3** — App Review Guidelines quick reference (all sections 1-5) +- **Part 4** — Age rating system (5-tier, capabilities, regional variations) +- **Part 5** — Export compliance (encryption decision tree) +- **Part 6** — Account and authentication requirements (deletion, SIWA) +- **Part 7** — Monetization and IAP submission pipeline +- **Part 8** — EU-specific compliance (DSA trader status) +- **Part 9** — Build upload and processing +- **Part 10** — WWDC25 changes (draft submissions, accessibility labels, tags) + +## When to Use This Skill + +#### Use when +- Looking up specific metadata field requirements or character limits +- Checking App Review guideline numbers for a specific topic +- Verifying privacy manifest schema fields or Required Reason API categories +- Understanding age rating tiers and new capability declarations +- Checking EU compliance requirements for DSA trader status +- Understanding IAP submission pipeline and review flow +- Preparing builds for upload (SDK requirements, encryption, signing) + +#### Do NOT use when +- Deciding if your app is ready to submit (use **app-store-submission**) +- Troubleshooting a rejection (use **app-store-diag**) +- Implementing in-app purchases (use **storekit-ref**) +- Writing privacy manifest code (use **privacy-ux**) +- Auditing accessibility compliance (use **accessibility-diag**) + +## Related Skills + +- **app-store-submission** — Discipline skill with pre-flight checklist and workflow +- **app-store-diag** — Rejection troubleshooting and appeal guidance +- **privacy-ux** — Privacy manifest implementation, ATT UX, permission requests +- **storekit-ref** — StoreKit 2 API reference for IAP implementation +- **accessibility-diag** — Accessibility compliance scanning and VoiceOver testing + +## Key Terminology + +| Term | Definition | +|------|-----------| +| **App Store Connect** | Web portal and API for managing app metadata, builds, pricing, TestFlight, and analytics | +| **App Review** | Apple's human review process that evaluates every app update against the App Review Guidelines | +| **Privacy Manifest** | `PrivacyInfo.xcprivacy` file declaring data collection, tracking domains, and Required Reason API usage | +| **Required Reason API** | System APIs (file timestamps, disk space, user defaults, etc.) that require declared usage reasons | +| **Privacy Nutrition Label** | App Store privacy cards showing what data your app collects and how it uses it | +| **DSA Trader Status** | EU Digital Services Act classification determining if you are a "trader" selling to EU consumers | +| **Build String** | Unique identifier for each uploaded build (e.g., "1.2.3.4"), separate from version number | +| **Bundle ID** | Reverse-domain identifier (e.g., "com.company.app") uniquely identifying your app across Apple's ecosystem | + +--- + +## Part 1: Required Metadata Fields + +### Immutable Fields (Cannot Change After Creation) + +These fields are locked once set. Choose carefully: +- **Bundle ID** — must match Xcode project exactly +- **SKU** — internal identifier, never shown to users +- **IAP Product ID** — cannot be changed or reused after creation +- **Subscription Duration** — locked after subscription is created + +### App Information + +| Field | Required | Localizable | Max Length | Notes | +|-------|----------|-------------|------------|-------| +| App Name | Yes | Yes | 30 chars | Must be unique on the App Store | +| Subtitle | No | Yes | 30 chars | Appears below app name in search results | +| Description | Yes | Yes | 4000 chars | Plain text, no HTML or rich formatting | +| Promotional Text | No | Yes | 170 chars | Editable without new submission | +| Keywords | Yes | Yes | 100 bytes | Comma-separated, each keyword >2 chars | +| What's New | Yes* | Yes | 4000 chars | *Required for all versions except first | +| Copyright | Yes | No | — | Format: "YYYY Company Name" | +| Support URL | Yes | Yes | — | Must link to actual contact information | +| Marketing URL | No | Yes | — | Optional promotional page | +| Privacy Policy URL | Yes | Yes | — | HTTPS, publicly accessible | + +### Visual Assets + +| Asset | Required | Localizable | Specification | +|-------|----------|-------------|---------------| +| App Icon | Yes | No | 1024x1024 PNG, no alpha, no rounded corners | +| Screenshots | Yes | Yes | Per device size, 2-10 per locale per device | +| App Preview | No | Yes | Up to 3 videos per device size per locale | + +#### Screenshot Requirements + +Format: `.jpeg`, `.jpg`, `.png`. Min 1, max 10 per device size per locale. + +**Fallback**: Provide highest resolution only — App Store auto-scales to smaller sizes. Required: 6.9" or 6.5" for iPhone, 13" for iPad. + +| Device | Portrait | Landscape | +|--------|----------|-----------| +| iPhone 6.9" (Air, 17 Pro Max, 16 Pro Max) | 1260x2736 | 2736x1260 | +| iPhone 6.5" (14 Plus, 13 Pro Max, XS Max) | 1284x2778 or 1242x2688 | 2778x1284 or 2688x1242 | +| iPhone 6.3" (17 Pro, 16 Pro, 16, 15 Pro) | 1179x2556 or 1206x2622 | 2556x1179 or 2622x1206 | +| iPhone 6.1" (17e, 16e, 14, 13, 12) | 1170x2532, 1125x2436, or 1080x2340 | 2532x1170, 2436x1125, or 2340x1080 | +| iPhone 5.5" (8 Plus, 7 Plus) | 1242x2208 | 2208x1242 | +| iPad 13" (Pro M5/M4, Air M4/M3) | 2064x2752 or 2048x2732 | 2752x2064 or 2732x2048 | +| iPad 11" (Pro, Air, mini, 10th gen) | 1488x2266 or 1668x2420 | 2266x1488 or 2420x1668 | +| Mac | 1280x800, 1440x900, 2560x1600, or 2880x1800 | — | +| Apple TV | 1920x1080 or 3840x2160 | — | +| Apple Vision Pro | 3840x2160 | — | + +Screenshots must show the app in actual use. Not permitted: title art alone, login screens, splash screens, or screens from other platforms. Cannot update screenshots on an approved version — must create a new version. + +#### App Preview Video Specifications + +| Spec | Value | +|------|-------| +| Duration | 15-30 seconds | +| Max file size | 500 MB | +| Max frame rate | 30 fps | +| Max per device/locale | 3 | +| H.264 | 10-12 Mbps, `.mov`/`.m4v`/`.mp4`, 256 kbps AAC stereo | +| ProRes 422 HQ | ~220 Mbps VBR, `.mov` only, PCM or 256 kbps AAC stereo | +| Audio sample rate | 44.1 or 48 kHz | +| Orientation | Portrait or landscape (Mac/tvOS/visionOS: landscape only) | + +**Gotchas**: Processing can take up to 24 hours — do not submit for review immediately after uploading previews. All audio tracks must be enabled (disabled tracks cause rejection). Previews always appear before screenshots on the product page. + +#### App Icon Requirements + +| Specification | Requirement | +|---------------|-------------| +| Size | 1024 x 1024 pixels | +| Format | PNG | +| Color space | sRGB or P3 | +| Alpha channel | Not allowed | +| Rounded corners | Not allowed (system applies automatically) | +| Layers/transparency | Not allowed | +| Content | Must be appropriate for 4+ rating regardless of app's actual rating | + +### App Review Information + +| Field | Required | Notes | +|-------|----------|-------| +| Contact First Name | Yes | Reviewer contact | +| Contact Last Name | Yes | Reviewer contact | +| Contact Email | Yes | Must be monitored | +| Contact Phone | Yes | Include country code | +| Notes for Review | No | Up to 4000 bytes; explain non-obvious features | +| Sign-in Username | If login required | Must not expire during review | +| Sign-in Password | If login required | Must not expire during review | +| Attachment | No | Up to 10 files, max 512 MB total | + +### Metadata Rules (Guideline 2.3) + +- App names must be unique, max 30 characters +- Keywords must not include trademarked terms, popular app names, or pricing terms ("free", "sale") +- Screenshots must show the app in use, not just marketing art +- Icons, screenshots, and previews must be appropriate for a 4+ rating even if the app is rated higher +- "For Kids" and "For Children" are reserved for the Kids category +- No other mobile platform names or imagery in screenshots (no Android phones, Windows logos) +- Metadata must accurately reflect app functionality; misleading metadata is grounds for rejection + +### Localization Requirements + +| Aspect | Details | +|--------|---------| +| Minimum | Primary language required; all other localizations optional | +| Per-locale metadata | App name, subtitle, description, keywords, What's New, screenshots | +| Promotional Text | Localizable and editable without new submission | +| Screenshots | Can differ per locale (show localized UI) | +| App Previews | Can differ per locale (show localized audio/UI) | +| URL fields | Support URL and Marketing URL can differ per locale | + +When localizing, provide screenshots that match the localized UI. Reviewers check that screenshots accurately represent the app in each locale. + +### Category Selection + +| Primary Category | Secondary Category | Rules | +|-----------------|-------------------|-------| +| Required | Optional | Choose the category that best describes your app | +| Must be accurate | Can complement primary | Inaccurate category is grounds for rejection (2.3.7) | +| Games have subcategories | — | Games must also select up to 2 game subcategories | + +Available categories: Books, Business, Developer Tools, Education, Entertainment, Finance, Food & Drink, Games, Graphics & Design, Health & Fitness, Lifestyle, Magazines & Newspapers, Medical, Music, Navigation, News, Photo & Video, Productivity, Reference, Shopping, Social Networking, Sports, Travel, Utilities, Weather. + +--- + +## Part 2: Privacy Requirements + +### Privacy Policy (Guideline 5.1.1(i)) + +Required in BOTH locations: +1. **App Store Connect** metadata (Privacy Policy URL field) +2. **Within the app** itself (accessible from settings or equivalent) + +The privacy policy must identify: +- What data is collected and by what means +- All uses of collected data +- Third-party sharing practices +- Data retention and deletion policies +- How users can revoke consent + +### Privacy Manifest Schema (PrivacyInfo.xcprivacy) + +```xml + +NSPrivacyTracking +NSPrivacyTrackingDomains +NSPrivacyCollectedDataTypes +NSPrivacyAccessedAPITypes +``` + +#### NSPrivacyCollectedDataTypes Entry + +Each dictionary in the array contains: + +| Key | Type | Description | +|-----|------|-------------| +| `NSPrivacyCollectedDataType` | String | Category key (e.g., "NSPrivacyCollectedDataTypeName") | +| `NSPrivacyCollectedDataTypePurposes` | Array<String> | Purpose keys for this data type | +| `NSPrivacyCollectedDataTypeLinked` | Boolean | Is this data linked to user identity? | +| `NSPrivacyCollectedDataTypeTracking` | Boolean | Is this data used for tracking? | + +#### NSPrivacyAccessedAPITypes Entry + +Each dictionary in the array contains: + +| Key | Type | Description | +|-----|------|-------------| +| `NSPrivacyAccessedAPIType` | String | API category identifier | +| `NSPrivacyAccessedAPITypeReasons` | Array<String> | Approved reason codes for usage | + +#### Complete PrivacyInfo.xcprivacy Example + +```xml + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeEmailAddress + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + +``` + +#### API Category Identifiers + +| Category | Identifier String | +|----------|------------------| +| File timestamp | `NSPrivacyAccessedAPICategoryFileTimestamp` | +| System boot time | `NSPrivacyAccessedAPICategorySystemBootTime` | +| Disk space | `NSPrivacyAccessedAPICategoryDiskSpace` | +| Active keyboard | `NSPrivacyAccessedAPICategoryActiveKeyboards` | +| User defaults | `NSPrivacyAccessedAPICategoryUserDefaults` | + +#### Generating Aggregate Privacy Report + +``` +Xcode > Product > Archive > Generate Privacy Report +``` + +This produces a PDF summarizing privacy manifests from your app and all embedded frameworks. + +**Privacy manifest gotchas**: +- Custom string values for `NSPrivacyCollectedDataType` or `NSPrivacyCollectedDataTypePurposes` are silently rejected by Xcode — must use Apple's predefined constants +- iOS 17 automatically blocks connections to declared tracking domains when user denies tracking +- IP address collection must be declared (as location, device ID, or diagnostics) +- Web view data collection must be declared +- You are responsible for ALL data collected by third-party SDKs in your app +- Some SDKs default to tracking unless explicitly disabled — creates unintentional tracking +- Fingerprinting is NEVER allowed, regardless of ATT permission +- Since May 1, 2024: missing required-reason API declarations cause automatic rejection (no human review) + +### Required Reason API Categories + +| Category | APIs Covered | Common Reasons | +|----------|-------------|----------------| +| File timestamp | `NSFileCreationDate`, `NSFileModificationDate`, `NSURLContentModificationDateKey` | DDA9.1 (display to user), C617.1 (inside app container) | +| System boot time | `systemUptime`, `mach_absolute_time` | 35F9.1 (measure elapsed time) | +| Disk space | `NSFileSystemFreeSize`, `NSFileSystemSize`, `volumeAvailableCapacityKey` | E174.1 (check before writing), 85F4.1 (display to user) | +| Active keyboard | `activeInputModes` | 54BD.1 (customize UI for keyboard) | +| User defaults | `UserDefaults` (all access requires declaration) | CA92.1 (access within app group), 1C8F.1 (access within same app) | + +### App Privacy Details (Nutrition Labels) + +#### Data Type Categories + +| Category | Examples | +|----------|---------| +| Contact Info | Name, email address, phone number, physical address | +| Health & Fitness | Health data, fitness data | +| Financial Info | Payment info, credit info | +| Location | Precise location, coarse location | +| Sensitive Info | Racial or ethnic data, sexual orientation, religion, biometrics | +| Contacts | Address book contacts | +| User Content | Photos, videos, audio, gameplay content, customer support messages | +| Browsing History | Web browsing history | +| Search History | In-app search history | +| Identifiers | User ID, device ID | +| Purchases | Purchase history | +| Usage Data | Product interaction, advertising data, app launches, taps, scrolls | +| Diagnostics | Crash data, performance data | +| Surroundings | Environment scanning (e.g., AR data) | +| Body | Hands, head (e.g., hand tracking in visionOS) | + +#### Purpose Categories + +| Purpose | Description | +|---------|-------------| +| Third-Party Advertising | Displaying third-party ads or sharing with ad networks | +| Developer's Advertising/Marketing | Your own marketing campaigns | +| Analytics | Understanding user behavior and measuring effectiveness | +| Product Personalization | Customizing features, content recommendations | +| App Functionality | Required for app to work (e.g., authentication, data sync) | +| Other | Any purpose not listed above | + +### Tracking and Collection Definitions + +**"Collected"** means data is transmitted off-device and accessible beyond what is needed to service the current request. On-device-only processing is NOT collection. + +**"Tracking"** means: +- Linking user/device data from your app with third-party data for advertising or measurement, OR +- Sharing user/device data with a data broker + +### App Tracking Transparency (ATT) + +Required if your app "tracks" per Apple's definition above. + +- Add `NSUserTrackingUsageDescription` to Info.plist (explains why tracking is needed) +- Call `ATTrackingManager.requestTrackingAuthorization()` before tracking +- Respect the result: + - `.authorized` — User granted permission to track + - `.denied` — User denied tracking; do not track + - `.notDetermined` — User has not yet been asked + - `.restricted` — Device-level restriction prevents tracking + +Request at a contextually appropriate moment, not at first launch. + +### Common Purpose Strings (NS*UsageDescription) + +These Info.plist keys must be present for each system permission your app requests: + +| Permission | Info.plist Key | +|------------|---------------| +| Camera | `NSCameraUsageDescription` | +| Microphone | `NSMicrophoneUsageDescription` | +| Photo Library (read) | `NSPhotoLibraryUsageDescription` | +| Photo Library (write) | `NSPhotoLibraryAddUsageDescription` | +| Location (when in use) | `NSLocationWhenInUseUsageDescription` | +| Location (always) | `NSLocationAlwaysAndWhenInUseUsageDescription` | +| Contacts | `NSContactsUsageDescription` | +| Calendars (full access) | `NSCalendarsFullAccessUsageDescription` | +| Reminders (full access) | `NSRemindersFullAccessUsageDescription` | +| Health | `NSHealthShareUsageDescription`, `NSHealthUpdateUsageDescription` | +| Motion | `NSMotionUsageDescription` | +| Bluetooth | `NSBluetoothAlwaysUsageDescription` | +| Face ID | `NSFaceIDUsageDescription` | +| Local Network | `NSLocalNetworkUsageDescription` | +| Tracking | `NSUserTrackingUsageDescription` | +| Speech Recognition | `NSSpeechRecognitionUsageDescription` | +| Apple Music | `NSAppleMusicUsageDescription` | + +Missing purpose strings cause immediate rejection. Purpose string text must clearly explain why the permission is needed in the context of your app's functionality. + +### Third-Party SDK Privacy Manifests + +Apple maintains a list of commonly used SDKs that require privacy manifests. Starting spring 2024, if your app includes these SDKs without privacy manifests, it will be flagged during submission. + +Third-party SDKs should include their own `PrivacyInfo.xcprivacy` in their framework bundle. The aggregate privacy report combines all manifests from your app and embedded frameworks. + +If a third-party SDK does not include a privacy manifest, you must declare its data collection in your app's privacy manifest. + +--- + +## Part 3: App Review Guidelines Quick Reference + +For the complete guideline index (Sections 1-5), see `references/app-review-guidelines.md`. + +### Most Common Rejection Reasons + +See `references/app-review-guidelines.md` for the full top-10 rejection causes table with percentages. + +### App Review Timeline + +| Stage | Typical Duration | +|-------|-----------------| +| Waiting for Review | Minutes to hours | +| In Review | Minutes to 24 hours | +| Total (90th percentile) | Under 24 hours | +| Total (edge cases) | Up to 7 days | +| Expedited Review | Same day to 24 hours (if approved) | + +Review times increase during holidays and major iOS release periods. Plan submissions accordingly. + +--- + +## Part 4: Age Rating System + +### Five-Tier Rating System (Updated January 31, 2026) + +| Rating | Triggers | +|--------|----------| +| **4+** | No objectionable material | +| **9+** | Infrequent or mild: profanity, cartoon/fantasy violence, horror/fear themes. Loot boxes present | +| **13+** | Frequent or intense: profanity or crude humor. Infrequent: alcohol/tobacco/drugs references, sexual content/nudity, realistic violence | +| **16+** | Unrestricted web access, frequent medical/treatment info, mature/suggestive themes | +| **18+** | Frequent or intense: alcohol/tobacco/drugs use, sexual content/nudity, realistic violence. Simulated gambling with real-money elements | +| **Unrated** | App cannot be published without completing the questionnaire | + +### Capability Declarations (New, WWDC25) + +Apps must declare if they include these capabilities: + +| Capability | When to Declare | +|------------|----------------| +| Messaging/chat | Any in-app messaging between users | +| User-generated content | Users can post, share, or upload content visible to others | +| Advertising | App displays ads from any ad network | +| Parental controls | App has parental restrictions or family features | +| Age assurance | App verifies user age for restricted content | + +These declarations appear alongside the age rating on the App Store product page, giving parents and users additional transparency. + +### Regional Variations + +Age ratings map differently across regions: + +| Apple Rating | Australia | Brazil | Korea | Germany (USK) | +|-------------|-----------|--------|-------|----------------| +| 4+ | 4+ | L (All ages) | All | 0 | +| 9+ | 9+ | A10 | 12+ | 6 | +| 13+ | 13+ | A12 | 15+ | 12 | +| 16+ | 15+ | A16 | 19+ | 16 | +| 18+ | R 18+ | A18 | 19+ | 18 | + +The age rating questionnaire automatically generates the appropriate regional ratings based on your answers. + +### Age Rating Best Practices + +- Answer the questionnaire conservatively; under-rating leads to rejection +- If your app accesses unrestricted web content (WebView without content filter), it will be rated 16+ minimum +- UGC apps typically need 13+ minimum due to moderation requirements +- Simulated gambling (even without real money) requires at least 9+ +- Realistic violence in gameplay requires at least 13+ + +### Age Rating Questionnaire Topics + +The questionnaire covers these content categories: + +| Category | Options | +|----------|---------| +| Cartoon or Fantasy Violence | None, Infrequent/Mild, Frequent/Intense | +| Realistic Violence | None, Infrequent/Mild, Frequent/Intense | +| Profanity or Crude Humor | None, Infrequent/Mild, Frequent/Intense | +| Mature/Suggestive Themes | None, Infrequent/Mild, Frequent/Intense | +| Alcohol, Tobacco, or Drug Use or References | None, Infrequent/Mild, Frequent/Intense | +| Sexual Content and Nudity | None, Infrequent/Mild, Frequent/Intense | +| Horror/Fear Themes | None, Infrequent/Mild, Frequent/Intense | +| Simulated Gambling | None, Infrequent/Mild, Frequent/Intense | +| Medical/Treatment Information | None, Infrequent/Mild, Frequent/Intense | +| Unrestricted Web Access | Yes/No | + +The system automatically calculates your app's age rating across all regions based on your answers. + +--- + +## Part 5: Export Compliance + +### Three-Tier Encryption System + +| Tier | Encryption Type | Documentation Required | +|------|----------------|----------------------| +| **Exempt** | Apple OS built-in (HTTPS via URLSession, AES data protection, CryptoKit, Keychain, Secure Enclave) | None — set `ITSAppUsesNonExemptEncryption = NO` | +| **Standard** | Industry-standard algorithms (IEEE, IETF, ISO, ITU, ETSI, 3GPP approved) | French ANSSI declaration only (if distributing in France) | +| **Proprietary** | Custom/unpublished algorithms not adopted by standards bodies | US CCATS + French declaration (if France) | + +### Encryption Decision Tree + +``` +Does your app use encryption? +├── No → ITSAppUsesNonExemptEncryption = NO → Done +├── Only Apple OS encryption (HTTPS, Keychain, CryptoKit)? +│ ├── Yes → ITSAppUsesNonExemptEncryption = NO → Done +│ │ (May need annual BIS self-classification report) +│ └── No → Industry-standard algorithm (AES, RSA, etc.)? +│ ├── Yes → ITSAppUsesNonExemptEncryption = YES +│ │ French declaration if distributing in France +│ │ Upload docs → receive ITSEncryptionExportComplianceCode +│ └── No (custom/proprietary) → +│ ITSAppUsesNonExemptEncryption = YES +│ US CCATS required (~2 business days) +│ French declaration if France +│ Upload docs → receive ITSEncryptionExportComplianceCode +``` + +### Info.plist Keys + +```xml + +ITSAppUsesNonExemptEncryption + + + +ITSAppUsesNonExemptEncryption + +ITSEncryptionExportComplianceCode +YOUR_COMPLIANCE_CODE +``` + +Setting `ITSAppUsesNonExemptEncryption` in Info.plist skips the encryption questionnaire on every submission. + +### Exempt Encryption Uses + +No documentation needed: +- HTTPS/TLS (URLSession, Network.framework, WKWebView) +- Secure Enclave, Keychain, biometric auth +- CryptoKit, Security.framework per Apple docs +- Password hashing (bcrypt, scrypt, PBKDF2) + +### Non-Exempt Encryption Uses + +Documentation required: +- Custom encryption algorithms +- OpenSSL, libsodium for non-standard purposes +- End-to-end encrypted messaging +- VPN implementations +- Custom DRM systems + +### France-Specific + +Import/export controlled by ANSSI. Banking and medical apps are exempt. Applies to: secure storage, secure communications, security/antivirus apps. + +--- + +## Part 6: Account and Authentication + +### Account Deletion (Required Since June 2022) + +Apps that support account creation must offer account deletion. Requirements: + +| Requirement | Details | +|-------------|---------| +| Full deletion | Must fully delete the account, not just deactivate | +| Easy to find | Must be accessible from app settings; not buried behind support tickets | +| Inform timeline | Tell user how long deletion takes | +| Confirm completion | Notify user when deletion is complete | +| Delete shared UGC | Must handle user-generated content shared with others | +| Revoke SIWA tokens | Call Apple's revoke token endpoint for Sign in with Apple accounts | +| Handle subscriptions | Warn about active subscriptions; direct to subscription management | + +#### Sign in with Apple Token Revocation + +```swift +// Server-side: revoke SIWA tokens when account deleted +// POST https://appleid.apple.com/auth/revoke +// Parameters: client_id, client_secret, token, token_type_hint +``` + +Failing to revoke SIWA tokens during account deletion is a common rejection reason. + +### Sign in with Apple (Guideline 4.8) + +**Required when:** Your app offers ANY third-party or social login option (Google, Facebook, Twitter, email/password via third-party provider). + +#### Exceptions — SIWA not required when +- App is for company employees only (internal enterprise app) +- App is for education or enterprise with existing institutional auth +- App uses government or industry-backed citizen ID systems +- App is a client for a specific third-party service (e.g., Gmail app, Slack) + +When SIWA is required, it must be offered as an equally prominent option alongside other sign-in methods. It cannot be hidden or given less visual weight. + +### Account Deletion Implementation Checklist + +| Step | Details | +|------|---------| +| 1. Add UI entry point | Settings screen, clearly labeled "Delete Account" | +| 2. Explain consequences | Show what will be deleted (data, subscriptions, purchases) | +| 3. Require confirmation | User must explicitly confirm deletion | +| 4. Handle active subscriptions | Direct user to cancel active subscriptions before deletion | +| 5. Process deletion | Delete all user data from your servers | +| 6. Revoke SIWA tokens | Call Apple's revoke endpoint if SIWA was used | +| 7. Confirm to user | Send email or in-app confirmation when deletion is complete | +| 8. Define timeline | State how long deletion takes (immediately, 30 days, etc.) | + +Apple specifically rejects apps that: +- Require users to call a phone number to delete their account +- Require users to send an email to request deletion +- Only offer account deactivation (hiding profile) instead of full deletion +- Don't handle SIWA token revocation + +--- + +## Part 7: Monetization and IAP + +### IAP Submission Pipeline + +In-app purchases have a separate review process from app submissions: + +| Scenario | Behavior | +|----------|----------| +| First IAP ever | Must be bundled with a new app version submission | +| Subsequent IAPs | Can be submitted independently of app updates | +| IAP metadata change | Submitted for review independently | +| IAP price change | Takes effect without review | + +#### Required IAP Metadata + +| Field | Required | Notes | +|-------|----------|-------| +| Reference Name | Yes | Internal name (not visible to users) | +| Product ID | Yes | Unique, cannot be reused after deletion | +| Type | Yes | Consumable, non-consumable, auto-renewable, non-renewing | +| Price | Yes | Select from Apple's price tiers | +| Display Name | Yes | Localizable, shown to users | +| Description | Yes | Localizable, shown to users | +| Screenshot | Yes | One screenshot showing the IAP in context | +| Review Notes | No | Explain what the IAP unlocks | + +#### IAP Status Flow + +``` +Missing Metadata → Ready to Submit → Waiting for Review → In Review → Approved + → Rejected +``` + +IAP must be in "Ready to Submit" status before it can be included in an app submission. + +### Subscription Rules (Guideline 3.1.2) + +| Rule | Details | +|------|---------| +| Ongoing value | Subscriptions must provide continuing value over time | +| Minimum duration | 7 days minimum subscription period | +| Cross-device | Must work across all user's devices where app is available | +| Transparent terms | Clearly state price, duration, auto-renewal, and cancellation | +| No removing features | Cannot remove previously paid functionality to force subscription | +| Grace period | Support billing grace period (user retains access during retry) | +| Upgrade/downgrade | Must support plan changes within subscription group | + +### Loot Boxes (Guideline 3.1.1) + +Apps offering loot boxes or random item mechanics must disclose the odds of receiving each type of item before purchase. + +### External Payment Eligibility + +| Category | Guideline | What's Allowed | +|----------|-----------|----------------| +| Reader apps | 3.1.3(a) | Link to website for previously purchased content (magazines, newspapers, books, audio, music, video) | +| Multiplatform services | 3.1.3(b) | Cross-platform subscriptions (e.g., Netflix, Spotify) | +| Enterprise services | 3.1.3(c) | B2B apps for organizations, not individual consumers | +| Person-to-person | 3.1.3(d) | Real-time one-to-one services (tutoring, consulting, ride-sharing) | +| Physical goods/services | 3.1.3(e) | Goods consumed outside the app (food delivery, clothing, physical subscriptions) | + +Apps in these categories may accept payment outside the IAP system. + +### Subscription Group Architecture + +| Concept | Details | +|---------|---------| +| Subscription Group | Collection of related subscription tiers (e.g., Basic, Pro, Premium) | +| Service Level | Rank within a group; determines upgrade/downgrade behavior | +| Upgrade | Moving to higher service level (immediate, prorated) | +| Downgrade | Moving to lower service level (effective at next renewal) | +| Crossgrade | Same service level, different duration (monthly ↔ annual) | +| Family Sharing | Can be enabled per subscription group | + +#### Subscription Pricing + +| Feature | Details | +|---------|---------| +| Price tiers | Apple provides 900+ price points across 175+ storefronts | +| Price equalization | Apple auto-equalizes prices across currencies | +| Custom pricing | Set custom prices per storefront | +| Introductory offers | Free trial, pay-as-you-go, pay-up-front | +| Promotional offers | For existing/lapsed subscribers; requires server-signed JWS | +| Win-back offers | For lapsed subscribers; displayed by system automatically | +| Offer codes | Distributable codes for free/discounted access | + +#### Subscription Restore Purchases + +All subscription apps must implement Restore Purchases functionality. This is tested during App Review. Implement via: + +```swift +try await AppStore.sync() +``` + +If Restore Purchases is missing or non-functional, the app will be rejected. + +### Free Trial Best Practices + +| Practice | Details | +|----------|---------| +| Duration display | Clearly show trial length before user commits | +| Post-trial pricing | Show what price will be charged after trial ends | +| Cancellation | Explain how to cancel before trial ends | +| No dark patterns | Don't make cancellation difficult or hard to find | +| Reminder | Consider sending a push notification before trial ends | + +--- + +## Part 8: EU-Specific Compliance + +### Digital Services Act (DSA) Trader Status + +**Applies to:** ALL apps distributed in the EU (27 member states) + +**Timeline:** Since February 17, 2025, apps without declared trader status are subject to removal from the EU App Store. + +#### What is Trader Status? + +A self-assessment: are you acting as a "trader" (selling goods/services to EU consumers) or a non-trader (hobby, open-source, non-commercial)? Apple cannot determine this for you. + +#### Trader Requirements + +If you declare as a trader, you must provide: + +| Field | Required | Verification | +|-------|----------|-------------| +| Legal name | Yes | — | +| Address | Yes | — | +| Phone number | Yes | Verified via 2FA | +| Email address | Yes | Verified via 2FA | +| Company registration | Where applicable | — | +| VAT ID | Where applicable | — | + +This contact information is displayed on your EU product page. + +#### Declaring in App Store Connect + +``` +App Store Connect > Users and Access > Developer Profile > Trader Status +``` + +Select your trader status for each app. If you have both paid and free apps, each app may have a different trader classification. + +### EU Alternative Distribution + +Under the Digital Markets Act (DMA), Apple allows alternative app distribution in the EU: +- Alternative app marketplaces +- Web distribution (notarized apps) +- Alternative payment processing + +These require separate business terms (Alternative Terms Addendum) and additional compliance steps. See Apple's EU developer documentation for details. + +### EU 27 Member States + +Apps distributed in any of these territories require DSA compliance: + +Austria, Belgium, Bulgaria, Croatia, Cyprus, Czech Republic, Denmark, Estonia, Finland, France, Germany, Greece, Hungary, Ireland, Italy, Latvia, Lithuania, Luxembourg, Malta, Netherlands, Poland, Portugal, Romania, Slovakia, Slovenia, Spain, Sweden. + +If your app is available in "All Territories" (the default), it is available in the EU and DSA compliance is required. + +--- + +## Part 9: Build Upload and Processing + +### Upload Methods + +| Method | Best For | +|--------|---------| +| **Xcode** (recommended) | Most developers; integrated with Archive workflow | +| **Xcode Cloud** | CI/CD with automatic builds and distribution | +| **Transporter** | Standalone macOS app for batch uploads | +| **altool** (CLI) | Scripted CI/CD pipelines | +| **App Store Connect API** | Fully automated workflows | + +### Build Identifiers + +| Identifier | Purpose | Example | Rules | +|------------|---------|---------|-------| +| Bundle ID | Uniquely identifies your app | `com.company.app` | Set once, cannot change | +| Version Number | User-facing version | `2.1.0` | Must increment for each release | +| Build String | Distinguishes builds of same version | `2.1.0.42` | Must be unique per version per platform | + +### Build Selection + +- Only one build can be selected per version +- Build selection can be changed until the version is submitted for review +- "Missing Compliance" status blocks build selection until export compliance questions are answered + +### SDK Requirements + +| Effective Date | Requirement | +|----------------|-------------| +| April 2025 (current) | Xcode 16, iOS 18 SDK | +| April 28, 2026 (upcoming) | Xcode 26, iOS 26 SDK | + +Apps built with outdated SDKs will be rejected after the effective date for new submissions. Existing apps on the store are not affected until they submit an update. + +### Build Processing + +After upload, Apple processes your build: + +1. **Upload** — Binary transferred to Apple (5-30 minutes depending on size) +2. **Processing** — Apple validates binary, runs automated checks (15-60 minutes) +3. **Available** — Build appears in App Store Connect, ready for TestFlight or submission +4. **Email notification** — Sent when processing completes or fails + +Common processing failures: +- Missing required architectures (arm64 required) +- Invalid provisioning profile or signing identity +- Missing privacy manifest for third-party SDKs on Apple's list +- Info.plist missing required keys +- Binary too large (OTA download limit: 200 MB over cellular) + +### IPv6 Compatibility + +All apps must work on IPv6-only networks. Apple's review environment uses IPv6. Common issues: +- Hard-coded IPv4 addresses +- Using low-level socket APIs instead of high-level networking +- Third-party SDKs with IPv4-only code + +Use `URLSession` or `Network.framework` to ensure IPv6 compatibility automatically. + +### App Thinning and Bitcode + +| Topic | Status | +|-------|--------| +| Bitcode | Deprecated since Xcode 14; no longer accepted | +| App Thinning | Active; Apple generates device-specific variants | +| On-Demand Resources | Active; tag resources for download on demand | +| Asset catalogs | Used for app thinning of images (1x/2x/3x) | + +### Entitlements and Capabilities + +Certain features require entitlements configured in Xcode and provisioning profiles: + +| Capability | Entitlement | Common Issues | +|------------|-------------|---------------| +| Push Notifications | `aps-environment` | Certificate expiry, missing provisioning | +| App Groups | `com.apple.security.application-groups` | Shared container ID mismatch | +| Associated Domains | `com.apple.developer.associated-domains` | AASA file not served correctly | +| HealthKit | `com.apple.developer.healthkit` | Missing required capabilities | +| Background Modes | `UIBackgroundModes` | Using modes without justification | +| Sign in with Apple | `com.apple.developer.applesignin` | Missing from provisioning profile | +| CloudKit | `com.apple.developer.icloud-services` | Container ID mismatch | +| In-App Purchase | — | Enabled by default; StoreKit config needed for testing | + +### TestFlight Submission + +| Aspect | Internal Testing | External Testing | +|--------|-----------------|-----------------| +| Testers | Up to 100 ASC users | Up to 10,000 via email/public link | +| Review required | No | Yes (first build per version, full App Review) | +| Review time | — | Usually under 24 hours | +| Build expiry | 90 days from upload | 90 days from upload | +| Groups | Automatic (ASC roles) | Custom groups with "What to Test" notes | +| Feedback | Crash reports | Screenshots, text feedback, crash reports | +| Submission limit | — | Max 6 builds per 24-hour period | + +**TestFlight readiness checklist**: +- [ ] Internal tester group exists (required before creating external groups) +- [ ] Beta App Description set (can differ from production) +- [ ] Feedback email configured +- [ ] "What to Test" notes written for each external group +- [ ] Export compliance answered (required for beta builds too) +- [ ] Demo credentials included (if login required) +- [ ] First external build: expect full App Review (guideline compliance checked) + +**Gotchas**: +- Builds uploaded as "TestFlight Internal Only" from Xcode/Xcode Cloud can only go to internal groups +- Managed Apple Accounts (School/Business Manager) cannot be testers +- Public link tester cap is configurable (1-10,000) per link +- Builds remain testable even after the app goes live on the App Store +- Developer can manually expire builds before 90 days + +--- + +## Part 10: WWDC25 Changes + +### Draft Submissions (WWDC 2025-328) + +Group multiple items into a single draft submission: +- App version + new IAPs + product page changes +- Review everything together instead of separate submissions +- Draft state: prepare items over time, submit when ready + +### Reusable Build Numbers on Failure + +When a build is rejected due to metadata issues (not binary issues), you can reuse the same build without re-uploading. Previously, rejected builds required a new build string. + +### Builds Retained After Error Rejection + +Builds are no longer removed from App Store Connect after certain rejection types. You can fix metadata issues and resubmit with the same build. + +### Accessibility Nutrition Labels + +New App Store metadata for accessibility features: +- Declare which accessibility features your app supports +- Displayed on your App Store product page +- Categories include VoiceOver support, Dynamic Type, Switch Control, etc. +- Helps users find apps that meet their accessibility needs + +### App Store Tags (LLM-Generated, Editable) + +Apple generates descriptive tags for your app using AI: +- Tags appear on your product page +- You can review and edit suggested tags +- Tags improve discoverability in search +- Based on app metadata, description, and functionality + +### Custom Product Page Keywords + +Product pages can now have unique keywords: +- Different keywords per custom product page +- Improves targeting for different audiences +- Each custom page can appear in different search results + +### Offer Codes Expanded + +Offer codes now support all IAP types: +- Consumables +- Non-consumables +- Non-renewing subscriptions +- Auto-renewable subscriptions (existing) + +### Review Summaries (AI-Generated) + +Apple generates AI summaries of user reviews: +- Summarizes common themes across reviews +- Displayed on the product page +- Updated as new reviews come in +- Helps users quickly understand app quality and common feedback + +### Analytics Enhancements + +100+ new analytics metrics including: +- Pre-order conversion funnels +- Custom product page performance comparison +- Subscription lifecycle metrics (trial to paid conversion, churn timing) +- Peer group benchmarking (compare performance against similar apps) +- Download source attribution refinements + +### Age Rating Overhaul + +Five-tier system with new capability declarations (see Part 4 for full details). + +### Custom Product Pages (Existing, Enhanced in WWDC25) + +Custom product pages allow different App Store presentations for different audiences: + +| Feature | Details | +|---------|---------| +| Maximum | Up to 35 custom product pages per app | +| Customizable | Screenshots, app previews, promotional text | +| NOT customizable | App name, icon, description, What's New | +| URL | Unique URL per custom page for attribution | +| Keywords | New in WWDC25: unique keywords per custom product page | +| Analytics | Impressions, downloads, conversion rates per page | + +### App Store Pricing Changes + +| Feature | Details | +|---------|---------| +| 900+ price points | Expanded from original 87 tiers | +| Global equalization | Automatic currency conversion with regional pricing | +| Custom pricing | Override auto-equalization for specific storefronts | +| Price increases | Existing subscribers notified; must consent for >50% increase | +| Regional pricing | Set prices optimized for each market's purchasing power | + +--- + +## Expert Review Checklist + +For the comprehensive 9-section submission checklist, see `references/expert-review-checklist.md`. +For the discipline-focused pre-flight workflow, see `app-store-submission`. + +--- + +## Troubleshooting + +### 10 Common Submission Issues + +| # | Issue | Cause | Fix | +|---|-------|-------|-----| +| 1 | "Missing Compliance" on build | Export compliance questions not answered | App Store Connect > build > answer encryption questions | +| 2 | Build not appearing in ASC | Processing delay or failure | Wait 15-60 min; check email for processing errors | +| 3 | "Add for Review" button grayed | Missing required metadata | Check all required fields in App Information and Version Information | +| 4 | Screenshots wrong size | Device spec mismatch | Use exact pixel dimensions for each device size class | +| 5 | Privacy policy URL invalid | Not HTTPS or not publicly accessible | Must be `https://` URL accessible without login | +| 6 | IAP not available for review | IAP not in "Ready to Submit" status | Complete all IAP metadata including screenshot; set status | +| 7 | Age rating warnings | Questionnaire incomplete or capabilities not declared | Complete questionnaire; answer new capability questions | +| 8 | DSA trader status incomplete | Email or phone not verified | Complete 2FA verification for both email and phone | +| 9 | Build string conflict | Duplicate build string for same version | Each build upload must have a unique build string | +| 10 | "In Review" for extended period | Complex review or holiday backlog | 90% of apps reviewed in <24h; use expedited review for critical/urgent issues | + +For rejection handling (expedited review, appeals, Resolution Center), see `app-store-diag` Pattern 7. +For pre-submission testing checklists, see `app-store-diag` or `app-store-submission`. + +### App Store Connect API for Submissions + +For automated submission workflows: + +| Endpoint | Purpose | +|----------|---------| +| `POST /v1/appStoreVersions` | Create new version | +| `PATCH /v1/appStoreVersions/{id}` | Update version metadata | +| `POST /v1/appStoreVersionSubmissions` | Submit version for review | +| `GET /v1/apps/{id}/appStoreVersions` | List all versions | +| `POST /v1/appScreenshots` | Upload screenshots | +| `POST /v1/appPreviews` | Upload app preview videos | +| `GET /v1/apps/{id}/builds` | List processed builds | + +Authentication requires an API key from App Store Connect (Users and Access > Integrations > App Store Connect API). + +--- + +## Resources + +**WWDC**: 2022-10166, 2025-224, 2025-241, 2025-252, 2025-328 + +**Docs**: /app-store/review/guidelines, /app-store/submitting, /app-store/app-privacy-details, /help/app-store-connect + +**Skills**: app-store-submission, app-store-diag, privacy-ux, storekit-ref, accessibility-diag diff --git a/.claude/skills/axiom-app-store-ref/agents/openai.yaml b/.claude/skills/axiom-app-store-ref/agents/openai.yaml new file mode 100644 index 0000000..b3225c3 --- /dev/null +++ b/.claude/skills/axiom-app-store-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "App Store Reference" + short_description: "Looking up ANY App Store metadata field requirement, privacy manifest schema, age rating tier, export compliance deci..." diff --git a/.claude/skills/axiom-app-store-ref/references/app-review-guidelines.md b/.claude/skills/axiom-app-store-ref/references/app-review-guidelines.md new file mode 100644 index 0000000..6930d26 --- /dev/null +++ b/.claude/skills/axiom-app-store-ref/references/app-review-guidelines.md @@ -0,0 +1,133 @@ +# App Review Guidelines Index + +Verified against Apple's published guidelines (February 6, 2026 revision). + +## Section 1: Safety + +| Guideline | Topic | +|-----------|-------| +| 1.1 | Objectionable Content | +| 1.1.1 | Defamatory, discriminatory, or mean-spirited content | +| 1.1.2 | Realistic portrayals of people or animals being killed/maimed/tortured/abused | +| 1.1.3 | Depictions encouraging weapons use against people/animals | +| 1.1.4 | Pornographic material (immediate removal) | +| 1.1.5 | Religious/cultural/ethnic commentary that fosters prejudice | +| 1.1.6 | False information, fake functionality ("for entertainment" does NOT excuse this) | +| 1.1.7 | Capitalizing on recent events (tragedies, conflicts, epidemics) | +| 1.2 | User-Generated Content — must have filtering, reporting, blocking, contact info, age verification | +| 1.3 | Kids Category — no third-party analytics/advertising, COPPA/GDPR-Kids compliance | +| 1.4 | Physical Harm | +| 1.4.1 | Medical apps: disclose limitations, link to real medical help | +| 1.4.2 | Drug dosage calculators: recognized institutions only | +| 1.4.3 | Tobacco, e-cigarettes, vape, illegal drug use encouragement | +| 1.4.4 | DUI/checkpoint apps that encourage reckless behavior | +| 1.4.5 | Activities that risk physical harm (bets, dares, body modification) | +| 1.5 | Developer Information — program membership must be current | +| 1.6 | Data Security — ATS required, justified exceptions only | + +## Section 2: Performance + +| Guideline | Topic | +|-----------|-------| +| 2.1 | App Completeness — no crashes, broken links, placeholders, missing demo accounts | +| 2.2 | Beta/Demo/Trial — use TestFlight, not "beta" in app name or bundle ID | +| 2.3 | Accurate Metadata | +| 2.3.1 | No hidden/undocumented features; no misleading descriptions | +| 2.3.2 | No concealed features | +| 2.3.3 | Screenshots must reflect actual app experience on correct device | +| 2.3.5 | Use accurate App Store category | +| 2.3.6 | Age rating must match actual content | +| 2.3.7 | App name max 30 chars; no keyword stuffing in name/subtitle | +| 2.3.8 | Metadata must be age-appropriate; "For Kids"/"For Children" reserved for Kids category | +| 2.4 | Hardware Compatibility — must work with current OS | +| 2.5 | Software Requirements | +| 2.5.1 | Only public APIs | +| 2.5.2 | Self-contained; no code downloads that change functionality | +| 2.5.3 | No viruses, malware, code injection (immediate removal) | +| 2.5.4 | Multitasking must use proper background modes | +| 2.5.5 | Must be fully functional on IPv6-only networks | +| 2.5.6 | Web browsing must use WebKit (alternative engine entitlement available) | +| 2.5.9 | Request only necessary permissions | +| 2.5.11 | SiriKit/HealthKit must actually use the declared feature | +| 2.5.17 | Matter integration must use Apple's framework; third-party components CSA-certified | +| 2.5.18 | No display advertising in extensions, App Clips, widgets, notifications, keyboards, watchOS | + +## Section 3: Business + +| Guideline | Topic | +|-----------|-------| +| 3.1.1 | In-App Purchase required for digital goods/services. Loot box odds must be disclosed before purchase. NFTs: may sell via IAP, ownership must not unlock features. | +| 3.1.2 | Subscriptions: ongoing value, 7-day minimum period, cross-device, transparent terms (price, duration, auto-renewal, cancellation). Schedule 2 of DPLA requires ToS/PP on purchase screen. | +| 3.1.3(a-e) | External payments: reader apps, multiplatform, enterprise, person-to-person, physical goods | +| 3.1.4 | No artificial barriers between IAP and web purchase options | +| 3.1.5 | Cryptocurrency: wallets require organization enrollment, exchanges need licensing, no on-device mining, no crypto rewards for tasks | +| 3.2.2(viii) | Binary options trading apps prohibited | +| 3.2.2(ix) | Loan apps: max 36% APR including fees, no full repayment required within 60 days | + +## Section 4: Design + +| Guideline | Topic | +|-----------|-------| +| 4.0 | General design standards (HIG compliance) | +| 4.1 | Copycats — apps confusingly similar to existing apps (4.1(b): impersonation = removal from Developer Program) | +| 4.2 | Minimum Functionality — no web wrappers, no single-media apps, must have lasting value | +| 4.2.6 | Template/app-generation-service apps rejected unless submitted by content provider | +| 4.3 | Spam — no duplicate apps from same developer | +| 4.4.1 | Keyboard extensions must include next-keyboard switching | +| 4.5.4 | Push notifications: no advertising, marketing, or spam | +| 4.7 | Mini apps, streaming games, chatbots, emulators: must provide universal link index, age restrictions, content filtering | +| 4.8 | Sign in with Apple required when ANY third-party/social login offered (exceptions: company-internal, education, government, client apps for specific services) | +| 4.10 | Cannot monetize built-in capabilities (push, camera, gyroscope, Apple Music, iCloud storage, Screen Time APIs) | + +## Section 5: Legal + +| Guideline | Topic | +|-----------|-------| +| 5.1.1(i) | Privacy policy required in App Store Connect AND within app | +| 5.1.1(ii) | Permission requests must explain purpose with benefit to user | +| 5.1.1(iii) | Don't require unnecessary personal info | +| 5.1.1(v) | Account deletion must be offered if account creation supported | +| 5.1.1(vi) | Surreptitiously discovering passwords (removal from Developer Program) | +| 5.1.2(i) | No sharing with third parties without consent; ATT required for tracking | +| 5.1.3 | Health data must not be stored in iCloud; no false HealthKit data | +| 5.1.4 | Kids Category requirements (COPPA) | +| 5.1.5 | Location Services must have clear purpose | +| 5.2 | Intellectual Property — no unauthorized copyrighted material | +| 5.3 | Gaming/Gambling — real-money gambling requires licensing | +| 5.4 | VPN Apps — must use NEVPNManager API | +| 5.5 | Developer Code of Conduct | +| 5.6 | Telecommunications | + +## Zero-Tolerance Guidelines (Immediate Removal Risk) + +| Guideline | Consequence | +|-----------|-------------| +| 1.1.4 | Pornographic content → immediate removal | +| 2.5.3 | Viruses/malware → immediate removal | +| 4.1(b) | App impersonation → removal from Developer Program | +| 5.1.1(vi) | Surreptitious password discovery → removal from Developer Program | + +## Top 10 Rejection Causes + +| Rank | Guideline | Issue | % of Rejections | +|------|-----------|-------|-----------------| +| 1 | 2.1 | App Completeness (crashes, placeholders, broken flows) | ~40% | +| 2 | 5.1.1(i) | Privacy policy missing/inadequate | — | +| 3 | 2.1 | Incomplete review info (missing demo accounts) | — | +| 4 | 2.3.3 | Screenshots don't match app | — | +| 5 | 4.0 | Substandard UI / HIG violations | — | +| 6 | 4.2 | Web wrapper / insufficient functionality | — | +| 7 | 2.3.1 | Misleading metadata | — | +| 8 | 4.2 | Insufficient lasting value | — | +| 9 | 4.1 | Copycat app | — | +| 10 | 4.3 | Repeated similar apps | — | + +## Sensitive App Types Requiring Extra Documentation + +| Type | Requirements | +|------|-------------| +| Kids apps with third-party ads | Links to ad policies, proof of human review | +| Medical hardware integration | Regulatory clearance for all regions | +| Third-party content/trademarks | Authorization documentation | +| Gambling, VPN, real money gaming | Licensing documentation | +| Banking, crypto, healthcare, air travel | Must be submitted by legal entity (not individuals) | diff --git a/.claude/skills/axiom-app-store-ref/references/expert-review-checklist.md b/.claude/skills/axiom-app-store-ref/references/expert-review-checklist.md new file mode 100644 index 0000000..26343fe --- /dev/null +++ b/.claude/skills/axiom-app-store-ref/references/expert-review-checklist.md @@ -0,0 +1,95 @@ +# Expert Review Checklist + +Comprehensive 9-section submission checklist. For the discipline-focused pre-flight workflow, see `app-store-submission`. + +## Build + +- [ ] Built with required SDK version (currently Xcode 16, iOS 18 SDK) +- [ ] Export compliance answered (`ITSAppUsesNonExemptEncryption`) +- [ ] Encryption documentation uploaded (if custom encryption) +- [ ] IPv6-only network compatible +- [ ] Signed with distribution certificate and provisioning profile +- [ ] Correct bundle ID for target environment (production, not development) +- [ ] Build string unique for this version +- [ ] Binary under 200 MB OTA cellular limit (or warn users) +- [ ] All required architectures included (arm64) +- [ ] No private API usage + +## Privacy + +- [ ] `PrivacyInfo.xcprivacy` present and complete +- [ ] Privacy policy URL set in App Store Connect +- [ ] Privacy policy accessible within the app +- [ ] All purpose strings (`NS*UsageDescription`) present for requested permissions +- [ ] ATT implemented if app tracks users +- [ ] Required Reason APIs declared with approved reasons +- [ ] Privacy Nutrition Labels match actual data collection +- [ ] Third-party SDK privacy manifests included +- [ ] Privacy report generated and reviewed (`Product > Archive > Generate Privacy Report`) + +## Metadata + +- [ ] App name unique, max 30 characters +- [ ] Description complete, max 4000 characters, plain text +- [ ] Keywords set, max 100 bytes, no trademarked terms +- [ ] Screenshots provided for all supported device sizes +- [ ] Screenshots show app in actual use (not title art or splash screens) +- [ ] What's New text updated for this version +- [ ] Copyright field current year +- [ ] Support URL links to real contact information +- [ ] Privacy Policy URL is HTTPS and publicly accessible +- [ ] Promotional Text set (editable without submission) +- [ ] App category accurate +- [ ] All metadata localized for target markets + +## Account + +- [ ] Account deletion implemented and easy to find +- [ ] SIWA token revocation on account deletion +- [ ] Sign in with Apple offered if any third-party login exists +- [ ] SIWA given equal visual prominence to other login options +- [ ] Demo credentials provided in App Review Information (if login required) +- [ ] Demo credentials will not expire during review period + +## Content + +- [ ] No placeholder content ("Lorem ipsum", "Coming Soon", etc.) +- [ ] All links functional and leading to real content +- [ ] Final production assets (not development/staging URLs) +- [ ] No test data visible in screenshots or app +- [ ] No references to other mobile platforms in metadata + +## Age Rating + +- [ ] Age rating questionnaire completed +- [ ] New capability declarations answered (messaging, UGC, advertising, parental, age assurance) +- [ ] UGC moderation implemented if applicable +- [ ] Content filtering in place for web views (or accept 16+ minimum) +- [ ] Loot box odds disclosed if applicable + +## Monetization + +- [ ] All IAPs configured and in "Ready to Submit" status +- [ ] IAP screenshots uploaded +- [ ] Subscription terms clear (price, duration, auto-renewal, cancellation) +- [ ] Loot box odds displayed before purchase +- [ ] Restore Purchases functionality working +- [ ] No removing paid features to force new purchases +- [ ] Subscription grace period supported +- [ ] Offer codes configured if planned + +## EU Compliance + +- [ ] DSA trader status declared for all EU-distributed apps +- [ ] Trader email verified via 2FA +- [ ] Trader phone verified via 2FA +- [ ] Contact information accurate and current +- [ ] Labels and markings complete (if applicable for product category) + +## App Review + +- [ ] Contact information complete (name, email, phone) +- [ ] Demo account credentials provided (if login required) +- [ ] Notes for Review explain any non-obvious features +- [ ] Attachment uploaded for features requiring special hardware or setup +- [ ] Review contact email actively monitored diff --git a/.claude/skills/axiom-app-store-submission/.openskills.json b/.claude/skills/axiom-app-store-submission/.openskills.json new file mode 100644 index 0000000..c74aaaa --- /dev/null +++ b/.claude/skills/axiom-app-store-submission/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-app-store-submission", + "installedAt": "2026-04-12T08:05:48.151Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-app-store-submission/SKILL.md b/.claude/skills/axiom-app-store-submission/SKILL.md new file mode 100644 index 0000000..7f80a8a --- /dev/null +++ b/.claude/skills/axiom-app-store-submission/SKILL.md @@ -0,0 +1,753 @@ +--- +name: axiom-app-store-submission +description: Use when preparing ANY app for App Store submission, responding to App Review rejections, or running a pre-submission audit. Covers privacy manifests, metadata requirements, IAP review, account deletion, SIWA, age ratings, export compliance, first-time developer setup. +license: MIT +metadata: + version: "1.0.0" +--- + +# App Store Submission + +## Overview + +Systematic pre-flight checklist that catches 90% of App Store rejection causes before submission. **Core principle**: Ship once, ship right. Over 40% of App Store rejections cite Guideline 2.1 (App Completeness) — crashes, placeholders, broken links. Another 30% are metadata and privacy issues. A disciplined pre-flight process eliminates these preventable rejections. + +**Key insight**: Apple rejected nearly 1.93 million submissions in 2024. Most rejections are not policy disagreements — they are checklist failures. A 30-minute pre-flight saves 3-7 days of rejection-fix-resubmit cycles. + +## When to Use This Skill + +✅ **Use this skill when**: +- Preparing to submit an app or update to the App Store +- Submitting your first app as a new developer +- Responding to an App Review rejection +- Running a pre-submission audit before TestFlight or production +- Updating an existing app after a long gap (new requirements may apply) +- Wondering "is my app ready to submit?" + +❌ **Do NOT use this skill for**: +- Code signing and provisioning profiles (use build-debugging) +- CI/CD pipeline setup +- Performance optimization (use ios-performance) +- UI testing automation (use ui-testing) +- In-app purchase implementation (use storekit-ref) +- Privacy manifest details (use privacy-ux for deep implementation) + +## Example Prompts + +Real questions developers ask that this skill answers: + +#### 1. "Is my app ready to submit to the App Store?" +> The skill provides a complete pre-flight checklist covering build, privacy, metadata, accounts, review info, content, and regional requirements + +#### 2. "What do I need before submitting my first iOS app?" +> The skill walks through every requirement from scratch — privacy manifest, metadata fields, screenshots, demo credentials, age rating + +#### 3. "I keep getting rejected, what am I missing?" +> The skill provides anti-patterns with specific rejection causes and the decision tree to identify gaps + +#### 4. "What's the pre-submission checklist for App Store?" +> The skill provides a categorized mandatory checklist with every item that triggers rejection if missing + +#### 5. "Do I need a privacy manifest?" +> Yes. Since May 2024, missing privacy manifests cause automatic rejection. The skill explains when and how. + +#### 6. "My app update was rejected for metadata issues" +> The skill covers metadata completeness requirements and the common gaps that trigger Guideline 2.3 rejections + +--- + +## Anti-Patterns + +### 1. Submitting without device testing + +**Time cost**: 3-7 days (rejection + fix + resubmit wait) + +**Symptom**: Rejection for Guideline 2.1 — App Completeness. Crashes, broken flows, or missing functionality discovered by App Review. + +❌ **BAD**: Test only in Simulator, submit when build succeeds +``` +"It works in Simulator, ship it" +→ Rejection: App crashes on launch on iPhone 15 Pro (memory limit) +→ 3-7 day delay +``` + +✅ **GOOD**: Test on physical device with latest shipping OS, exercise all user flows +```bash +# Build for device +xcodebuild -scheme YourApp \ + -destination 'platform=iOS,name=Your iPhone' + +# Test critical paths: +# - Launch → main screen loads +# - All tabs/screens accessible +# - Core user flows complete without crash +# - Edge cases: no network, low storage, interruptions +``` + +**Why it works**: Simulator hides real-device constraints — memory limits, cellular networking behavior, hardware-specific APIs, thermal throttling. App Review tests on physical devices. + +--- + +### 2. Missing or inadequate privacy policy + +**Time cost**: 2-5 days (rejection + policy creation + resubmit) + +**Symptom**: Rejection for Guideline 5.1.1(i) — Data Collection and Storage. Privacy policy missing, inaccessible, or inconsistent with actual data practices. + +❌ **BAD**: No privacy policy URL, or a generic template that doesn't match actual data collection +``` +Privacy Policy URL: (empty) +— or — +Privacy Policy: "We respect your privacy" (generic, no specifics) +``` + +✅ **GOOD**: Privacy policy accessible in two places, specific to your app's data practices +``` +1. App Store Connect → App Information → Privacy Policy URL +2. In-app → Settings/About screen → Privacy Policy link +3. Policy content lists: + - All collected data types + - How each type is used + - Third-party sharing (who and why) + - Data retention period + - How to request deletion +``` + +**Three-way consistency**: Apple compares (a) your app's actual behavior, (b) your privacy policy content, and (c) your Privacy Nutrition Labels in ASC. All three must agree. If any of these three disagree, you get a 5.1.1 rejection. Check each SDK's documentation for its privacy manifest and data collection disclosure — your app's total data collection is your code PLUS all SDK data collection. + +**Why it works**: Guideline 5.1.1(i) requires privacy policy accessible BOTH in ASC metadata AND within the app. The policy must specifically describe your app's data practices, not a generic template. + +--- + +### 3. Placeholder content left in build + +**Time cost**: 3-5 days (rejection + content replacement + resubmit) + +**Symptom**: Rejection for Guideline 2.1 — App Completeness. Reviewers find placeholder text, empty screens, or TODO artifacts. + +❌ **BAD**: Ship with development artifacts visible to users +``` +- "Lorem ipsum" text in onboarding +- Empty tab that shows "Coming Soon" +- Button that opens alert "Not implemented yet" +- Default app icon (white grid) +``` + +✅ **GOOD**: Every screen has final content and production assets +``` +Pre-submission content audit: +- [ ] Every screen has real content (no lorem ipsum) +- [ ] All images are final production assets +- [ ] No "Coming Soon" or "Under Construction" screens +- [ ] All buttons perform their intended action +- [ ] Default/empty states have proper messaging +- [ ] App icon is final and meets spec (1024x1024, no alpha) +``` + +**Why it works**: App Review tests every screen and tab, including states you might consider edge cases. They open every menu, tap every button, and switch every tab. + +--- + +### 4. Ignoring privacy manifest + +**Time cost**: 1-3 days (automatic rejection + manifest creation + resubmit) + +**Symptom**: Automatic rejection before human review. Missing `PrivacyInfo.xcprivacy` or undeclared Required Reason APIs. + +❌ **BAD**: No privacy manifest, or missing Required Reason API declarations +``` +No PrivacyInfo.xcprivacy in project +— or — +Using UserDefaults, file timestamps, disk space APIs +without declaring approved reasons +``` + +✅ **GOOD**: Privacy manifest present with all Required Reason APIs declared +``` +Project must contain: +├── PrivacyInfo.xcprivacy +│ ├── NSPrivacyTracking (true/false) +│ ├── NSPrivacyTrackingDomains (if tracking) +│ ├── NSPrivacyCollectedDataTypes (all types) +│ └── NSPrivacyAccessedAPITypes (all Required Reason APIs) +│ +└── Third-party SDK manifests (each SDK includes its own) +``` + +Common Required Reason APIs that need declaration: +- `UserDefaults` → Reason `CA92.1` +- File timestamp APIs → Reason `C617.1` +- Disk space APIs → Reason `E174.1` +- System boot time → Reason `35F9.1` + +**Why it works**: Since May 2024, this is an automated gate. No human reviewer involved — the build processing system rejects submissions missing required privacy declarations. + +--- + +### 5. Missing Sign in with Apple + +**Time cost**: 3-7 days (SIWA implementation + resubmit) + +**Symptom**: Rejection for Guideline 4.8. App offers third-party login (Google, Facebook, email) but no Sign in with Apple option. + +❌ **BAD**: Third-party login without SIWA +```swift +// Login screen offers: +// - Sign in with Google +// - Sign in with Facebook +// - Email/password +// ← Missing: Sign in with Apple +``` + +✅ **GOOD**: SIWA offered as equivalent option alongside any third-party login +```swift +// Login screen offers: +// - Sign in with Apple ← Required if others exist +// - Sign in with Google +// - Sign in with Facebook +// - Email/password +``` + +**Exceptions** (SIWA not required): +- Company-internal or employee-only apps +- Education apps using existing school accounts +- Government/tax/banking apps requiring government ID +- Apps that are a client for a specific third-party service +- Apps using only the company's own authentication system + +**Why it works**: Guideline 4.8 requires SIWA as an option whenever ANY third-party or social login is offered. Apple enforces this strictly. + +--- + +### 6. No account deletion flow + +**Time cost**: 5-10 days (implementation + testing + resubmit) + +**Symptom**: Rejection for Guideline 5.1.1(v). App allows account creation but provides no way to delete the account. + +❌ **BAD**: Account creation without deletion capability +``` +- Sign up button exists +- No "Delete Account" anywhere in app +- "Contact support to delete" (not sufficient) +- "Deactivate account" (not the same as delete) +``` + +✅ **GOOD**: Full account deletion flow accessible in-app +``` +Account deletion requirements: +1. Discoverable in Settings/Profile (not hidden) +2. Clearly labeled "Delete Account" (not "Deactivate") +3. Explains what deletion means (data removed, timeline) +4. Confirms completion to user +5. If Sign in with Apple used → revoke SIWA token +6. If active subscriptions → inform user to cancel first +7. Deletion completes within reasonable timeframe (days, not months) +``` + +```swift +// Revoking Sign in with Apple token (required) +let appleIDProvider = ASAuthorizationAppleIDProvider() +let request = appleIDProvider.createRequest() + +// After user confirms deletion: +// POST to Apple's revoke endpoint with the user's token +// Then delete server-side account data +``` + +**Why it works**: Required since June 2022. Must be actual deletion (not deactivation), must be in-app (not just email/website), and must revoke SIWA tokens if used. Apple tests this flow specifically. + +--- + +### 7. Wrong age rating + +**Time cost**: 2-4 days (re-answer questionnaire + possible content changes + resubmit) + +**Symptom**: Rejection for Guideline 2.3.6 — Inaccurate metadata. Age rating doesn't reflect actual app content or capabilities. + +❌ **BAD**: Understate content to get lower rating +``` +App has user-generated content (chat, posts) +but age rating questionnaire answered "No UGC" + +App has cartoon violence in gameplay +but answered "No violence" +``` + +✅ **GOOD**: Answer age rating questionnaire accurately +``` +Declare honestly: +- User-generated content (chat, forums, social features) +- Violence (even cartoon/fantasy) +- Mature themes +- Profanity / crude humor +- Gambling (simulated or real) +- Horror / fear themes +- Medical / treatment information +- Unrestricted web access (WebView with open URLs) +``` + +**Updated age ratings (effective January 31, 2026)**: Apple expanded from 4+/9+/12+/17+ to 5 tiers (4+/9+/13+/16+/18+) with new capability declarations for messaging, UGC, advertising, and parental controls. All developers must have completed the updated questionnaire — app updates are blocked without it. + +**Why it works**: Mismatched ratings violate Guideline 2.3.6. Apple compares your questionnaire answers against observed app behavior. UGC and web access are the most commonly missed declarations. + +--- + +### 8. Missing demo credentials + +**Time cost**: 3-5 days (rejection + credential creation + resubmit wait) + +**Symptom**: Rejection for Guideline 2.1. Reviewer unable to test app because login is required and no test account was provided. + +❌ **BAD**: App requires login, but no demo account in review notes +``` +App Review Information: + Notes: (empty) + Demo Account: (empty) + Demo Password: (empty) +→ Reviewer sees login screen, can't proceed, rejects +``` + +✅ **GOOD**: Working demo credentials with clear instructions +``` +App Review Information: + Demo Account: demo@yourapp.com + Demo Password: AppReview2025! + Notes: "Log in with the demo account above. + The account has sample data pre-loaded. + To test [feature X], navigate to Tab 2 > Settings. + If 2FA is required, use code: 123456" + +Requirements: +- Account must not expire during review (1-2 weeks minimum) +- Account must have representative data +- Include any special setup steps +- If hardware required, explain workarounds +- If location-specific, provide test coordinates +``` + +**Why it works**: Reviewers cannot test what they cannot access. They will not create their own account. If your app requires any form of authentication, demo credentials are mandatory. This is one of the most common rejection reasons for apps with login flows. + +--- + +## Decision Tree + +``` +Is my app ready to submit? +│ +├─ Does it crash on a real device? +│ ├─ YES → STOP. Fix crashes first (Guideline 2.1) +│ └─ NO → Continue +│ +├─ Privacy manifest (PrivacyInfo.xcprivacy) present? +│ ├─ NO → Add privacy manifest with Required Reason APIs +│ └─ YES → Continue +│ +├─ Privacy policy URL set in App Store Connect? +│ ├─ NO → Add privacy policy URL in ASC +│ └─ YES → Is it also accessible in-app? +│ ├─ NO → Add in-app privacy policy link +│ └─ YES → Continue +│ +├─ All screenshots final and matching current app? +│ ├─ NO → Update screenshots for all required device sizes +│ └─ YES → Continue +│ +├─ Does app create user accounts? +│ ├─ YES → Account deletion implemented and discoverable? +│ │ ├─ NO → Implement account deletion flow +│ │ └─ YES → Continue +│ └─ NO → Continue +│ +├─ Does app offer third-party login (Google, Facebook, etc.)? +│ ├─ YES → Sign in with Apple offered? +│ │ ├─ NO → Add SIWA (unless exemption applies) +│ │ └─ YES → Continue +│ └─ NO → Continue +│ +├─ Does app have in-app purchases or subscriptions? +│ ├─ YES → IAP items submitted for review in ASC? +│ │ ├─ NO → Submit IAP for review (can be reviewed separately) +│ │ └─ YES → Restore Purchases button implemented? +│ │ ├─ NO → Add Restore Purchases functionality +│ │ └─ YES → Continue +│ └─ NO → Continue +│ +├─ Does app use encryption beyond standard HTTPS? +│ ├─ YES → Export compliance documentation uploaded? +│ │ ├─ NO → Add ITSAppUsesNonExemptEncryption to Info.plist +│ │ │ and upload compliance documentation +│ │ └─ YES → Continue +│ └─ NO → Set ITSAppUsesNonExemptEncryption = NO in Info.plist +│ +├─ Distributing in EU? +│ ├─ YES → DSA trader status verified in ASC? +│ │ ├─ NO → Complete trader verification in ASC +│ │ └─ YES → Continue +│ └─ NO → Continue +│ +├─ Does app require login to function? +│ ├─ YES → Demo credentials in App Review notes? +│ │ ├─ NO → Add working demo account + password + instructions +│ │ └─ YES → Continue +│ └─ NO → Continue +│ +├─ Age rating questionnaire completed honestly? +│ ├─ NO → Complete updated questionnaire (new 13+/16+/18+ ratings) +│ └─ YES → Continue +│ +├─ Any placeholder content remaining? +│ ├─ YES → Replace all placeholders with final content +│ └─ NO → Continue +│ +└─ All checks passed → READY TO SUBMIT +``` + +--- + +## Mandatory Pre-Flight Checklist + +Run this entire checklist before every submission. Check every item, not just the ones you think apply. + +### 1. Build Configuration + +- [ ] Built with current required SDK (iOS 18 SDK / Xcode 16 as of 2025; iOS 26 SDK / Xcode 26 required starting April 28, 2026) +- [ ] `ITSAppUsesNonExemptEncryption` set in Info.plist (`NO` if only HTTPS) +- [ ] App tested on physical device with latest shipping iOS version +- [ ] App works over IPv6-only network (Apple review network is IPv6) +- [ ] No private/undocumented API usage +- [ ] No references to pre-release/beta OS features unless targeting that OS +- [ ] Minimum deployment target is reasonable (not unnecessarily high) +- [ ] Release build tested (not just Debug configuration) + +### 2. Privacy + +- [ ] `PrivacyInfo.xcprivacy` file present in app target +- [ ] All Required Reason APIs declared with approved reason codes +- [ ] Third-party SDKs each include their own privacy manifests +- [ ] Privacy policy URL set in App Store Connect +- [ ] Privacy policy accessible in-app (Settings/About screen) +- [ ] Privacy policy content matches actual data collection practices +- [ ] All `NS*UsageDescription` purpose strings present (Camera, Location, etc.) +- [ ] Purpose strings explain user benefit (not just "we need access") +- [ ] App Tracking Transparency implemented if tracking users (ATT) +- [ ] Privacy Nutrition Labels completed in ASC matching manifest + +### 3. Metadata + +- [ ] App name (30 character limit, no keyword stuffing) +- [ ] Subtitle (30 character limit) +- [ ] Description (accurate, no misleading claims) +- [ ] Keywords (100 character limit, comma-separated) +- [ ] Category and secondary category set +- [ ] Screenshots for all required device sizes (6.9", 6.7", 6.5", 5.5" for iPhone; 13" for iPad if universal) +- [ ] Screenshots reflect current app UI (not outdated) +- [ ] App Preview videos current (if using) +- [ ] Support URL valid and accessible +- [ ] Marketing URL valid (if set) +- [ ] Copyright string current year +- [ ] Version number and build number incremented +- [ ] "What's New" text written (for updates) + +### 4. Account and Authentication + +- [ ] Account deletion flow implemented (if account creation exists) +- [ ] Account deletion is actual deletion (not just deactivation) +- [ ] SIWA token revocation on account deletion (if SIWA used) +- [ ] Sign in with Apple offered (if any third-party login exists) +- [ ] Active subscriptions handled during account deletion +- [ ] Restore Purchases button works (if IAP exists) +- [ ] IAP items submitted for review in ASC (if new/changed) +- [ ] Subscription terms clearly communicated before purchase + +### 5. App Review Information + +- [ ] Contact information (name, phone, email) provided +- [ ] Demo account username provided (if login required) +- [ ] Demo account password provided (if login required) +- [ ] Demo account won't expire during review period (1-2 weeks) +- [ ] Demo account has representative sample data +- [ ] Special instructions for hardware-dependent features +- [ ] Notes explain any non-obvious features or flows +- [ ] If app uses location, provide test coordinates + +### 6. Content Completeness + +- [ ] No placeholder text (lorem ipsum, TODO, "Coming Soon") +- [ ] No broken links or dead-end screens +- [ ] All images are production assets (no stock watermarks) +- [ ] App icon meets spec (1024x1024, no alpha channel, no rounded corners) +- [ ] All tabs/screens have functional content +- [ ] Error states and empty states have proper messaging +- [ ] Onboarding/tutorial flows complete and accurate +- [ ] All deep links and universal links resolve correctly + +### 7. Regional and Compliance + +- [ ] EU DSA trader status verified (if distributing in EU) +- [ ] Age rating questionnaire completed with updated categories (13+/16+/18+) +- [ ] Age rating reflects actual content (UGC, violence, web access declared) +- [ ] Export compliance documentation uploaded (if non-exempt encryption) +- [ ] Content complies with local laws for each distribution territory +- [ ] GDPR compliance (if distributing in EU) + +### 8. New for 2025-2026 + +- [ ] Updated age rating questionnaire completed (required since January 31, 2026) +- [ ] Accessibility Nutrition Labels declared (becoming required for new submissions) +- [ ] External AI service consent modal (if app sends personal data to external AI) +- [ ] SDK minimum version met (Xcode 16/iOS 18 SDK now; Xcode 26/iOS 26 SDK starting April 28, 2026) + +--- + +## Pressure Scenarios + +### Scenario 1: "Ship by end of day" + +**Setup**: PM says the app must be submitted today for a marketing launch next week. + +**Pressure**: Deadline + executive visibility + +**Rationalization traps**: +- "We'll fix the privacy policy after approval" +- "The placeholder is only on one screen, they won't notice" +- "We'll add account deletion in the next update" +- "It passed internal testing, no need for device testing" + +**MANDATORY**: Run the full pre-flight checklist. Every item. Missing items cause rejection, which costs 3-7 MORE days — far worse than the 30 minutes the checklist takes. + +Skipping the checklist to save 30 minutes costs 3-7 days when it causes rejection. + +**Communication template**: "The pre-flight check found [N] issues that will cause rejection. Fixing them takes [X hours]. Submitting without fixing guarantees rejection, which costs 3-7 days minimum. Let me fix these now — it's the fastest path to being live." + +--- + +### Scenario 2: "Third rejection, just make it work" + +**Setup**: App rejected 3 times for different issues each time. Developer is frustrated and tempted to cut corners or argue with Apple. + +**Pressure**: Frustration + sunk cost + temptation to appeal instead of fix + +**Rationalization traps**: +- "They keep finding new issues — they're being unfair" +- "I'll appeal this one, it's unreasonable" +- "I'll just hide that screen from reviewers" + +**MANDATORY**: Read the FULL text of every rejection message. Run the complete pre-flight checklist from scratch. Reviewers often find new issues on subsequent reviews because they test deeper each pass — they explore screens they didn't reach before, test flows they skipped, and review with stricter attention. + +Each rejection is a signal that the pre-submission process has gaps. Do not fight the feedback — absorb it and close the gaps systematically. + +**Communication template**: "Multiple rejections mean we have systematic gaps, not bad luck. I'm running the complete pre-flight checklist — this takes 30 minutes but prevents the 3-7 day cycle of partial fixes followed by new rejections." + +--- + +### Scenario 3: "It's just a bug fix update" + +**Setup**: Simple one-line bug fix. Developer assumes the update will sail through because the app was already approved. + +**Pressure**: Complacency + false confidence from prior approval + +**Rationalization traps**: +- "It was approved last time with the same metadata" +- "I only changed one file, they don't need to re-review everything" +- "They won't re-check the privacy stuff, it hasn't changed" + +**MANDATORY**: Updates are reviewed against CURRENT guidelines. Requirements change between releases. Privacy manifests became mandatory mid-cycle. Age rating questionnaire was overhauled. SDK minimums increase annually. A bug fix update can be rejected for issues that didn't exist when the previous version was approved. + +Run the pre-flight checklist every time. Requirements that didn't exist when your app was last reviewed may now be enforced. + +**Communication template**: "Even for a bug fix, App Review applies current guidelines — not the ones from when we were last approved. The privacy manifest requirement and age rating overhaul both came mid-cycle. Running the 30-minute pre-flight now prevents a surprise rejection." + +--- + +## Screenshot Requirements + +Screenshots must match current app UI. See `app-store-ref` Part 1 for required sizes, dimensions, and rules. + +--- + +## Handling Rejections + +### Reading the Rejection Message + +Every rejection includes: +1. **Guideline number** — Specific section violated +2. **Description** — What the reviewer found +3. **Screenshots/recordings** — Visual evidence (if applicable) +4. **Suggestions** — Sometimes included with fixes + +### Response Strategy + +``` +1. Read the FULL rejection message (every word) +2. Identify ALL guidelines cited (may be multiple) +3. Fix EVERY cited issue (not just the first one) +4. Run the complete pre-flight checklist +5. In your resubmission notes, explain what you fixed +6. Do NOT argue or explain why you think the rejection is wrong +``` + +### When to Appeal + +Appeals are appropriate when: +- You believe the reviewer misunderstood your app's functionality +- Your app clearly complies with the cited guideline +- You have evidence supporting your position + +Appeals are NOT appropriate when: +- You disagree with the guideline itself +- The rejection is technically correct but feels unfair +- You want to delay fixing the issue + +### Appeal Process + +``` +App Store Connect → Resolution Center → Reply +- Explain clearly why you believe the rejection is incorrect +- Provide specific evidence (screenshots, documentation) +- Remain professional and factual +- Apple's App Review Board will re-review +- One appeal per rejection +- Must respond to all information requests BEFORE appealing +``` + +### Expedited Review + +Two eligible scenarios: +1. **Critical Bug Fix** — include steps to reproduce the bug on the current live version +2. **Event-Related App** — include event name, date, and your association with the event + +### Communication Options (Often Overlooked) + +- **"Meet with App Review" sessions** — Apple periodically offers 30-minute appointments; check developer.apple.com/events for availability (not always open) +- **Bug fix leniency** — Apple may sometimes approve a bug fix submission with informational notes about additional non-legal/safety issues to address in the next update, rather than blocking the release. This is not guaranteed but happens in practice. +- **App Store Connect mobile app** for status tracking on the go + +### Metadata Rejected vs Binary Rejected + +| Type | What it means | What to do | +|------|--------------|------------| +| Metadata Rejected | Screenshots, description, or ASC fields need fixing | Fix in ASC, resubmit (no new build needed) | +| Binary Rejected | Code/app issue needs fixing | Fix code, create new archive, upload new build | + +--- + +## In-App Purchase Review + +IAP items require separate review. Missing or broken IAP is a top rejection cause under Guideline 3.1.1. + +### IAP Submission Checklist + +- [ ] All IAP products created in ASC with complete metadata +- [ ] **IAP review screenshot uploaded** for each product (shows what user sees when purchasing — review-only, not displayed on App Store) +- [ ] **IAP products attached to THIS version** (first submission only: App Version page → In-App Purchases section → Select → checkbox each product). After first approval, subsequent IAPs can be submitted independently. +- [ ] IAP products submitted for review +- [ ] Restore Purchases button visible and functional +- [ ] Subscription terms displayed before purchase confirmation (price, period, auto-renewal) +- [ ] **Terms of Use and Privacy Policy links visible on the purchase screen** (required by Schedule 2 of the DPLA, referenced by Guideline 3.1.2(c)). `SubscriptionStoreView` handles this automatically from ASC metadata — if using custom paywall UI, add links manually. +- [ ] Free trial terms clearly communicated +- [ ] Pricing displayed matches ASC configuration +- [ ] No external purchase links (Guideline 3.1.1) unless eligible for entitlement +- [ ] StoreKit testing completed in sandbox environment + +### Common IAP Rejection Patterns + +``` +❌ "Buy Premium" button that does nothing → Guideline 3.1.1 +❌ No Restore Purchases option → Guideline 3.1.1 +❌ Subscription auto-renews without clear disclosure → Guideline 3.1.2 +❌ Free trial duration not shown before purchase → Guideline 3.1.2 +❌ External purchase link without entitlement → Guideline 3.1.1 +❌ IAP products not attached to submission version → Guideline 2.1 (reviewer can't see them) +❌ No review screenshot on IAP product → Guideline 2.1 (reviewer can't verify purchase UI) +❌ Terms of Use / Privacy Policy missing from paywall → DPLA Schedule 2 (use SubscriptionStoreView to avoid) +``` + +--- + +## Common Rejection Reasons Quick Reference + +| Guideline | Issue | Prevention | +|-----------|-------|------------| +| 2.1 | Crashes, broken features, incomplete | Device testing, content audit | +| 2.1 | IAP not visible to reviewer | Attach IAP to version (checkbox in ASC), upload review screenshots | +| 2.3 | Inaccurate metadata, wrong screenshots | Screenshot audit, metadata review | +| 2.3.6 | Incorrect age rating | Honest questionnaire, declare UGC | +| 3.1.1 | IAP issues, missing Restore Purchases | Test all IAP flows, add restore | +| 4.0 | Design: poor UI, non-standard patterns | Follow HIG, test on all sizes | +| 4.8 | Missing Sign in with Apple | Add SIWA with any third-party login | +| 5.1.1(i) | Privacy policy missing/inadequate | Both ASC and in-app, specific content | +| 5.1.1(v) | No account deletion | In-app deletion, not just deactivation | +| 5.1.2 | Missing Required Reason APIs | Complete privacy manifest | + +--- + +## App Store Connect Submission Workflow + +See `app-store-ref` Part 9 for the ASC upload workflow and build processing details. + +--- + +## Encryption Export Compliance + +Most apps need `ITSAppUsesNonExemptEncryption = false`. See `app-store-ref` Part 5 for the full decision tree. + +--- + +## Accessibility Nutrition Labels (New 2025) + +Accessibility Nutrition Labels are becoming required for new submissions. See `app-store-ref` Part 10 for the full label list and declaration rules. Run `axiom-accessibility-diag` before declaring. + +--- + +## First-Time Developer Checklist + +For developers submitting their first app, these are additional items often missed. If you need to create a privacy policy from scratch, use a privacy policy generator (many free options exist) and customize it to match your app's actual data practices — a generic template will be rejected. + +### Apple Developer Program + +- [ ] Enrolled in Apple Developer Program ($99/year) +- [ ] Accepted latest Apple Developer Program License Agreement +- [ ] Tax and banking information completed in ASC (for paid apps/IAP) +- [ ] Distribution certificate created +- [ ] App ID registered with correct bundle identifier +- [ ] Provisioning profile created for App Store distribution + +### App Store Connect Setup + +- [ ] New app record created in ASC +- [ ] Bundle ID matches Xcode project exactly +- [ ] Primary language set +- [ ] Content rights declared (original content or licensed) +- [ ] Pricing and availability configured +- [ ] Territory selection (worldwide or specific countries) + +### Common First-Time Mistakes + +| Mistake | Result | Fix | +|---------|--------|-----| +| Bundle ID mismatch between Xcode and ASC | Upload rejected | Match exactly, including case | +| Distribution cert expired/missing | Archive fails to upload | Create new cert in Developer Portal | +| License agreement not accepted | Upload blocked | Accept in developer.apple.com | +| Tax forms incomplete | Paid app not distributed | Complete in ASC → Agreements, Tax, and Banking | +| Wrong team selected in Xcode | Signing errors | Select correct team in Signing & Capabilities | + +--- + +## Real-World Impact + +**Before**: Developer submits without checklist → rejected for missing privacy manifest (3 days) → fixes, resubmits → rejected for missing SIWA (5 days) → fixes, resubmits → rejected for placeholder content (3 days) → 11 days lost to preventable issues + +**After**: Developer runs 30-minute pre-flight → catches all three issues → fixes in 4 hours → approved on first submission + +**Key insight**: The checklist takes 30 minutes. Each rejection cycle takes 3-7 days. The math is simple. + +--- + +## Resources + +**WWDC**: 2022-10166, 2025-328, 2025-224, 2025-241 + +**Docs**: /app-store/review/guidelines, /app-store/submitting, /app-store/app-privacy-details, /support/offering-account-deletion-in-your-app, /documentation/security/complying-with-encryption-export-regulations + +**Skills**: axiom-privacy-ux, axiom-storekit-ref, axiom-accessibility-diag, axiom-testflight-triage, axiom-app-store-connect-ref diff --git a/.claude/skills/axiom-app-store-submission/agents/openai.yaml b/.claude/skills/axiom-app-store-submission/agents/openai.yaml new file mode 100644 index 0000000..0066d6b --- /dev/null +++ b/.claude/skills/axiom-app-store-submission/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "App Store Submission" + short_description: "Preparing ANY app for App Store submission, responding to App Review rejections, or running a pre-submission audit" diff --git a/.claude/skills/axiom-apple-docs-research/.openskills.json b/.claude/skills/axiom-apple-docs-research/.openskills.json new file mode 100644 index 0000000..3f99d9a --- /dev/null +++ b/.claude/skills/axiom-apple-docs-research/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-apple-docs-research", + "installedAt": "2026-04-12T08:05:48.717Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-apple-docs-research/SKILL.md b/.claude/skills/axiom-apple-docs-research/SKILL.md new file mode 100644 index 0000000..5957c9c --- /dev/null +++ b/.claude/skills/axiom-apple-docs-research/SKILL.md @@ -0,0 +1,327 @@ +--- +name: axiom-apple-docs-research +description: Use when researching Apple frameworks, APIs, or WWDC sessions - provides techniques for retrieving full transcripts, code samples, and documentation using Chrome browser and sosumi.ai +license: MIT +metadata: + version: "1.0.0" +--- + +# Apple Documentation Research + +## When to Use This Skill + +✅ **Use this skill when**: +- Researching Apple frameworks or APIs (WidgetKit, SwiftUI, etc.) +- Need full WWDC session transcripts with code samples +- Looking for Apple Developer documentation +- Want to extract code examples from WWDC presentations +- Building comprehensive skills based on Apple technologies + +❌ **Do NOT use this skill for**: +- Third-party framework documentation +- General web research +- Questions already answered in existing skills +- Basic Swift language questions (use Swift documentation) + +## Related Skills + +- Use **superpowers-chrome:browsing** for interactive browser control +- Use **writing-skills** when creating new skills from Apple documentation +- Use **reviewing-reference-skills** to validate Apple documentation skills + +## Core Philosophy + +> Apple Developer video pages contain full verbatim transcripts with timestamps and complete code samples. Chrome's auto-capture feature makes this content instantly accessible without manual copying. + +**Key insight**: Don't manually transcribe or copy code from WWDC videos. The transcripts are already on the page, fully timestamped and formatted. + +## WWDC Session Transcripts via Chrome + +### The Technique + +Apple Developer video pages (`developer.apple.com/videos/play/wwdc20XX/XXXXX/`) contain complete transcripts that Chrome auto-captures. + +#### Step-by-Step Process + +1. **Navigate** using Chrome browser MCP tool: + ```json + { + "action": "navigate", + "payload": "https://developer.apple.com/videos/play/wwdc2025/278/" + } + ``` + + Tool name: `mcp__plugin_superpowers-chrome_chrome__use_browser` + + **Complete invocation**: + ``` + Use the mcp__plugin_superpowers-chrome_chrome__use_browser tool with: + - action: "navigate" + - payload: "https://developer.apple.com/videos/play/wwdc2025/278/" + ``` + +2. **Locate** the auto-captured file: + - Chrome saves to: `~/.../superpowers/browser/YYYY-MM-DD/session-TIMESTAMP/` + - Session directory uses Unix timestamp in milliseconds (e.g., `session-1765217804099`) + - Filename pattern: `NNN-navigate.md` (e.g., `001-navigate.md`) + + **Finding the latest session**: + ```bash + # List sessions sorted by modification time (newest first) + ls -lt ~/Library/Caches/superpowers/browser/*/session-* | head -5 + ``` + +3. **Read** the captured transcript: + - Full spoken content with timestamps (e.g., `[0:07]`, `[1:23]`) + - Descriptions of code and API usage (spoken, not formatted) + - Chapter markers and resource links + +### What You Get + +**✅ WWDC transcripts contain:** +- Full spoken content with timestamps (e.g., `[0:07]`, `[1:23]`) +- API names mentioned by speakers (e.g., `widgetRenderingMode`, `supportedMountingStyles`) +- Descriptions of what code does ("I'll add the widgetRenderingMode environment variable") +- Step-by-step explanations of implementations +- Chapter markers and resource links + +**❌ WWDC transcripts do NOT contain:** +- Formatted Swift code blocks ready to copy-paste +- Complete implementations +- Structured code examples + +**Critical Understanding**: Transcripts are **spoken word, not code**. You'll read sentences like "I'll add the widgetRenderingMode environment variable to my widget view" and need to **reconstruct the code yourself** from these descriptions. + +### When Code Isn't Clear from Transcript + +If the transcript's code descriptions aren't detailed enough, follow this fallback workflow: + +1. **Check Resources Tab** + - Navigate back to the WWDC session page + - Click "Resources" tab + - Look for "Download Sample Code" or "View on GitHub" + - Download Xcode project with complete working implementation + +2. **Use sosumi.ai for API Details** + - Look up specific APIs mentioned in transcript + - Example: Transcript says "widgetAccentedRenderingMode" → look up `sosumi.ai/documentation/swiftui/widgetaccentedrenderingmode` + - Get exact signature, parameters, usage + +3. **Jump to Timestamp in Video** + - Use transcript timestamp to jump directly to code explanation in video + - Example: Transcript says code at `[4:23]` → watch that specific 30-second segment + - Faster than watching entire 45-minute session + +4. **Combine Sources** + - Transcript = conceptual understanding + workflow + - Resources = complete code + - sosumi.ai = API details + - Result: Full picture without manually reconstructing everything + +**Example transcript structure**: +```markdown +# Session Title - WWDC## - Videos - Apple Developer + +## Chapters +- 0:00 - Introduction +- 1:23 - Key Topic 1 + +## Transcript +0:00 +Speaker: Welcome to this session... + +[timestamp] +Now I'll add the widgetAccentedRenderingMode modifier... +``` + +### Example Session + +**WWDC 2025-278** "What's new in widgets": +- Navigate: `https://developer.apple.com/videos/play/wwdc2025/278/` +- Captured: `001-navigate.md` +- Contains: ~15 minutes of full transcript with API references and code concepts + +## Apple Documentation via sosumi.ai + +### Why sosumi.ai + +Developer.apple.com documentation is HTML-heavy and difficult to parse. sosumi.ai provides the same content in clean markdown format. + +### URL Pattern + +**Instead of**: +``` +https://developer.apple.com/documentation/widgetkit +``` + +**Use**: +``` +https://sosumi.ai/documentation/widgetkit +``` + +### URL Pattern Rules + +**Format**: `https://sosumi.ai/documentation/[framework]` + +**Rules for framework name**: +1. **Lowercase** - Use lowercase even if framework is capitalized (SwiftUI → swiftui) +2. **No spaces** - Remove all spaces (Core Data → coredata) +3. **No hyphens** - Remove all hyphens (App Intents → appintents, NOT app-intents) +4. **Case-insensitive** - Both `SwiftUI` and `swiftui` work, but lowercase is recommended + +**Common mistakes**: +- ❌ `app-intents` → ✅ `appintents` +- ❌ `axiom-core-data` → ✅ `coredata` +- ❌ `AVFoundation` → ✅ `avfoundation` + +**Examples**: +| Framework Name | sosumi.ai URL | +|----------------|---------------| +| SwiftUI | `sosumi.ai/documentation/swiftui` | +| App Intents | `sosumi.ai/documentation/appintents` | +| Core Data | `sosumi.ai/documentation/coredata` | +| AVFoundation | `sosumi.ai/documentation/avfoundation` | +| UIKit | `sosumi.ai/documentation/uikit` | + +### Using with WebFetch or Read Tools + +``` +WebFetch: + url: https://sosumi.ai/documentation/widgetkit/widget + prompt: "Extract information about Widget protocol" + +Result: Clean markdown with API signatures, descriptions, examples +``` + +### Framework Examples + +| Framework | sosumi.ai URL | +|-----------|---------------| +| WidgetKit | `https://sosumi.ai/documentation/widgetkit` | +| SwiftUI | `https://sosumi.ai/documentation/swiftui` | +| ActivityKit | `https://sosumi.ai/documentation/activitykit` | +| App Intents | `https://sosumi.ai/documentation/appintents` | +| Foundation | `https://sosumi.ai/documentation/foundation` | + +## Common Research Workflows + +### Workflow 1: New iOS Feature Research + +**Goal**: Create a comprehensive skill for a new iOS 26 feature. + +1. **Find WWDC sessions** — Search "WWDC 2025 [feature name]" +2. **Get transcripts** — Navigate with Chrome to each session +3. **Read transcripts** — Extract key concepts, code patterns, gotchas +4. **Get API docs** — Use sosumi.ai for framework reference +5. **Cross-reference** — Verify code samples match documentation +6. **Create skill** — Combine transcript insights + API reference + +**Time saved**: 3-4 hours vs. watching videos and manual transcription + +### Workflow 2: API Deep Dive + +**Goal**: Understand a specific API or protocol. + +1. **sosumi.ai docs** — Get protocol/class definition +2. **WWDC sessions** — Search for sessions mentioning the API +3. **Code samples** — Extract from transcript code blocks +4. **Verify patterns** — Ensure examples match latest API + +### Workflow 3: Multiple Sessions Research + +**Goal**: Comprehensive coverage across multiple years (e.g., widgets evolution). + +1. **Parallel navigation** — Use Chrome to visit 3-6 sessions +2. **Read all transcripts** — Compare how APIs evolved +3. **Extract timeline** — iOS 14 → 17 → 18 → 26 changes +4. **Consolidate** — Create unified skill with version annotations + +**Example**: Extensions & Widgets skill used 6 WWDC sessions (2023-2025) + +## Anti-Patterns + +### ❌ DON'T: Manual Video Watching + +``` +BAD: +1. Play WWDC video +2. Pause and take notes +3. Rewind to capture code +4. Type out examples manually + +Result: 45 minutes per session +``` + +### ✅ DO: Chrome Auto-Capture + +``` +GOOD: +1. Navigate with Chrome +2. Read captured .md file +3. Copy code blocks directly +4. Reference timestamps for context + +Result: 5 minutes per session +``` + +### ❌ DON'T: Scrape developer.apple.com HTML + +``` +BAD: +Use WebFetch on developer.apple.com/documentation +Result: Complex HTML parsing required +``` + +### ✅ DO: Use sosumi.ai + +``` +GOOD: +Use WebFetch on sosumi.ai/documentation +Result: Clean markdown, instant access +``` + +## Troubleshooting + +### Chrome Session Directory Not Found + +**Symptom**: Can't locate `001-navigate.md` file + +**Solution**: +1. Check Chrome actually navigated (look for URL confirmation) +2. Find latest session: `ls -lt ~/Library/Caches/superpowers/browser/*/` +3. Session directory format: `YYYY-MM-DD/session-TIMESTAMP/` + +### Transcript Incomplete + +**Symptom**: File exists but missing transcript + +**Solution**: +1. Page may still be loading - wait 2-3 seconds +2. Try navigating again +3. Some sessions require scrolling to load full content + +### sosumi.ai Returns Error + +**Symptom**: 404 or invalid URL + +**Solution**: +1. Verify framework name spelling +2. Check sosumi.ai format: `/documentation/[frameworkname]` +3. Fallback: Use developer.apple.com but expect HTML + +## Verification Checklist + +Before using captured content: +- ☐ Transcript includes timestamps +- ☐ Code samples are complete (not truncated) +- ☐ Speaker names and chapter markers present +- ☐ Multiple speakers properly attributed +- ☐ Code syntax highlighting preserved + +## Resources + +**Skills**: superpowers-chrome:browsing, writing-skills, reviewing-reference-skills + +--- + +**Time Saved**: Using this technique saves 30-40 minutes per WWDC session vs. manual video watching and transcription. For comprehensive research spanning multiple sessions, savings compound to 3-4 hours per skill. diff --git a/.claude/skills/axiom-apple-docs-research/agents/openai.yaml b/.claude/skills/axiom-apple-docs-research/agents/openai.yaml new file mode 100644 index 0000000..11e44a2 --- /dev/null +++ b/.claude/skills/axiom-apple-docs-research/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Apple Docs Research" + short_description: "Researching Apple frameworks, APIs, or WWDC sessions" diff --git a/.claude/skills/axiom-apple-docs/.openskills.json b/.claude/skills/axiom-apple-docs/.openskills.json new file mode 100644 index 0000000..6df171f --- /dev/null +++ b/.claude/skills/axiom-apple-docs/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-apple-docs", + "installedAt": "2026-04-12T08:05:35.591Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-apple-docs/SKILL.md b/.claude/skills/axiom-apple-docs/SKILL.md new file mode 100644 index 0000000..e361f6d --- /dev/null +++ b/.claude/skills/axiom-apple-docs/SKILL.md @@ -0,0 +1,156 @@ +--- +name: axiom-apple-docs +description: Use when ANY question involves Apple framework APIs, Swift compiler errors, or Xcode-bundled documentation. Covers Liquid Glass, Swift 6.2 concurrency, Foundation Models, SwiftData, StoreKit, 32 Swift compiler diagnostics. +license: MIT +--- + +# Apple Documentation Router + +Apple bundles for-LLM markdown documentation inside Xcode. These are authoritative, up-to-date guides and diagnostics written by Apple engineers. Use them alongside Axiom skills for the most accurate information. + +## When to Use + +Use Apple's bundled docs when: +- You need the exact API signature or behavior from Apple +- Axiom skills reference an Apple framework and you want the official source +- A Swift compiler diagnostic needs explanation +- The user asks about a specific Apple framework feature + +**Priority**: Axiom skills provide opinionated guidance (decision trees, anti-patterns, pressure scenarios). Apple docs provide authoritative API details. Use both together. + +## Guide Topics (AdditionalDocumentation) + +Read these with the MCP `axiom_read_skill` tool using the skill name. + +### UI & Design + +| Topic | Skill Name | +|-------|-----------| +| Liquid Glass in SwiftUI | `apple-guide-swiftui-implementing-liquid-glass-design` | +| Liquid Glass in UIKit | `apple-guide-uikit-implementing-liquid-glass-design` | +| Liquid Glass in AppKit | `apple-guide-appkit-implementing-liquid-glass-design` | +| Liquid Glass in WidgetKit | `apple-guide-widgetkit-implementing-liquid-glass-design` | +| SwiftUI toolbar features | `apple-guide-swiftui-new-toolbar-features` | +| SwiftUI styled text editing | `apple-guide-swiftui-styled-text-editing` | +| SwiftUI WebKit integration | `apple-guide-swiftui-webkit-integration` | +| SwiftUI AlarmKit integration | `apple-guide-swiftui-alarmkit-integration` | +| Swift Charts 3D visualization | `apple-guide-swift-charts-3d-visualization` | +| Foundation AttributedString | `apple-guide-foundation-attributedstring-updates` | + +### Data & Persistence + +| Topic | Skill Name | +|-------|-----------| +| SwiftData class inheritance | `apple-guide-swiftdata-class-inheritance` | + +### Concurrency & Performance + +| Topic | Skill Name | +|-------|-----------| +| Swift concurrency updates | `apple-guide-swift-concurrency-updates` | +| InlineArray and Span | `apple-guide-swift-inlinearray-span` | + +### Apple Intelligence + +| Topic | Skill Name | +|-------|-----------| +| Foundation Models (on-device LLM) | `apple-guide-foundationmodels-using-on-device-llm-in-your-app` | + +### System Integration + +| Topic | Skill Name | +|-------|-----------| +| App Intents updates | `apple-guide-appintents-updates` | +| StoreKit updates | `apple-guide-storekit-updates` | +| MapKit GeoToolbox | `apple-guide-mapkit-geotoolbox-placedescriptors` | +| Widgets for visionOS | `apple-guide-widgets-for-visionos` | + +### Accessibility + +| Topic | Skill Name | +|-------|-----------| +| Assistive Access in iOS | `apple-guide-implementing-assistive-access-in-ios` | + +### Computer Vision + +| Topic | Skill Name | +|-------|-----------| +| Visual Intelligence in iOS | `apple-guide-implementing-visual-intelligence-in-ios` | + +## Swift Compiler Diagnostics + +These explain specific Swift compiler errors and warnings with examples and fixes. + +### Concurrency Diagnostics + +| Diagnostic | Skill Name | +|-----------|-----------| +| Actor-isolated call from nonisolated context | `apple-diag-actor-isolated-call` | +| Conformance isolation | `apple-diag-conformance-isolation` | +| Isolated conformances | `apple-diag-isolated-conformances` | +| Nonisolated nonsending by default | `apple-diag-nonisolated-nonsending-by-default` | +| Sendable closure captures | `apple-diag-sendable-closure-captures` | +| Sendable metatypes | `apple-diag-sendable-metatypes` | +| Sending closure risks data race | `apple-diag-sending-closure-risks-data-race` | +| Sending risks data race | `apple-diag-sending-risks-data-race` | +| Mutable global variable | `apple-diag-mutable-global-variable` | +| Preconcurrency import | `apple-diag-preconcurrency-import` | + +### Type System Diagnostics + +| Diagnostic | Skill Name | +|-----------|-----------| +| Existential any | `apple-diag-existential-any` | +| Existential member access limitations | `apple-diag-existential-member-access-limitations` | +| Nominal types | `apple-diag-nominal-types` | +| Multiple inheritance | `apple-diag-multiple-inheritance` | +| Protocol type non-conformance | `apple-diag-protocol-type-non-conformance` | +| Opaque type inference | `apple-diag-opaque-type-inference` | + +### Build & Migration Diagnostics + +| Diagnostic | Skill Name | +|-----------|-----------| +| Deprecated declaration | `apple-diag-deprecated-declaration` | +| Error in future Swift version | `apple-diag-error-in-future-swift-version` | +| Strict language features | `apple-diag-strict-language-features` | +| Strict memory safety | `apple-diag-strict-memory-safety` | +| Implementation only deprecated | `apple-diag-implementation-only-deprecated` | +| Member import visibility | `apple-diag-member-import-visibility` | +| Missing module on known paths | `apple-diag-missing-module-on-known-paths` | +| Clang declaration import | `apple-diag-clang-declaration-import` | +| Availability unrecognized name | `apple-diag-availability-unrecognized-name` | +| Unknown warning group | `apple-diag-unknown-warning-group` | + +### Swift Language Diagnostics + +| Diagnostic | Skill Name | +|-----------|-----------| +| Dynamic callable requirements | `apple-diag-dynamic-callable-requirements` | +| Property wrapper requirements | `apple-diag-property-wrapper-requirements` | +| Result builder methods | `apple-diag-result-builder-methods` | +| String interpolation conformance | `apple-diag-string-interpolation-conformance` | +| Trailing closure matching | `apple-diag-trailing-closure-matching` | +| Temporary pointers | `apple-diag-temporary-pointers` | + +## Routing Decision Tree + +``` +User question about Apple API/framework? +├── Specific compiler error/warning → Read matching apple-diag-* skill +├── Liquid Glass implementation → Read apple-guide-*-liquid-glass-design (SwiftUI/UIKit/AppKit) +├── Swift concurrency patterns → Read apple-guide-swift-concurrency-updates +├── Foundation Models / on-device AI → Read apple-guide-foundationmodels-* +├── SwiftData features → Read apple-guide-swiftdata-* +├── StoreKit / IAP → Read apple-guide-storekit-updates +├── App Intents / Siri → Read apple-guide-appintents-updates +├── Charts / visualization → Read apple-guide-swift-charts-3d-visualization +├── Text editing / AttributedString → Read apple-guide-swiftui-styled-text-editing or apple-guide-foundation-attributedstring-updates +├── WebKit in SwiftUI → Read apple-guide-swiftui-webkit-integration +├── Toolbar features → Read apple-guide-swiftui-new-toolbar-features +└── Other → Search with axiom_search_skills using source filter "apple" +``` + +## Resources + +**Skills**: axiom-ios-ui, axiom-ios-concurrency, axiom-ios-data, axiom-ios-ai, axiom-ios-integration diff --git a/.claude/skills/axiom-asc-mcp/.openskills.json b/.claude/skills/axiom-asc-mcp/.openskills.json new file mode 100644 index 0000000..5c75c5e --- /dev/null +++ b/.claude/skills/axiom-asc-mcp/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-asc-mcp", + "installedAt": "2026-04-12T08:05:49.297Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-asc-mcp/SKILL.md b/.claude/skills/axiom-asc-mcp/SKILL.md new file mode 100644 index 0000000..76b0271 --- /dev/null +++ b/.claude/skills/axiom-asc-mcp/SKILL.md @@ -0,0 +1,303 @@ +--- +name: axiom-asc-mcp +description: Use when automating App Store Connect via MCP — submit builds, manage TestFlight, respond to reviews, triage feedback programmatically +license: MIT +--- + +# App Store Connect MCP Integration + +**Core principle**: When asc-mcp is configured, you can manage the entire App Store Connect workflow without leaving Claude Code — submit builds, distribute to TestFlight, respond to reviews, and monitor metrics programmatically. + +## Setup + +### Install + +```bash +brew install mint +mint install zelentsov-dev/asc-mcp@1.4.0 +``` + +### Create API Key + +1. Open [App Store Connect → Users and Access → Integrations → API](https://appstoreconnect.apple.com/access/integrations/api) +2. Generate key with **Admin** or **App Manager** role +3. Download the `.p8` file (one-time download — save it securely) +4. Note the **Key ID** and **Issuer ID** + +### Add to Claude Code + +```bash +claude mcp add asc-mcp \ + -e ASC_KEY_ID=YOUR_KEY_ID \ + -e ASC_ISSUER_ID=YOUR_ISSUER_ID \ + -e ASC_PRIVATE_KEY_PATH=/path/to/AuthKey.p8 \ + -- ~/.mint/bin/asc-mcp +``` + +### Verify + +Ask Claude to call `company_current`. If it returns your team name, you're connected. + +--- + +## Worker Filtering + +asc-mcp has 25 workers (~208 tools). Loading all of them wastes context. Use `--workers` to load only what you need. + +### Presets + +| Preset | Workers | Tools | Use Case | +|--------|---------|-------|----------| +| **TestFlight** | `apps,builds,beta_groups,beta_testers` | ~34 | Beta distribution | +| **Release** | `apps,builds,versions,reviews` | ~40 | App Store submission | +| **Monetization** | `apps,iap,subscriptions,offer_codes,pricing` | ~55 | IAP and subscriptions | +| **Full** | (default, all workers) | ~208 | Everything | + +To use a preset, add `--workers` when registering the server: + +```bash +claude mcp add asc-mcp \ + -e ASC_KEY_ID=... \ + -e ASC_ISSUER_ID=... \ + -e ASC_PRIVATE_KEY_PATH=... \ + -- ~/.mint/bin/asc-mcp --workers apps,builds,versions,reviews +``` + +**Note**: `company` and `auth` workers always load regardless of `--workers`. When `builds` is enabled, `build_processing` and `build_beta` are included automatically. + +### Worker Selection Decision Tree + +```dot +digraph workers { + "What are you doing?" [shape=diamond]; + "TestFlight preset" [shape=box, label="--workers apps,builds,\nbeta_groups,beta_testers"]; + "Release preset" [shape=box, label="--workers apps,builds,\nversions,reviews"]; + "Monetization preset" [shape=box, label="--workers apps,iap,\nsubscriptions,offer_codes,pricing"]; + "Full (no flag)" [shape=box, label="No --workers flag\n(all 208 tools)"]; + + "What are you doing?" -> "TestFlight preset" [label="distributing beta builds"]; + "What are you doing?" -> "Release preset" [label="submitting to App Store"]; + "What are you doing?" -> "Monetization preset" [label="managing IAP/subscriptions"]; + "What are you doing?" -> "Full (no flag)" [label="multiple tasks or unsure"]; +} +``` + +--- + +## Workflow: Release Pipeline + +Submit a new version to the App Store. + +``` +1. apps_search(query: "MyApp") → get app ID +2. builds_list(appId, limit: 5) → find latest processed build +3. app_versions_create(appId, platform: "IOS", versionString: "2.1.0") +4. app_versions_attach_build(versionId, buildId) +5. app_versions_set_review_details(versionId, { contactEmail, notes, ... }) +6. app_versions_submit_for_review(versionId) +7. (After approval) app_versions_create_phased_release(versionId) +``` + +**Before step 3**: Version string must not already exist. Check with `app_versions_list`. + +**Before step 4**: Build must be in `VALID` processing state. Check with `builds_get_processing_state`. + +**Before step 6**: Version must be in `PREPARE_FOR_SUBMISSION` state. Attaching a build and setting review details are prerequisites. + +--- + +## Workflow: TestFlight Distribution + +Distribute a build to beta testers. + +``` +1. apps_search(query: "MyApp") → get app ID +2. builds_list(appId, limit: 5) → find latest build +3. builds_set_beta_localization(buildId, locale: "en-US", whatsNew: "Bug fixes") +4. beta_groups_list(appId) → find or create group + OR beta_groups_create(appId, name: "Internal Testers", isInternal: true) +5. beta_groups_add_builds(groupId, [buildId]) +6. builds_send_beta_notification(buildId) → notify testers (optional) +``` + +**Tip**: Internal testers (up to 100) get builds immediately. External testers (up to 10,000) require Beta App Review for the first build of each version. + +--- + +## Workflow: Review Management + +Monitor and respond to App Store reviews. + +``` +1. apps_search(query: "MyApp") → get app ID +2. reviews_list(appId, sort: "-createdDate", limit: 20) + OR reviews_list(appId, filterRating: "1,2") → negative reviews only +3. reviews_stats(appId) → rating distribution summary +4. reviews_create_response(reviewId, responseBody: "Thank you for...") +``` + +**Response guidelines**: +- Respond to negative reviews within 24-48 hours +- Be professional and offer specific help +- Updating a response replaces the previous one (users see "Developer Response") + +--- + +## Workflow: Feedback Triage + +Correlate TestFlight feedback with build diagnostics. + +``` +1. builds_list(appId, limit: 10) → recent builds +2. builds_get_beta_testers(buildId) → who tested this build +3. metrics_build_diagnostics(buildId) → crash signatures for this build +4. metrics_get_diagnostic_logs(signatureId) → individual crash logs +``` + +**Limitation**: TestFlight text feedback and screenshots are NOT available via the App Store Connect API. Use Xcode Organizer or the ASC web dashboard for feedback content. + +--- + +## Workflow: Multi-Company + +Switch between App Store Connect teams. + +``` +1. company_list → see configured accounts +2. company_switch(companyId: "client-a") → switch active account +3. (All subsequent calls use client-a's credentials) +``` + +### Multi-Company Setup + +Add to `~/.config/asc-mcp/companies.json`: + +```json +{ + "companies": [ + { + "id": "my-company", + "name": "My Company", + "key_id": "KEY_ID_1", + "issuer_id": "ISSUER_1", + "key_path": "/Users/you/.keys/AuthKey1.p8" + }, + { + "id": "client-a", + "name": "Client A", + "key_id": "KEY_ID_2", + "issuer_id": "ISSUER_2", + "key_path": "/Users/you/.keys/AuthKey2.p8" + } + ] +} +``` + +Or use numbered environment variables: `ASC_COMPANY_1_NAME`, `ASC_COMPANY_1_KEY_ID`, etc. + +--- + +## Key Tool Quick Reference + +### Apps & Builds + +| Tool | Parameters | Returns | +|------|-----------|---------| +| `apps_search` | `query` | App ID, name, bundleId, platform | +| `apps_list` | `limit` | All apps in account | +| `builds_list` | `appId`, `limit`, `sort` | Build number, version, processing state | +| `builds_find_by_number` | `appId`, `buildNumber` | Specific build details | +| `builds_get_processing_state` | `buildId` | PROCESSING, VALID, INVALID, etc. | +| `builds_check_readiness` | `buildId` | Whether build is ready for distribution | + +### Versions & Submission + +| Tool | Parameters | Returns | +|------|-----------|---------| +| `app_versions_create` | `appId`, `platform`, `versionString` | New version in PREPARE_FOR_SUBMISSION | +| `app_versions_attach_build` | `versionId`, `buildId` | Attached build | +| `app_versions_set_review_details` | `versionId`, `contactEmail`, `notes`, etc. | Review details | +| `app_versions_submit_for_review` | `versionId` | Submitted for review | +| `app_versions_cancel_review` | `versionId` | Cancelled review | +| `app_versions_release` | `versionId` | Manual release | +| `app_versions_create_phased_release` | `versionId` | 7-day phased rollout | + +### TestFlight + +| Tool | Parameters | Returns | +|------|-----------|---------| +| `beta_groups_create` | `appId`, `name`, `isInternal` | New group | +| `beta_groups_add_testers` | `groupId`, `testerIds` | Updated group | +| `beta_groups_add_builds` | `groupId`, `buildIds` | Build distributed | +| `builds_set_beta_localization` | `buildId`, `locale`, `whatsNew` | "What to Test" text | +| `builds_send_beta_notification` | `buildId` | Testers notified | + +### Reviews & Metrics + +| Tool | Parameters | Returns | +|------|-----------|---------| +| `reviews_list` | `appId`, `sort`, `filterRating`, `limit` | Review entries | +| `reviews_stats` | `appId` | Rating distribution | +| `reviews_create_response` | `reviewId`, `responseBody` | Developer response | +| `metrics_app_perf` | `appId` | App-level performance metrics | +| `metrics_build_diagnostics` | `buildId` | Crash signatures per build | + +--- + +## API Constraints + +| Constraint | Details | +|-----------|---------| +| **No emoji in metadata** | Version "What's New", descriptions, keywords — use words, not emoji | +| **Version state** | Only `PREPARE_FOR_SUBMISSION` versions are editable. Once submitted, create a new version to make changes. | +| **JWT lifetime** | 20-minute tokens, auto-refreshed by asc-mcp | +| **Rate limits** | Apple enforces per-account limits. asc-mcp retries with exponential backoff on 429s. | +| **Locale format** | Standard codes: `en-US`, `ja`, `de-DE`, `zh-Hans`, `ru` | +| **Build processing** | Newly uploaded builds take 15-30 minutes to process. Poll `builds_get_processing_state` before attaching. | +| **Phased release** | Only available after approval. Can pause/resume with `app_versions_update_phased_release`. | + +--- + +## Gotchas + +| Gotcha | Details | +|--------|---------| +| **Build not found** | Build may still be processing. Check `builds_get_processing_state` — must be `VALID`. | +| **"Version already exists"** | Can't create duplicate version strings. Use `app_versions_list` to check first. | +| **Attach fails** | Build must be processed AND the version must be in `PREPARE_FOR_SUBMISSION`. | +| **Review details rejected** | Contact info fields have format requirements. Email must be valid, phone must include country code. | +| **Wrong app** | `apps_search` is fuzzy. Verify the returned bundleId matches your target app. | +| **Multi-company confusion** | Always call `company_current` first to confirm which account is active before making changes. | +| **Beta App Review** | First external TestFlight build per version requires review. Subsequent builds to the same version are auto-approved. | +| **Missing analytics** | `analytics_*` tools require `vendor_number` in company config. Without it, sales/financial reports fail silently. | + +--- + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "I'll just use the ASC web dashboard" | MCP tools are faster for repetitive tasks — respond to 20 reviews, distribute to 5 groups, create versions across apps. | +| "I don't need worker filtering" | 208 tools consume ~30K tokens of context. Filter to what you need. | +| "I'll submit without review details" | Submission will fail. `app_versions_set_review_details` is required before `app_versions_submit_for_review`. | +| "I'll skip `company_current` — I only have one account" | Multi-company configs persist between sessions. Always verify. | +| "Feedback triage is the same via API" | Text feedback and screenshots are NOT in the ASC API. Use Organizer for feedback content, MCP for crash diagnostics. | + +--- + +## When asc-mcp is NOT Available + +If asc-mcp is not configured, fall back to manual workflows: + +- **Crash analysis**: Use Xcode Organizer (see `axiom-testflight-triage`) or App Store Connect web dashboard (see `axiom-app-store-connect-ref`) +- **TestFlight distribution**: Use Xcode → Product → Archive → Distribute, or `xcodebuild` + `altool` +- **Review management**: Use App Store Connect web dashboard +- **Submission**: Use Xcode → Product → Archive → Distribute to App Store + +--- + +## Resources + +**Skills**: axiom-app-store-submission, axiom-app-store-ref, axiom-app-store-connect-ref, axiom-testflight-triage + +**Agents**: crash-analyzer, security-privacy-scanner, iap-auditor diff --git a/.claude/skills/axiom-asc-mcp/agents/openai.yaml b/.claude/skills/axiom-asc-mcp/agents/openai.yaml new file mode 100644 index 0000000..5b9267f --- /dev/null +++ b/.claude/skills/axiom-asc-mcp/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "ASC MCP" + short_description: "Automating App Store Connect via MCP" diff --git a/.claude/skills/axiom-assume-isolated/.openskills.json b/.claude/skills/axiom-assume-isolated/.openskills.json new file mode 100644 index 0000000..a7d877e --- /dev/null +++ b/.claude/skills/axiom-assume-isolated/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-assume-isolated", + "installedAt": "2026-04-12T08:05:49.835Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-assume-isolated/SKILL.md b/.claude/skills/axiom-assume-isolated/SKILL.md new file mode 100644 index 0000000..a8cb7d7 --- /dev/null +++ b/.claude/skills/axiom-assume-isolated/SKILL.md @@ -0,0 +1,233 @@ +--- +name: axiom-assume-isolated +description: Use when needing synchronous actor access in tests, legacy delegate callbacks, or performance-critical code. Covers MainActor.assumeIsolated, @preconcurrency protocol conformances, crash behavior, Task vs assumeIsolated. +license: MIT +metadata: + version: "1.0.0" +--- + +# assumeIsolated — Synchronous Actor Access + +Synchronously access actor-isolated state when you **know** you're already on the correct isolation domain. + +## When to Use + +✅ **Use when:** +- Testing MainActor code synchronously (avoiding Task overhead) +- Legacy delegate callbacks documented to run on main thread +- Performance-critical code avoiding async hop overhead +- Protocol conformances where callbacks are guaranteed on specific actor + +❌ **Don't use when:** +- Uncertain about current isolation (use `await` instead) +- Already in async context (you have isolation) +- Cross-actor calls needed (use async) +- Callback origin is unknown or untrusted + +## API Reference + +### MainActor.assumeIsolated + +```swift +static func assumeIsolated( + _ operation: @MainActor () throws -> T, + file: StaticString = #fileID, + line: UInt = #line +) rethrows -> T where T: Sendable +``` + +**Behavior**: Executes synchronously. **Crashes** if not on MainActor's serial executor. + +### Custom Actor assumeIsolated + +```swift +func assumeIsolated( + _ operation: (isolated Self) throws -> T, + file: StaticString = #fileID, + line: UInt = #line +) rethrows -> T where T: Sendable +``` + +## Task vs assumeIsolated + +| Aspect | `Task { @MainActor in }` | `MainActor.assumeIsolated` | +|--------|--------------------------|---------------------------| +| Timing | Deferred (next run loop) | Synchronous (inline) | +| Async support | Yes (can await) | No (sync only) | +| Context | From any context | Must be sync function | +| Failure mode | Runs anyway | **Crashes** if wrong isolation | +| Use case | Start async work | Verify + access isolated state | + +## Patterns + +### Pattern 1: Testing MainActor Code + +```swift +@Test func viewModelUpdates() { + MainActor.assumeIsolated { + let vm = ViewModel() + vm.update() + #expect(vm.state == .updated) + } +} +``` + +### Pattern 2: Legacy Delegate Callbacks + +From WWDC 2024-10169 — When documentation guarantees main thread delivery: + +```swift +@MainActor +class LocationDelegate: NSObject, CLLocationManagerDelegate { + var location: CLLocation? + + // CLLocationManager created on main thread delivers callbacks on main thread + nonisolated func locationManager( + _ manager: CLLocationManager, + didUpdateLocations locations: [CLLocation] + ) { + MainActor.assumeIsolated { + self.location = locations.last + } + } +} +``` + +### Pattern 3: @preconcurrency Shorthand + +`@preconcurrency` is equivalent shorthand — wraps in `assumeIsolated` automatically: + +```swift +// ❌ Manual approach (verbose) +extension MyClass: SomeDelegate { + nonisolated func callback() { + MainActor.assumeIsolated { + self.updateUI() + } + } +} + +// ✅ Using @preconcurrency (equivalent, cleaner) +extension MyClass: @preconcurrency SomeDelegate { + func callback() { + self.updateUI() // Compiler wraps in assumeIsolated + } +} +``` + +**When protocol adds isolation**: `@preconcurrency` becomes unnecessary and compiler warns. + +### Pattern 4: Thread Check Before assumeIsolated + +When caller context is unknown (e.g., library code): + +```swift +func getView() -> UIView { + if Thread.isMainThread { + return createHostingViewOnMain() + } else { + return DispatchQueue.main.sync { + createHostingViewOnMain() + } + } +} + +private func createHostingViewOnMain() -> UIView { + MainActor.assumeIsolated { + let hosting = UIHostingController(rootView: MyView()) + return hosting.view + } +} +``` + +### Pattern 5: Custom Actor Access + +```swift +actor DataStore { + var cache: [String: Data] = [:] + + nonisolated func synchronousRead(key: String) -> Data? { + // Only safe if called from DataStore's executor + assumeIsolated { isolated in + isolated.cache[key] + } + } +} +``` + +## Common Mistakes + +### Mistake 1: Silencing Compiler Errors + +```swift +// ❌ DANGEROUS: Using assumeIsolated to silence warnings +func unknownContext() { + MainActor.assumeIsolated { + updateUI() // Crashes if not actually on main actor! + } +} + +// ✅ When uncertain, use proper async +func unknownContext() async { + await MainActor.run { + updateUI() + } +} +``` + +### Mistake 2: Assuming GCD Main Queue == MainActor + +They're **usually** the same, but not guaranteed. Check documentation or use async. + +### Mistake 3: Using in Async Context + +```swift +// ❌ Unnecessary — you already have isolation +@MainActor +func updateState() async { + MainActor.assumeIsolated { // Pointless + self.state = .ready + } +} + +// ✅ Direct access +@MainActor +func updateState() async { + self.state = .ready +} +``` + +## When @preconcurrency Becomes Unnecessary + +If the protocol later adds MainActor isolation: + +```swift +// Library update: +@MainActor +protocol CaffeineThresholdDelegate: AnyObject { + func caffeineLevel(at level: Double) +} + +// Your code — @preconcurrency now warns: +// "@preconcurrency attribute on conformance has no effect" +extension Recaffeinater: CaffeineThresholdDelegate { + func caffeineLevel(at level: Double) { + // Direct access, no wrapper needed + } +} +``` + +## Crash Behavior + +Per Apple documentation: +> "If the current context is not running on the actor's serial executor... this method will crash with a fatal error." + +**Trapping is intentional**: Better to crash than corrupt user data with a race condition. + +## Resources + +**WWDC**: 2024-10169 + +**Docs**: /swift/mainactor/assumeisolated, /swift/actor/assumeisolated + +**Skills**: axiom-swift-concurrency diff --git a/.claude/skills/axiom-assume-isolated/agents/openai.yaml b/.claude/skills/axiom-assume-isolated/agents/openai.yaml new file mode 100644 index 0000000..c41bba2 --- /dev/null +++ b/.claude/skills/axiom-assume-isolated/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Assume Isolated" + short_description: "Needing synchronous actor access in tests, legacy delegate callbacks, or performance-critical code" diff --git a/.claude/skills/axiom-audit-accessibility/.openskills.json b/.claude/skills/axiom-audit-accessibility/.openskills.json new file mode 100644 index 0000000..a1bb505 --- /dev/null +++ b/.claude/skills/axiom-audit-accessibility/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-accessibility", + "installedAt": "2026-04-12T08:05:49.836Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-accessibility/SKILL.md b/.claude/skills/axiom-audit-accessibility/SKILL.md new file mode 100644 index 0000000..45876b2 --- /dev/null +++ b/.claude/skills/axiom-audit-accessibility/SKILL.md @@ -0,0 +1,252 @@ +--- +name: axiom-audit-accessibility +description: Use when the user mentions accessibility checking, App Store submission, code review, or WCAG compliance. +license: MIT +disable-model-invocation: true +--- +# Accessibility Auditor Agent + +You are an expert at detecting accessibility violations — both known anti-patterns AND missing/incomplete assistive technology support that prevents users with disabilities from using the app and causes App Store rejections. + +## Your Mission + +Run a comprehensive accessibility audit using 5 phases: map the UI hierarchy and assistive technology surface, detect known violations, reason about what's unreachable or incomplete, correlate compound issues, and score accessibility health. Report all issues with: +- File:line references with confidence levels +- WCAG compliance levels +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Fix recommendations with code examples + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Phase 1: Map UI Hierarchy and Assistive Technology Surface + +Before grepping for violations, build a mental model of the app's UI and how assistive technologies would experience it. + +### Step 1: Identify Interactive Surfaces + +``` +Glob: **/*.swift (excluding test/vendor paths) +Grep for: + - `Button`, `NavigationLink`, `Toggle`, `Picker`, `Slider` — standard interactive elements + - `.onTapGesture`, `.onLongPressGesture`, `DragGesture`, `MagnificationGesture` — gesture-based interactions + - `.swipeActions` — swipe actions (automatically VoiceOver-accessible) + - `UIButton`, `UISwitch`, `UISlider`, `addTarget` — UIKit interactive elements +``` + +### Step 2: Identify Content Surfaces + +``` +Grep for: + - `Image("` — custom images (need labels or accessibilityHidden) + - `AsyncImage(` — network images (need labels or accessibilityHidden) + - `Image(systemName:` — SF Symbols (auto-labeled, usually safe) + - `.font(.system(size:`, `UIFont.systemFont(ofSize:` — explicit font sizing + - `.custom(` — custom fonts +``` + +### Step 3: Identify Accessibility Configuration + +Read 3-5 key view files to understand: +- Is there a consistent accessibility pattern? (labels, traits, hints) +- Are there custom controls? (custom gestures, drawn content) +- Is Dynamic Type supported? (@ScaledMetric, preferredFont, relativeTo) +- Are there accessibility-specific modifiers? (accessibilityElement, accessibilityChildren) + +### Output + +Write a brief **Accessibility Surface Map** (8-12 lines) summarizing: +- Interactive element types and count +- Gesture-based interactions (require manual accessibility support) +- Custom image count (need labels or hidden) +- Font sizing strategy (semantic vs fixed vs mixed) +- Existing accessibility configuration patterns + +Present this map in the output before proceeding. + +## Phase 2: Detect Known Anti-Patterns + +Run all 8 existing detection categories. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification. + +### 1. Missing VoiceOver Labels (CRITICAL — App Store Rejection Risk) + +**Pattern**: Interactive elements and images without accessibility labels +**Search**: `Image("` without `accessibilityLabel` or `accessibilityHidden` in nearby lines; `Button` with only `systemName` without `accessibilityLabel`; `AsyncImage(` without `accessibilityLabel` or `accessibilityHidden`; `accessibilityLabel("Button")` or `accessibilityLabel("Image")` (generic labels) +**Issue**: VoiceOver users can't identify or interact with elements +**Fix**: Add descriptive `.accessibilityLabel("Add to cart")` +**Note**: `Image(systemName:)` auto-generates VoiceOver labels — don't flag + +### 2. Fixed Font Sizes — Dynamic Type (HIGH) + +**Pattern**: Hardcoded font sizes that won't scale with Dynamic Type +**Search**: `.font(.system(size:` without `relativeTo:`; `UIFont.systemFont(ofSize:` without UIFontMetrics; `UIFont(name:` without UIFontMetrics; `.withSize(` without UIFontMetrics +**Issue**: Text stays tiny when user enables larger text (WCAG 1.4.4) +**Fix**: Use `.font(.body)` or `.font(.system(size: 17, design: .default).relativeTo(.body))` +**Note**: Before flagging `.system(size: variable)`, check if the variable is `@ScaledMetric` — already scales + +### 3. Custom Font Scaling (HIGH) + +**Pattern**: Custom fonts without scaling support +**Search**: `UIFont(name:` without UIFontMetrics; `UIFont(descriptor:` without UIFontMetrics; `.custom(` without `relativeTo:` +**Issue**: Custom fonts ignore Dynamic Type settings (WCAG 1.4.4) +**Fix**: UIKit: `UIFontMetrics(forTextStyle: .body).scaledFont(for: customFont)`. SwiftUI: `.custom("FontName", size: X, relativeTo: .body)` + +### 4. Layout Scaling (MEDIUM) + +**Pattern**: Fixed padding/spacing that doesn't scale with Dynamic Type +**Search**: Check for `@ScaledMetric` usage, `scaledValue` usage. Absence of both with fixed padding constants indicates issue. +**Issue**: Layout doesn't adapt to larger text sizes (WCAG 1.4.4) +**Fix**: SwiftUI: `@ScaledMetric(relativeTo: .body) var spacing: CGFloat = 20`. UIKit: `UIFontMetrics(forTextStyle: .body).scaledValue(for: 20.0)` + +### 5. Color Contrast (HIGH) + +**Pattern**: Low contrast text/background combinations +**Search**: `.foregroundColor(.gray)`, `.foregroundStyle(.secondary)` on small text; custom color definitions with low contrast pairs; missing `accessibilityDifferentiateWithoutColor` +**Issue**: Text unreadable for low vision users (WCAG 1.4.3 — 4.5:1 for text, 3:1 for large text) +**Fix**: Use semantic colors, verify contrast ratios, add differentiation without color + +### 6. Touch Target Sizes (MEDIUM) + +**Pattern**: Interactive elements smaller than 44x44pt +**Search**: `.frame(` with width or height under 44 on buttons/tappable elements +**Issue**: Hard to tap for users with motor impairments (WCAG 2.5.5) +**Fix**: Use `.frame(minWidth: 44, minHeight: 44)` or increase contentShape + +### 7. Reduce Motion Support (MEDIUM) + +**Pattern**: Animations without Reduce Motion check +**Search**: `withAnimation` without `isReduceMotionEnabled` check; `.animation(` without motion check +**Issue**: Causes discomfort for users with vestibular disorders (WCAG 2.3.3) +**Fix**: Check `UIAccessibility.isReduceMotionEnabled` or use `.animation(.default, value:)` which respects Reduce Motion + +### 8. Keyboard Navigation (MEDIUM — iPadOS/macOS) + +**Pattern**: Missing keyboard shortcuts and focus management +**Search**: Missing `.keyboardShortcut` on primary actions; non-focusable interactive elements; missing `.focusable()` on custom controls +**Issue**: Keyboard-only users can't navigate (iPadOS with external keyboard, macOS) +**Fix**: Add keyboard shortcuts for primary actions, ensure focus traversal + +## Phase 3: Reason About Accessibility Completeness + +Using the Accessibility Surface Map from Phase 1 and your domain knowledge, check for what's *missing* — not just what's wrong. + +| Question | What it detects | Why it matters | +|----------|----------------|----------------| +| Are there flows that are completely inaccessible via VoiceOver? (gesture-only interactions without accessibility equivalents) | Inaccessible critical paths | VoiceOver users can't complete core tasks — App Store rejection risk | +| Are there screens where the only way to perform an action is via a gesture (drag, long press, pinch) with no button alternative? | Gesture-only paths | Users who can't perform gestures (motor impairments, Switch Control) are blocked | +| Do custom-drawn views (Canvas, UIView with drawRect) expose their content to assistive technologies? | Hidden custom content | Custom rendering is invisible to VoiceOver unless manually exposed | +| Is there a consistent accessibility pattern across the app, or do some views have labels while others don't? | Inconsistent coverage | Partial accessibility is worse than none — users start trusting VoiceOver then hit a wall | +| Do modal flows (sheets, alerts, full-screen covers) properly manage VoiceOver focus? | Focus management gaps | VoiceOver focus stays on the background view instead of the presented modal | +| Are there information-conveying images that are marked as decorative (accessibilityHidden)? | Over-hidden content | Meaningful images hidden from VoiceOver users lose information | +| Does the app support the full range of Dynamic Type sizes (up to AX5) without layout breakage? | Partial Dynamic Type support | Users at accessibility text sizes get clipped/overlapping content | + +For each finding, explain what's missing and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code. + +## Phase 4: Cross-Reference Findings + +When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations: + +| Finding A | + Finding B | = Compound | Severity | +|-----------|------------|-----------|----------| +| Gesture-only interaction | No accessibilityAction | Feature completely inaccessible | CRITICAL | +| Missing labels on buttons | In critical flow (purchase, auth) | Core transaction inaccessible | CRITICAL | +| Fixed font sizes | No @ScaledMetric for spacing | Completely ignores Dynamic Type | CRITICAL | +| Custom font without scaling | In main content area | Primary text doesn't scale | HIGH | +| Missing Reduce Motion | Looping/auto-play animation | Persistent discomfort trigger | HIGH | +| Small touch targets | In frequently used controls | Repeated frustration for motor-impaired users | HIGH | +| Missing labels | In list cells (repeated N times) | Entire list unusable for VoiceOver | HIGH | +| Inconsistent labeling | Some views labeled, others not | Users can't predict what's accessible | MEDIUM | + +Also note overlaps with other auditors: +- Gesture-only + no accessibilityAction → compound with ux-flow-auditor +- Missing labels in navigation destinations → compound with swiftui-nav-auditor +- Dynamic Type + layout issues → compound with swiftui-layout-auditor + +## Phase 5: Accessibility Health Score + +Calculate and present a health score: + +```markdown +## Accessibility Health Score + +| Metric | Value | +|--------|-------| +| VoiceOver label coverage | N interactive elements, M with labels (Z%) | +| Dynamic Type support | Semantic fonts: N, Fixed fonts: M, Scaling coverage: Z% | +| Gesture accessibility | N gesture-based interactions, M with accessibilityAction equivalents (Z%) | +| WCAG Level A | N violations | +| WCAG Level AA | N violations | +| WCAG Level AAA | N violations | +| **Health** | **COMPLIANT / GAPS / NON-COMPLIANT** | +``` + +Scoring: +- **COMPLIANT**: No CRITICAL issues, 0 Level A violations, >90% VoiceOver label coverage, all gestures have accessibility equivalents +- **GAPS**: No CRITICAL issues, but Level A or AA violations present, or 70-90% label coverage, or some gesture-only paths +- **NON-COMPLIANT**: Any CRITICAL issues, or multiple Level A violations, or <70% label coverage, or critical flows inaccessible + +## Output Format + +```markdown +# Accessibility Audit Results + +## Accessibility Surface Map +[8-12 line summary from Phase 1] + +## Summary +- CRITICAL: [N] issues (App Store rejection risk) +- HIGH: [N] issues (Major usability impact) +- MEDIUM: [N] issues (Moderate usability impact) +- LOW: [N] issues (Best practices) +- Phase 2 (anti-pattern detection): [N] issues +- Phase 3 (completeness reasoning): [N] issues +- Phase 4 (compound findings): [N] issues + +## Accessibility Health Score +[Phase 5 table] + +## Issues by Severity + +### [SEVERITY/CONFIDENCE] [Category]: [Description] +**File**: path/to/file.swift:line +**Phase**: [2: Detection | 3: Completeness | 4: Compound] +**WCAG**: [guideline number and level] +**Issue**: What's wrong or missing +**Impact**: What users with disabilities experience +**Fix**: Code example showing the fix +**Cross-Auditor Notes**: [if overlapping with another auditor] + +## Recommendations +1. [Immediate actions — CRITICAL fixes (App Store rejection risk)] +2. [Short-term — HIGH fixes (WCAG Level A/AA compliance)] +3. [Long-term — accessibility improvements from Phase 3 findings] + +## Testing Checklist +- [ ] Test with VoiceOver (Cmd+F5 on simulator) +- [ ] Test with Dynamic Type at AX5 (Settings → Accessibility → Display & Text Size → Larger Text) +- [ ] Test with Reduce Motion (Settings → Accessibility → Motion → Reduce Motion) +- [ ] Test with external keyboard on iPad (Tab, arrow keys, Enter) +``` + +## Output Limits + +If >50 issues in one category: Show top 10, provide total count, list top 3 files +If >100 total issues: Summarize by category, show only CRITICAL/HIGH details + +## False Positives (Not Issues) + +- Decorative images with `.accessibilityHidden(true)` +- Spacer views without labels +- Background images marked as decorative +- `.swipeActions` on List rows — automatically exposed via VoiceOver Actions rotor +- `.font(.system(size: variable))` where the variable is `@ScaledMetric` +- `Image(systemName:)` — auto-generates VoiceOver labels +- Static/singleton formatters (not in view body) +- `.animation(.default, value:)` — already respects Reduce Motion system setting + +## Related + +For comprehensive accessibility debugging: `axiom-accessibility-diag` skill +For Dynamic Type and typography: `axiom-typography-ref` skill +For UX flow accessibility: `axiom-ux-flow-audit` skill diff --git a/.claude/skills/axiom-audit-accessibility/agents/openai.yaml b/.claude/skills/axiom-audit-accessibility/agents/openai.yaml new file mode 100644 index 0000000..24a0b9a --- /dev/null +++ b/.claude/skills/axiom-audit-accessibility/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit Accessibility" + short_description: "The user mentions accessibility checking, App Store submission, code review, or WCAG compliance." diff --git a/.claude/skills/axiom-audit-camera/.openskills.json b/.claude/skills/axiom-audit-camera/.openskills.json new file mode 100644 index 0000000..8535c79 --- /dev/null +++ b/.claude/skills/axiom-audit-camera/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-camera", + "installedAt": "2026-04-12T08:05:49.837Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-camera/SKILL.md b/.claude/skills/axiom-audit-camera/SKILL.md new file mode 100644 index 0000000..abbeef5 --- /dev/null +++ b/.claude/skills/axiom-audit-camera/SKILL.md @@ -0,0 +1,235 @@ +--- +name: axiom-audit-camera +description: Use this agent to scan Swift code for camera, video, and audio capture issues including deprecated APIs, missing interruption handlers, threading violations, and permission anti-patterns. +license: MIT +disable-model-invocation: true +--- +# Camera & Capture Auditor Agent + +You are an expert at detecting camera, video, and audio capture issues in iOS apps that cause freezes, poor UX, App Store rejections, and reliability problems. + +## Your Mission + +Run a comprehensive camera/capture audit and report all issues with: +- File:line references with confidence levels +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Specific fix recommendations +- Links to relevant skill patterns + +## Files to Scan + +Look for capture code in: +- `**/*.swift` - All Swift files +- Focus on files containing: `AVCaptureSession`, `AVCaptureDevice`, `AVCapturePhotoOutput`, `AVAudioSession` + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## What You Check + +### 1. Main Thread Session Work (CRITICAL - UI Freezes) + +**Pattern to find**: +```swift +// BAD: startRunning on main thread +session.startRunning() // Without being on session queue +``` + +**What to look for**: +- `startRunning()` or `stopRunning()` not wrapped in `DispatchQueue` async +- Missing `let sessionQueue = DispatchQueue(label:` pattern +- Session configuration without dedicated queue + +**Fix**: Move all session work to dedicated serial queue + +### 2. Deprecated videoOrientation API (HIGH - iOS 17+ Issues) + +**Pattern to find**: +```swift +// DEPRECATED +connection.videoOrientation = .portrait +AVCaptureConnection.videoOrientation +``` + +**What to look for**: +- Any use of `videoOrientation` property +- Manual device orientation observation for camera +- Missing `RotationCoordinator` + +**Fix**: Use `AVCaptureDevice.RotationCoordinator` (iOS 17+) + +### 3. Missing Interruption Handling (HIGH - Camera Freezes) + +**Pattern to find**: +```swift +// Missing observer for: +.AVCaptureSessionWasInterrupted +AVCaptureSession.interruptionEndedNotification +``` + +**What to look for**: +- Files with `AVCaptureSession` but no interruption notification observers +- No handling for phone calls, multitasking +- No UI feedback for interrupted state + +**Fix**: Add observers for session interruption notifications + +### 4. UIImagePickerController for Photo Selection (MEDIUM - Deprecated) + +**Pattern to find**: +```swift +// DEPRECATED for photo selection +UIImagePickerController() +.sourceType = .photoLibrary +``` + +**What to look for**: +- `UIImagePickerController` with `photoLibrary` source type +- Should use `PHPickerViewController` or `PhotosPicker` instead + +**Fix**: Replace with PHPicker (UIKit) or PhotosPicker (SwiftUI) + +### 5. Over-Requesting Photo Library Access (MEDIUM - Privacy Issue) + +**Pattern to find**: +```swift +// BAD: Requesting access just to pick photos +PHPhotoLibrary.requestAuthorization +PHPhotoLibrary.authorizationStatus +// Before showing PHPicker or PhotosPicker +``` + +**What to look for**: +- Permission requests when only using system pickers +- PHPicker/PhotosPicker don't need library permission +- Unnecessary privacy prompts + +**Fix**: Remove permission requests if only using system pickers + +### 6. Missing Photo Quality Settings (MEDIUM - Slow Capture) + +**Pattern to find**: +```swift +// Missing quality prioritization +AVCapturePhotoSettings() +// Without setting photoQualityPrioritization +``` + +**What to look for**: +- `AVCapturePhotoSettings` without `photoQualityPrioritization` +- Default is often `.quality` which is slow +- Social/sharing apps should use `.speed` or `.balanced` + +**Fix**: Set appropriate `photoQualityPrioritization` + +### 7. AVAudioSession Category Mismatch (MEDIUM - Audio Issues) + +**Pattern to find**: +```swift +// BAD: Wrong category for recording +.setCategory(.playback) // Can't record with this +.setCategory(.ambient) // Can't record with this +``` + +**What to look for**: +- Video recording code with non-recording audio category +- Should use `.playAndRecord` for video with audio +- Missing category configuration before recording + +**Fix**: Set appropriate AVAudioSession category (`.playAndRecord` or `.record`) + +### 8. Missing Purpose Strings (CRITICAL - App Store Rejection) + +**What to check**: +- Look for camera/audio usage without corresponding Info.plist keys +- Required keys: + - `NSCameraUsageDescription` - For camera access + - `NSMicrophoneUsageDescription` - For audio recording + - `NSPhotoLibraryUsageDescription` - For photo library access + - `NSPhotoLibraryAddUsageDescription` - For saving photos + +**Note**: You may not be able to check Info.plist directly, but flag when camera/audio code exists + +### 9. Configuration Without Block (LOW - Race Conditions) + +**Pattern to find**: +```swift +// BAD: Modifying session without configuration block +session.addInput(input) +session.addOutput(output) +// Without beginConfiguration/commitConfiguration +``` + +**What to look for**: +- `addInput` or `addOutput` without surrounding `beginConfiguration`/`commitConfiguration` +- Session modifications that could cause race conditions + +**Fix**: Wrap session changes in `beginConfiguration()`/`commitConfiguration()` + +### 10. Synchronous Photo Loading (LOW - UI Blocking) + +**Pattern to find**: +```swift +// BAD: Blocking main thread +try! item.loadTransferable(type:) // Force try, no async +``` + +**What to look for**: +- Non-async Transferable loading +- `PHImageManager.requestImage` without async handling +- Image loading on main thread + +**Fix**: Use async/await for all image loading + +## Output Format + +For each issue found: + +``` +## [SEVERITY] Issue Title + +**File**: `path/to/File.swift:123` +**Confidence**: HIGH/MEDIUM/LOW + +**What was found**: +```swift +// The problematic code +``` + +**Why it's a problem**: +Brief explanation of the issue + +**Fix**: +```swift +// The corrected code +``` + +**See**: camera-capture skill, Pattern X +``` + +## Summary Section + +After listing all issues, provide a summary: + +``` +## Audit Summary + +- **CRITICAL**: X issues +- **HIGH**: X issues +- **MEDIUM**: X issues +- **LOW**: X issues + +**Top priority fixes**: +1. [Most important issue] +2. [Second most important] +3. [Third most important] +``` + +## Related Skills + +For detailed patterns and solutions, refer developers to: +- `axiom-camera-capture` - Session setup, rotation, interruption handling +- `axiom-camera-capture-diag` - Troubleshooting decision trees +- `axiom-camera-capture-ref` - API reference +- `axiom-photo-library` - Photo picker and library patterns diff --git a/.claude/skills/axiom-audit-camera/agents/openai.yaml b/.claude/skills/axiom-audit-camera/agents/openai.yaml new file mode 100644 index 0000000..a86c680 --- /dev/null +++ b/.claude/skills/axiom-audit-camera/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit Camera" + short_description: "Use this agent to scan Swift code for camera, video, and audio capture issues including deprecated APIs, missing inte..." diff --git a/.claude/skills/axiom-audit-codable/.openskills.json b/.claude/skills/axiom-audit-codable/.openskills.json new file mode 100644 index 0000000..7b55780 --- /dev/null +++ b/.claude/skills/axiom-audit-codable/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-codable", + "installedAt": "2026-04-12T08:05:49.838Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-codable/SKILL.md b/.claude/skills/axiom-audit-codable/SKILL.md new file mode 100644 index 0000000..3f14e97 --- /dev/null +++ b/.claude/skills/axiom-audit-codable/SKILL.md @@ -0,0 +1,362 @@ +--- +name: axiom-audit-codable +description: Use when the user mentions Codable review, JSON encoding/decoding issues, data serialization audit, or modernizing legacy code. +license: MIT +disable-model-invocation: true +--- +# Codable Auditor Agent + +You are an expert at detecting Codable anti-patterns and JSON serialization issues that cause silent data loss and production bugs. + +## Your Mission + +Run a comprehensive Codable audit and report all issues with: +- File:line references for easy fixing +- Severity ratings (HIGH/MEDIUM/LOW) +- Specific issue types (anti-patterns vs configuration issues) +- Fix recommendations with code examples + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Output Limits + +If >50 issues in one category: +- Show top 10 examples +- Provide total count +- List top 3 files with most issues + +If >100 total issues: +- Summarize by category +- Show only HIGH details +- Always show: Severity counts, top 3 files by issue count + +## What You Check + +### High-Severity Anti-Patterns + +#### 1. Manual JSON String Building (HIGH) +**Patterns to detect**: +```swift +// String interpolation with JSON +"\"{" or "'{\"" +"\\\"" in string literals + +// Common examples: +let json = "{\"key\": \"\(value)\"}" +let json = "{ \"name\": \"\(name)\", \"age\": \(age) }" +``` + +**Why it's bad**: Injection vulnerabilities, escaping bugs, no type safety +**Impact**: Production crashes, security vulnerabilities, data corruption + +**Fix recommendation**: +```swift +// ❌ Manual string building +let json = "{\"name\": \"\(user.name)\", \"id\": \(user.id)}" + +// ✅ Use JSONEncoder +struct UserPayload: Codable { + let name: String + let id: Int +} +let data = try JSONEncoder().encode(UserPayload(name: user.name, id: user.id)) +``` + +#### 2. try? Swallowing DecodingError (HIGH) +**Patterns to detect**: +```swift +"try? JSONDecoder" +"try? decoder.decode" +"try? JSONEncoder" +"try? encoder.encode" +``` + +**Why it's bad**: Silent failures, debugging nightmares, data loss +**Impact**: Users lose data without knowing, impossible to debug in production + +**Fix recommendation**: +```swift +// ❌ Silent failure +let user = try? JSONDecoder().decode(User.self, from: data) + +// ✅ Explicit error handling +do { + let user = try JSONDecoder().decode(User.self, from: data) +} catch DecodingError.keyNotFound(let key, let context) { + logger.error("Missing key '\(key)' at path: \(context.codingPath)") +} catch { + logger.error("Failed to decode User: \(error)") +} +``` + +#### 3. String Interpolation in JSON (HIGH) +**Patterns to detect**: +```swift +// String interpolation with \( +"\\\(.*\)" in context with { or } + +// Common patterns: +"\\(variable)" +``` + +**Why it's bad**: Escaping issues, injection, breaks on special characters +**Impact**: Production crashes when names contain quotes or backslashes + +**Fix recommendation**: Use Codable types with JSONEncoder + +### Medium-Severity Issues + +#### 4. JSONSerialization Instead of Codable (MEDIUM) +**Patterns to detect**: +```swift +"JSONSerialization.jsonObject" +"JSONSerialization.data" +"NSJSONSerialization" +``` + +**Why it's bad**: Legacy pattern, manual type casting, error-prone +**Impact**: 3x more boilerplate, no type safety, harder to maintain + +**Fix recommendation**: +```swift +// ❌ JSONSerialization +let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] +let name = json?["name"] as? String + +// ✅ Codable +struct User: Codable { + let name: String +} +let user = try JSONDecoder().decode(User.self, from: data) +``` + +#### 5. Date Without Explicit Strategy (MEDIUM) +**Patterns to detect**: +```swift +// Date property in Codable type +struct.*:.*Codable.*\n.*Date + +// But no dateDecodingStrategy configuration in the file +// (check if file contains JSONDecoder but no dateDecodingStrategy) +``` + +**Why it's bad**: Timezone bugs, intermittent failures across regions +**Impact**: Data corruption, bugs only appear for users in different timezones + +**Fix recommendation**: +```swift +// ❌ No strategy configured +let decoder = JSONDecoder() +let user = try decoder.decode(User.self, from: data) + +// ✅ Explicit strategy +let decoder = JSONDecoder() +decoder.dateDecodingStrategy = .iso8601 // Or .secondsSince1970, etc. +let user = try decoder.decode(User.self, from: data) +``` + +#### 6. DateFormatter Without Locale/Timezone (MEDIUM) +**Patterns to detect**: +```swift +"DateFormatter()" without "locale" or "timeZone" in nearby lines +"DateFormatter.dateFormat" without "locale" +``` + +**Why it's bad**: Locale-dependent parsing failures +**Impact**: App breaks for users with non-US locale settings + +**Fix recommendation**: +```swift +// ❌ No locale/timezone +let formatter = DateFormatter() +formatter.dateFormat = "yyyy-MM-dd" + +// ✅ With locale and timezone +let formatter = DateFormatter() +formatter.dateFormat = "yyyy-MM-dd" +formatter.locale = Locale(identifier: "en_US_POSIX") +formatter.timeZone = TimeZone(secondsFromGMT: 0) +``` + +#### 7. Optional Properties to Avoid Decode Errors (MEDIUM) +**Pattern**: Look for optional properties with comments mentioning "decode", "fail", "error", "crash" + +**Why it's bad**: Masks structural problems, runtime crashes, nil checks everywhere +**Impact**: Field is required but marked optional, leads to crashes later + +**Fix recommendation**: +```swift +// ❌ Optional to avoid decode errors +struct User: Codable { + let id: UUID + let email: String? // Made optional because decoding was failing +} + +// ✅ Fix root cause +// 1. Check if API structure changed (nested? renamed?) +// 2. Use CodingKeys to map to correct key +// 3. Use DecodableWithConfiguration if data comes from elsewhere +``` + +### Low-Severity Issues + +#### 8. No Error Context in Catch Blocks (LOW) +**Patterns to detect**: +```swift +catch { + print("Failed") // No error variable +} +``` + +**Why it's bad**: No debugging information when things fail +**Impact**: Cannot diagnose production issues + +**Fix recommendation**: +```swift +// ❌ No context +catch { + print("Failed to decode") +} + +// ✅ Include error +catch { + print("Failed to decode: \(error)") + // Or use structured logging + logger.error("Decode failed", error: error) +} +``` + +## Audit Workflow + +### Step 1: Find Swift Files + +``` +Use Glob: **/*.swift (apply Skip exclusions above) +``` + +### Step 2: Scan for Anti-Patterns + +For each severity level: + +**HIGH severity (fail fast)**: +1. Manual JSON building: `"\"{"` +2. try? with decoder: `"try? JSONDecoder"`, `"try? decoder.decode"` +3. String interpolation in JSON context + +**MEDIUM severity**: +1. JSONSerialization: `"JSONSerialization"`, `"NSJSONSerialization"` +2. Date properties without strategy +3. DateFormatter without locale +4. Suspicious optionals (grep for comments mentioning decode/fail/error near optional Date/String properties) + +**LOW severity**: +1. Empty catch blocks or print-only error handling + +### Step 3: Read Context + +For each match: +1. Read the file with context (-B 5 -A 5) +2. Determine if it's a true positive +3. Identify the specific issue type +4. Formulate fix recommendation + +### Step 4: Generate Report + +Format output as: + +```markdown +# Codable Audit Results + +## Summary +- Files scanned: [X] +- Total issues: [Y] + - HIGH: [Z] + - MEDIUM: [A] + - LOW: [B] + +## 🔴 High Priority Issues ([count]) + +### Manual JSON String Building +- **file/path.swift:45** - Building JSON with string interpolation + ```swift + let json = "{\"key\": \"\(value)\"}" + ``` + **Fix**: Use JSONEncoder with Codable type + **Impact**: Injection vulnerabilities, escaping bugs + +### try? Swallowing Errors +- **file/path.swift:89** - Silent decode failure with try? + ```swift + let user = try? decoder.decode(User.self, from: data) + ``` + **Fix**: Handle DecodingError cases explicitly + **Impact**: Silent data loss, impossible to debug + +## 🟡 Medium Priority Issues ([count]) + +### JSONSerialization Usage +- **file/path.swift:112** - Using legacy JSONSerialization + **Fix**: Migrate to Codable + **Time saved**: Reduce boilerplate by 60% + +### Date Handling +- **file/path.swift:134** - Date property without explicit strategy + **Fix**: Set decoder.dateDecodingStrategy = .iso8601 + **Impact**: Prevents timezone bugs + +## 🟢 Low Priority Issues ([count]) + +[List issues with file:line and brief description] + +## Recommendations + +1. **Immediate**: Fix all HIGH severity issues (silent failures, injection risks) +2. **This sprint**: Address MEDIUM severity (technical debt, potential bugs) +3. **Backlog**: Clean up LOW severity (code quality improvements) + +## Quick Wins + +[List 2-3 most impactful fixes that take <10 minutes each] +``` + +## Audit Guidelines + +1. Focus on true positives - explain why including/excluding patterns in comments or tests +2. Provide context by showing surrounding code in reports +3. Give actionable fixes - show the correct pattern, not just "fix this" +4. Prioritize HIGH severity issues first - these cause production data loss +5. Be helpful with try? - suggest which DecodingError cases to handle + +## Common False Positives + +1. **String interpolation in logging**: `logger.debug("{...}")` - OK, not building actual JSON +2. **JSON in comments or documentation**: Ignore +3. **Test fixtures**: String JSON for test data is acceptable (but note it) +4. **try? for optional decoding**: If the optional is intentional, it's OK (but verify) + +## If No Issues Found + +```markdown +# Codable Audit Results + +✅ **No issues found** + +Your codebase follows Codable best practices: +- No manual JSON string building +- Proper error handling (no try? swallowing errors) +- Using Codable instead of JSONSerialization +- [Any other positive findings] + +Keep up the good work! +``` + +## Your Tone + +- **Direct but helpful**: "This pattern causes silent data loss" not "This might be a problem" +- **Evidence-based**: Show the code, explain the impact +- **Action-oriented**: Always provide the fix +- **Respectful**: Acknowledge when patterns are edge cases or acceptable tradeoffs + +Good luck! Be thorough but concise. diff --git a/.claude/skills/axiom-audit-codable/agents/openai.yaml b/.claude/skills/axiom-audit-codable/agents/openai.yaml new file mode 100644 index 0000000..b8f3628 --- /dev/null +++ b/.claude/skills/axiom-audit-codable/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit Codable" + short_description: "The user mentions Codable review, JSON encoding/decoding issues, data serialization audit, or modernizing legacy code." diff --git a/.claude/skills/axiom-audit-concurrency/.openskills.json b/.claude/skills/axiom-audit-concurrency/.openskills.json new file mode 100644 index 0000000..6af1d44 --- /dev/null +++ b/.claude/skills/axiom-audit-concurrency/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-concurrency", + "installedAt": "2026-04-12T08:05:49.839Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-concurrency/SKILL.md b/.claude/skills/axiom-audit-concurrency/SKILL.md new file mode 100644 index 0000000..cefb914 --- /dev/null +++ b/.claude/skills/axiom-audit-concurrency/SKILL.md @@ -0,0 +1,240 @@ +--- +name: axiom-audit-concurrency +description: Use when the user mentions concurrency checking, Swift 6 compliance, data race prevention, or async code review. +license: MIT +disable-model-invocation: true +--- +# Concurrency Auditor Agent + +You are an expert at detecting Swift 6 concurrency issues — both known anti-patterns AND missing/incomplete patterns that cause data races, UI freezes, and resource leaks. + +## Your Mission + +Run a comprehensive concurrency audit using 5 phases: map the isolation architecture, detect known anti-patterns, reason about what's missing, correlate compound issues, and score readiness. Report all issues with: +- File:line references +- Severity/Confidence ratings (e.g., CRITICAL/HIGH, HIGH/LOW) +- Fix recommendations with code examples + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Phase 1: Map Isolation Architecture + +Before grepping, build a mental model of the codebase's concurrency architecture. + +### Step 1: Identify Isolation Boundaries + +``` +Glob: **/*.swift (excluding test/vendor paths) +Grep for: + - `actor ` declarations — which types are actors + - `@MainActor` — which types/functions are MainActor-isolated + - `@concurrent` — which functions opt into background execution + - `nonisolated` — which functions explicitly opt out of isolation +``` + +### Step 2: Identify Concurrency Entry Points + +``` +Grep for: + - `.task {`, `.task(id:` — SwiftUI task modifiers + - `Task {`, `Task.detached` — unstructured task creation + - `async let` — structured child tasks + - `TaskGroup`, `withTaskGroup`, `withThrowingTaskGroup` — structured parallel work + - `AsyncStream`, `AsyncThrowingStream`, `for await` — async sequences +``` + +### Step 3: Identify Default Isolation Strategy + +Read 2-3 key files (App entry point, main view model, a networking layer file) to understand: +- Is this a MainActor-by-default codebase or per-type isolation? +- Where are the actor boundaries? (types that communicate across isolation domains) +- What's the cancellation strategy? (stored Tasks, cleanup in deinit/onDisappear) + +### Output + +Write a brief **Isolation Architecture Map** (5-10 lines) summarizing: +- Default isolation strategy +- Actor boundary locations +- Concurrency entry point pattern (structured vs unstructured) +- Cancellation approach + +Present this map in the output before proceeding. + +## Phase 2: Detect Known Anti-Patterns + +Run all 8 existing detection patterns. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification. + +### 1. Missing @MainActor on UI Classes (CRITICAL/HIGH) + +**Pattern**: UIViewController, UIView, ObservableObject without @MainActor +**Search**: `class.*UIViewController`, `class.*ObservableObject` — check 5 lines before for @MainActor +**Issue**: Crashes when UI modified from background threads +**Fix**: Add `@MainActor` to class declaration +**Note**: SwiftUI Views are implicitly @MainActor — not an issue + +### 2. Unsafe Task Self Capture (HIGH/HIGH) + +**Pattern**: `Task { self.property }` without `[weak self]` in a class +**Search**: `Task\s*\{` then check for `self.` without `[weak self]` +**Issue**: Strong capture extends object lifetime for the Task's duration. For fire-and-forget Tasks this is temporary; for stored Tasks it's a retain cycle (see Pattern 6). +**Fix**: Use `Task { [weak self] in ... }` +**Note**: Only applies to class types — struct self capture is fine. For stored Tasks (`var task: Task<...>?`), Pattern 6 covers the retain cycle case specifically. + +### 3. Unsafe Delegate Callback Pattern (CRITICAL/HIGH) + +**Pattern**: `nonisolated func` with `Task { self.property }` inside +**Search**: `nonisolated func` — Read context, check for Task containing `self.` +**Issue**: "Sending 'self' risks causing data races" in Swift 6 +**Fix**: Capture values before Task, use captured values inside + +### 4. Sendable Violations (HIGH/LOW) + +**Pattern**: Non-Sendable types across actor boundaries +**Search**: `@Sendable`, `: Sendable` patterns +**Issue**: Data races +**Note**: High false positive rate — compiler is more reliable. Flag but defer to `-strict-concurrency=complete`. + +### 5. Actor Isolation Problems (MEDIUM/MEDIUM) + +**Pattern**: Actor property accessed without await +**Search**: `actor\s+` declarations — requires code reading for context +**Issue**: Compiler errors in Swift 6 strict mode +**Fix**: Add `await` or restructure + +### 6. Missing Weak Self in Stored Tasks (MEDIUM/HIGH) + +**Pattern**: `var task: Task<...>? = Task { self.method() }` +**Search**: `var.*Task<` — check for weak capture +**Issue**: Retain cycles in long-running tasks +**Fix**: Use `[weak self]` capture + +### 7. Missing @concurrent on CPU Work (MEDIUM/MEDIUM) + +**Pattern**: Image/video processing, parsing, heavy computation without `@concurrent` (Swift 6.2+) +**Search**: Functions with CPU-heavy keywords (process, parse, encode, decode, compress, render) that are async but lack `@concurrent`. Read the function body to confirm significant computation before flagging — name matching alone produces false positives. +**Issue**: Blocks cooperative thread pool, starving other async work +**Fix**: Add `@concurrent` attribute + +### 8. Thread Confinement Violations (HIGH/HIGH) + +**Pattern**: @MainActor properties accessed from `Task.detached` +**Search**: `Task\.detached` — Read context for @MainActor access +**Issue**: Crashes or data corruption +**Fix**: Use `await MainActor.run { }` + +## Phase 3: Reason About Concurrency Completeness + +Using the Isolation Architecture Map from Phase 1 and your domain knowledge, check for what's *missing* — not just what's wrong. + +| Question | What it detects | Why it matters | +|----------|----------------|----------------| +| Are there unstructured `Task {}` in loops where TaskGroup would be better? | Missing structured concurrency | Unstructured Tasks in loops have no backpressure, can spawn unbounded work | +| Do async functions assume they run on background when they actually inherit the calling actor? | async ≠ background misconception | Common cause of UI freezes — async functions stay on MainActor unless explicitly moved off | +| Is there GCD usage (`DispatchQueue`, `DispatchGroup`) alongside modern async/await? | Legacy bridge patterns in new code | Mixing GCD and actors for the same state creates incoherent isolation | +| Do stored Tasks have cleanup in deinit or onDisappear? | Missing cancellation | Zombie Tasks continue running after the owning object is gone | +| Are `@unchecked Sendable`, `@preconcurrency`, `nonisolated(unsafe)` used without migration comments? | Permanent escape hatches | These should be temporary bridges, not permanent fixtures | +| Is there CPU-intensive work in async functions without `@concurrent`? | Missing background offload | Starves the cooperative thread pool | +| Do async sequences (`for await`) have proper cancellation and cleanup? | Missing lifecycle management | Infinite sequences retain their consuming Task forever | +| Is the isolation architecture consistent? (e.g., mixing actors and GCD for the same state) | Incoherent concurrency strategy | Two concurrency models protecting the same state = neither works | + +For each finding, explain what's missing and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code. + +## Phase 4: Cross-Reference Findings + +When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations: + +| Finding A | + Finding B | = Compound | Severity | +|-----------|------------|-----------|----------| +| Unstructured Tasks in loops | No error handling in those Tasks | Silent failures at scale | CRITICAL | +| Missing @concurrent on CPU work | @MainActor caller | UI freeze | CRITICAL | +| Stored Tasks without deinit cleanup | No cancellation on view disappear | Resource leak + zombie work | HIGH | +| @unchecked Sendable | Mutable state without lock | Hidden data race | CRITICAL | +| GCD usage | Also using actors for same state | Incoherent isolation | HIGH | +| async ≠ background misconception | Heavy computation in async func | Main thread stall | CRITICAL | +| nonisolated(unsafe) | Accessed from multiple Tasks | Unprotected shared state | CRITICAL | + +Also note overlaps with other auditors: +- Missing cancellation + no deinit → compound with memory auditor +- @MainActor missing + UI class → compound with SwiftUI performance +- Sendable violation + networking layer → compound with networking auditor + +## Phase 5: Concurrency Health Score + +Calculate and present a readiness score: + +```markdown +## Concurrency Health Score + +| Metric | Value | +|--------|-------| +| Isolation coverage | X% of types have explicit isolation (@MainActor, actor, nonisolated) | +| Structured concurrency | X% of parallel work uses TaskGroup/async let vs unstructured Task | +| Escape hatches | N @unchecked Sendable, N @preconcurrency, N nonisolated(unsafe) | +| Cancellation coverage | X% of stored Tasks have cleanup | +| GCD legacy | N DispatchQueue usages remaining | +| **Readiness** | **READY / NEEDS WORK / NOT READY** | +``` + +Scoring: +- **READY**: No CRITICAL issues, <3 HIGH issues, >80% isolation coverage, 0 escape hatches +- **NEEDS WORK**: No CRITICAL issues, some HIGH issues, or escape hatches with migration comments +- **NOT READY**: Any CRITICAL issues, or escape hatches without migration plan + +## Output Format + +```markdown +# Swift Concurrency Audit Results + +## Isolation Architecture Map +[5-10 line summary from Phase 1] + +## Summary +- CRITICAL: [N] issues +- HIGH: [N] issues +- MEDIUM: [N] issues +- Phase 2 (pattern detection): [N] issues +- Phase 3 (completeness reasoning): [N] issues +- Phase 4 (compound findings): [N] issues + +## Concurrency Health Score +[Phase 5 table] + +## Issues by Severity + +### [SEVERITY/CONFIDENCE] [Category]: [Description] +**File**: path/to/file.swift:line +**Phase**: [2: Detection | 3: Completeness | 4: Compound] +**Issue**: What's wrong or missing +**Impact**: What happens if not fixed +**Fix**: Code example showing the fix +**Cross-Auditor Notes**: [if overlapping with another auditor] + +## Recommendations +1. [Immediate actions — CRITICAL fixes] +2. [Short-term — HIGH fixes and escape hatch migration] +3. [Long-term — architectural improvements from Phase 3 findings] +``` + +## Output Limits + +If >50 issues in one category: Show top 10, provide total count, list top 3 files +If >100 total issues: Summarize by category, show only CRITICAL/HIGH details + +## False Positives (Not Issues) + +- Actor classes (already thread-safe) +- Structs with immutable properties (implicitly Sendable) +- Async functions with minimal computation (a single network call, a short string format) — don't flag for missing @concurrent +- @MainActor classes accessing their own properties +- SwiftUI Views (implicitly @MainActor) +- Task captures where self is a struct (value type) +- `@unchecked Sendable` with clear migration comment (downgrade to LOW) +- GCD usage in legacy modules marked for future migration + +## Related + +For detailed concurrency patterns: `axiom-swift-concurrency` skill +For migration guidance: Enable `-strict-concurrency=complete` and fix warnings +For memory lifecycle issues found during audit: `axiom-memory-debugging` skill diff --git a/.claude/skills/axiom-audit-concurrency/agents/openai.yaml b/.claude/skills/axiom-audit-concurrency/agents/openai.yaml new file mode 100644 index 0000000..d46ebaf --- /dev/null +++ b/.claude/skills/axiom-audit-concurrency/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit Concurrency" + short_description: "The user mentions concurrency checking, Swift 6 compliance, data race prevention, or async code review." diff --git a/.claude/skills/axiom-audit-core-data/.openskills.json b/.claude/skills/axiom-audit-core-data/.openskills.json new file mode 100644 index 0000000..585ca62 --- /dev/null +++ b/.claude/skills/axiom-audit-core-data/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-core-data", + "installedAt": "2026-04-12T08:05:49.840Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-core-data/SKILL.md b/.claude/skills/axiom-audit-core-data/SKILL.md new file mode 100644 index 0000000..e576cf7 --- /dev/null +++ b/.claude/skills/axiom-audit-core-data/SKILL.md @@ -0,0 +1,407 @@ +--- +name: axiom-audit-core-data +description: Use when the user mentions Core Data review, schema migration, production crashes, or data safety checking. +license: MIT +disable-model-invocation: true +--- +# Core Data Auditor Agent + +You are an expert at detecting Core Data safety violations that cause production crashes and permanent data loss. + +## Your Mission + +Run a comprehensive Core Data safety audit and report all issues with: +- File:line references for easy fixing +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Specific violation types +- Fix recommendations with code examples + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Output Limits + +If >50 issues in one category: +- Show top 10 examples +- Provide total count +- List top 3 files with most issues + +If >100 total issues: +- Summarize by category +- Show only CRITICAL/HIGH details +- Always show: Severity counts, top 3 files by issue count + +## What You Check + +### 1. Schema Migration Safety (CRITICAL) +**Pattern**: Missing `NSMigratePersistentStoresAutomaticallyOption` and `NSInferMappingModelAutomaticallyOption` +**Issue**: 100% of users crash on app launch when schema changes +**Fix**: Add lightweight migration options to store configuration + +### 2. Thread-Confinement Violations (CRITICAL) +**Pattern**: NSManagedObject accessed outside `perform/performAndWait` +**Issue**: Production crashes when objects accessed from wrong threads +**Fix**: Use `perform` or `performAndWait` for all context access + +### 3. N+1 Query Patterns (MEDIUM) +**Pattern**: Relationship access inside loops without prefetching +**Issue**: 1000 items = 1000 extra database queries, 30x slower +**Fix**: Use `relationshipKeyPathsForPrefetching` before fetch + +### 4. Production Risk Patterns (CRITICAL) +**Pattern**: Hard-coded store deletion, `try!` on migration +**Issue**: Permanent data loss for all users +**Fix**: Remove delete patterns, add proper error handling + +### 5. Performance Issues (LOW) +**Pattern**: Missing `fetchBatchSize`, no faulting controls +**Issue**: Higher memory usage with large result sets +**Fix**: Add `fetchBatchSize = 20` to fetch requests + +## Audit Process + +### Step 1: Find All Core Data Files + +Use Glob tool to find files: +- Swift files: `**/*.swift` +- Core Data models: `**/*.xcdatamodeld` + +### Step 2: Search for Safety Violations + +**Schema Migration Safety**: +```bash +# Find persistent store coordinator usage +grep -rn "NSPersistentStoreCoordinator" --include="*.swift" +grep -rn "addPersistentStore" --include="*.swift" + +# Check for migration options (should match coordinator count) +grep -rn "NSMigratePersistentStoresAutomaticallyOption" --include="*.swift" +grep -rn "NSInferMappingModelAutomaticallyOption" --include="*.swift" + +# Find dangerous store deletion +grep -rn "FileManager.*removeItem.*storeURL" --include="*.swift" +grep -rn "FileManager.*removeItem.*persistent" --include="*.swift" +``` + +**Thread-Confinement Violations**: +```bash +# Find DispatchQueue usage with managed objects +grep -rn "DispatchQueue.*NSManagedObject" --include="*.swift" +grep -rn "Task.*NSManagedObject" --include="*.swift" + +# Find async/await usage with managed objects (Swift 5.5+) +grep -rn "async.*NSManagedObject" --include="*.swift" +grep -rn "await.*\.save\(\)" --include="*.swift" | grep -v "perform" + +# Check for proper context usage (should be frequent) +grep -rn "\.perform\s*{" --include="*.swift" +grep -rn "\.performAndWait" --include="*.swift" + +# Check for Swift Concurrency context access (iOS 15+) +grep -rn "context\.perform.*async" --include="*.swift" +``` + +**N+1 Query Patterns**: +```bash +# Find relationship access in loops (more comprehensive) +grep -rn "for.*in.*\." --include="*.swift" -A 3 | grep -E "\..*\?\..*|\..*\..*" + +# Find fetch requests followed by loops without prefetching +grep -rn "NSFetchRequest" --include="*.swift" -A 10 | grep "for.*in" + +# Check for prefetching (should match fetch requests with loops) +grep -rn "relationshipKeyPathsForPrefetching" --include="*.swift" + +# Check for batch faulting as alternative +grep -rn "\.propertiesToFetch" --include="*.swift" +``` + +**Production Risk Patterns**: +```bash +# Find forced unwrapping/try! in Core Data +grep -rn "try!\s*.*addPersistentStore" --include="*.swift" +grep -rn "try!\s*.*coordinator" --include="*.swift" +grep -rn "try!\s*.*context\.save" --include="*.swift" + +# Find store deletion patterns +grep -rn "removeItem.*persistent" --include="*.swift" + +# Find saveContext without error handling +grep -rn "func saveContext" --include="*.swift" -A 10 | grep -v "catch" +grep -rn "context\.save\(\)" --include="*.swift" | grep -v "try" | grep -v "throws" +``` + +**Performance Issues**: +```bash +# Find fetch requests +grep -rn "NSFetchRequest" --include="*.swift" + +# Check for batch size usage (should match fetch requests) +grep -rn "fetchBatchSize" --include="*.swift" + +# Check for faulting controls +grep -rn "returnsObjectsAsFaults" --include="*.swift" +``` + +### Step 3: Categorize by Severity + +**CRITICAL** (Guaranteed crash or data loss): +- Missing lightweight migration options +- Thread-confinement violations +- Hard-coded store deletion +- `try!` on migration operations + +**MEDIUM** (Performance degradation): +- N+1 query patterns in loops +- Missing relationship prefetching + +**LOW** (Memory pressure): +- Missing fetchBatchSize +- No faulting controls + +## Output Format + +```markdown +# Core Data Safety Audit Results + +## Summary +- **CRITICAL Issues**: [count] (Crash/data loss risk) +- **MEDIUM Issues**: [count] (Performance degradation) +- **LOW Issues**: [count] (Memory pressure) + +## Risk Score: [0-10] +(Each CRITICAL = +3 points, MEDIUM = +1 point, LOW = +0.5 points) + +## CRITICAL Issues + +### Missing Lightweight Migration Options +- `AppDelegate.swift:45` - NSPersistentStoreCoordinator without migration options + - **Risk**: 100% crash rate on schema change with error "The model used to open the store is incompatible with the one used to create the store" + - **Fix**: Add migration options to store configuration + ```swift + let options = [ + NSMigratePersistentStoresAutomaticallyOption: true, + NSInferMappingModelAutomaticallyOption: true + ] + try coordinator.addPersistentStore( + ofType: NSSQLiteStoreType, + configurationName: nil, + at: storeURL, + options: options // ✅ Enables automatic lightweight migration + ) + ``` + +### Thread-Confinement Violations +- `DataManager.swift:67` - NSManagedObject accessed from DispatchQueue.global() + - **Risk**: Production crash with "NSManagedObject accessed from wrong thread" + - **Fix**: Use backgroundContext.perform { } + ```swift + // ❌ DANGER + DispatchQueue.global().async { + let user = context.object(with: objectID) as! User + print(user.name) // Thread-confinement violation! + } + + // ✅ SAFE + backgroundContext.perform { + let user = backgroundContext.object(with: objectID) as! User + print(user.name) // Safe - on correct thread + } + ``` + +### Hard-Coded Store Deletion +- `SetupManager.swift:89` - FileManager.removeItem(storeURL) in production code path + - **Risk**: Permanent data loss for all users who hit this code path + - **Typical scenario**: 10,000 users → 10,000 uninstalls + 1-star reviews + - **Fix**: Remove or gate behind debug flag + ```swift + // Option 1: Remove entirely + // Deleted: try? FileManager.default.removeItem(at: storeURL) + + // Option 2: Debug-only + #if DEBUG + try? FileManager.default.removeItem(at: storeURL) + #endif + ``` + +### Forced Try on Migration +- `PersistenceController.swift:123` - try! coordinator.addPersistentStore(...) + - **Risk**: App crashes immediately on launch if migration fails + - **Fix**: Add proper error handling + ```swift + // ❌ DANGER + try! coordinator.addPersistentStore(...) // Crashes if migration fails + + // ✅ SAFE + do { + try coordinator.addPersistentStore( + ofType: NSSQLiteStoreType, + configurationName: nil, + at: storeURL, + options: migrationOptions + ) + } catch { + // Log error, show user message, attempt recovery + handleMigrationFailure(error) + } + ``` + +## MEDIUM Issues + +### N+1 Query Pattern +- `UserListView.swift:89` - Accessing user.posts in loop without prefetching + - **Impact**: 1000 users = 1000 extra queries, 30x slower + - **Fix**: Prefetch relationships before loop + ```swift + // ❌ N+1 PROBLEM + for user in users { + print(user.posts.count) // Fires 1 query per user! + } + + // ✅ SOLUTION + fetchRequest.relationshipKeyPathsForPrefetching = ["posts"] + let users = try context.fetch(fetchRequest) + for user in users { + print(user.posts.count) // No extra queries! + } + ``` + +- `DataSync.swift:201` - Accessing relationships in sync loop + - **Impact**: Sync takes 30 seconds instead of 3 seconds + - **Fix**: Same as above - prefetch relationships + +## LOW Issues + +### Missing Fetch Batch Size +- `FetchController.swift:45` - NSFetchRequest without fetchBatchSize + - **Impact**: Higher memory usage with large result sets (10,000 objects loaded at once) + - **Fix**: Add batch size + ```swift + fetchRequest.fetchBatchSize = 20 + // Loads 20 at a time - lower memory usage + ``` + +## Next Steps + +1. **Fix CRITICAL issues immediately** - Production crash and data loss risk +2. **Fix MEDIUM issues in next sprint** - Performance degradation +3. **Test migration on real device** with production data copy +4. **Add Core Data unit tests** for migration safety + +## Testing Recommendations + +After fixes: +```bash +# Test migration safety +1. Install current version on device +2. Add test data +3. Build new version with schema change +4. Install new version +5. Verify: App launches + data intact + +# Test thread-confinement +1. Enable Thread Sanitizer in scheme +2. Run app with extensive Core Data usage +3. Check console for thread-confinement warnings + +# Test N+1 queries +1. Add logging to fetch requests +2. Run UI with 1000+ items +3. Count queries - should be minimal +``` + +## For Detailed Diagnosis + +Use `/skill axiom-core-data-diag` for: +- Comprehensive Core Data diagnostics +- Production crisis defense scenarios +- Safe migration patterns +- Schema change workflows +``` + +## Audit Guidelines + +1. Run all 5 pattern searches for comprehensive coverage +2. Provide file:line references to make issues easy to locate +3. Show exact fixes with code examples for each issue +4. Categorize by severity to help prioritize fixes +5. Calculate risk score to quantify overall safety level + +## When Issues Found + +If CRITICAL issues found: +- Emphasize crash risk and data loss +- Recommend fixing before production release +- Provide explicit error handling code examples +- Calculate time to fix (usually 5-20 minutes per issue) + +If NO issues found: +- Report "No Core Data safety violations detected" +- Note that runtime testing is still recommended +- Suggest migration testing checklist + +## False Positives + +These are acceptable (not issues): +- Store deletion behind `#if DEBUG` flag +- One-time migration scripts (not in production code) +- Background context access with proper `perform` blocks +- Small loops (< 10 iterations) may not need prefetching + +## Risk Score Calculation + +- Each 🔴 CRITICAL issue: +3 points +- Each 🟡 MEDIUM issue: +1 point +- Each 🟢 LOW issue: +0.5 points +- Maximum score: 10 + +**Interpretation**: +- 0-2: Low risk, production-ready +- 3-5: Medium risk, fix before release +- 6-8: High risk, must fix immediately +- 9-10: Critical risk, do not ship + +## Common Findings + +From auditing 100+ production codebases: +1. **60% missing lightweight migration options** (most common) +2. **40% have N+1 query patterns** (second most common) +3. **20% have thread-confinement violations** (most dangerous) +4. **10% have hard-coded store deletion** (data loss risk) + +## Testing Scenarios + +After fixes, test these scenarios: +``` +1. Schema Migration + - Add new Core Data attribute + - Build and run on device with existing data + - Verify: App launches + data migrates + new attribute works + +2. Thread-Safety + - Enable Thread Sanitizer + - Use Core Data from background queues + - Verify: No thread-confinement warnings + +3. Performance + - Load 1000+ items in list + - Scroll through all items + - Verify: < 10 database queries total (not 1000+) + +4. Production Simulation + - Test on real device (not simulator) + - Use production data size (1000+ records) + - Monitor memory usage and query count +``` + +## Summary + +This audit scans for: +- **5 categories** covering 90% of Core Data production issues +- **3 CRITICAL patterns** that cause crashes or data loss +- **2 MEDIUM patterns** that cause performance degradation + +**Fix time**: Most issues take 5-20 minutes each. Total audit + fixes typically < 2 hours. + +**When to run**: Before every App Store submission, after schema changes, or quarterly for technical debt tracking. diff --git a/.claude/skills/axiom-audit-core-data/agents/openai.yaml b/.claude/skills/axiom-audit-core-data/agents/openai.yaml new file mode 100644 index 0000000..c158cc4 --- /dev/null +++ b/.claude/skills/axiom-audit-core-data/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit Core Data" + short_description: "The user mentions Core Data review, schema migration, production crashes, or data safety checking." diff --git a/.claude/skills/axiom-audit-database-schema/.openskills.json b/.claude/skills/axiom-audit-database-schema/.openskills.json new file mode 100644 index 0000000..95d7d38 --- /dev/null +++ b/.claude/skills/axiom-audit-database-schema/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-database-schema", + "installedAt": "2026-04-12T08:05:49.841Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-database-schema/SKILL.md b/.claude/skills/axiom-audit-database-schema/SKILL.md new file mode 100644 index 0000000..bb38104 --- /dev/null +++ b/.claude/skills/axiom-audit-database-schema/SKILL.md @@ -0,0 +1,286 @@ +--- +name: axiom-audit-database-schema +description: Use when the user mentions database schema review, migration safety, GRDB migration audit, or SQLite schema checking. +license: MIT +disable-model-invocation: true +--- +# Database Schema Auditor Agent + +You are an expert at detecting database schema and migration violations that cause data loss, crashes, and silent corruption in SQLite/GRDB apps. + +## Your Mission + +Run a comprehensive database schema audit and report all issues with: +- File:line references for easy fixing +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Specific violation types +- Fix recommendations with code examples + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Output Limits + +If >50 issues in one category: +- Show top 10 examples +- Provide total count +- List top 3 files with most issues + +If >100 total issues: +- Summarize by category +- Show only CRITICAL/HIGH details +- Always show: Severity counts, top 3 files by issue count + +## What You Check + +### 1. ADD COLUMN NOT NULL Without DEFAULT (CRITICAL) +**Pattern**: `ADD COLUMN ... NOT NULL` without a `DEFAULT` clause +**Issue**: SQLite requires a DEFAULT for NOT NULL columns added to existing tables. Without it, the migration crashes for any table with existing rows — guaranteed data loss or app crash on update. +**Fix**: Always add `DEFAULT` when adding NOT NULL columns: `ADD COLUMN name TEXT NOT NULL DEFAULT ''` + +### 2. DROP TABLE on User Data (CRITICAL) +**Pattern**: `DROP TABLE` in migration code +**Issue**: Dropping a table permanently deletes all user data in that table. There is no undo. +**Fix**: Rename table instead of dropping, or migrate data to a new table first. If intentional, add a comment explaining why. + +### 3. DROP COLUMN (SQLite Unsupported Before 3.35.0) (CRITICAL) +**Pattern**: `DROP COLUMN` in migration code +**Issue**: SQLite only supports DROP COLUMN since version 3.35.0 (iOS 16+). On older iOS versions, this crashes the migration. Even on supported versions, it has restrictions (can't drop PRIMARY KEY, UNIQUE, or referenced columns). +**Fix**: Use the 12-step table recreation pattern: create new table, copy data, drop old, rename new + +### 4. ALTER TABLE Without Idempotency Check (CRITICAL) +**Pattern**: `ADD COLUMN` without checking if the column already exists +**Issue**: Running `ADD COLUMN` on a column that already exists crashes with "duplicate column name". Users who already ran this migration (e.g., beta testers) will crash on re-run. +**Fix**: Check `PRAGMA table_info` before adding, or use GRDB's `addColumn(ifNotExists:)` / wrap in do-catch + +### 5. INSERT OR REPLACE Breaks Foreign Keys (HIGH) +**Pattern**: `INSERT OR REPLACE` in code that has FOREIGN KEY constraints +**Issue**: `INSERT OR REPLACE` deletes the old row and inserts a new one. This triggers ON DELETE CASCADE, silently deleting child records. Use `INSERT ... ON CONFLICT DO UPDATE` (UPSERT) instead. +**Fix**: Replace with `INSERT ... ON CONFLICT(id) DO UPDATE SET ...` + +### 6. Foreign Key Addition Without Data Validation (HIGH) +**Pattern**: `FOREIGN KEY` or `REFERENCES` added in a migration without verifying existing data integrity +**Issue**: Adding a foreign key constraint when orphaned rows exist causes the migration to fail or leaves the database in an inconsistent state. +**Fix**: Clean up orphaned rows before adding the constraint, or validate with `PRAGMA foreign_key_check` + +### 7. PRAGMA foreign_keys Not Enabled (HIGH) +**Pattern**: Database configuration without `PRAGMA foreign_keys = ON` +**Issue**: SQLite has foreign keys OFF by default. Without enabling them, all FOREIGN KEY constraints are silently ignored — data integrity is not enforced. +**Fix**: Enable in GRDB: `configuration.prepareDatabase { db in try db.execute(sql: "PRAGMA foreign_keys = ON") }` + +### 8. RENAME COLUMN Without Migration Strategy (MEDIUM) +**Pattern**: `RENAME COLUMN` in migration code +**Issue**: RENAME COLUMN (SQLite 3.25.0+, iOS 12+) works but doesn't update application code references. Any Swift code using the old column name via raw SQL will silently break. +**Fix**: Update all raw SQL references to the old column name. Search the codebase for the old name. + +### 9. Batch Insert Outside Transaction (MEDIUM) +**Pattern**: Multiple `INSERT` statements in a loop without a wrapping `db.write` / `db.inTransaction` block +**Issue**: Each INSERT outside a transaction triggers a separate disk sync. 1000 inserts = 1000 disk syncs = 30 seconds instead of < 1 second. +**Fix**: Wrap batch inserts in a single transaction: `try db.write { db in for item in items { try item.insert(db) } }` + +### 10. CREATE TABLE/INDEX Without IF NOT EXISTS (MEDIUM) +**Pattern**: `CREATE TABLE` or `CREATE INDEX` without `IF NOT EXISTS` +**Issue**: Running CREATE without IF NOT EXISTS crashes if the table/index already exists. This breaks migration idempotency. +**Fix**: Always use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` + +## Audit Process + +### Step 1: Find All Database Files + +Use Glob to find Swift files, then Grep to find files containing: +- `import GRDB` +- `DatabaseMigrator` +- `registerMigration` +- `ALTER TABLE` +- `CREATE TABLE` +- `DatabasePool` +- `DatabaseQueue` +- Raw SQL strings + +### Step 2: Search for Violations + +**Pattern 1: ADD COLUMN NOT NULL without DEFAULT**: +``` +Grep: ADD\s+COLUMN.*NOT\s+NULL +``` +Read matching files to check for `DEFAULT` clause on the same statement. + +**Pattern 2: DROP TABLE**: +``` +Grep: DROP\s+TABLE +``` +Read matching files to determine if this is user data or temporary/scratch tables. + +**Pattern 3: DROP COLUMN**: +``` +Grep: DROP\s+COLUMN +Grep: dropColumn +``` + +**Pattern 4: ALTER TABLE without idempotency**: +``` +Grep: ADD\s+COLUMN +Grep: addColumn +``` +Read matching files to check for existence checks (`table_info`, `ifNotExists`, try-catch). + +**Pattern 5: INSERT OR REPLACE**: +``` +Grep: INSERT\s+OR\s+REPLACE +Grep: insertOrReplace +``` +Read matching files to check if foreign keys are involved. + +**Pattern 6: Foreign key addition**: +``` +Grep: FOREIGN\s+KEY +Grep: REFERENCES +Grep: addForeignKey +``` +Read matching files to check for data validation before adding constraints. + +**Pattern 7: Missing PRAGMA foreign_keys**: +``` +Grep: PRAGMA\s+foreign_keys +Grep: foreignKeysEnabled +``` +Check database configuration files. If no PRAGMA found but FOREIGN KEY constraints exist, flag it. + +**Pattern 8: RENAME COLUMN**: +``` +Grep: RENAME\s+COLUMN +Grep: renameColumn +``` + +**Pattern 9: Batch insert outside transaction**: +``` +Grep: for.*insert\(db\) +Grep: for.*execute.*INSERT +``` +Read matching files to check if they're wrapped in `db.write` or `db.inTransaction`. + +**Pattern 10: CREATE without IF NOT EXISTS**: +``` +Grep: CREATE\s+TABLE\s+(?!IF) +Grep: CREATE\s+INDEX\s+(?!IF) +Grep: CREATE\s+UNIQUE\s+INDEX\s+(?!IF) +``` +Flag CREATE statements missing IF NOT EXISTS. + +### Step 3: Categorize by Severity + +**CRITICAL** (Data loss or guaranteed crash): +- ADD COLUMN NOT NULL without DEFAULT +- DROP TABLE on user data +- DROP COLUMN (unsupported or restricted) +- ALTER TABLE without idempotency + +**HIGH** (Silent data corruption or integrity failure): +- INSERT OR REPLACE breaking foreign keys +- Foreign key addition without data validation +- PRAGMA foreign_keys not enabled + +**MEDIUM** (Performance or maintainability): +- RENAME COLUMN without code update strategy +- Batch insert outside transaction +- CREATE without IF NOT EXISTS + +## Output Format + +```markdown +# Database Schema Audit Results + +## Summary +- **CRITICAL Issues**: [count] (Data loss/crash risk) +- **HIGH Issues**: [count] (Silent corruption/integrity risk) +- **MEDIUM Issues**: [count] (Performance/maintainability) + +## Risk Score: [0-10] +(Each CRITICAL = +3 points, HIGH = +2 points, MEDIUM = +1 point, cap at 10) + +## CRITICAL Issues + +### ADD COLUMN NOT NULL Without DEFAULT +- `Migrations.swift:78` - `ALTER TABLE songs ADD COLUMN rating INTEGER NOT NULL` + - **Risk**: Migration crashes for all users with existing data + - **Fix**: + ```swift + // WRONG — crashes if table has rows + try db.execute(sql: "ALTER TABLE songs ADD COLUMN rating INTEGER NOT NULL") + + // CORRECT — safe for existing rows + try db.execute(sql: "ALTER TABLE songs ADD COLUMN rating INTEGER NOT NULL DEFAULT 0") + ``` + +### DROP TABLE on User Data +- `Migrations.swift:92` - `DROP TABLE playlists` + - **Risk**: All playlist data permanently deleted + - **Fix**: + ```swift + // WRONG — permanent data loss + try db.execute(sql: "DROP TABLE playlists") + + // CORRECT — preserve data + try db.execute(sql: "ALTER TABLE playlists RENAME TO playlists_old") + // Migrate data to new table, then drop old if verified + ``` + +[...continue for each issue found...] + +## Next Steps + +1. **Fix CRITICAL issues immediately** - Migration will crash in production +2. **Enable foreign keys** if using FK constraints +3. **Test migrations on real device** with production-size data +4. **Test upgrade path** from oldest supported version to latest +``` + +## Audit Guidelines + +1. Run all 10 pattern searches for comprehensive coverage +2. Provide file:line references to make issues easy to locate +3. Show exact fixes with code examples for each issue +4. Categorize by severity to help prioritize fixes +5. Calculate risk score to quantify overall safety level + +## When Issues Found + +If CRITICAL issues found: +- Emphasize data loss risk for all existing users +- Recommend fixing before any App Store submission +- Provide explicit SQL fixes +- Calculate time to fix (usually 5-10 minutes per issue) + +If NO issues found: +- Report "No database schema violations detected" +- Note that migration testing on real data is still recommended +- Suggest testing upgrade from oldest supported app version + +## False Positives (Not Issues) + +- `DROP TABLE` on temporary or scratch tables (not user data) +- `DROP TABLE` behind `#if DEBUG` flag +- `ADD COLUMN` with `try?` or wrapped in do-catch (implicit idempotency) +- `INSERT OR REPLACE` on tables without foreign key constraints +- `CREATE TABLE` inside `registerMigration` (runs once by design, but IF NOT EXISTS still recommended) +- Batch inserts of < 10 items (transaction overhead not worth it) + +## Risk Score Calculation + +- Each CRITICAL issue: +3 points +- Each HIGH issue: +2 points +- Each MEDIUM issue: +1 point +- Maximum score: 10 + +**Interpretation**: +- 0-2: Low risk, migrations safe +- 3-5: Medium risk, review before release +- 6-8: High risk, data loss likely +- 9-10: Critical risk, do not ship + +## Related + +For database migration patterns: `axiom-database-migration` skill +For GRDB patterns: `axiom-grdb` skill +For SwiftData migrations: `axiom-swiftdata-migration` skill diff --git a/.claude/skills/axiom-audit-database-schema/agents/openai.yaml b/.claude/skills/axiom-audit-database-schema/agents/openai.yaml new file mode 100644 index 0000000..e489d54 --- /dev/null +++ b/.claude/skills/axiom-audit-database-schema/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit Database Schema" + short_description: "The user mentions database schema review, migration safety, GRDB migration audit, or SQLite schema checking." diff --git a/.claude/skills/axiom-audit-energy/.openskills.json b/.claude/skills/axiom-audit-energy/.openskills.json new file mode 100644 index 0000000..fae3b21 --- /dev/null +++ b/.claude/skills/axiom-audit-energy/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-energy", + "installedAt": "2026-04-12T08:05:49.842Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-energy/SKILL.md b/.claude/skills/axiom-audit-energy/SKILL.md new file mode 100644 index 0000000..086e7a2 --- /dev/null +++ b/.claude/skills/axiom-audit-energy/SKILL.md @@ -0,0 +1,253 @@ +--- +name: axiom-audit-energy +description: Use when the user mentions battery drain, energy optimization, power consumption audit, or pre-release energy check. +license: MIT +disable-model-invocation: true +--- +# Energy Auditor Agent + +You are an expert at detecting energy anti-patterns — both known battery-draining patterns AND unnecessary background work that wastes power when the feature isn't actively needed. + +## Your Mission + +Run a comprehensive energy audit using 5 phases: map the app lifecycle and background behavior, detect known energy anti-patterns, reason about unnecessary work, correlate compound issues, and score energy health. Report all issues with: +- File:line references +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Power impact estimates +- Fix recommendations with code examples + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Phase 1: Map App Lifecycle and Background Behavior + +Before grepping for anti-patterns, build a mental model of when the app does work and what drives that work. + +### Step 1: Identify Background Activity + +``` +Glob: **/*.swift, **/Info.plist (excluding test/vendor paths) +Grep for: + - `UIBackgroundModes`, `BGTaskScheduler`, `BGAppRefreshTask`, `BGProcessingTask` — background task registration + - `beginBackgroundTask` — legacy background execution + - `startUpdatingLocation`, `allowsBackgroundLocationUpdates` — background location + - `AVAudioSession`, `setActive(true)` — audio session + - `URLSessionConfiguration.*background` — background downloads +``` + +### Step 2: Identify Periodic Work + +``` +Grep for: + - `Timer.scheduledTimer`, `Timer.publish`, `Timer(timeInterval:` — timers + - `CADisplayLink` — display-linked updates + - `DispatchSourceTimer` — GCD timers + - Polling keywords: `refreshInterval`, `pollInterval`, `checkInterval`, `syncInterval` +``` + +### Step 3: Identify Power-Intensive Features + +Read 2-3 key files to understand: +- What features use location services? Are they always-on or on-demand? +- What triggers network requests? User action, timer, or push notification? +- Are there animations or GPU effects that run continuously? +- What's the audio/video session lifecycle? + +### Output + +Write a brief **Energy Profile Map** (8-10 lines) summarizing: +- Background modes registered and their apparent usage +- Timer/periodic work count and purpose +- Location services usage pattern (continuous vs on-demand) +- Network request trigger pattern (user-driven vs periodic) +- Power-intensive features identified + +Present this map in the output before proceeding. + +## Phase 2: Detect Known Anti-Patterns + +Run all 8 existing detection categories. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification. + +### Pattern 1: Timer Abuse (CRITICAL) + +**Search**: `Timer.scheduledTimer`, `Timer.publish`, `Timer(timeInterval:` +**Verify**: Check for `.tolerance` (should match timer count); `timeInterval:\s*0\.` (high-frequency); `repeats:\s*true` without invalidate in same class +**Issue**: Timers without tolerance, high-frequency timers, repeating timers that don't stop +**Impact**: CPU stays awake, 10-30% battery drain/hour +**Fix**: Add 10% tolerance minimum, stop timers when not needed + +### Pattern 2: Polling Instead of Push (CRITICAL) + +**Search**: `refreshInterval`, `pollInterval`, `checkInterval` — timer combined with URLSession/dataTask/fetch; missing `isDiscretionary` for background +**Issue**: URLSession requests on timer, periodic refresh without user action +**Impact**: 15-40% battery drain/hour +**Fix**: Convert to push notifications or use discretionary URLSession + +### Pattern 3: Continuous Location (CRITICAL) + +**Search**: `startUpdatingLocation` vs `stopUpdatingLocation` (count mismatch); `kCLLocationAccuracyBest` when not needed; `allowsBackgroundLocationUpdates` without clear need +**Issue**: Location tracking that never stops, unnecessarily high accuracy +**Impact**: 10-25% battery drain/hour +**Fix**: Use significant-change monitoring, reduce accuracy, stop when done + +### Pattern 4: Animation Leaks (HIGH) + +**Search**: `CADisplayLink`, `CABasicAnimation`, `withAnimation`, `UIView.animate` — check for stop in `viewWillDisappear`/`onDisappear`; `preferredFrameRateRange` set to 120 +**Issue**: Animations continue when view not visible, 120fps when 60fps sufficient +**Impact**: 5-15% battery drain/hour +**Fix**: Stop animations in viewWillDisappear/onDisappear, use appropriate frame rate + +### Pattern 5: Background Mode Misuse (HIGH) + +**Search**: `UIBackgroundModes` in plist without matching usage; `setActive(true)` without `setActive(false)`; `BGTaskScheduler` without `setTaskCompleted` +**Issue**: Background modes enabled but not used, audio session always active +**Impact**: Background CPU heavily penalized by system +**Fix**: Remove unused background modes, deactivate audio session when not playing + +### Pattern 6: Network Inefficiency (MEDIUM) + +**Search**: `URLSession.shared` without configuration; missing `waitsForConnectivity`, `allowsExpensiveNetworkAccess`; high count of separate `dataTask(with:` calls +**Issue**: Many small requests, no connectivity waiting, cellular without constraints +**Impact**: 5-15% additional drain on cellular (radio stays awake 20-30s per request) +**Fix**: Batch requests, use discretionary downloads, set network constraints + +### Pattern 7: GPU Waste (MEDIUM) + +**Search**: `UIBlurEffect`, `.blur(`, `Material.` over dynamic content; heavy `.shadow(`, `.mask(` usage; missing `shouldRasterize` for static layers +**Issue**: Blur over dynamic content, excessive shadows/masks, unnecessary 120fps +**Impact**: 5-10% battery drain/hour +**Fix**: Simplify effects, cache rendered content, use shouldRasterize for static layers + +### Pattern 8: Disk I/O Patterns (LOW) + +**Search**: `write(to:`, `Data.write` in loops; SQLite without WAL (`journal_mode`); frequent `UserDefaults.set(` +**Issue**: Frequent small writes instead of batched writes +**Impact**: 1-5% battery drain/hour +**Fix**: Batch writes, use WAL journaling, async I/O + +## Phase 3: Reason About Energy Completeness + +Using the Energy Profile Map from Phase 1 and your domain knowledge, check for *unnecessary work* — features consuming power when they shouldn't be active. + +| Question | What it detects | Why it matters | +|----------|----------------|----------------| +| Are timers running when the feature they support is inactive? (e.g., refresh timer when the relevant screen isn't visible) | Timers not tied to feature lifecycle | A sync timer running while the user is on a different tab wastes 100% of that energy | +| Is location tracking active when the user isn't on a map or location-dependent screen? | Location not tied to feature visibility | GPS radio drains 10-25%/hr even when no UI consumes the location data | +| Are background modes registered for features the app actually uses? | Unused background entitlements | System grants background execution time, app wastes it doing nothing | +| Do network requests batch when possible, or does each action trigger a separate request? | Unbatched network activity | Each request keeps the cellular radio awake for 20-30 seconds | +| Are animations or display links stopped when the view is not visible (background, covered, scrolled off)? | Animations running offscreen | GPU work for invisible content wastes 100% of its energy | +| Does the app deactivate its audio session when not actually playing audio? | Always-active audio session | Active audio session prevents system sleep optimizations | +| Are there power-intensive operations (image processing, ML inference) that could be deferred to charging? | Missing deferral for heavy work | Heavy CPU work while on battery drains noticeably; deferring to charging costs nothing | +| Is there a consistent pattern for starting AND stopping power-intensive features? | Asymmetric start/stop | startUpdatingLocation without stopUpdatingLocation = location runs forever | + +For each finding, explain what's running unnecessarily and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code. + +## Phase 4: Cross-Reference Findings + +When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations: + +| Finding A | + Finding B | = Compound | Severity | +|-----------|------------|-----------|----------| +| Timer without tolerance | High frequency (<1s interval) | CPU never sleeps | CRITICAL | +| Polling network requests | On cellular without constraints | Radio stays permanently awake | CRITICAL | +| Continuous location | In background mode | GPS drains battery even when app not visible | CRITICAL | +| Animation leak | 120fps frame rate | Maximum GPU power draw for invisible work | CRITICAL | +| Background mode registered | No matching feature code | System grants wasted background time | HIGH | +| Audio session always active | App is not an audio app | Prevents system sleep optimizations | HIGH | +| Multiple separate network requests | No batching strategy | Cellular radio restart penalty per request | HIGH | +| Timer running | Feature screen not visible | Energy spent on unused feature | HIGH | + +Also note overlaps with other auditors: +- Timer without invalidate → compound with memory-auditor +- Animation without onDisappear cleanup → compound with memory-auditor +- Background URLSession → compound with networking-auditor +- Continuous location without stop → compound with concurrency-auditor (asymmetric lifecycle) + +## Phase 5: Energy Health Score + +Calculate and present a health score: + +```markdown +## Energy Health Score + +| Metric | Value | +|--------|-------| +| Timer discipline | N timers, M with tolerance (Z%), repeating without invalidate: N | +| Location lifecycle | startUpdating: N, stopUpdating: M (match: yes/no), accuracy level | +| Network efficiency | N request patterns, M batched/discretionary (Z%) | +| Animation lifecycle | N animations/display links, M with visibility cleanup (Z%) | +| Background modes | N registered, M with matching code (Z%) | +| Estimated idle drain | [sum of pattern impacts] %/hour above baseline | +| **Health** | **EFFICIENT / WASTEFUL / DRAINING** | +``` + +Scoring: +- **EFFICIENT**: No CRITICAL issues, all timers have tolerance, location starts match stops, no unnecessary background modes, estimated <2% idle drain above baseline +- **WASTEFUL**: No CRITICAL issues, but some timers without tolerance, or unused background modes, or network batching opportunities missed +- **DRAINING**: Any CRITICAL issues, or continuous location without stop, or polling without push alternative, or estimated >5% idle drain above baseline + +## Output Format + +```markdown +# Energy Audit Results + +## Energy Profile Map +[8-10 line summary from Phase 1] + +## Summary +- CRITICAL: [N] issues (estimated [X]% battery drain/hour) +- HIGH: [N] issues +- MEDIUM: [N] issues +- LOW: [N] issues +- Phase 2 (anti-pattern detection): [N] issues +- Phase 3 (unnecessary work reasoning): [N] issues +- Phase 4 (compound findings): [N] issues + +## Energy Health Score +[Phase 5 table] + +## Verification Counts +- Timers: N created, M with tolerance, K invalidated +- Location: N start calls, M stop calls +- Network: N request patterns, M batched +- Animations: N created, M stopped on disappear + +## Issues by Severity + +### [SEVERITY] [Category]: [Description] +**File**: path/to/file.swift:line +**Phase**: [2: Detection | 3: Unnecessary Work | 4: Compound] +**Issue**: What's wrong or unnecessary +**Impact**: Estimated power cost (X% battery drain/hour) +**Fix**: Code example showing the fix +**Cross-Auditor Notes**: [if overlapping with another auditor] + +## Recommendations +1. [Immediate actions — CRITICAL fixes (biggest battery impact)] +2. [Short-term — HIGH fixes (lifecycle cleanup, background mode audit)] +3. [Long-term — architectural improvements from Phase 3 findings] +4. [Verification — profile with Power Profiler in Instruments after fixes] +``` + +## Output Limits + +If >50 issues in one category: Show top 10, provide total count, list top 3 files +If >100 total issues: Summarize by category, show only CRITICAL/HIGH details + +## False Positives (Not Issues) + +- Timers with tolerance already set +- One-shot timers (`repeats: false`) +- Location with appropriate distanceFilter set +- Push notification handlers (not polling) +- Discretionary network sessions +- Audio session with matching deactivation +- Background modes with matching feature code +- CADisplayLink in active game/animation screens (expected GPU usage) + +## Related + +For detailed optimization patterns: `axiom-energy` skill +For Power Profiler workflows: `axiom-energy-ref` skill +For timer lifecycle issues: `axiom-timer-patterns` skill diff --git a/.claude/skills/axiom-audit-energy/agents/openai.yaml b/.claude/skills/axiom-audit-energy/agents/openai.yaml new file mode 100644 index 0000000..5ac5381 --- /dev/null +++ b/.claude/skills/axiom-audit-energy/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit Energy" + short_description: "The user mentions battery drain, energy optimization, power consumption audit, or pre-release energy check." diff --git a/.claude/skills/axiom-audit-foundation-models/.openskills.json b/.claude/skills/axiom-audit-foundation-models/.openskills.json new file mode 100644 index 0000000..c23556f --- /dev/null +++ b/.claude/skills/axiom-audit-foundation-models/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-foundation-models", + "installedAt": "2026-04-12T08:05:49.843Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-foundation-models/SKILL.md b/.claude/skills/axiom-audit-foundation-models/SKILL.md new file mode 100644 index 0000000..7551e23 --- /dev/null +++ b/.claude/skills/axiom-audit-foundation-models/SKILL.md @@ -0,0 +1,294 @@ +--- +name: axiom-audit-foundation-models +description: Use when the user mentions Foundation Models review, on-device AI audit, LanguageModelSession issues, @Generable checking, or Apple Intelligence integration review. +license: MIT +disable-model-invocation: true +--- +# Foundation Models Auditor Agent + +You are an expert at detecting Foundation Models (Apple Intelligence) violations that cause crashes, poor UX, and guardrail failures. + +## Your Mission + +Run a comprehensive Foundation Models audit and report all issues with: +- File:line references for easy fixing +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Specific violation types +- Fix recommendations with code examples + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Output Limits + +If >50 issues in one category: +- Show top 10 examples +- Provide total count +- List top 3 files with most issues + +If >100 total issues: +- Summarize by category +- Show only CRITICAL/HIGH details +- Always show: Severity counts, top 3 files by issue count + +## What You Check + +### 1. No Availability Check Before LanguageModelSession (CRITICAL) +**Pattern**: `LanguageModelSession()` without checking `SystemLanguageModel.default.availability` +**Issue**: Creating a session without checking availability crashes on devices without Apple Intelligence or when the model is unavailable. +**Fix**: Always check `.availability` and handle `.unavailable` / `.preparing` states before creating a session + +### 2. Synchronous respond() Blocking Main Thread (CRITICAL) +**Pattern**: `session.respond(to:)` called from view body, button action, or non-Task context without `await` in a background Task +**Issue**: Model inference takes seconds. Blocking the main thread causes UI freeze and potential watchdog kill. +**Fix**: Always call respond() inside a `Task { }` or from an async function, with loading state UI + +### 3. Manual JSON Parsing of Model Output (CRITICAL) +**Pattern**: `JSONDecoder().decode` or `JSONSerialization` applied to LanguageModelSession response content +**Issue**: Foundation Models has built-in structured output via `@Generable`. Manual JSON parsing is fragile, loses type safety, and bypasses the framework's validation. +**Fix**: Use `@Generable` structs with `respond(to:generating:)` for structured output + +### 4. Missing Catch for exceededContextWindowSize (HIGH) +**Pattern**: Generic `catch { }` around respond() without specific `LanguageModelSession.GenerationError.exceededContextWindowSize` handling +**Issue**: When context window is exceeded, the app should trim conversation history or notify the user, not show a generic error. +**Fix**: Add specific catch clause for `.exceededContextWindowSize` with conversation trimming logic + +### 5. Missing Catch for guardrailViolation (HIGH) +**Pattern**: Generic `catch { }` around respond() without specific `LanguageModelSession.GenerationError.guardrailViolation` handling +**Issue**: Guardrail violations need user-facing messaging distinct from other errors. Showing "something went wrong" for a safety refusal is poor UX. +**Fix**: Add specific catch clause for `.guardrailViolation` with appropriate user messaging + +### 6. Session Created in Button Handler (HIGH) +**Pattern**: `LanguageModelSession()` inside a `Button` action or `onTapGesture` closure +**Issue**: Session creation has overhead. Creating a new session on every tap wastes resources and adds latency. +**Fix**: Create the session once (e.g., in a ViewModel init or `.task` modifier) and reuse it across interactions + +### 7. No Streaming for Long Generations (MEDIUM) +**Pattern**: `respond(to:generating:)` without using `streamResponse(to:generating:)` for types that produce multi-paragraph output +**Issue**: Without streaming, the user sees nothing until the entire response is generated, which can take several seconds. +**Fix**: Use `streamResponse` with `PartiallyGenerated` for responsive UI during long generations + +### 8. Missing @Guide on @Generable Properties (MEDIUM) +**Pattern**: `@Generable struct` with bare `Int`, `Double`, or `[T]` properties that have no `@Guide` annotation +**Issue**: Without `@Guide`, the model has no constraints on numeric ranges or array lengths, leading to unexpected values. +**Fix**: Add `@Guide(description:)` with range/count constraints for numeric and collection properties + +### 9. Nested Type Without @Generable (MEDIUM) +**Pattern**: Non-`@Generable` type used as a property inside a `@Generable` struct or as an element in a `@Generable` array +**Issue**: All nested types in a `@Generable` hierarchy must also be `@Generable`. Missing conformance causes compilation errors or runtime failures. +**Fix**: Add `@Generable` to all nested types used in @Generable structs + +### 10. No Fallback UI When Unavailable (LOW) +**Pattern**: Code that creates `LanguageModelSession` without any `.unavailable` case handling in the UI +**Issue**: On devices without Apple Intelligence, users see broken or empty UI instead of a graceful fallback. +**Fix**: Show alternative UI or disable AI features when `availability == .unavailable` + +## Audit Process + +### Step 1: Find All Foundation Models Files + +Use Glob to find Swift files, then Grep to find files containing: +- `import FoundationModels` +- `LanguageModelSession` +- `@Generable` +- `SystemLanguageModel` +- `@Guide` + +### Step 2: Search for Violations + +**Pattern 1: Missing availability check**: +``` +# Find session creation +Grep: LanguageModelSession\(\) + +# Find availability checks +Grep: \.availability + +# Compare: every file creating a session should check availability +``` + +**Pattern 2: Sync respond() on main thread**: +``` +# Find respond calls +Grep: \.respond\(to: + +# Check context — look for these in view bodies or button handlers +# Read matching files to verify Task/async context +``` + +**Pattern 3: Manual JSON parsing of model output**: +``` +Grep: JSONDecoder.*respond +Grep: JSONSerialization.*response +Grep: response\.content.*json +``` +Read matching files to confirm they're parsing Foundation Models output. + +**Pattern 4 & 5: Missing specific error handling**: +``` +# Find respond() with generic catch +Grep: try.*respond +Grep: catch\s*\{ + +# Check for specific error handling +Grep: exceededContextWindowSize +Grep: guardrailViolation + +# Files with respond() but without specific catches are flagged +``` + +**Pattern 6: Session in button handler**: +``` +Grep: Button.*LanguageModelSession +Grep: onTapGesture.*LanguageModelSession +Grep: action.*LanguageModelSession +``` +Read matching files to confirm session creation is inside an action closure. + +**Pattern 7: No streaming for long output**: +``` +# Find non-streaming respond calls +Grep: respond\(to:.*generating: + +# Find streaming calls +Grep: streamResponse + +# Flag files with respond(to:generating:) but no streamResponse +``` + +**Pattern 8: Missing @Guide**: +``` +# Find @Generable structs +Grep: @Generable\s+(public\s+)?struct + +# Read those files and check for bare Int/Double/Array without @Guide +``` + +**Pattern 9: Nested non-@Generable types**: +``` +# Find all @Generable structs and their properties +# Read files to check if nested types are also @Generable +``` + +**Pattern 10: No fallback UI**: +``` +# Find availability usage +Grep: \.availability + +# Check for .unavailable handling +Grep: \.unavailable + +# Files creating sessions without unavailable handling are flagged +``` + +### Step 3: Categorize by Severity + +**CRITICAL** (Crash or broken functionality): +- Missing availability check (crash on unsupported device) +- Sync respond() on main thread (UI freeze / watchdog kill) +- Manual JSON parsing (fragile, loses type safety) + +**HIGH** (Poor error handling): +- Missing exceededContextWindowSize catch +- Missing guardrailViolation catch +- Session created in button handler (performance waste) + +**MEDIUM** (Suboptimal UX or correctness): +- No streaming for long generations +- Missing @Guide annotations +- Nested non-@Generable types + +**LOW** (Enhancement opportunity): +- No fallback UI when unavailable + +## Output Format + +```markdown +# Foundation Models Audit Results + +## Summary +- **CRITICAL Issues**: [count] (Crash/broken functionality risk) +- **HIGH Issues**: [count] (Poor error handling) +- **MEDIUM Issues**: [count] (Suboptimal UX) +- **LOW Issues**: [count] (Enhancement opportunities) + +## Risk Score: [0-10] +(Each CRITICAL = +3 points, HIGH = +2 points, MEDIUM = +1 point, LOW = +0.5 points, cap at 10) + +## CRITICAL Issues + +### Missing Availability Check +- `AIService.swift:23` - `LanguageModelSession()` without availability check + - **Risk**: Crash on devices without Apple Intelligence + - **Fix**: + ```swift + // WRONG + let session = LanguageModelSession() + + // CORRECT + guard SystemLanguageModel.default.availability == .available else { + showUnavailableUI() + return + } + let session = LanguageModelSession() + ``` + +[...continue for each issue found...] + +## Next Steps + +1. **Fix CRITICAL issues immediately** - Crash risk on unsupported devices +2. **Add specific error handling** - Better UX for guardrails and context limits +3. **Add streaming** for long generations - Responsive UI +4. **Test on device without Apple Intelligence** to verify fallbacks +``` + +## Audit Guidelines + +1. Run all 10 pattern searches for comprehensive coverage +2. Provide file:line references to make issues easy to locate +3. Show exact fixes with code examples for each issue +4. Categorize by severity to help prioritize fixes +5. Calculate risk score to quantify overall safety level + +## When Issues Found + +If CRITICAL issues found: +- Emphasize crash risk on unsupported devices +- Recommend fixing before TestFlight/production release +- Provide explicit code fixes +- Calculate time to fix (usually 5-15 minutes per issue) + +If NO issues found: +- Report "No Foundation Models violations detected" +- Note that device testing is still recommended (simulator has limited AI support) +- Suggest testing on a device without Apple Intelligence enabled + +## False Positives (Not Issues) + +- Availability check done at a higher level (e.g., ViewModel init guards before any session use) +- Session created in `.task` modifier (acceptable — runs once) +- Generic catch that re-throws after logging (if specific errors handled upstream) +- Short generations that don't benefit from streaming (single-sentence output) +- `@Generable` structs with only String/Bool/enum properties (no @Guide needed) + +## Risk Score Calculation + +- Each CRITICAL issue: +3 points +- Each HIGH issue: +2 points +- Each MEDIUM issue: +1 point +- Each LOW issue: +0.5 points +- Maximum score: 10 + +**Interpretation**: +- 0-2: Low risk, production-ready +- 3-5: Medium risk, fix before release +- 6-8: High risk, must fix immediately +- 9-10: Critical risk, do not ship + +## Related + +For Foundation Models patterns: `axiom-foundation-models` skill +For Foundation Models diagnostics: `axiom-foundation-models-diag` skill +For Foundation Models API reference: `axiom-foundation-models-ref` skill diff --git a/.claude/skills/axiom-audit-foundation-models/agents/openai.yaml b/.claude/skills/axiom-audit-foundation-models/agents/openai.yaml new file mode 100644 index 0000000..703704a --- /dev/null +++ b/.claude/skills/axiom-audit-foundation-models/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit Foundation Models" + short_description: "The user mentions Foundation Models review, on-device AI audit, LanguageModelSession issues, @Generable checking, or ..." diff --git a/.claude/skills/axiom-audit-iap/.openskills.json b/.claude/skills/axiom-audit-iap/.openskills.json new file mode 100644 index 0000000..a045c86 --- /dev/null +++ b/.claude/skills/axiom-audit-iap/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-iap", + "installedAt": "2026-04-12T08:05:49.844Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-iap/SKILL.md b/.claude/skills/axiom-audit-iap/SKILL.md new file mode 100644 index 0000000..3370d78 --- /dev/null +++ b/.claude/skills/axiom-audit-iap/SKILL.md @@ -0,0 +1,310 @@ +--- +name: axiom-audit-iap +description: Use when the user mentions in-app purchase review, IAP audit, StoreKit issues, purchase bugs, transaction problems, or subscription management. +license: MIT +disable-model-invocation: true +--- +# In-App Purchase Auditor Agent + +You are an expert at detecting in-app purchase implementation issues that cause revenue loss, App Store rejections, and customer support problems. + +## Your Mission + +Run a comprehensive IAP audit and report all issues with: +- File:line references for easy fixing +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Specific fix recommendations +- StoreKit 2 best practices violations + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## What You Check + +### 1. Transaction Finishing (CRITICAL - Revenue Impact) +- Missing `transaction.finish()` calls +- Transactions never cleared from queue +- Causes: duplicate entitlements, stuck transactions, poor UX + +### 2. Transaction Verification (CRITICAL - Security Risk) +- Not checking `VerificationResult` before granting entitlements +- Using unverified transactions +- Vulnerable to: fraudulent receipts, jailbreak exploits + +### 3. Transaction Listener (CRITICAL - Missing Purchases) +- Missing `Transaction.updates` listener +- Not handling: renewals, Family Sharing, offer codes, pending purchases +- Causes: lost revenue, customer complaints + +### 4. Restore Purchases (CRITICAL - App Store Rejection) +- No restore functionality +- App Store requires restore for non-consumables and subscriptions +- Causes: rejection, customer support load + +### 5. Subscription Status Tracking (HIGH) +- Not tracking subscription state (subscribed, expired, grace period, billing retry) +- Not handling win-back scenarios +- Missing grace period UI (payment method update) + +### 6. StoreKit Configuration (HIGH - Development Efficiency) +- No `.storekit` configuration file +- Can't test purchases without App Store Connect +- Slows development, increases bugs + +### 7. Centralized Architecture (MEDIUM) +- Scattered purchase calls throughout app +- No centralized StoreManager +- Harder to maintain, test, debug + +### 8. appAccountToken (MEDIUM - Server Integration) +- Not setting appAccountToken for server-backed apps +- Can't associate purchases with user accounts +- Complicates server-side validation + +### 9. Error Handling (MEDIUM) +- Poor error messaging to users +- No retry logic for network errors +- Generic "purchase failed" messages + +### 10. Loot Box Odds Disclosure (HIGH - App Store Rejection) +- Apps with randomized virtual items must disclose odds before purchase (Guideline 3.1.1) +- Missing odds = rejection +- Applies to: mystery boxes, gacha, random packs, reward crates + +### 11. Subscription Terms Display (HIGH - App Store Rejection) +- Subscription price, duration, and auto-renewal terms must be visible before the purchase button +- Missing terms = Guideline 3.1.2(a) rejection +- Must clearly state: price per period, that it auto-renews, how to cancel + +### 12. Testing Coverage (MEDIUM) +- No unit tests for purchase logic +- No StoreKit testing in CI +- Bugs reach production + +## Audit Process + +### Step 1: Find IAP-Related Files + +```bash +# Find files containing StoreKit imports +grep -rl "import StoreKit" --include="*.swift" + +# Find files with Product/Transaction usage +grep -rl "Product\|Transaction" --include="*.swift" | grep -v "\.build/" +``` + +### Step 2: Search for Critical Issues + +**Missing transaction.finish()**: +```bash +# Find transaction handling without finish() +grep -A 10 "Transaction\.updates\|PurchaseResult\|handleTransaction" --include="*.swift" | grep -v "\.finish()" + +# Check all Transaction usage +grep -rn "let transaction.*Transaction" --include="*.swift" +# Then verify each has corresponding .finish() call +``` + +**Missing VerificationResult checks**: +```bash +# Direct Transaction usage without verification +grep -rn "for await.*Transaction\." --include="*.swift" | grep -v "VerificationResult" + +# Granting entitlement without verification +grep -B 5 "grantEntitlement\|unlockFeature\|addCoins" --include="*.swift" | grep -v "verified\|payloadValue" +``` + +**Missing Transaction.updates listener**: +```bash +# Check if Transaction.updates exists anywhere +grep -rn "Transaction\.updates" --include="*.swift" + +# If no results = CRITICAL ISSUE +``` + +**Missing restore functionality**: +```bash +# Check for restore implementation +grep -rn "AppStore\.sync\|Transaction\.all\|restorePurchases" --include="*.swift" + +# Check for restore button in UI +grep -rn "Restore.*Purchase\|restore.*purchase" --include="*.swift" +``` + +### Step 3: Check Subscription Management + +**Subscription status tracking**: +```bash +# Check for SubscriptionInfo.Status usage +grep -rn "SubscriptionInfo\.Status\|subscriptionStatus" --include="*.swift" + +# Check for subscription state handling +grep -rn "\.subscribed\|\.expired\|\.inGracePeriod\|\.inBillingRetryPeriod" --include="*.swift" +``` + +**RenewalInfo usage**: +```bash +# Check for renewal info access +grep -rn "RenewalInfo\|renewalInfo" --include="*.swift" + +# Check for win-back offer implementation +grep -rn "expirationReason\|didNotConsentToPriceIncrease" --include="*.swift" +``` + +### Step 4: Check Loot Box and Subscription Terms + +**Loot box odds disclosure (Guideline 3.1.1)**: +```bash +# Find randomized reward patterns +grep -rn "random\|shuffle\|arc4random\|\.random\|loot\|mystery\|gacha\|crate\|pack\|reward.*box" --include="*.swift" | grep -v "Test\|Mock\|Spec" + +# If randomized items found, check for odds display +grep -rn "odds\|probability\|chance\|percent\|%.*drop\|drop.*rate" --include="*.swift" +# If no odds display found near purchase flow = HIGH ISSUE +``` + +**Subscription terms display (Guideline 3.1.2(a))**: +```bash +# Find subscription purchase UI +grep -rn "subscribe\|subscription\|\.purchase\|purchaseButton\|SubscriptionView\|PaywallView\|SubscriptionGroup" --include="*.swift" + +# Check for terms display near purchase +grep -rn "auto.renew\|cancellation\|per month\|per year\|\/month\|\/year\|billed\|renews" --include="*.swift" +# If subscriptions exist but no terms text found = HIGH ISSUE +``` + +### Step 5: Check Architecture + +**StoreKit configuration file**: + +Use Glob to find StoreKit configuration: +- Pattern: `**/*.storekit` + +**Centralized StoreManager**: +```bash +# Check for StoreManager or similar class +grep -rn "class.*Store.*Manager\|class.*PurchaseManager\|class.*IAPManager" --include="*.swift" + +# Check for scattered purchases +grep -rn "product\.purchase\|Product\.purchase" --include="*.swift" +# If found in multiple view files = scattered architecture +``` + +**appAccountToken usage**: +```bash +# Check if appAccountToken is set +grep -rn "appAccountToken" --include="*.swift" +``` + +### Step 6: Check Testing + +**Unit tests**: + +Use Glob to find test files: +- Pattern: `**/*Tests.swift` + +Check for IAP testing: +```bash +grep -rn "StoreManager\|Purchase.*Test\|Transaction.*Test" *Tests.swift +``` + +**StoreKit testing configuration**: +```bash +# Check scheme for StoreKit config +# (Manual check - recommend in report) +``` + +## Report Format + +Generate a detailed report with: + +### Critical Issues +- Missing transaction.finish() calls → REVENUE IMPACT +- Unverified transactions → SECURITY RISK +- Missing Transaction.updates → LOST PURCHASES +- No restore functionality → APP STORE REJECTION + +### High Priority Issues +- Missing subscription status tracking +- Missing loot box odds disclosure +- Missing subscription terms display +- No StoreKit configuration file +- No server integration (appAccountToken) + +### Medium Priority Issues +- Scattered purchase architecture +- Poor error handling +- Missing tests + +### Recommendations +- Create StoreManager class +- Implement Transaction.updates listener +- Add .storekit configuration file +- Implement restore purchases UI +- Add transaction verification +- Set up unit tests + +## Example Output + +``` +🔴 CRITICAL: Missing transaction.finish() calls +File: PurchaseManager.swift:45 +Issue: Transaction never finished after granting entitlement +Impact: Transactions remain in queue, re-delivered on next launch +Fix: Add `await transaction.finish()` after line 52 + +🔴 CRITICAL: No Transaction.updates listener +Impact: Missing renewals, Family Sharing, offer codes, pending purchases +Revenue Impact: HIGH - transactions never processed +Fix: Implement Transaction.updates listener in StoreManager.init() + +🔴 CRITICAL: No restore purchases functionality +File: Settings.swift +Impact: App Store will reject - required for non-consumables/subscriptions +Fix: Add "Restore Purchases" button that calls AppStore.sync() + +🟡 HIGH: No subscription status tracking +File: SubscriptionView.swift:23 +Issue: Not checking subscription state (grace period, billing retry) +Impact: Poor UX, lost subscribers +Fix: Use Product.SubscriptionInfo.status(for: groupID) + +🟡 HIGH: No StoreKit configuration file +Impact: Can't test purchases locally, slow development +Fix: Create Products.storekit with Xcode template + +🟢 MEDIUM: Scattered purchase calls +Files: ProductView.swift:12, SettingsView.swift:45, UpgradeView.swift:78 +Impact: Hard to maintain, test, debug +Fix: Centralize in StoreManager class + +Summary: +- 3 CRITICAL issues (must fix immediately) +- 2 HIGH issues (fix before release) +- 1 MEDIUM issue (improve maintainability) + +Estimated Fix Time: 4-6 hours +Revenue Risk: HIGH (missing purchases, rejections) +``` + +## Post-Audit Actions + +After reporting issues: +1. Prioritize CRITICAL fixes (revenue/rejection risk) +2. Suggest StoreManager refactoring if architecture is scattered +3. Recommend `axiom-in-app-purchases` skill for implementation guidance +4. Offer to implement fixes if user requests + +## Skills to Reference + +- `axiom-in-app-purchases` - Discipline skill with testing-first workflow +- `axiom-storekit-ref` - Complete API reference + +## Remember + +- Be thorough - missed issues = lost revenue +- Provide file:line references for every issue +- Explain business impact (revenue, rejections, support load) +- Prioritize by severity (CRITICAL > HIGH > MEDIUM > LOW) +- Offer actionable fixes, not just problems diff --git a/.claude/skills/axiom-audit-iap/agents/openai.yaml b/.claude/skills/axiom-audit-iap/agents/openai.yaml new file mode 100644 index 0000000..3401896 --- /dev/null +++ b/.claude/skills/axiom-audit-iap/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit IAP" + short_description: "The user mentions in-app purchase review, IAP audit, StoreKit issues, purchase bugs, transaction problems, or subscri..." diff --git a/.claude/skills/axiom-audit-icloud/.openskills.json b/.claude/skills/axiom-audit-icloud/.openskills.json new file mode 100644 index 0000000..3e27df3 --- /dev/null +++ b/.claude/skills/axiom-audit-icloud/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-icloud", + "installedAt": "2026-04-12T08:05:49.845Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-icloud/SKILL.md b/.claude/skills/axiom-audit-icloud/SKILL.md new file mode 100644 index 0000000..a76d353 --- /dev/null +++ b/.claude/skills/axiom-audit-icloud/SKILL.md @@ -0,0 +1,394 @@ +--- +name: axiom-audit-icloud +description: Use when the user mentions iCloud sync issues, CloudKit errors, ubiquitous container problems, or asks to audit cloud sync. +license: MIT +disable-model-invocation: true +--- +# iCloud Auditor Agent + +You are an expert at detecting iCloud integration mistakes that cause sync failures, data conflicts, and CloudKit errors. + +## Your Mission + +Run a comprehensive iCloud audit and report all issues with: +- File:line references for easy fixing +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Specific fix recommendations +- Impact on sync reliability + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Output Limits + +If >50 issues in one category: +- Show top 10 examples +- Provide total count +- List top 3 files with most issues + +If >100 total issues: +- Summarize by category +- Show only CRITICAL/HIGH details +- Always show: Severity counts, top 3 files by issue count + +## What You Check + +### 1. Missing NSFileCoordinator (CRITICAL - Data Corruption Risk) + +**Pattern**: Reading/writing iCloud Drive files without NSFileCoordinator +**Risk**: Race conditions with sync → data corruption, lost updates + +Must use NSFileCoordinator for: +- All reads from ubiquitous URLs +- All writes to ubiquitous URLs +- File moves/deletes in iCloud container + +### 2. Missing CloudKit Error Handling (HIGH - Sync Failures) + +**Pattern**: CloudKit operations without proper CKError handling +**Risk**: Silent failures, quota exceeded unhandled, conflicts ignored + +Must handle: +- `.quotaExceeded` → Prompt user to free space +- `.networkUnavailable` → Queue for retry +- `.serverRecordChanged` → Resolve conflict +- `.notAuthenticated` → Prompt iCloud sign-in + +### 3. Missing Entitlement Checks (HIGH - Runtime Crashes) + +**Pattern**: Accessing ubiquitous container without checking availability +**Risk**: Crashes when user not signed into iCloud + +Must check: +- `FileManager.default.ubiquityIdentityToken != nil` +- `CKContainer.default().accountStatus()` returns `.available` + +### 4. SwiftData + CloudKit Anti-Patterns (HIGH - Sync Failures) + +**Pattern**: Using unsupported features with CloudKit sync +**Risk**: Sync breaks silently + +CloudKit doesn't support: +- `@Attribute(.unique)` constraint +- Complex predicates in @Query +- Custom transformable types + +### 5. Missing Conflict Resolution (MEDIUM - Data Loss Risk) + +**Pattern**: Not handling `hasUnresolvedConflicts` for iCloud Drive +**Risk**: User edits on multiple devices conflict, data lost + +Must implement: +- Detect conflicts via `ubiquitousItemHasUnresolvedConflictsKey` +- Resolve with `NSFileVersion` API + +### 6. CKSyncEngine Migration Issues (MEDIUM - Modern API) + +**Pattern**: Using legacy CKDatabase APIs instead of CKSyncEngine +**Risk**: Manually reimplementing what CKSyncEngine provides + +Should use CKSyncEngine (iOS 17+) for custom persistence. + +## Audit Process + +### Step 1: Find All Swift Files + +Use Glob tool: +``` +**/*.swift +``` + +### Step 2: Search for Anti-Patterns + +Run these grep searches: + +**Unsafe iCloud Drive Access**: +```bash +# File operations on ubiquitous URLs without NSFileCoordinator +ubiquityContainerIdentifier|ubiquitousItemDownloading|NSMetadataQuery +``` + +Then check if NSFileCoordinator is used nearby. + +**Missing CloudKit Error Handling**: +```bash +# CloudKit operations without error handling +\.save\(|\.fetch|CKDatabase|CKRecord +``` + +Then check for CKError handling nearby. + +**Missing Entitlement Checks**: +```bash +# Accessing iCloud without availability check +ubiquityIdentityToken|CKContainer.*accountStatus +``` + +Then verify checks before usage. + +**SwiftData CloudKit Anti-Patterns**: +```bash +# Unsupported features with CloudKit +@Attribute\(\.unique\)|\.unique|cloudKitDatabase.*\.private +``` + +**Missing Conflict Resolution**: +```bash +# Checking for conflicts +ubiquitousItemHasUnresolvedConflicts|NSFileVersion +``` + +**Legacy CloudKit APIs**: +```bash +# Check if using old APIs +CKDatabase|CKFetchRecordZoneChanges|CKModifyRecords +``` + +Then check if CKSyncEngine is available (iOS 17+). + +### Step 3: Categorize by Severity + +**CRITICAL** (Data Corruption Risk): +- NSFileCoordinator missing on ubiquitous file operations +- Writing to iCloud Drive without coordination + +**HIGH** (Sync Failures): +- CloudKit operations without error handling +- Missing iCloud availability checks +- SwiftData using unsupported features with CloudKit +- Runtime crashes when iCloud unavailable + +**MEDIUM** (Data Loss Risk): +- Missing conflict resolution +- Using legacy APIs instead of CKSyncEngine +- Missing quota exceeded handling + +**LOW** (Best Practices): +- Could improve error messages +- Could add better logging + +## Output Format + +```markdown +# iCloud Audit Results + +## Summary +- **CRITICAL Issues**: [count] (Data corruption risk) +- **HIGH Issues**: [count] (Sync failures) +- **MEDIUM Issues**: [count] (Data loss risk) +- **LOW Issues**: [count] (Best practices) + +## CRITICAL Issues + +### Missing NSFileCoordinator (Data Corruption Risk) +- `src/Managers/DocumentManager.swift:78` - Writing to iCloud URL without coordination + - **Risk**: Race condition with sync → data corruption + - **Fix**: Wrap in NSFileCoordinator: + ```swift + let coordinator = NSFileCoordinator() + coordinator.coordinate(writingItemAt: icloudURL, options: .forReplacing, error: nil) { newURL in + try? data.write(to: newURL) + } + ``` + +- `src/Services/FileService.swift:45` - Reading ubiquitous file without coordination + - **Risk**: Reading partially synced file + - **Fix**: Use coordinated read: + ```swift + let coordinator = NSFileCoordinator() + coordinator.coordinate(readingItemAt: icloudURL, options: [], error: nil) { newURL in + let data = try? Data(contentsOf: newURL) + } + ``` + +## HIGH Issues + +### Missing CloudKit Error Handling +- `src/Sync/CloudKitManager.swift:123` - CKDatabase.save() without error handling + - **Risk**: Silent failures, quota exceeded unhandled + - **Fix**: Handle critical errors: + ```swift + do { + try await database.save(record) + } catch let error as CKError { + switch error.code { + case .quotaExceeded: + // Prompt user to purchase more iCloud storage + showStorageFullAlert() + case .networkUnavailable: + // Queue for retry when online + queueForRetry(record) + case .serverRecordChanged: + // Resolve conflict + if let serverRecord = error.serverRecord { + let merged = mergeRecords(server: serverRecord, client: record) + try await database.save(merged) + } + case .notAuthenticated: + // Prompt iCloud sign-in + showSignInPrompt() + default: + throw error + } + } + ``` + +### Missing Entitlement Checks +- `src/Services/ICloudService.swift:34` - Accessing ubiquitous container without check + - **Risk**: Crash when user not signed into iCloud + - **Fix**: Check availability first: + ```swift + guard FileManager.default.ubiquityIdentityToken != nil else { + // User not signed into iCloud + showNotSignedInAlert() + return + } + + let containerURL = FileManager.default.url( + forUbiquityContainerIdentifier: nil + ) + ``` + +### SwiftData CloudKit Anti-Patterns +- `src/Models/User.swift:12` - Using @Attribute(.unique) with CloudKit sync + - **Risk**: Sync will break silently + - **Fix**: Remove .unique constraint OR disable CloudKit sync for this model: + ```swift + // Option 1: Remove constraint + @Attribute var email: String // No .unique + + // Option 2: Manual uniqueness checking + // Check duplicates before save with @Query + ``` + +## MEDIUM Issues + +### Missing Conflict Resolution +- `src/Documents/DocumentController.swift:67` - Not checking for iCloud conflicts + - **Risk**: User edits on iPad and iPhone conflict, one version lost + - **Fix**: Detect and resolve conflicts: + ```swift + let values = try? url.resourceValues(forKeys: [ + .ubiquitousItemHasUnresolvedConflictsKey + ]) + + if values?.ubiquitousItemHasUnresolvedConflicts == true { + let conflicts = NSFileVersion.unresolvedConflictVersionsOfItem(at: url) ?? [] + + // Show conflict resolution UI + // Or keep current version + for conflict in conflicts { + conflict.isResolved = true + } + try? NSFileVersion.removeOtherVersionsOfItem(at: url) + } + ``` + +### Using Legacy CloudKit APIs +- `src/Sync/LegacySyncEngine.swift:45` - Using CKFetchRecordZoneChangesOperation + - **Impact**: Manually reimplementing what CKSyncEngine provides + - **Fix**: Migrate to CKSyncEngine (iOS 17+): + ```swift + let config = CKSyncEngine.Configuration( + database: CKContainer.default().privateCloudDatabase, + stateSerialization: loadState(), + delegate: self + ) + let syncEngine = try CKSyncEngine(config) + // CKSyncEngine handles fetch/upload cycles, conflicts, account changes + ``` + +## CloudKit Error Handling Checklist + +All CloudKit operations should handle: + +- [ ] `.quotaExceeded` - User's iCloud storage full +- [ ] `.networkUnavailable` - No internet connection +- [ ] `.serverRecordChanged` - Conflict (concurrent modification) +- [ ] `.notAuthenticated` - User signed out of iCloud +- [ ] `.zoneNotFound` - Custom zone doesn't exist yet +- [ ] `.partialFailure` - Batch operation partially failed + +## NSFileCoordinator Patterns + +Always use coordination for iCloud Drive: + +```swift +// ✅ Coordinated read +let coordinator = NSFileCoordinator() +coordinator.coordinate(readingItemAt: url, options: [], error: nil) { newURL in + let data = try? Data(contentsOf: newURL) +} + +// ✅ Coordinated write +coordinator.coordinate(writingItemAt: url, options: .forReplacing, error: nil) { newURL in + try? data.write(to: newURL) +} + +// ❌ WRONG - Direct access +let data = try? Data(contentsOf: icloudURL) // Race condition! +``` + +## Next Steps + +1. **Fix CRITICAL issues first** - Data corruption risk +2. **Fix HIGH issues** - Sync will fail without proper error handling +3. **Test offline scenarios** - Turn off Wi-Fi, verify queue/retry logic +4. **Test quota exceeded** - Fill iCloud storage, verify user prompt +5. **Test conflicts** - Edit same file on two devices simultaneously + +## Related Skills + +For comprehensive iCloud debugging: +- Use `/skill axiom:cloud-sync-diag` for sync troubleshooting +- Use `/skill axiom:cloudkit-ref` for modern CloudKit patterns +- Use `/skill axiom:icloud-drive-ref` for file coordination details +``` + +## Audit Guidelines + +1. Run all searches for comprehensive coverage +2. Provide file:line references to make it easy to find issues +3. Categorize by severity to help prioritize fixes +4. Show specific fixes - don't just report problems +5. Explain sync impact - data corruption vs sync failures + +## When Issues Found + +If CRITICAL issues found: +- Emphasize data corruption risk +- Recommend immediate fix +- Provide exact NSFileCoordinator code + +If NO issues found: +- Report "No iCloud violations detected" +- Note runtime testing still recommended +- Suggest testing with multiple devices + +## False Positives + +These are acceptable (not issues): +- Local file operations (not in iCloud container) +- CloudKit Console access (not runtime code) +- Test code with mock CloudKit + +## Testing Recommendations + +After fixes: +```bash +# Test multi-device sync +# Edit same document on two devices + +# Test offline mode +# Turn off Wi-Fi, verify queue/retry + +# Test quota exceeded +# Settings → [Profile] → Manage Storage → Delete to <100MB + +# Test not signed in +# Settings → [Profile] → Sign Out + +# Test conflicts +# Edit same file offline on two devices, then go online +``` diff --git a/.claude/skills/axiom-audit-icloud/agents/openai.yaml b/.claude/skills/axiom-audit-icloud/agents/openai.yaml new file mode 100644 index 0000000..420a3ed --- /dev/null +++ b/.claude/skills/axiom-audit-icloud/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit iCloud" + short_description: "The user mentions iCloud sync issues, CloudKit errors, ubiquitous container problems, or asks to audit cloud sync." diff --git a/.claude/skills/axiom-audit-liquid-glass/.openskills.json b/.claude/skills/axiom-audit-liquid-glass/.openskills.json new file mode 100644 index 0000000..fb27e59 --- /dev/null +++ b/.claude/skills/axiom-audit-liquid-glass/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-liquid-glass", + "installedAt": "2026-04-12T08:05:49.846Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-liquid-glass/SKILL.md b/.claude/skills/axiom-audit-liquid-glass/SKILL.md new file mode 100644 index 0000000..f223d1c --- /dev/null +++ b/.claude/skills/axiom-audit-liquid-glass/SKILL.md @@ -0,0 +1,135 @@ +--- +name: axiom-audit-liquid-glass +description: Use when the user mentions Liquid Glass review, iOS 26 UI updates, toolbar improvements, or visual effect migration. +license: MIT +disable-model-invocation: true +--- +# Liquid Glass Auditor Agent + +You are an expert at identifying Liquid Glass adoption opportunities in SwiftUI codebases for iOS 26+. + +## Your Mission + +Run a comprehensive Liquid Glass adoption audit and report all opportunities with: +- File:line references +- Priority ratings (HIGH/MEDIUM/LOW) +- Example code for each recommendation + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## What You Check + +### 1. Migration from Old Blur Effects (HIGH) +**Pattern**: `UIBlurEffect`, `NSVisualEffectView`, `.background(.material)`, `.blur()` +**Opportunity**: Migrate to `.glassEffect()` or `.glassBackgroundEffect()` for iOS 26+ +**Note**: Keep old effects for iOS 18-25 compatibility if needed + +### 2. Toolbar Improvements (HIGH) +**Pattern**: Toolbars missing `.buttonStyle(.borderedProminent)`, `Spacer(.fixed)`, or `.tint()` +**Opportunity**: Better button grouping and primary action prominence +**Fix**: Add `Spacer(.fixed)` for grouping, `.borderedProminent` + `.tint()` for primary actions + +### 3. Custom Views for Glass Effects (MEDIUM) +**Pattern**: Custom view types (cards, galleries, overlays) without glass effect +**Opportunity**: Enhanced visual depth with `.glassBackgroundEffect()` +**Variants**: Regular (default, reflects content) vs Clear (`.glassBackgroundEffect(in: .clear)` for media overlays) + +### 4. Search Pattern Opportunities (MEDIUM) +**Pattern**: `.searchable()` not in `NavigationSplitView`, missing `.tabRole(.search)` +**Opportunity**: Platform-specific bottom-alignment for search + +### 5. Glass-on-Glass Layering (MEDIUM) +**Pattern**: Nested views with multiple glass effects +**Issue**: Layering creates visual muddiness +**Fix**: Use glass effects only on outermost container + +### 6. Tinting Opportunities (LOW) +**Pattern**: `.buttonStyle(.borderedProminent)` without `.tint()` +**Opportunity**: Add color prominence to important actions + +### 7. Missing .interactive() on Custom Controls (LOW) +**Pattern**: Custom buttons with glass effects missing `.interactive()` +**Opportunity**: Automatic visual feedback for press states + +## Regular vs Clear Variants + +**Regular** (default): `.glassBackgroundEffect()` - subtle tint that reflects content +- Best for: Content containers, cards, galleries + +**Clear**: `.glassBackgroundEffect(in: .clear)` - no tint, pure transparency +- Best for: Controls over photos/videos where color accuracy matters + +## Audit Process + +### Step 1: Find SwiftUI Files +Use Glob: `**/*.swift` + +### Step 2: Search for Opportunities + +**Old Blur Effects**: +- `UIBlurEffect`, `UIVisualEffectView` +- `NSVisualEffectView` +- `.blur(`, `.background(.*Material` + +**Toolbars**: +- `.toolbar {`, `ToolbarItem`, `ToolbarItemGroup` +- Missing `.borderedProminent` for primary actions +- Missing `Spacer(.fixed)` for grouping + +**Custom Views**: +- `struct.*Card|Container|Overlay|Gallery.*: View` +- Views that could benefit from `.glassBackgroundEffect()` + +**Search Patterns**: +- `.searchable(` placement +- `NavigationSplitView` context +- `.tabRole(` usage + +**Glass-on-Glass**: +- Multiple `.glassEffect()` or `.glassBackgroundEffect()` in nested views + +**Tinting**: +- `.borderedProminent` without `.tint(` + +### Step 3: Categorize by Priority + +**HIGH**: Migration from old blur effects, primary action prominence +**MEDIUM**: Custom views for glass, search placement, glass-on-glass fixes +**LOW**: Tinting, `.interactive()` for custom controls + +## Output Format + +Generate a "Liquid Glass Adoption Audit Results" report with: +1. **Summary**: Opportunity counts by category +2. **By priority**: HIGH first, with file:line, current code, recommended code +3. **Variant guidance**: When to use Regular vs Clear for each recommendation +4. **Next steps**: Implementation order + +## Output Limits + +If >50 opportunities in one category: Show top 10, provide total count, list top 3 files +If >100 total opportunities: Summarize by category, show only HIGH/MEDIUM details + +## Audit Guidelines + +1. Run all 7 category searches +2. Provide file:line references +3. Show before/after code examples +4. Recommend appropriate variant (Regular vs Clear) based on context +5. Note iOS 26+ requirement + +## False Positives (Not Issues) + +- `.ultraThinMaterial` for iOS 18-25 compatibility +- UIKit blur in legacy code paths +- `.blur()` for intentional blur (not backgrounds) +- Custom views that don't need glass (text-only) +- Glass effects on sibling views (not nested) + +## Related + +For design guidance: `axiom-liquid-glass` skill +For comprehensive API reference: `axiom-liquid-glass-ref` skill +For SwiftUI 26 features: `axiom-swiftui-26-ref` skill diff --git a/.claude/skills/axiom-audit-liquid-glass/agents/openai.yaml b/.claude/skills/axiom-audit-liquid-glass/agents/openai.yaml new file mode 100644 index 0000000..fac26f6 --- /dev/null +++ b/.claude/skills/axiom-audit-liquid-glass/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit Liquid Glass" + short_description: "The user mentions Liquid Glass review, iOS 26 UI updates, toolbar improvements, or visual effect migration." diff --git a/.claude/skills/axiom-audit-memory/.openskills.json b/.claude/skills/axiom-audit-memory/.openskills.json new file mode 100644 index 0000000..0e931f5 --- /dev/null +++ b/.claude/skills/axiom-audit-memory/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-memory", + "installedAt": "2026-04-12T08:05:49.847Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-memory/SKILL.md b/.claude/skills/axiom-audit-memory/SKILL.md new file mode 100644 index 0000000..98b9d8e --- /dev/null +++ b/.claude/skills/axiom-audit-memory/SKILL.md @@ -0,0 +1,237 @@ +--- +name: axiom-audit-memory +description: Use when the user mentions memory leak prevention, code review for memory issues, or proactive leak checking. +license: MIT +disable-model-invocation: true +--- +# Memory Auditor Agent + +You are an expert at detecting memory leak patterns — both known anti-patterns AND missing/incomplete resource lifecycle management that causes progressive memory growth and crashes. + +## Your Mission + +Run a comprehensive memory audit using 5 phases: map resource ownership, detect known leak patterns, reason about what's missing, correlate compound issues, and score lifecycle health. Report all issues with: +- File:line references with confidence levels +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Fix recommendations with code examples + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Phase 1: Map Resource Ownership + +Before grepping, build a mental model of the codebase's resource ownership. + +### Step 1: Identify Resource-Owning Classes + +``` +Glob: **/*.swift (excluding test/vendor paths) +Grep for: + - `Timer.scheduledTimer`, `Timer.publish` — timer ownership + - `addObserver`, `NotificationCenter`, `.sink`, `.assign(to:` — observer ownership + - `var.*Task<`, `Task {` stored in properties — async task ownership + - `var.*delegate:`, `var.*Delegate:` — delegate relationships + - `deinit {` — classes with explicit cleanup +``` + +### Step 2: Identify Cleanup Patterns + +Read 3-5 key resource-owning classes to understand: +- What's the ownership graph? (who creates, who retains, who cleans up) +- Are there clear owner→resource→cleanup chains? +- Which classes have `deinit` and which don't? +- Are there objects that accumulate resources without bounds? + +### Step 3: Identify Long-Lived Objects + +``` +Grep for: + - `static let`, `static var` — singletons (intentionally long-lived) + - `shared` — shared instances + - Classes without clear deallocation point +``` + +### Output + +Write a brief **Resource Ownership Map** (5-10 lines) summarizing: +- Which classes own long-lived resources +- Where cleanup happens (deinit, onDisappear, explicit teardown) +- Any classes that own resources but lack cleanup +- Singleton/static instances (intentionally long-lived — not bugs) + +Present this map in the output before proceeding. + +## Phase 2: Detect Known Leak Patterns + +Run all 6 existing detection patterns with pair counting. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — pair counting needs contextual verification to avoid false positives. + +### Pattern 1: Timer Leaks (CRITICAL/HIGH) + +**Issue**: `Timer.scheduledTimer(repeats: true)` without `.invalidate()` +**Search**: `Timer\.scheduledTimer.*repeats.*true`, `Timer\.publish` +**Verify**: Count timers vs `.invalidate()` calls in same file/class +**Impact**: Memory grows 10-30MB/minute, guaranteed crash +**Fix**: Add `timer?.invalidate()` in `deinit` +**Note**: One-shot timers (`repeats: false`) are safe — skip them. + +### Pattern 2: Observer/Notification Leaks (HIGH/HIGH) + +**Issue**: `addObserver` without `removeObserver` +**Search**: `addObserver(self,`, `NotificationCenter.default.addObserver` +**Verify**: Count observers vs `removeObserver(self` in same class +**Also check**: `.sink {`, `.assign(to:`, `Timer.publish` without `AnyCancellable` storage (`var.*cancellable`, `Set`) +**Impact**: Multiple instances accumulate, listening redundantly +**Fix**: Add `removeObserver(self)` in `deinit`, or store Combine subscriptions in `Set` + +### Pattern 3: Closure Capture Leaks (HIGH/MEDIUM) + +**Issue**: Closures in arrays/collections capturing self strongly +**Search**: `.append.*{.*self\.` without `[weak self]`; `var.*:.*\[.*->` (closure arrays); `DispatchQueue.*{.*self\.`, `Task.*{.*self\.` without `[weak self]` +**Impact**: Retain cycles, memory never released +**Fix**: Use `[weak self]` capture lists +**Note**: Only applies to class types. Struct self capture is fine. + +### Pattern 4: Strong Delegate Cycles (MEDIUM/HIGH) + +**Issue**: Delegate properties without `weak` +**Search**: `var.*delegate:` without `weak`, `var.*Delegate:` without `weak` +**Impact**: Parent→Child→Parent cycle, neither deallocates +**Fix**: Mark delegates as `weak` + +### Pattern 5: View Callback Leaks (MEDIUM/LOW) + +**Issue**: View callbacks capturing self and stored +**Search**: `.onAppear {` or `.onDisappear {` with stored closures or async context +**Impact**: SwiftUI views retained, memory accumulates +**Fix**: Use `[weak self]` in callbacks when stored or async +**Note**: Most SwiftUI callbacks are safe (views are value types). Only flag when there's clear evidence of class-based storage. + +### Pattern 6: PhotoKit Accumulation (LOW/MEDIUM) + +**Issue**: PHImageManager requests without cancellation +**Search**: `PHImageManager.*request` without `cancelImageRequest` +**Impact**: Large images accumulate during scrolling +**Fix**: Cancel requests in `prepareForReuse()` or `onDisappear` + +## Phase 3: Reason About Memory Completeness + +Using the Resource Ownership Map from Phase 1 and your domain knowledge, check for what's *missing* — not just what's wrong. + +| Question | What it detects | Why it matters | +|----------|----------------|----------------| +| Do all classes that own stored Tasks cancel them in deinit? | Missing Task cancellation | Zombie Tasks continue running after the owning object is gone, consuming CPU and memory | +| Do classes with async sequence iteration (for await) have cancellation paths? | Infinite sequence retention | AsyncStream consumers retain their Task forever if not cancelled | +| Are there classes that create resources in methods but only clean up some of them? | Partial cleanup | Timer invalidated but observer not removed = still leaking | +| Do closures stored in collections use [weak self]? | Closure accumulation | Each append adds another strong reference, none ever released | +| Are there view controllers or view models that register observers but lack a clear teardown counterpart? | Observer lifecycle mismatch | Observers outlive their owner's useful lifetime | +| Do any classes grow collections without bounds (appending without eviction)? | Unbounded accumulation | Arrays, dictionaries, or caches that only grow = slow memory leak | +| Is there a consistent memory management pattern, or does each class do it differently? | Inconsistent lifecycle strategy | Ad-hoc cleanup means some paths are always missed | + +For each finding, explain what's missing and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code. + +## Phase 4: Cross-Reference Findings + +When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations: + +| Finding A | + Finding B | = Compound | Severity | +|-----------|------------|-----------|----------| +| No deinit | Owns stored Task + timer + observer | No cleanup path exists for multiple resources | CRITICAL | +| [weak self] missing in closure | Closure stored in collection | Accumulating retain cycles | CRITICAL | +| Timer without invalidate | No deinit on owning class | Timer runs forever, class never deallocates | CRITICAL | +| PHImageManager requests | In ScrollView/List cell | Image accumulation during scrolling | HIGH | +| Observer added in init | No removeObserver anywhere | Permanent observer leak | HIGH | +| Stored Task without cancel | No onDisappear/deinit cleanup | Zombie async work after navigation | HIGH | +| Unbounded collection growth | In long-lived singleton | Memory grows for entire app lifetime | HIGH | + +Also note overlaps with other auditors: +- Missing Task cancellation + no deinit → compound with concurrency auditor +- Closure captures in async context → compound with concurrency auditor +- PHImageManager in List cell → compound with SwiftUI performance + +## Phase 5: Resource Lifecycle Health Score + +Calculate and present a health score: + +```markdown +## Memory Health Score + +| Metric | Value | +|--------|-------| +| Resource ownership coverage | X classes own resources, Y have cleanup (Z%) | +| Timer lifecycle | N repeating timers, M invalidate calls (match: yes/no) | +| Observer lifecycle | N observers, M removals (match: yes/no) | +| Task lifecycle | N stored Tasks, M with deinit/onDisappear cancellation (Z%) | +| Combine subscriptions | N .sink/.assign calls, M with cancellable storage (Z%) | +| Unbounded collections | N potential accumulation points | +| **Health** | **CLEAN / NEEDS ATTENTION / LEAKING** | +``` + +Scoring: +- **CLEAN**: No CRITICAL issues, all resource pairs match, >90% cleanup coverage, 0 unbounded collections +- **NEEDS ATTENTION**: No CRITICAL issues, some mismatched pairs or <90% cleanup coverage +- **LEAKING**: Any CRITICAL issues, or multiple unmatched resource pairs, or unbounded growth in long-lived objects + +## Output Format + +```markdown +# Memory Leak Audit Results + +## Resource Ownership Map +[5-10 line summary from Phase 1] + +## Summary +- CRITICAL: [N] issues +- HIGH: [N] issues +- MEDIUM: [N] issues +- LOW: [N] issues +- Phase 2 (pattern detection): [N] issues +- Phase 3 (completeness reasoning): [N] issues +- Phase 4 (compound findings): [N] issues + +## Memory Health Score +[Phase 5 table] + +## Verification Counts +- Timers: N created, M invalidated +- Observers: N added, M removed +- Tasks: N stored, M cancelled in cleanup +- Combine: N subscriptions, M with cancellable storage + +## Issues by Severity + +### [SEVERITY/CONFIDENCE] [Category]: [Description] +**File**: path/to/file.swift:line +**Phase**: [2: Detection | 3: Completeness | 4: Compound] +**Issue**: What's wrong or missing +**Impact**: What happens if not fixed +**Fix**: Code example showing the fix +**Cross-Auditor Notes**: [if overlapping with another auditor] + +## Recommendations +1. [Immediate actions — CRITICAL fixes] +2. [Short-term — HIGH fixes and lifecycle cleanup] +3. [Long-term — architectural improvements from Phase 3 findings] +4. [Instruments verification — suggested profiling workflows] +``` + +## Output Limits + +If >50 issues in one category: Show top 10, provide total count, list top 3 files +If >100 total issues: Summarize by category, show only CRITICAL/HIGH details + +## False Positives (Not Issues) + +- `weak var delegate` — Already safe +- Closures with `[weak self]` — Already safe +- Static/singleton timers (intentionally long-lived) +- One-shot timers with `repeats: false` +- Most SwiftUI callbacks (views are value types) +- Task captures where self is a struct (value type) +- Combine subscriptions stored in `Set` or `AnyCancellable` property + +## Related + +For Instruments workflows: `axiom-memory-debugging` skill +For Memory Graph Debugger: `axiom-memory-debugging` skill +For Task lifecycle issues found during audit: `axiom-swift-concurrency` skill diff --git a/.claude/skills/axiom-audit-memory/agents/openai.yaml b/.claude/skills/axiom-audit-memory/agents/openai.yaml new file mode 100644 index 0000000..9433ee5 --- /dev/null +++ b/.claude/skills/axiom-audit-memory/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit Memory" + short_description: "The user mentions memory leak prevention, code review for memory issues, or proactive leak checking." diff --git a/.claude/skills/axiom-audit-networking/.openskills.json b/.claude/skills/axiom-audit-networking/.openskills.json new file mode 100644 index 0000000..414f9fb --- /dev/null +++ b/.claude/skills/axiom-audit-networking/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-networking", + "installedAt": "2026-04-12T08:05:49.848Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-networking/SKILL.md b/.claude/skills/axiom-audit-networking/SKILL.md new file mode 100644 index 0000000..f32eab6 --- /dev/null +++ b/.claude/skills/axiom-audit-networking/SKILL.md @@ -0,0 +1,147 @@ +--- +name: axiom-audit-networking +description: Use when the user mentions networking review, deprecated APIs, connection issues, or App Store submission prep. +license: MIT +disable-model-invocation: true +--- +# Networking Auditor Agent + +You are an expert at detecting deprecated networking APIs and anti-patterns that cause App Store rejections and connection failures. + +## Your Mission + +Run a comprehensive networking audit and report all issues with: +- File:line references +- Severity ratings (HIGH/MEDIUM/LOW) +- Fix recommendations with code examples + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## What You Check + +### Deprecated APIs (WWDC 2018) + +#### 1. SCNetworkReachability (HIGH) +**Pattern**: `SCNetworkReachability`, `SCNetworkReachabilityCreateWithName` +**Issue**: Race condition between check and connect, misses proxy/VPN +**Fix**: Use NWConnection waiting state or NWPathMonitor + +#### 2. CFSocket (MEDIUM) +**Pattern**: `CFSocketCreate`, `CFSocketConnectToAddress` +**Issue**: 30% CPU penalty vs Network.framework, no smart connection +**Fix**: Use NWConnection or NetworkConnection (iOS 26+) + +#### 3. NSStream / CFStream (MEDIUM) +**Pattern**: `NSInputStream`, `NSOutputStream`, `CFStreamCreatePairWithSocket` +**Issue**: No TLS integration, manual buffer management +**Fix**: Use NWConnection for TCP/TLS streams + +#### 4. NSNetService (LOW) +**Pattern**: `NSNetService`, `NSNetServiceBrowser` +**Issue**: Legacy API, no structured concurrency +**Fix**: Use NWBrowser (iOS 12-25) or NetworkBrowser (iOS 26+) + +#### 5. Manual DNS (MEDIUM) +**Pattern**: `getaddrinfo`, `gethostbyname` +**Issue**: Misses Happy Eyeballs (IPv4/IPv6 racing), no proxy evaluation +**Fix**: Let NWConnection handle DNS automatically + +### Anti-Patterns + +#### 6. Reachability Before Connect (HIGH) +**Pattern**: `if SCNetworkReachability` followed by `connection.start()` +**Issue**: Race condition - network changes between check and connect +**Fix**: Use waiting state handler, let framework manage connectivity + +#### 7. Hardcoded IP Addresses (MEDIUM) +**Pattern**: IP literals like `"192.168.1.1"`, `"10.0.0.1"` +**Issue**: Breaks proxy/VPN compatibility, no DNS load balancing +**Fix**: Use hostnames + +#### 8. Missing [weak self] in Callbacks (MEDIUM) +**Pattern**: `connection.send` or `stateUpdateHandler` with `self.` but no `[weak self]` +**Issue**: Retain cycle → memory leak +**Fix**: Use `[weak self]` or migrate to NetworkConnection (iOS 26+) + +#### 9. Blocking Socket Calls (HIGH) +**Pattern**: `connect()`, `send()`, `recv()` without async wrapper +**Issue**: Main thread hang → App Store rejection, ANR crashes +**Fix**: Use NWConnection (non-blocking) + +#### 10. Not Handling Waiting State (LOW) +**Pattern**: `stateUpdateHandler` without `.waiting` case +**Issue**: Shows "failed" instead of "waiting for network" +**Fix**: Handle `.waiting` state with user feedback + +## Audit Process + +### Step 1: Find Source Files +Use Glob: `**/*.swift`, `**/*.m`, `**/*.h` + +### Step 2: Search for Issues + +**Deprecated APIs**: +- `SCNetworkReachability` - HIGH +- `CFSocket`, `CFSocketCreate` - MEDIUM +- `NSStream`, `CFStream`, `NSInputStream`, `NSOutputStream` - MEDIUM +- `NSNetService`, `NSNetServiceBrowser` - LOW +- `getaddrinfo`, `gethostbyname` - MEDIUM + +**Anti-Patterns**: +- `isReachable` followed by connection start +- IP addresses: `[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}` +- `stateUpdateHandler`, `.send.*completion` without `[weak self]` +- `socket(`, `connect(`, `send(`, `recv(` in main code paths +- `stateUpdateHandler` without `.waiting` case + +### Step 3: Check Good Patterns +- `NWConnection` (iOS 12+) +- `NetworkConnection` (iOS 26+) +- `URLSession` (correct for HTTP) + +### Step 4: Categorize by Severity + +**HIGH** (App Store rejection risk): +- SCNetworkReachability, blocking sockets, reachability before connect + +**MEDIUM** (Memory leaks, VPN/proxy issues): +- CFSocket, NSStream, missing [weak self], hardcoded IPs, manual DNS + +**LOW** (Technical debt, UX): +- NSNetService, missing waiting state handler + +## Output Format + +Generate a "Networking Audit Results" report with: +1. **Summary**: Issue counts by severity +2. **Deprecated APIs section**: Each with file:line, issue, impact, fix with code +3. **Anti-Patterns section**: Each with file:line, issue, fix with code +4. **Positive Patterns**: What's already correct +5. **Priority Fixes**: Ordered action items + +## Output Limits + +If >50 issues in one category: Show top 10, provide total count, list top 3 files +If >100 total issues: Summarize by category, show only HIGH details + +## Audit Guidelines + +1. Run all pattern searches +2. Provide file:line references +3. Show before/after code examples +4. Categorize by App Store risk + +## False Positives (Not Issues) + +- IP addresses in comments/docs +- URLSession usage (correct for HTTP) +- socket() in test/debug code +- [weak self] in non-NWConnection contexts + +## Related + +For implementation patterns: `axiom-networking` skill +For connection troubleshooting: `axiom-networking-diag` skill +For API reference: `axiom-network-framework-ref` skill diff --git a/.claude/skills/axiom-audit-networking/agents/openai.yaml b/.claude/skills/axiom-audit-networking/agents/openai.yaml new file mode 100644 index 0000000..7dcaed0 --- /dev/null +++ b/.claude/skills/axiom-audit-networking/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit Networking" + short_description: "The user mentions networking review, deprecated APIs, connection issues, or App Store submission prep." diff --git a/.claude/skills/axiom-audit-spritekit/.openskills.json b/.claude/skills/axiom-audit-spritekit/.openskills.json new file mode 100644 index 0000000..a0533a7 --- /dev/null +++ b/.claude/skills/axiom-audit-spritekit/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-spritekit", + "installedAt": "2026-04-12T08:05:49.849Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-spritekit/SKILL.md b/.claude/skills/axiom-audit-spritekit/SKILL.md new file mode 100644 index 0000000..4fb83d5 --- /dev/null +++ b/.claude/skills/axiom-audit-spritekit/SKILL.md @@ -0,0 +1,150 @@ +--- +name: axiom-audit-spritekit +description: Use when the user wants to audit SpriteKit game code for common issues. +license: MIT +disable-model-invocation: true +--- +# SpriteKit Auditor Agent + +You are an expert at detecting SpriteKit anti-patterns that cause physics bugs, performance issues, memory leaks, and gameplay problems. + +## Your Mission + +Run a comprehensive SpriteKit audit across 8 anti-pattern categories and report all issues with: +- File:line references +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Impact descriptions +- Fix recommendations with code examples + +## Files to Scan + +Include: `**/*.swift` files containing SpriteKit imports or patterns +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## What You Check + +### Pattern 1: Physics Bitmask Issues (CRITICAL) +**Issue**: Default bitmasks (0xFFFFFFFF), missing contactTestBitMask, magic number bitmasks +**Impact**: Phantom collisions, contacts never fire, unpredictable physics +**Fix**: Use PhysicsCategory struct with explicit named bitmasks + +**Search for**: +- `categoryBitMask` — verify set to explicit named values +- `contactTestBitMask` — verify exists for bodies needing contact detection +- `collisionBitMask` — verify not left as default 0xFFFFFFFF +- `0xFFFFFFFF` or `4294967295` — explicit use of "everything" mask +- Magic numbers like `0x1`, `1 <<` without clear naming + +### Pattern 2: Draw Call Waste (HIGH) +**Issue**: SKShapeNode for gameplay sprites, missing texture atlases, unbatched sprites +**Impact**: Each SKShapeNode = 1 draw call, 50+ draw calls causes frame drops +**Fix**: Pre-render shapes to textures, use texture atlases + +**Search for**: +- `SKShapeNode(` — check if used for gameplay (not just debug) +- `.atlas` or `SKTextureAtlas` — should exist for games with many sprites +- Multiple different `imageNamed:` calls — should use atlas instead + +### Pattern 3: Node Accumulation (HIGH) +**Issue**: Nodes created but never removed, growing node count +**Impact**: Memory growth, eventual frame drops and crashes +**Fix**: Remove offscreen nodes, implement object pooling + +**Search for**: +- Count `addChild(` vs `removeFromParent()` — significant imbalance indicates leak +- `addChild` inside `update(` or timer callbacks without corresponding removal +- Missing `removeFromParent()` in bullet/projectile/effect lifecycle + +### Pattern 4: Action Memory Leaks (HIGH) +**Issue**: Strong self capture in action closures, repeatForever without withKey +**Impact**: Retain cycles prevent scene deallocation, memory grows +**Fix**: Use [weak self], use withKey for cancellable actions + +**Search for**: +- `SKAction.run {` or `SKAction.run({` — check for `[weak self]` +- `.repeatForever(` — check for `withKey:` parameter +- `SKAction.customAction` — check for `[weak self]` + +### Pattern 5: Coordinate Confusion (MEDIUM) +**Issue**: Using view coordinates instead of scene coordinates +**Impact**: Touch positions are Y-flipped, nodes appear in wrong location +**Fix**: Use touch.location(in: self) not touch.location(in: self.view) + +**Search for**: +- `touch.location(in: self.view` or `touch.location(in: view` — should be `touch.location(in: self)` +- `convertPoint(fromView:` — verify correct direction + +### Pattern 6: Touch Handling Bugs (MEDIUM) +**Issue**: Implementing touchesBegan without setting isUserInteractionEnabled +**Impact**: Touches never register on non-scene nodes +**Fix**: Set isUserInteractionEnabled = true on interactive nodes + +**Search for**: +- `touchesBegan` in SKNode subclasses — verify `isUserInteractionEnabled = true` is set +- `touchesMoved`, `touchesEnded` — same check + +### Pattern 7: Missing Object Pooling (MEDIUM) +**Issue**: Creating new SKSpriteNode instances for frequently spawned objects +**Impact**: GC pressure, frame drops during intense gameplay +**Fix**: Implement object pool pattern + +**Search for**: +- `SKSpriteNode(` inside methods named `spawn`, `fire`, `create`, or inside `update(` +- High-frequency creation patterns (bullets, particles, effects) + +### Pattern 8: Missing Debug Overlays (LOW) +**Issue**: No debug overlays configured in development +**Impact**: Performance problems go unnoticed until it's too late +**Fix**: Enable showsFPS, showsNodeCount, showsDrawCount during development + +**Search for**: +- `showsFPS` — should exist somewhere in the project +- `showsNodeCount` — should exist +- `showsDrawCount` — should exist + +## Audit Process + +### Step 1: Find SpriteKit Files +Use Glob: `**/*.swift` +Then Grep for files containing `SpriteKit` or `SKScene` or `SKSpriteNode` + +### Step 2: Search for Anti-Patterns +Run all 8 pattern searches using Grep + +### Step 3: Read and Verify +For each match, read the surrounding code (5-10 lines context) to confirm it's a real issue, not a false positive + +### Step 4: Categorize by Severity + +**CRITICAL**: Physics bitmask issues +**HIGH**: Draw call waste, node accumulation, action memory leaks +**MEDIUM**: Coordinate confusion, touch handling bugs, missing pooling +**LOW**: Missing debug overlays + +## Output Format + +Generate a "SpriteKit Audit Results" report with: +1. **Summary**: Issue counts by severity +2. **Issues by severity**: CRITICAL first, then HIGH, MEDIUM, LOW +3. **Each issue**: File:line, pattern detected, impact, fix with code example +4. **Verification checklist**: Key items to confirm after fixes + +## Output Limits + +If >50 issues in one category: Show top 10, provide total count, list top 3 files +If >100 total issues: Summarize by category, show only CRITICAL/HIGH details + +## False Positives (Not Issues) + +- PhysicsCategory struct definitions (these are the FIX, not the problem) +- SKShapeNode used only for debug visualization +- `[weak self]` already present in action closures +- `isUserInteractionEnabled = true` already set +- Debug overlays behind `#if DEBUG` flag +- Test files using SKShapeNode for test fixtures + +## Related + +For SpriteKit patterns: `axiom-spritekit` skill +For API reference: `axiom-spritekit-ref` skill +For troubleshooting: `axiom-spritekit-diag` skill diff --git a/.claude/skills/axiom-audit-spritekit/agents/openai.yaml b/.claude/skills/axiom-audit-spritekit/agents/openai.yaml new file mode 100644 index 0000000..dc9efa1 --- /dev/null +++ b/.claude/skills/axiom-audit-spritekit/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit SpriteKit" + short_description: "The user wants to audit SpriteKit game code for common issues." diff --git a/.claude/skills/axiom-audit-storage/.openskills.json b/.claude/skills/axiom-audit-storage/.openskills.json new file mode 100644 index 0000000..08a6da5 --- /dev/null +++ b/.claude/skills/axiom-audit-storage/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-storage", + "installedAt": "2026-04-12T08:05:49.850Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-storage/SKILL.md b/.claude/skills/axiom-audit-storage/SKILL.md new file mode 100644 index 0000000..d4d8a74 --- /dev/null +++ b/.claude/skills/axiom-audit-storage/SKILL.md @@ -0,0 +1,294 @@ +--- +name: axiom-audit-storage +description: Use when the user mentions file storage issues, data loss, backup bloat, or asks to audit storage usage. +license: MIT +disable-model-invocation: true +--- +# Storage Auditor Agent + +You are an expert at detecting file storage mistakes that cause data loss, backup bloat, and file access errors. + +## Your Mission + +Run a comprehensive storage audit and report all issues with: +- File:line references for easy fixing +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Specific fix recommendations +- Impact on user data and iCloud quota + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Output Limits + +If >50 issues in one category: +- Show top 10 examples +- Provide total count +- List top 3 files with most issues + +If >100 total issues: +- Summarize by category +- Show only CRITICAL/HIGH details +- Always show: Severity counts, top 3 files by issue count + +## What You Check + +### 1. Files in tmp/ Directory (CRITICAL - Data Loss Risk) + +**Pattern**: Anything written to `tmp/` that isn't truly temporary +**Risk**: iOS aggressively purges tmp/ - users lose data + +Files that should NOT be in tmp/: +- Downloads (should be Caches/ with isExcludedFromBackup) +- User content (should be Documents/) +- App state (should be Application Support/) + +### 2. Large Files Missing isExcludedFromBackup (HIGH - Backup Bloat) + +**Pattern**: Files >1MB in Documents/ or Application Support/ without isExcludedFromBackup +**Risk**: User's iCloud quota filled unnecessarily + +Should be excluded: +- Downloaded media (can re-download) +- Cached API responses +- Generated content (can regenerate) + +Should NOT be excluded: +- User-created content +- App data that can't be regenerated + +### 3. Missing File Protection (MEDIUM - Security Risk) + +**Pattern**: File writes without specifying FileProtectionType +**Risk**: Sensitive data not encrypted at rest + +All files should have explicit protection: +- Sensitive data → `.complete` +- Most app data → `.completeUntilFirstUserAuthentication` +- Public caches → `.none` + +### 4. Wrong Storage Location (HIGH - Various Issues) + +**Anti-Patterns**: +- User content in Application Support/ (not visible in Files app) +- Re-downloadable content in Documents/ (backup bloat) +- App data in tmp/ (data loss) +- Large data in UserDefaults (performance impact) + +### 5. UserDefaults Abuse (MEDIUM - Performance Impact) + +**Pattern**: Storing >1MB data in UserDefaults +**Risk**: Performance degradation, not designed for large data + +Should use files or database instead. + +## Audit Process + +### Step 1: Find All Swift Files + +Use Glob tool: +``` +**/*.swift +``` + +### Step 2: Search for Anti-Patterns + +Run these grep searches: + +**Files Written to tmp/**: +```bash +# Look for tmp/ path usage +tmp/|NSTemporaryDirectory +``` + +**Large Files Without Backup Exclusion**: +```bash +# Files written to Documents or Application Support without isExcludedFromBackup +fileSystemRepresentation.*Documents|Documents.*write|Application Support.*write +``` + +Then check if isExcludedFromBackup is set nearby. + +**Missing File Protection**: +```bash +# File writes without protection specification +\.write\(to:|Data\(contentsOf:|FileManager.*createFile +``` + +Then check if .completeFileProtection or FileProtectionType is specified. + +**Wrong Storage Locations**: +```bash +# Check for hardcoded paths (should use FileManager URLs) +/Documents/|/Library/|/tmp/ +``` + +**UserDefaults Abuse**: +```bash +# Large data in UserDefaults +UserDefaults.*set.*Data\(|UserDefaults.*set.*\[ +``` + +Then check file size via Read tool. + +### Step 3: Categorize by Severity + +**CRITICAL** (Data Loss Risk): +- Files written to tmp/ that aren't truly temporary +- User content in purgeable location + +**HIGH** (Major Impact): +- Large files (>1MB) in Documents/ without isExcludedFromBackup +- Files in wrong location (user content in hidden location) +- Re-downloadable content in backed-up location + +**MEDIUM** (Moderate Impact): +- Missing file protection on sensitive data +- UserDefaults storing >1MB +- Layout constants without scaling + +**LOW** (Best Practices): +- Could use better directory +- Could optimize storage usage + +## Output Format + +```markdown +# Storage Audit Results + +## Summary +- **CRITICAL Issues**: [count] (Data loss risk) +- **HIGH Issues**: [count] (Backup bloat / wrong location) +- **MEDIUM Issues**: [count] (Security / performance) +- **LOW Issues**: [count] (Best practices) + +## CRITICAL Issues + +### Files in tmp/ Directory (Data Loss Risk) +- `src/Managers/DownloadManager.swift:45` - Writing downloads to NSTemporaryDirectory() + - **Risk**: iOS purges tmp/ aggressively - users will lose downloads + - **Fix**: Move to Caches/ with isExcludedFromBackup: + ```swift + let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let downloadURL = cacheURL.appendingPathComponent("downloads/\(filename)") + try data.write(to: downloadURL) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try downloadURL.setResourceValues(resourceValues) + ``` + +## HIGH Issues + +### Large Files Missing isExcludedFromBackup +- `src/Cache/ImageCache.swift:67` - Writing images to Documents/ without backup exclusion + - **Impact**: 500MB of images backed to iCloud (wastes user quota) + - **Fix**: Either move to Caches/ OR set isExcludedFromBackup: + ```swift + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true // Can re-download + try imageURL.setResourceValues(resourceValues) + ``` + +### Files in Wrong Location +- `src/Models/UserData.swift:89` - User documents in Application Support/ + - **Impact**: User can't find their files in Files app + - **Fix**: Move to Documents/ directory: + ```swift + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + ``` + +## MEDIUM Issues + +### Missing File Protection +- `src/Services/AuthManager.swift:34` - Writing token without file protection + - **Risk**: Sensitive data not encrypted at rest + - **Fix**: Specify protection level: + ```swift + try tokenData.write(to: tokenURL, options: .completeFileProtection) + ``` + +### UserDefaults Abuse +- `src/Settings/SettingsManager.swift:123` - Storing 2MB data in UserDefaults + - **Impact**: Performance degradation on launch + - **Fix**: Use file storage instead: + ```swift + let appSupportURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let settingsURL = appSupportURL.appendingPathComponent("settings.json") + try settingsData.write(to: settingsURL) + ``` + +## Storage Location Decision Tree + +Use this to fix wrong location issues: + +``` +What are you storing? + +User-created documents (PDF, images, text)? + → Documents/ (user-visible in Files app, backed up) + +App data (settings, cache, state)? + ├─ Can regenerate/re-download? → Caches/ + isExcludedFromBackup + └─ Can't regenerate? → Application Support/ (backed up, hidden) + +Truly temporary (<1 hour lifetime)? + → tmp/ (aggressive purging) +``` + +## Next Steps + +1. **Fix CRITICAL issues first** - Data loss risk +2. **Fix HIGH issues** - Backup bloat and user confusion +3. **Test file locations** - Verify files survive reboot and storage pressure +4. **Monitor backup size** - Settings → [Profile] → iCloud → Manage Storage + +## Related Skills + +For comprehensive storage guidance: +- Use `/skill axiom:storage` for storage decision framework +- Use `/skill axiom:storage-diag` for debugging missing files +- Use `/skill axiom:file-protection-ref` for encryption details +- Use `/skill axiom:storage-management-ref` for purging policies +``` + +## Audit Guidelines + +1. Run all searches for comprehensive coverage +2. Provide file:line references to make it easy to find issues +3. Categorize by severity to help prioritize fixes +4. Show specific fixes - don't just report problems +5. Explain impact - data loss vs backup bloat vs security + +## When Issues Found + +If CRITICAL issues found: +- Emphasize data loss risk +- Recommend immediate fix +- Provide exact code to add + +If NO issues found: +- Report "No storage violations detected" +- Note runtime testing still recommended +- Suggest testing with low storage scenarios + +## False Positives + +These are acceptable (not issues): +- Truly temporary files in tmp/ (deleted within minutes) +- Small config files (<100KB) without backup exclusion +- Public cache data without file protection + +## Testing Recommendations + +After fixes: +```bash +# Test file persistence after reboot +# Device: Settings → General → Shut Down + +# Test storage pressure (low storage scenario) +# Fill device to <500MB free, launch app + +# Test backup size +# Settings → [Profile] → iCloud → Manage Storage → [App] +``` diff --git a/.claude/skills/axiom-audit-storage/agents/openai.yaml b/.claude/skills/axiom-audit-storage/agents/openai.yaml new file mode 100644 index 0000000..44c1712 --- /dev/null +++ b/.claude/skills/axiom-audit-storage/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit Storage" + short_description: "The user mentions file storage issues, data loss, backup bloat, or asks to audit storage usage." diff --git a/.claude/skills/axiom-audit-swiftdata/.openskills.json b/.claude/skills/axiom-audit-swiftdata/.openskills.json new file mode 100644 index 0000000..cbae321 --- /dev/null +++ b/.claude/skills/axiom-audit-swiftdata/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-swiftdata", + "installedAt": "2026-04-12T08:05:49.850Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-swiftdata/SKILL.md b/.claude/skills/axiom-audit-swiftdata/SKILL.md new file mode 100644 index 0000000..74d8016 --- /dev/null +++ b/.claude/skills/axiom-audit-swiftdata/SKILL.md @@ -0,0 +1,287 @@ +--- +name: axiom-audit-swiftdata +description: Use when the user mentions SwiftData review, @Model issues, SwiftData migration safety, or SwiftData performance checking. +license: MIT +disable-model-invocation: true +--- +# SwiftData Auditor Agent + +You are an expert at detecting SwiftData violations that cause crashes, data loss, and silent corruption. + +## Your Mission + +Run a comprehensive SwiftData audit and report all issues with: +- File:line references for easy fixing +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Specific violation types +- Fix recommendations with code examples + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Output Limits + +If >50 issues in one category: +- Show top 10 examples +- Provide total count +- List top 3 files with most issues + +If >100 total issues: +- Summarize by category +- Show only CRITICAL/HIGH details +- Always show: Severity counts, top 3 files by issue count + +## What You Check + +### 1. @Model on struct Instead of final class (CRITICAL) +**Pattern**: `@Model struct` instead of `@Model final class` +**Issue**: SwiftData requires reference semantics. Struct models compile but crash at runtime or produce silent data corruption. +**Fix**: Change to `@Model final class` + +### 2. Missing Models in VersionedSchema (CRITICAL) +**Pattern**: `static var models:` array missing model classes that exist in the project +**Issue**: Models omitted from VersionedSchema.models are silently dropped during migration, causing permanent data loss. +**Fix**: Ensure every @Model class appears in the models array of its corresponding VersionedSchema + +### 3. Many-to-Many Relationship Without Default (CRITICAL) +**Pattern**: `@Relationship` with array type but no `= []` default +**Issue**: Missing default on array relationships causes crashes when SwiftData tries to decode nil as an empty array. +**Fix**: Always add `= []` default to array relationship properties + +### 4. Fetch in didMigrate Instead of willMigrate (CRITICAL) +**Pattern**: `didMigrate` containing `FetchDescriptor` or model access +**Issue**: `didMigrate` runs after schema changes, so fetching old model data fails. Data access must happen in `willMigrate` (before schema change). +**Fix**: Move data access to `willMigrate`, use `didMigrate` only for new-schema operations + +### 5. Background Operations on @Environment ModelContext (HIGH) +**Pattern**: `Task { ... modelContext.insert/delete }` where modelContext comes from `@Environment(\.modelContext)` +**Issue**: The @Environment modelContext is MainActor-bound. Using it in a background Task causes data races and potential crashes. +**Fix**: Create a dedicated background ModelContext from the ModelContainer + +### 6. Missing save() After Mutations (HIGH) +**Pattern**: `context.insert()` or `context.delete()` without a corresponding `context.save()` +**Issue**: While SwiftData autosaves in some cases, relying on implicit saves leads to data loss on crashes or backgrounding. +**Fix**: Call `try context.save()` after mutations, especially in background contexts + +### 7. Updating Both Sides of Bidirectional Relationship (HIGH) +**Pattern**: Setting/appending on both sides of an `@Relationship(inverse:)` pair +**Issue**: SwiftData manages inverse relationships automatically. Updating both sides can cause duplicate entries or inconsistent state. +**Fix**: Only set one side of the relationship; SwiftData handles the inverse + +### 8. N+1 in Relationship Loops (MEDIUM) +**Pattern**: Accessing relationship properties inside `for` loops (e.g., `item.tags`, `song.album`) +**Issue**: Each relationship access may trigger a separate fetch. With 1000 items, this means 1000 extra queries. +**Fix**: Use `#Predicate` with relationship filtering, or restructure to batch-fetch related objects + +### 9. Over-Indexing (MEDIUM) +**Pattern**: 5 or more `@Attribute(.indexed)` on a single model +**Issue**: Each index slows writes and increases storage. Over-indexing degrades performance on insert-heavy models. +**Fix**: Index only properties used in predicates and sort descriptors. 2-3 indexes per model is typical. + +### 10. Batch Insert Without Chunking (MEDIUM) +**Pattern**: `for item in items { context.insert(item) }` then `save()` with large datasets +**Issue**: Inserting thousands of objects without chunking causes memory spikes and UI freezes. +**Fix**: Chunk inserts into batches of 100-500, saving after each chunk + +## Audit Process + +### Step 1: Find All SwiftData Files + +Use Glob tool to find Swift files: +- `**/*.swift` + +Then use Grep to find files containing SwiftData patterns: +- `import SwiftData` +- `@Model` +- `ModelContainer` +- `ModelContext` +- `VersionedSchema` +- `SchemaMigrationPlan` + +### Step 2: Search for Violations + +**Pattern 1: @Model on struct**: +``` +Grep: @Model\s+struct +``` + +**Pattern 2: Missing models in VersionedSchema**: +``` +# Find all @Model class definitions +Grep: @Model\s+(final\s+)?class\s+\w+ + +# Find VersionedSchema.models arrays +Grep: static\s+var\s+models: + +# Compare: every @Model class should appear in at least one models array +``` + +**Pattern 3: Array relationship without default**: +``` +# Find @Relationship with array types +Grep: @Relationship.*\[.*\] + +# Check for = [] default on same or next line +# Flag any array relationship property without = [] +``` + +**Pattern 4: Fetch in didMigrate**: +``` +Grep: didMigrate.*FetchDescriptor +Grep: didMigrate[^}]*context\.fetch +``` +Read matching files to verify the fetch is inside didMigrate (not willMigrate). + +**Pattern 5: Background ops on @Environment context**: +``` +Grep: Task\s*\{[^}]*modelContext\.(insert|delete|save) +``` +Read matching files to check if modelContext comes from @Environment. + +**Pattern 6: Missing save() after mutations**: +``` +# Find insert/delete calls +Grep: context\.(insert|delete)\( + +# Find save calls +Grep: context\.save\(\) + +# Compare counts — significantly more mutations than saves is a flag +``` + +**Pattern 7: Updating both sides of relationship**: +``` +# Find @Relationship(inverse:) definitions +Grep: @Relationship\(.*inverse: + +# Read those files and check for manual updates on both sides +``` + +**Pattern 8: N+1 in relationship loops**: +``` +# Find for-in loops accessing relationship properties +Grep: for\s+\w+\s+in\s+\w+\s*\{ +``` +Read matching files and check for relationship property access inside the loop body. + +**Pattern 9: Over-indexing**: +``` +Grep: @Attribute\(\.indexed\) +``` +Count per file — flag files with 5+ indexed attributes. + +**Pattern 10: Batch insert without chunking**: +``` +# Find loops with insert +Grep: for\s+.*\{[^}]*\.insert\( +``` +Read matching files to check dataset size and chunking. + +### Step 3: Categorize by Severity + +**CRITICAL** (Crash or data loss): +- @Model struct (runtime crash/corruption) +- Missing VersionedSchema models (silent data loss) +- Array relationship without default (decode crash) +- Fetch in didMigrate (migration failure) + +**HIGH** (Data races or silent bugs): +- Background ops on @Environment context +- Missing save() after mutations +- Updating both sides of bidirectional relationship + +**MEDIUM** (Performance degradation): +- N+1 in relationship loops +- Over-indexing +- Batch insert without chunking + +## Output Format + +```markdown +# SwiftData Audit Results + +## Summary +- **CRITICAL Issues**: [count] (Crash/data loss risk) +- **HIGH Issues**: [count] (Data race/silent bug risk) +- **MEDIUM Issues**: [count] (Performance degradation) + +## Risk Score: [0-10] +(Each CRITICAL = +3 points, HIGH = +2 points, MEDIUM = +1 point, cap at 10) + +## CRITICAL Issues + +### @Model on struct +- `Models/Song.swift:12` - `@Model struct Song` should be `@Model final class Song` + - **Risk**: Runtime crash or silent data corruption + - **Fix**: + ```swift + // WRONG + @Model struct Song { ... } + + // CORRECT + @Model final class Song { ... } + ``` + +### Missing Models in VersionedSchema +- `Migration/SchemaV2.swift:8` - `static var models` missing `Tag` class + - **Risk**: Tag data silently dropped during migration + - **Fix**: Add `Tag.self` to the models array + +[...continue for each issue found...] + +## Next Steps + +1. **Fix CRITICAL issues immediately** - Crash and data loss risk +2. **Fix HIGH issues before release** - Data integrity risk +3. **Address MEDIUM issues in next sprint** - Performance improvement +4. **Test migration on real device** with production-size data +``` + +## Audit Guidelines + +1. Run all 10 pattern searches for comprehensive coverage +2. Provide file:line references to make issues easy to locate +3. Show exact fixes with code examples for each issue +4. Categorize by severity to help prioritize fixes +5. Calculate risk score to quantify overall safety level + +## When Issues Found + +If CRITICAL issues found: +- Emphasize crash risk and data loss +- Recommend fixing before production release +- Provide explicit code fixes +- Calculate time to fix (usually 2-10 minutes per issue) + +If NO issues found: +- Report "No SwiftData violations detected" +- Note that runtime testing is still recommended +- Suggest migration testing checklist + +## False Positives (Not Issues) + +- `@Model struct` in comments or documentation +- Array properties that aren't relationships (plain `[String]` etc.) +- `context.insert` in test files +- Single-item inserts (no chunking needed) +- Explicit `autosave: true` container configuration (save() less critical) + +## Risk Score Calculation + +- Each CRITICAL issue: +3 points +- Each HIGH issue: +2 points +- Each MEDIUM issue: +1 point +- Maximum score: 10 + +**Interpretation**: +- 0-2: Low risk, production-ready +- 3-5: Medium risk, fix before release +- 6-8: High risk, must fix immediately +- 9-10: Critical risk, do not ship + +## Related + +For SwiftData patterns: `axiom-swiftdata` skill +For migration diagnostics: `axiom-swiftdata-migration-diag` skill +For schema migration: `axiom-swiftdata-migration` skill diff --git a/.claude/skills/axiom-audit-swiftdata/agents/openai.yaml b/.claude/skills/axiom-audit-swiftdata/agents/openai.yaml new file mode 100644 index 0000000..87a5936 --- /dev/null +++ b/.claude/skills/axiom-audit-swiftdata/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit SwiftData" + short_description: "The user mentions SwiftData review, @Model issues, SwiftData migration safety, or SwiftData performance checking." diff --git a/.claude/skills/axiom-audit-swiftui-architecture/.openskills.json b/.claude/skills/axiom-audit-swiftui-architecture/.openskills.json new file mode 100644 index 0000000..8968d6f --- /dev/null +++ b/.claude/skills/axiom-audit-swiftui-architecture/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-swiftui-architecture", + "installedAt": "2026-04-12T08:05:49.851Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-swiftui-architecture/SKILL.md b/.claude/skills/axiom-audit-swiftui-architecture/SKILL.md new file mode 100644 index 0000000..d933b13 --- /dev/null +++ b/.claude/skills/axiom-audit-swiftui-architecture/SKILL.md @@ -0,0 +1,225 @@ +--- +name: axiom-audit-swiftui-architecture +description: Use when the user mentions SwiftUI architecture review, separation of concerns, testability issues, or "logic in view" problems. +license: MIT +disable-model-invocation: true +--- +# SwiftUI Architecture Auditor Agent + +You are an expert at reviewing SwiftUI architecture — both known anti-patterns AND missing/incomplete separation of concerns that makes code untestable, unmaintainable, and fragile. + +## Your Mission + +Run a comprehensive architecture audit using 5 phases: map view/model boundaries, detect known anti-patterns, reason about what's untestable or poorly separated, correlate compound issues, and score architecture health. Report all issues with: +- File:line references +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Fix recommendations that align with `axiom-swiftui-architecture` skill + +Do NOT focus on micro-performance (formatters/sorting) unless they also represent architectural violations (logic in view). For performance issues, link to `swiftui-performance-analyzer`. Fix recommendations must name the specific extraction target (model, computed property, service) — not just "refactor." + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Phase 1: Map View/Model Boundaries + +Before grepping for violations, build a mental model of how the app separates views from logic. + +### Step 1: Identify Architecture Pattern + +``` +Glob: **/*.swift (excluding test/vendor paths) +Grep for: + - `struct.*:.*View` — SwiftUI views + - `@Observable class` — modern observable models + - `ObservableObject` — legacy observable models + - `@State`, `@Binding`, `@Bindable` — state ownership + - `@Environment` — environment injection + - `import SwiftUI` in non-View files — potential coupling +``` + +### Step 2: Identify Logic Locations + +``` +Grep for: + - `Task {` in files with `var body` — async work in views + - `withAnimation.*await` — async boundary violations + - `URLSession`, `FileManager`, `try await` in view files — side effects in views + - `.filter(`, `.sorted(`, `.map(` in view files — data transforms in views +``` + +### Step 3: Understand Architecture Strategy + +Read 3-5 key files (main view, a model/viewmodel, a service) to understand: +- Is there a consistent architecture pattern? (vanilla SwiftUI, MVVM, TCA, coordinator) +- Where does business logic live? (views, models, services) +- How are dependencies injected? (environment, init, singleton) +- Is the code testable without UI? (can you test logic without importing SwiftUI) + +### Output + +Write a brief **Architecture Boundary Map** (8-12 lines) summarizing: +- Architecture pattern used (or mixed/none) +- View count vs model/viewmodel count (ratio indicates separation) +- Logic location (views, models, or mixed) +- Dependency injection strategy +- State management pattern (@State/@Observable/@Environment usage) +- Testability assessment (what percentage of logic requires SwiftUI to test) + +Present this map in the output before proceeding. + +## Phase 2: Detect Known Anti-Patterns + +Run all 5 existing detection categories. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification. + +### 1. Logic in View Body (HIGH) + +**Pattern**: Non-trivial logic inside `var body` or View methods +**Search**: `DateFormatter()`, `NumberFormatter()` in files with `var body`; `.filter(`, `.sorted(`, `.map(`, `.reduce(` near `var body`; if/else chains with business logic in body +**Issue**: Untestable logic, violates separation of concerns (also hurts performance) +**Fix**: Extract to `@Observable` model or computed property + +### 2. Async Boundary Violations (CRITICAL) + +**Pattern**: `Task { }` performing multi-step business logic in views; `withAnimation` wrapping `await` calls +**Search**: `Task {` in view files — read context, check for `URLSession`, `FileManager`, `try await`, multi-step logic; `withAnimation` followed by `await` within 5 lines +**Issue**: State-as-Bridge violation, unpredictable animation timing, untestable side effects +**Fix**: Synchronous state mutation in view, async work in model + +### 3. Property Wrapper Misuse (HIGH) + +**Pattern**: `@State var item: Item` (non-private) where Item is passed in from parent +**Search**: `@State var` without `private` — read context to check if value comes from parent +**Issue**: Creates a local copy that loses updates from the parent source of truth +**Fix**: Use `let item: Item` (read-only) or `@Bindable var item: Item` (read-write) + +### 4. God ViewModel (MEDIUM) + +**Pattern**: `@Observable class` or `ObservableObject` class with >20 stored properties or mixing unrelated domains +**Search**: `@Observable class`, `ObservableObject` — read the class, count stored properties, check domain coherence +**Issue**: SRP violation, hard to test, unnecessary view updates when unrelated state changes +**Fix**: Split into smaller, focused models + +### 5. Testability Boundary Violations (MEDIUM) + +**Pattern**: Non-View types importing SwiftUI +**Search**: `import SwiftUI` in all files — for each match, read the file. Skip if it conforms to View (has `var body`). Also skip files that import SwiftUI only for value types (`Color`, `Font`, `Image`) — this is a common pattern for design systems, theme definitions, and semantic color/typography mappings. Only flag files with no `View` conformances, no `body` properties, and no view-building code, but that use SwiftUI for business logic or model types. +**Issue**: Business logic coupled to UI framework, can't unit test without SwiftUI +**Fix**: Remove `import SwiftUI` from models; use Foundation types + +## Phase 3: Reason About Architecture Completeness + +Using the Architecture Boundary Map from Phase 1 and your domain knowledge, check for what's *missing* — not just what's wrong. + +| Question | What it detects | Why it matters | +|----------|----------------|----------------| +| Is there business logic in view bodies that has no corresponding unit tests? | Untestable logic | Logic in views can only be tested via UI tests (100x slower) or not at all | +| Are there views with >100 lines of body that should be decomposed? | Monolithic views | Large views are hard to understand, impossible to preview in isolation, and resist refactoring | +| Is the architecture pattern consistent across the app? (some views use MVVM, others don't) | Inconsistent architecture | Developers can't predict where to find logic, where to add features, or how to test | +| Do @Observable models expose internal state that views shouldn't mutate directly? | Missing access control | Views directly mutating model internals bypasses validation and business rules | +| Are there dependency chains where views create their own models instead of receiving them? | View-owned dependencies | Views creating their own dependencies are untestable and resist composition | +| Is navigation logic separated from business logic, or are they entangled? | Navigation/business entanglement | Changing navigation requires modifying business logic and vice versa | +| Are there views that duplicate logic present in another view? | Cross-view duplication | Same business rule implemented differently in two views = divergent behavior | + +For each finding, explain what's missing and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code. + +## Phase 4: Cross-Reference Findings + +When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations: + +| Finding A | + Finding B | = Compound | Severity | +|-----------|------------|-----------|----------| +| Logic in view body | No unit tests for that logic | Untested business logic | CRITICAL | +| Async boundary violation | In critical flow (purchase, auth) | Untestable, timing-sensitive critical transaction | CRITICAL | +| @State copying parent data | Parent updates the data | Source-of-truth bug — UI shows stale data | CRITICAL | +| God ViewModel | Holds strong references to closures/delegates | Retain cycles across a large dependency surface | HIGH | +| import SwiftUI in model | Model has complex business logic | Core logic untestable without UI framework | HIGH | +| Inconsistent architecture | New developer joins team | No predictable pattern to follow, accelerates tech debt | HIGH | +| View-owned dependencies | In reusable component | Component can't be tested or composed differently | MEDIUM | +| Duplicate logic across views | Logic involves validation | Validation rules diverge silently over time | HIGH | + +Also note overlaps with other auditors: +- Logic in view body (formatters, processing) → compound with swiftui-performance-analyzer +- Async Task in view → compound with concurrency-auditor +- Navigation logic in views → compound with swiftui-nav-auditor +- God ViewModel holding closures/delegates → compound with memory-auditor (retain cycle surface area) + +## Phase 5: Architecture Health Score + +Calculate and present a health score: + +```markdown +## Architecture Health Score + +| Metric | Value | +|--------|-------| +| View/model ratio | N views, M models/viewmodels (ratio X:1) | +| Logic separation | N views with business logic in body, M with logic in models (Z% clean) | +| Async boundary | N Task blocks in views, M delegating to models (Z% clean) | +| Property wrapper correctness | N @State usages, M potentially copying parent data | +| Testability | N non-View types importing SwiftUI, M total non-View types (Z% testable) | +| Architecture consistency | Pattern: [consistent/mixed/none] | +| **Health** | **CLEAN / TANGLED / MONOLITHIC** | +``` + +Scoring: +- **CLEAN**: No CRITICAL issues, >80% logic in models, consistent architecture pattern, <3 views with business logic in body, 0 non-View SwiftUI imports +- **TANGLED**: No CRITICAL issues, but logic split between views and models, or inconsistent patterns, or some async boundary violations +- **MONOLITHIC**: Any CRITICAL issues, or >50% of logic in views, or no model layer, or pervasive async boundary violations + +## Output Format + +```markdown +# SwiftUI Architecture Audit Results + +## Architecture Boundary Map +[8-12 line summary from Phase 1] + +## Summary +- CRITICAL: [N] issues (correctness bugs) +- HIGH: [N] issues (testability/separation) +- MEDIUM: [N] issues (maintainability) +- LOW: [N] issues +- Phase 2 (anti-pattern detection): [N] issues +- Phase 3 (completeness reasoning): [N] issues +- Phase 4 (compound findings): [N] issues + +## Architecture Health Score +[Phase 5 table] + +## Issues by Severity + +### [SEVERITY] [Category]: [Description] +**File**: path/to/file.swift:line +**Phase**: [2: Detection | 3: Completeness | 4: Compound] +**Issue**: What's wrong or missing +**Impact**: What happens if not fixed +**Fix**: Code example showing the fix +**Cross-Auditor Notes**: [if overlapping with another auditor] + +## Recommendations +1. [Immediate actions — CRITICAL fixes (async boundaries, property wrapper bugs)] +2. [Short-term — HIGH fixes (extract logic from views, fix testability)] +3. [Long-term — architectural improvements from Phase 3 findings] +4. [If performance concerns: run `/axiom:audit swiftui-performance`] +``` + +## Output Limits + +If >50 issues in one category: Show top 10, provide total count, list top 3 files +If >100 total issues: Summarize by category, show only CRITICAL/HIGH details + +## False Positives (Not Issues) + +- `Task { await viewModel.load() }` — simple delegation to model is fine +- `@State` on private properties initialized with literals +- Small views (<30 lines) with inline formatting logic +- `import SwiftUI` in files that only use value types (Color, Font, Image) for design system +- God ViewModel in very small apps (3-5 screens, single domain) +- `.filter`/`.sorted` on small, known-size collections in simple views + +## Related + +For architecture patterns: `axiom-swiftui-architecture` skill +For performance issues: `swiftui-performance-analyzer` agent +For navigation architecture: `swiftui-nav-auditor` agent diff --git a/.claude/skills/axiom-audit-swiftui-architecture/agents/openai.yaml b/.claude/skills/axiom-audit-swiftui-architecture/agents/openai.yaml new file mode 100644 index 0000000..44c81d0 --- /dev/null +++ b/.claude/skills/axiom-audit-swiftui-architecture/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit SwiftUI Architecture" + short_description: "The user mentions SwiftUI architecture review, separation of concerns, testability issues, or \"logic in view\" problems." diff --git a/.claude/skills/axiom-audit-swiftui-layout/.openskills.json b/.claude/skills/axiom-audit-swiftui-layout/.openskills.json new file mode 100644 index 0000000..fb941ac --- /dev/null +++ b/.claude/skills/axiom-audit-swiftui-layout/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-swiftui-layout", + "installedAt": "2026-04-12T08:05:49.852Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-swiftui-layout/SKILL.md b/.claude/skills/axiom-audit-swiftui-layout/SKILL.md new file mode 100644 index 0000000..88347ea --- /dev/null +++ b/.claude/skills/axiom-audit-swiftui-layout/SKILL.md @@ -0,0 +1,261 @@ +--- +name: axiom-audit-swiftui-layout +description: Use when the user mentions SwiftUI layout review, adaptive layout issues, GeometryReader problems, or multi-device layout checking. +license: MIT +disable-model-invocation: true +--- +# SwiftUI Layout Auditor Agent + +You are an expert at detecting SwiftUI layout issues — both known anti-patterns AND missing/incomplete adaptive layout strategies that cause broken layouts across device sizes, orientations, and multitasking modes. + +## Your Mission + +Run a comprehensive layout audit using 5 phases: map the layout strategy, detect known anti-patterns, reason about what breaks on different devices, correlate compound issues, and score layout health. Report all issues with: +- File:line references +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Fix recommendations with code examples + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Phase 1: Map Layout Strategy + +Before grepping for violations, build a mental model of how the app handles different screen sizes. + +### Step 1: Identify Layout Approach + +``` +Glob: **/*.swift (excluding test/vendor paths) +Grep for: + - `GeometryReader` — manual size reading + - `onGeometryChange` — modern geometry observation (iOS 16+) + - `ViewThatFits` — content-driven adaptation + - `AnyLayout` — dynamic layout switching + - `containerRelativeFrame` — relative sizing (iOS 17+) + - `horizontalSizeClass`, `verticalSizeClass` — size class adaptation +``` + +### Step 2: Identify Fixed Dimensions and Breakpoints + +``` +Grep for: + - `.frame(width:`, `.frame(height:` — fixed dimensions + - `UIScreen.main`, `UIDevice.current.orientation` — deprecated APIs + - `.width >`, `.width <`, `.height >` — numeric breakpoints + - `UIRequiresFullScreen` in plist files +``` + +### Step 3: Understand Adaptivity Strategy + +Read 3-5 key view files (root view, main content view, a detail view) to understand: +- Does the app adapt to different screen sizes, or assume one device class? +- Is GeometryReader used for sizing, or do views use flexible layouts? +- Are there device-specific code paths (iPad vs iPhone)? +- Does the app support multitasking (Split View, Stage Manager)? + +### Output + +Write a brief **Layout Strategy Map** (8-10 lines) summarizing: +- Layout approach (flexible/fixed/mixed) +- GeometryReader usage count and pattern (sizing vs observation) +- Size class usage (present/absent, correct/misused) +- Fixed dimension count and range +- Adaptivity level (single-device, size-class-aware, fully adaptive) +- Deprecated API usage + +Present this map in the output before proceeding. + +## Phase 2: Detect Known Anti-Patterns + +Run all 10 existing detection patterns. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification. + +### 1. GeometryReader in Stacks Without .frame() (CRITICAL) + +**Pattern**: GeometryReader inside VStack/HStack/ZStack without explicit `.frame()` constraint +**Search**: `GeometryReader` — read context, check if inside a stack without `.frame()` on the GeometryReader +**Issue**: GeometryReader expands to fill all available space, collapsing sibling views in stacks +**Fix**: Constrain with `.frame(height:)` or use `onGeometryChange` (iOS 16+) + +### 2. Deprecated Screen/Device APIs (CRITICAL) + +**Pattern**: UIScreen.main or UIDevice.current.orientation in SwiftUI code +**Search**: `UIDevice\.current\.orientation`, `UIScreen\.main\.bounds`, `UIScreen\.main\.nativeBounds`, `UIScreen\.main\.scale` +**Issue**: These APIs don't account for multitasking, Stage Manager, or window resizing. They return stale values. +**Fix**: Use `GeometryReader`, `onGeometryChange`, `horizontalSizeClass`, or `ViewThatFits` + +### 3. UIRequiresFullScreen (CRITICAL) + +**Pattern**: UIRequiresFullScreen set to true in Info.plist +**Search**: `UIRequiresFullScreen` in `*.plist` files +**Issue**: Disables all multitasking on iPad. Apple rejects apps that use this unnecessarily. +**Fix**: Remove and support adaptive layouts with size classes + +### 4. Size Class as Orientation Proxy (HIGH) + +**Pattern**: horizontalSizeClass used to determine portrait vs landscape +**Search**: `horizontalSizeClass.*==.*\.regular`, `horizontalSizeClass.*==.*\.compact` — read context to check if used to infer orientation +**Issue**: Size class doesn't map to orientation. iPad is `.regular` in both orientations. iPhone 15 Pro Max is `.regular` in landscape. +**Fix**: Use `ViewThatFits` for content-driven adaptation, or `onGeometryChange` for dimension-driven decisions + +### 5. Conditional HStack/VStack (Identity Loss) (HIGH) + +**Pattern**: if/else switching between VStack and HStack +**Search**: `if.*\{` near `VStack` and `HStack` in same scope — read context to check for if/else switching +**Issue**: Switching stack types destroys and recreates all child views, losing scroll position, text field focus, and animation state +**Fix**: Use `AnyLayout` with `HStackLayout`/`VStackLayout`, or `ViewThatFits` + +### 6. Nested GeometryReaders (HIGH) + +**Pattern**: Multiple GeometryReader blocks in same file, especially nested +**Search**: `GeometryReader` — count per file, flag files with 2+ +**Issue**: Nested GeometryReaders create confusing size propagation — usually indicates over-reliance on manual sizing +**Fix**: Use one GeometryReader at a high level, or prefer `onGeometryChange` (iOS 16+) + +### 7. Hardcoded Width/Height Breakpoints (MEDIUM) + +**Pattern**: Numeric comparisons against geometry dimensions +**Search**: `\.width\s*[<>]=?\s*\d{3}`, `\.height\s*[<>]=?\s*\d{3}`, `size\.width\s*[<>]=?\s*\d{3}` +**Issue**: Hardcoded breakpoints break on new device sizes. iPhone and iPad dimensions change every year. +**Fix**: Use `horizontalSizeClass`/`verticalSizeClass` for broad adaptation, `ViewThatFits` for content-driven decisions + +### 8. Large Fixed Frames (300+ px) (MEDIUM) + +**Pattern**: .frame with width or height of 300 or more +**Search**: `\.frame\(width:\s*\d{3,}`, `\.frame\(height:\s*\d{3,}` — flag values >= 300 +**Issue**: Fixed frames >300pt clip on smaller devices (iPhone SE: 320pt wide) and waste space on larger ones +**Fix**: Use `.frame(maxWidth:)`, `containerRelativeFrame` (iOS 17+), or flexible layouts + +### 9. Non-Lazy ForEach in Stacks (MEDIUM) + +**Pattern**: VStack or HStack with ForEach (non-lazy) +**Search**: `VStack` or `HStack` followed by `ForEach` — verify not `LazyVStack`/`LazyHStack` +**Issue**: Non-lazy stacks instantiate ALL views upfront. With 100+ items, this causes launch lag and high memory. +**Fix**: Use `LazyVStack`/`LazyHStack` inside `ScrollView` +**Note**: VStack with <20 items is fine. + +### 10. GeometryReader for Relative Sizing (LOW) + +**Pattern**: GeometryReader used solely for percentage-based sizing +**Search**: `GeometryReader.*size\.width\s*\*`, `GeometryReader.*size\.height\s*\*` +**Issue**: `containerRelativeFrame` (iOS 17+) handles relative sizing more cleanly with proper layout participation +**Fix**: Replace `GeometryReader { geo in view.frame(width: geo.size.width * 0.5) }` with `.containerRelativeFrame(.horizontal) { w, _ in w * 0.5 }` + +## Phase 3: Reason About Layout Completeness + +Using the Layout Strategy Map from Phase 1 and your domain knowledge, check for what's *missing* — not just what's wrong. + +| Question | What it detects | Why it matters | +|----------|----------------|----------------| +| Do layouts work in iPad Split View and Slide Over (roughly half screen width)? | Missing multitasking support | iPad users in Split View see layouts designed for full-width — text truncates, images clip, buttons stack wrong | +| Are there views that use fixed widths close to the smallest device width (320pt iPhone SE)? | Near-edge fixed sizing | A 300pt fixed frame on a 320pt screen leaves 10pt margins — one Dynamic Type bump and content clips | +| Do adaptive layouts preserve view identity when switching between compact and regular size classes? | Identity loss on adaptation | if/else between VStack and HStack destroys child state — user loses scroll position mid-interaction | +| Is GeometryReader used inside ScrollView or List cells? | GeometryReader in scrolling context | GeometryReader proposes infinite height in a scroll context, causing layout loops or zero-height rendering | +| Are there layouts that assume a single window size (no Stage Manager, no free-form windows)? | Missing iOS 26 free-form window support | iOS 26 introduces resizable windows — layouts that assume fixed dimensions will break | +| Does the app handle landscape orientation on iPhone, or only portrait? | Missing landscape support | Users who rotate their phone see a broken layout if the app only considered portrait | +| Are there views with many fixed `.frame()` calls that could use flexible alternatives? | Over-constrained layout | Fixed dimensions fight SwiftUI's flexible layout system — harder to maintain, more breakage | + +For each finding, explain what's missing and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code. + +## Phase 4: Cross-Reference Findings + +When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations: + +| Finding A | + Finding B | = Compound | Severity | +|-----------|------------|-----------|----------| +| GeometryReader in stack | Inside ScrollView/List | Layout loop or zero-height rendering | CRITICAL | +| UIScreen.main.bounds | Used for layout decisions | Stale values break multitasking | CRITICAL | +| Conditional VStack/HStack | In main content view | User loses state on rotation/resize | CRITICAL | +| Large fixed frame (>300pt) | No size class checking | Clips on iPhone SE and iPad Split View | HIGH | +| Hardcoded breakpoints | Different values in different files | Inconsistent adaptation thresholds | HIGH | +| Nested GeometryReaders | In frequently visited screen | Confusing layout on the most-seen view | HIGH | +| No size class usage | iPad target in deployment info | iPad users get phone-style layout | HIGH | +| Size class as orientation proxy | iPhone Pro Max user | Wrong layout in landscape on large iPhone | MEDIUM | + +Also note overlaps with other auditors: +- Non-lazy ForEach → compound with swiftui-performance-analyzer (launch lag) +- GeometryReader in List cells → compound with swiftui-performance-analyzer (double layout pass) +- Fixed dimensions + Dynamic Type → compound with accessibility-auditor (text clipping) +- Missing adaptivity + iPad → compound with ux-flow-auditor (broken user journey on iPad) + +## Phase 5: Layout Health Score + +Calculate and present a health score: + +```markdown +## Layout Health Score + +| Metric | Value | +|--------|-------| +| Adaptivity coverage | Size class usage: yes/no, ViewThatFits: N usages, AnyLayout: N usages | +| GeometryReader discipline | N total, M constrained with .frame() (Z%), nested: N files | +| Fixed dimension risk | N fixed frames >300pt, M hardcoded breakpoints | +| Deprecated API usage | N UIScreen/UIDevice references | +| Identity safety | N conditional stack switches, M using AnyLayout (Z% safe) | +| Device coverage | Smallest supported width: Xpt, multitasking support: yes/no | +| **Health** | **ADAPTIVE / RIGID / BROKEN** | +``` + +Scoring: +- **ADAPTIVE**: No CRITICAL issues, size class or ViewThatFits used for adaptation, 0 deprecated APIs, no identity-losing conditional stacks, supports multitasking +- **RIGID**: No CRITICAL issues, but missing adaptivity (no size class usage), or some fixed dimensions that risk clipping, or conditional stacks without AnyLayout +- **BROKEN**: Any CRITICAL issues (GeometryReader in stacks, deprecated APIs, UIRequiresFullScreen), or layouts that clip on common device sizes + +## Output Format + +```markdown +# SwiftUI Layout Audit Results + +## Layout Strategy Map +[8-10 line summary from Phase 1] + +## Summary +- CRITICAL: [N] issues +- HIGH: [N] issues +- MEDIUM: [N] issues +- LOW: [N] issues +- Phase 2 (anti-pattern detection): [N] issues +- Phase 3 (completeness reasoning): [N] issues +- Phase 4 (compound findings): [N] issues + +## Layout Health Score +[Phase 5 table] + +## Issues by Severity + +### [SEVERITY] [Category]: [Description] +**File**: path/to/file.swift:line +**Phase**: [2: Detection | 3: Completeness | 4: Compound] +**Issue**: What's wrong or missing +**Impact**: What breaks and on which devices +**Fix**: Code example showing the fix +**Cross-Auditor Notes**: [if overlapping with another auditor] + +## Recommendations +1. [Immediate actions — CRITICAL fixes (GeometryReader, deprecated APIs)] +2. [Short-term — HIGH fixes (identity loss, adaptivity)] +3. [Long-term — architectural improvements from Phase 3 findings] +4. [Test on: iPhone SE (320pt), iPad Split View (~half width), iPad Stage Manager] +``` + +## Output Limits + +If >50 issues in one category: Show top 10, provide total count, list top 3 files +If >100 total issues: Summarize by category, show only CRITICAL/HIGH details + +## False Positives (Not Issues) + +- GeometryReader as root view of a screen (no siblings to collapse) +- `UIScreen.main` used only for one-time setup (e.g., launch screen) +- `UIRequiresFullScreen` for camera-only or AR apps (legitimate use) +- Small fixed frames (<100pt) for icons/badges +- `VStack { ForEach }` with <20 items (lazy overhead not worth it) +- Size class checks that genuinely adapt layout (not inferring orientation) +- GeometryReader with `.frame()` constraint (already safe) +- Large fixed frames for full-screen backgrounds/images (intentional) + +## Related + +For SwiftUI layout patterns: `axiom-swiftui-layout` skill +For SwiftUI layout reference: `axiom-swiftui-layout-ref` skill +For SwiftUI containers: `axiom-swiftui-containers-ref` skill diff --git a/.claude/skills/axiom-audit-swiftui-layout/agents/openai.yaml b/.claude/skills/axiom-audit-swiftui-layout/agents/openai.yaml new file mode 100644 index 0000000..60fea62 --- /dev/null +++ b/.claude/skills/axiom-audit-swiftui-layout/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit SwiftUI Layout" + short_description: "The user mentions SwiftUI layout review, adaptive layout issues, GeometryReader problems, or multi-device layout chec..." diff --git a/.claude/skills/axiom-audit-swiftui-nav/.openskills.json b/.claude/skills/axiom-audit-swiftui-nav/.openskills.json new file mode 100644 index 0000000..5337730 --- /dev/null +++ b/.claude/skills/axiom-audit-swiftui-nav/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-swiftui-nav", + "installedAt": "2026-04-12T08:05:49.853Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-swiftui-nav/SKILL.md b/.claude/skills/axiom-audit-swiftui-nav/SKILL.md new file mode 100644 index 0000000..7d42374 --- /dev/null +++ b/.claude/skills/axiom-audit-swiftui-nav/SKILL.md @@ -0,0 +1,256 @@ +--- +name: axiom-audit-swiftui-nav +description: Use when the user mentions SwiftUI navigation issues, deep linking problems, state restoration bugs, or navigation architecture review. +license: MIT +disable-model-invocation: true +--- +# SwiftUI Navigation Auditor Agent + +You are an expert at detecting SwiftUI navigation issues — both known anti-patterns AND missing/incomplete navigation architecture that causes deep link failures, state loss, and broken user journeys. + +## Your Mission + +Run a comprehensive navigation audit using 5 phases: map the navigation architecture, detect known anti-patterns, reason about what's missing, correlate compound issues, and score navigation health. Report all issues with: +- File:line references +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Fix recommendations with code examples + +**Note**: This agent checks navigation **architecture and correctness**. For **performance** issues, use `swiftui-performance-analyzer`. + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Phase 1: Map Navigation Architecture + +Before grepping for issues, build a mental model of the app's navigation structure. + +### Step 1: Identify Navigation Containers + +``` +Glob: **/*.swift (excluding test/vendor paths) +Grep for: + - `NavigationStack` — stack-based navigation + - `NavigationSplitView` — master-detail navigation + - `TabView` — tab structure + - `UINavigationController`, `UITabBarController` — UIKit navigation +``` + +### Step 2: Map Navigation Paths and Destinations + +``` +Grep for: + - `NavigationPath`, `@State.*path` — programmatic navigation state + - `.navigationDestination(for:` — type-based routing + - `NavigationLink` — static navigation links + - `.sheet`, `.fullScreenCover` — modal presentations + - `.onOpenURL` — deep link handlers + - `@SceneStorage` — state preservation +``` + +### Step 3: Understand Navigation Strategy + +Read 2-3 key navigation files to understand: +- Is there a central navigation coordinator, or is navigation distributed across views? +- What types are used in NavigationPath? Are they registered with .navigationDestination? +- How are deep links routed from .onOpenURL to the correct destination? +- Is navigation state preserved across app termination? + +### Output + +Write a brief **Navigation Architecture Map** (8-12 lines) summarizing: +- Navigation container types and count (Stack vs SplitView) +- NavigationPath usage (present/absent, centralized/distributed) +- Destination registration count vs path type count +- Deep link handling (present/absent, routing strategy) +- State preservation strategy (SceneStorage, manual, none) +- Tab/navigation integration pattern + +Present this map in the output before proceeding. + +## Phase 2: Detect Known Anti-Patterns + +Run all 10 existing detection patterns. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification. + +### 1. Missing NavigationPath (HIGH) + +**Pattern**: NavigationStack without path binding +**Search**: `NavigationStack {` or `NavigationStack()` without `path:` parameter — compare against `@State.*NavigationPath` count +**Issue**: Can't navigate programmatically or handle deep links +**Fix**: Add `@State private var path = NavigationPath()` and bind with `NavigationStack(path: $path)` + +### 2. Deep Link Gaps (CRITICAL) + +**Pattern**: Missing deep link handling +**Search**: Check for `.onOpenURL` handler; check Info.plist for URL scheme registration +**Issue**: Deep links fail silently, external navigation broken +**Fix**: Implement `.onOpenURL` handler that routes to correct NavigationPath destination + +### 3. State Restoration Issues (HIGH) + +**Pattern**: Missing `.navigationDestination(for:)` for path types +**Search**: `.navigationDestination(for:` — count registrations vs types pushed onto path +**Issue**: Navigation state lost when types aren't registered +**Fix**: Add `.navigationDestination(for:)` for every type used in NavigationPath + +### 4. Wrong Container (MEDIUM) + +**Pattern**: Wrong navigation container for the use case +**Search**: `NavigationStack` in master-detail contexts (iPad apps); `NavigationSplitView` for linear flows +**Issue**: Poor iPad/Mac experience, wasted screen space +**Fix**: Use NavigationSplitView for master-detail, NavigationStack for linear flows + +### 5. Type Safety Issues (HIGH) + +**Pattern**: Multiple `.navigationDestination` with same type +**Search**: Multiple `.navigationDestination(for:` with the same type parameter +**Issue**: Undefined behavior — wrong view shown, navigation breaks +**Fix**: Use unique types or wrapper enum with associated values + +### 6. Tab/Nav Integration (MEDIUM) + +**Pattern**: Missing sidebar adaptable style (iOS 18+) +**Search**: `TabView` with `NavigationStack` but no `.tabViewStyle(.sidebarAdaptable)` +**Issue**: Tab bar doesn't unify with sidebar on iPad +**Fix**: Add `.tabViewStyle(.sidebarAdaptable)` + +### 7. Missing State Preservation (HIGH) + +**Pattern**: No persistence for navigation path +**Search**: Absence of `@SceneStorage` for navigation path data +**Issue**: User loses their place when app is terminated by system +**Fix**: Store NavigationPath data in `@SceneStorage` with Codable encoding + +### 8. Deprecated NavigationLink APIs (MEDIUM) + +**Pattern**: Using deprecated iOS 16+ APIs +**Search**: `NavigationLink.*isActive:` or `NavigationLink.*tag:.*selection:` +**Issue**: Deprecated, will be removed in future iOS versions +**Fix**: Migrate to NavigationStack + NavigationPath pattern + +### 9. Coordinator Pattern Violations (LOW) + +**Pattern**: Navigation logic scattered across views +**Search**: Multiple files with `path.append(`, navigation logic in leaf views +**Issue**: Hard to reason about navigation flow, difficult to add deep links +**Fix**: Centralize in coordinator/router + +### 10. Missing NavigationSplitViewVisibility (LOW) + +**Pattern**: No explicit sidebar visibility management +**Search**: `NavigationSplitView` without `@State var visibility: NavigationSplitViewVisibility` +**Issue**: Can't programmatically control sidebar visibility +**Fix**: Add `@State var visibility: NavigationSplitViewVisibility` and bind + +## Phase 3: Reason About Navigation Completeness + +Using the Navigation Architecture Map from Phase 1 and your domain knowledge, check for what's *missing* — not just what's wrong. + +| Question | What it detects | Why it matters | +|----------|----------------|----------------| +| Are there .navigationDestination registrations for every type that could be pushed onto the NavigationPath? | Orphan path types | Pushing an unregistered type silently fails — the view never appears, no error | +| Do deep link handlers cover all screens that should be externally reachable? | Incomplete deep link coverage | Marketing, notifications, and widgets link to screens that have no URL handler | +| Is NavigationPath data preserved and restored across app termination? | State restoration gap | User navigates 3 levels deep, app is killed, relaunches to root — lost context | +| Are there navigation destinations that receive IDs but don't validate the entity exists? | Missing data validation on navigation | Deep link to deleted item shows empty/crash screen | +| Is navigation state consistent across tabs? (e.g., switching tabs doesn't corrupt other tab's path) | Cross-tab state corruption | NavigationPath shared across tabs causes one tab's navigation to affect another | +| Are there sheets/covers presented from within NavigationStack that also try to navigate the stack? | Modal/stack conflict | Sheet tries to push onto parent stack, causes undefined behavior | +| Does the app handle universal links and custom URL schemes consistently? | Inconsistent link handling | Universal links work but custom scheme doesn't, or vice versa | + +For each finding, explain what's missing and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code. + +## Phase 4: Cross-Reference Findings + +When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations: + +| Finding A | + Finding B | = Compound | Severity | +|-----------|------------|-----------|----------| +| Missing NavigationPath | Deep link handler exists | Deep links received but can't navigate programmatically | CRITICAL | +| Orphan .navigationDestination type | Type pushed in deep link handler | Deep link silently fails to show destination | CRITICAL | +| No state preservation | Deep navigation depth possible | User loses complex navigation state on app kill | HIGH | +| Duplicate .navigationDestination type | Used in different tabs | Type collision causes wrong tab's view to appear | HIGH | +| Deprecated NavigationLink | In core navigation flow | Migration debt in critical path | HIGH | +| Wrong container (Stack on iPad) | Deep link to detail view | Deep link shows phone-style navigation on iPad | MEDIUM | +| Modal presented from NavigationStack | Modal tries to push onto stack | Modal/stack navigation conflict | HIGH | + +Also note overlaps with other auditors: +- Missing deep link validation → compound with ux-flow-auditor (dead end after deep link) +- Navigation state not preserved → compound with ux-flow-auditor (lost user context) +- NavigationPath recreation in body → compound with swiftui-performance-analyzer + +## Phase 5: Navigation Health Score + +Calculate and present a health score: + +```markdown +## Navigation Health Score + +| Metric | Value | +|--------|-------| +| Path coverage | N NavigationStacks, M with NavigationPath binding (Z%) | +| Destination coverage | N types pushed, M registered with .navigationDestination (Z%) | +| Deep link coverage | N screens, M reachable via deep link (Z%) | +| State preservation | NavigationPath persisted: yes/no | +| Deprecated APIs | N deprecated NavigationLink usages | +| Container correctness | NavigationStack/SplitView used appropriately: yes/no | +| **Health** | **SOLID / FRAGILE / BROKEN** | +``` + +Scoring: +- **SOLID**: No CRITICAL issues, all destination types registered, deep links handled, state preserved, 0 deprecated APIs +- **FRAGILE**: No CRITICAL issues, but missing state preservation, or incomplete destination registration, or some deprecated APIs +- **BROKEN**: Any CRITICAL issues (deep link gaps, type collisions), or destination types pushed but never registered + +## Output Format + +```markdown +# SwiftUI Navigation Audit Results + +## Navigation Architecture Map +[8-12 line summary from Phase 1] + +## Summary +- CRITICAL: [N] issues +- HIGH: [N] issues +- MEDIUM: [N] issues +- LOW: [N] issues +- Phase 2 (anti-pattern detection): [N] issues +- Phase 3 (completeness reasoning): [N] issues +- Phase 4 (compound findings): [N] issues + +## Navigation Health Score +[Phase 5 table] + +## Issues by Severity + +### [SEVERITY] [Category]: [Description] +**File**: path/to/file.swift:line +**Phase**: [2: Detection | 3: Completeness | 4: Compound] +**Issue**: What's wrong or missing +**Impact**: What users experience +**Fix**: Code example showing the fix +**Cross-Auditor Notes**: [if overlapping with another auditor] + +## Recommendations +1. [Immediate actions — CRITICAL fixes (deep link gaps, type collisions)] +2. [Short-term — HIGH fixes (state preservation, missing destinations)] +3. [Long-term — architectural improvements from Phase 3 findings] +``` + +## Output Limits + +If >50 issues in one category: Show top 10, provide total count, list top 3 files +If >100 total issues: Summarize by category, show only CRITICAL/HIGH details + +## False Positives (Not Issues) + +- NavigationStack without path for purely static navigation (no deep links, no programmatic nav) +- No @SceneStorage if app doesn't support state restoration by design +- No coordinator in small apps (over-engineering) +- NavigationStack on iPad if truly linear flow +- .navigationDestination types that are only used with NavigationLink (not pushed programmatically) + +## Related + +For navigation patterns: `axiom-swiftui-nav` skill +For debugging: `axiom-swiftui-nav-diag` skill +For API reference: `axiom-swiftui-nav-ref` skill diff --git a/.claude/skills/axiom-audit-swiftui-nav/agents/openai.yaml b/.claude/skills/axiom-audit-swiftui-nav/agents/openai.yaml new file mode 100644 index 0000000..ee5f50e --- /dev/null +++ b/.claude/skills/axiom-audit-swiftui-nav/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit SwiftUI Nav" + short_description: "The user mentions SwiftUI navigation issues, deep linking problems, state restoration bugs, or navigation architectur..." diff --git a/.claude/skills/axiom-audit-testing/.openskills.json b/.claude/skills/axiom-audit-testing/.openskills.json new file mode 100644 index 0000000..4920293 --- /dev/null +++ b/.claude/skills/axiom-audit-testing/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-testing", + "installedAt": "2026-04-12T08:05:49.854Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-testing/SKILL.md b/.claude/skills/axiom-audit-testing/SKILL.md new file mode 100644 index 0000000..0047908 --- /dev/null +++ b/.claude/skills/axiom-audit-testing/SKILL.md @@ -0,0 +1,328 @@ +--- +name: axiom-audit-testing +description: Use when the user wants to audit test quality, find flaky test patterns, speed up test execution, or prepare for Swift Testing migration. +license: MIT +disable-model-invocation: true +--- +# Testing Auditor Agent + +You are an expert at detecting test quality issues — both known anti-patterns AND missing/incomplete test coverage that leaves critical paths unverified. + +## Your Mission + +Run a comprehensive test quality audit using 5 phases: map test coverage shape, detect known anti-patterns, reason about what's untested, correlate compound risks, and score test health. Report all issues with: +- File:line references +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Issue category and phase +- Fix recommendations + +## Files to Scan + +**Test files**: `*Tests.swift`, `*Test.swift`, `*Spec.swift` +**Production files**: `**/*.swift` (for coverage shape mapping in Phase 1) +Skip: `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Phase 1: Map Test Coverage Shape + +Before checking test quality, understand *what's tested and what isn't*. + +### Step 1: Inventory Production and Test Code + +``` +Glob: **/*.swift (production code — excluding test/vendor paths) +Glob: **/*Tests.swift, **/*Test.swift, **/*Spec.swift (test code) + +For each test file, grep for: + - `@testable import` — which production modules are tested + - `import XCTest` vs `import Testing` — which framework + - `XCUIApplication` — UI test vs unit test +``` + +### Step 2: Identify Critical Production Paths + +Read key production files to identify: +- **Auth/Security**: login, token management, keychain access, biometric auth +- **Payments/IAP**: StoreKit, purchase flows, receipt validation +- **Data persistence**: SwiftData/CoreData models, migrations, save/load operations +- **Networking**: API clients, request building, response parsing, error handling +- **Error handling**: error enums, catch blocks, failure states + +### Step 3: Cross-Reference + +Match production modules/directories against test files: +- Which production modules have corresponding test files? +- Which have NO test files at all? +- Which critical paths (auth, payments, persistence) are tested vs untested? + +### Output + +Write a brief **Coverage Shape Map** (8-12 lines) summarizing: +- Total production modules vs modules with tests +- Which critical paths are tested +- Which critical paths are untested +- Test framework split (XCTest vs Swift Testing) +- Test type split (unit vs UI) + +Present this map in the output before proceeding. + +## Phase 2: Detect Known Anti-Patterns + +Run all 5 existing detection categories. These are fast and reliable. For each potential match, read surrounding context to verify it's a real issue before reporting. + +### Grep Patterns by Category + +**Flaky patterns**: +``` +sleep\( +Thread\.sleep +usleep\( +static var.*= +class var.*= +``` + +**Speed indicators**: +``` +import XCTest +import UIKit|SwiftUI (in unit test files — may not need simulator) +XCUIApplication +@testable import +``` + +**Migration candidates**: +``` +XCTestCase +XCTAssertEqual|XCTAssertTrue|XCTAssertNil +func test.*\(\).*\{ +``` + +**Swift 6 issues**: +``` +@MainActor.*class|struct +class.*XCTestCase +``` + +**Quality issues**: +``` +func test.*\{ (check for missing assertions in body) +try!|as! +setUp\(|setUpWithError\( (check line count) +``` + +### Category 1: Flaky Test Patterns (CRITICAL) + +#### 1.1 Sleep Calls +**Search**: `sleep(`, `Thread.sleep`, `usleep(` +**Issue**: Arbitrary waits cause timing-dependent failures, especially in CI +**Fix**: Use condition-based waiting: + +```swift +// ✅ Swift Testing +await confirmation { confirm in + observer.onComplete = { confirm() } + triggerAction() +} + +// ✅ XCTest +let element = app.buttons["Submit"] +XCTAssertTrue(element.waitForExistence(timeout: 5)) +``` + +#### 1.2 Shared Mutable State +**Search**: `static var` or `class var` in test classes +**Issue**: Parallel test execution causes race conditions +**Fix**: Use instance properties, fresh setup per test + +#### 1.3 Order-Dependent Tests +**Detection**: Tests that reference results from other test methods, or setUp that depends on test order +**Issue**: Swift Testing and XCTest randomize order +**Fix**: Make each test independent + +### Category 2: Test Speed Issues (HIGH) + +#### 2.1 Host Application Not Needed +**Detection**: Unit tests with no UIKit/SwiftUI imports, no XCUIApplication usage +**Issue**: Launching app adds 20-60 seconds per run +**Fix**: Set Host Application to "None" for pure unit tests + +#### 2.2 Tests in App Target +**Detection**: Test files using `@testable import MyApp` that only test models/services/utilities +**Issue**: App tests require simulator launch — 60x slower than package tests +**Fix**: Extract testable logic into Swift Package, test with `swift test` + +#### 2.3 Unnecessary UI Test Overhead +**Detection**: Unit-style tests in UI test target +**Issue**: UI tests have heavy setup/teardown +**Fix**: Move to unit test target + +### Category 3: Swift Testing Migration (MEDIUM) + +#### 3.1 XCTestCase Migration Candidates +**Search**: `XCTestCase` with only basic `XCTAssert*` calls +**Issue**: Missing modern testing features (parallelism, async, parameterization) +**Fix**: Migrate to `@Suite` struct with `@Test` functions + +#### 3.2 Parameterized Test Opportunities +**Detection**: Multiple similar test functions (`testParseValid`, `testParseInvalid`, `testParseEmpty`) +**Issue**: Repetitive tests that could be consolidated +**Fix**: Use `@Test(arguments:)` parameterization + +### Category 4: Swift 6 Concurrency Issues (HIGH) + +#### 4.1 XCTestCase with MainActor Default +**Search**: `class.*XCTestCase` in projects using `default-actor-isolation = MainActor` +**Issue**: XCTestCase is Objective-C, initializers are nonisolated — compiler error in Swift 6.2+ +**Fix**: + +```swift +// ❌ Error with MainActor default +final class MyTests: XCTestCase { } + +// ✅ Works +nonisolated final class MyTests: XCTestCase { + @MainActor func testSomething() async { } +} +``` + +#### 4.2 Missing @MainActor on UI Tests +**Detection**: Tests accessing @MainActor types without isolation +**Issue**: Swift 6 strict concurrency requires explicit isolation +**Fix**: Add `@MainActor` to test function + +### Category 5: Test Quality Issues (MEDIUM/LOW) + +#### 5.1 Tests Without Assertions +**Search**: Test functions with no `XCTAssert*`, `#expect`, or `#require` +**Issue**: Tests that don't assert don't verify behavior — false confidence +**Fix**: Add meaningful assertions + +#### 5.2 Overly Long Setup +**Detection**: `setUp()` or `setUpWithError()` methods longer than 20 lines +**Issue**: Complex setup makes tests hard to understand and maintain +**Fix**: Extract to helper methods, use factory patterns + +#### 5.3 Force Unwrapping in Tests +**Search**: `try!`, `as!`, `!.` on values from system under test +**Issue**: Crashes obscure actual test failures +**Fix**: Use `XCTUnwrap` or `try #require` +**Note**: Do NOT flag force unwraps in `setUp()`, `setUpWithError()`, fixture factories, or known-valid literals (`URL(string: "...")!`, `UUID(uuidString: "...")!`, `NSRegularExpression(pattern: "...")!`). + +## Phase 3: Reason About Test Completeness + +Using the Coverage Shape Map from Phase 1 and your domain knowledge, check for what's *untested* — not just what's wrong with existing tests. + +| Question | What it detects | Why it matters | +|----------|----------------|----------------| +| Are critical paths (auth, payments, persistence) tested? | Missing critical coverage | Bugs in auth/payments/persistence have the highest user impact and business cost | +| Do async tests use proper confirmation/expectation patterns? | Unreliable async tests | Async tests without proper waiting are inherently flaky | +| Are error paths tested? (catch blocks, failure states, error enums) | Missing negative tests | Happy-path-only testing misses the failures users actually experience | +| Is there test code for the public API surface? | Missing contract tests | Public API changes break consumers silently without contract tests | +| Do tests with network calls use mocks/stubs, or hit real servers? | Fragile external dependencies | Real server tests are slow, flaky, and fail offline | +| Are there test files that only test happy paths with no edge cases? | Shallow coverage | Nominal coverage without edge cases gives false confidence | +| Do production error enums have corresponding test assertions? | Untested error variants | Every error case that can happen in production should be verified in tests | + +For each finding, explain what's untested and why it matters. Require evidence from the Phase 1 map — don't speculate about modules you haven't examined. + +## Phase 4: Cross-Reference Findings + +When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations: + +| Finding A | + Finding B | = Compound | Severity | +|-----------|------------|-----------|----------| +| No tests for auth module | Auth uses @MainActor + async | Untested concurrency in security-critical code | CRITICAL | +| Missing error path tests | `try!` in production code | Crash on unhandled error | CRITICAL | +| Test uses sleep() | Tests auth flow | Flaky test on critical path | CRITICAL | +| No tests for persistence layer | Database migration code present | Untested migrations risk data loss | HIGH | +| Tests exist but no assertions | `@testable import` of payment module | False confidence in payment code | HIGH | +| XCTestCase with shared mutable state | Swift 6 strict concurrency enabled | Data races in test infrastructure | HIGH | +| No mock/stub for network layer | Tests import networking module | Fragile tests dependent on external servers | MEDIUM | + +Also note overlaps with other auditors: +- Untested @MainActor code → compound with concurrency auditor +- Untested persistence migrations → compound with data auditor +- Tests with sleep() in async context → compound with concurrency auditor + +## Phase 5: Test Health Score + +Calculate and present a health score: + +```markdown +## Test Health Score + +| Metric | Value | +|--------|-------| +| Module coverage | X/Y production modules have tests (Z%) | +| Critical path coverage | auth (yes/no), payments (yes/no), persistence (yes/no), networking (yes/no) | +| Error path coverage | N error enums, M with test assertions (Z%) | +| Test reliability | N sleep() calls, M shared mutable state instances | +| Test speed | N tests requiring simulator, M pure unit tests | +| Test framework | N XCTest, M Swift Testing (migration %) | +| **Health** | **WELL TESTED / GAPS / UNDERTESTED** | +``` + +Scoring: +- **WELL TESTED**: All critical paths tested, <3 flaky patterns, >70% module coverage, error paths covered +- **GAPS**: Most critical paths tested, some flaky patterns or missing error coverage, or 40-70% module coverage +- **UNDERTESTED**: Critical paths untested, or >5 flaky patterns, or <40% module coverage + +## Output Format + +```markdown +# Test Quality Audit Results + +## Coverage Shape Map +[8-12 line summary from Phase 1] + +## Summary +- CRITICAL: [N] issues +- HIGH: [N] issues +- MEDIUM: [N] issues +- LOW: [N] issues +- Phase 2 (anti-pattern detection): [N] issues +- Phase 3 (completeness reasoning): [N] issues +- Phase 4 (compound findings): [N] issues + +## Test Health Score +[Phase 5 table] + +## Issues by Severity + +### [SEVERITY] [Category]: [Description] +**File**: path/to/file.swift:line (or module name for coverage gaps) +**Phase**: [2: Detection | 3: Completeness | 4: Compound] +**Issue**: What's wrong or missing +**Impact**: What happens if not fixed +**Fix**: Code example or recommended action +**Cross-Auditor Notes**: [if overlapping with another auditor] + +## Quick Wins +1. [Fastest impact fix] +2. [Biggest speedup] +3. [Easiest migration] + +## Recommendations +1. [Immediate actions — CRITICAL fixes (flaky tests, untested critical paths)] +2. [Short-term — HIGH fixes (speed improvements, Swift 6 compliance)] +3. [Long-term — coverage expansion from Phase 3 findings] +``` + +## Output Limits + +If >50 issues in one category: Show top 10, provide total count, list top 3 files +If >100 total issues: Summarize by category, show only CRITICAL/HIGH details + +## False Positives (Not Issues) + +- `sleep()` in test helpers for rate limiting (check context) +- `static let` constants (immutable is fine) +- UI tests that legitimately need XCUIApplication +- Performance tests using XCTMetric +- Tests intentionally using XCTest for Objective-C interop +- Force unwraps in `setUp()` / fixture setup on known-valid literals +- Modules with no tests that are pure UI (better tested via UI tests or previews) + +## Related + +For unit test patterns: `axiom-swift-testing` skill +For UI test patterns: `axiom-ui-testing` skill +For async test patterns: `axiom-testing-async` skill +For flaky test diagnosis: `axiom-test-failure-analyzer` agent diff --git a/.claude/skills/axiom-audit-testing/agents/openai.yaml b/.claude/skills/axiom-audit-testing/agents/openai.yaml new file mode 100644 index 0000000..0394be9 --- /dev/null +++ b/.claude/skills/axiom-audit-testing/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit Testing" + short_description: "The user wants to audit test quality, find flaky test patterns, speed up test execution, or prepare for Swift Testing..." diff --git a/.claude/skills/axiom-audit-textkit/.openskills.json b/.claude/skills/axiom-audit-textkit/.openskills.json new file mode 100644 index 0000000..819ebfb --- /dev/null +++ b/.claude/skills/axiom-audit-textkit/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-textkit", + "installedAt": "2026-04-12T08:05:49.855Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-textkit/SKILL.md b/.claude/skills/axiom-audit-textkit/SKILL.md new file mode 100644 index 0000000..7387979 --- /dev/null +++ b/.claude/skills/axiom-audit-textkit/SKILL.md @@ -0,0 +1,361 @@ +--- +name: axiom-audit-textkit +description: Use when the user mentions TextKit review, text layout issues, Writing Tools integration, or UITextView/NSTextView code review. +license: MIT +disable-model-invocation: true +--- +# TextKit Auditor Agent + +You are an expert at detecting TextKit 1 fallback triggers and deprecated text layout patterns that prevent Writing Tools integration and cause incorrect behavior with complex scripts. + +## Your Mission + +Run a comprehensive TextKit audit and report all issues with: +- File:line references for easy fixing +- Severity ratings (CRITICAL/HIGH/MEDIUM) +- Specific violation types +- Fix recommendations with code examples + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Output Limits + +If >50 issues in one category: +- Show top 10 examples +- Provide total count +- List top 3 files with most issues + +If >100 total issues: +- Summarize by category +- Show only CRITICAL/HIGH details +- Always show: Severity counts, top 3 files by issue count + +## What You Check + +### 1. TextKit 1 Fallback Triggers (CRITICAL) +**Pattern**: Direct `.layoutManager` access without checking `.textLayoutManager` first +**Issue**: One-way fallback to TextKit 1, loses Writing Tools, incorrect complex script handling +**Fix**: Check `textLayoutManager` first, only fall back for old OS versions + +### 2. NSLayoutManager Usage (CRITICAL) +**Pattern**: Using `NSLayoutManager` class or delegate +**Issue**: TextKit 1 only, no Writing Tools, deprecated paradigm +**Fix**: Migrate to `NSTextLayoutManager` (TextKit 2) + +### 3. Glyph API Usage (CRITICAL) +**Pattern**: `numberOfGlyphs`, `glyphRange`, `glyphIndex`, `rectForGlyph`, `characterIndex(forGlyphAt:)` +**Issue**: Incorrect for complex scripts (Arabic, Kannada, Thai), data corruption risk +**Fix**: Use `NSTextLayoutFragment` and `NSTextLineFragment` for measurement + +### 4. NSRange with TextKit 2 (HIGH) +**Pattern**: Using NSRange instead of NSTextRange/NSTextLocation with TextKit 2 APIs +**Issue**: Wrong paradigm, breaks with structured documents +**Fix**: Use `NSTextLocation` and `NSTextRange` for TextKit 2 + +### 5. Missing Writing Tools Integration (MEDIUM) +**Pattern**: UITextView/NSTextView without `writingToolsBehavior` property +**Issue**: No Writing Tools support (iOS 18+) +**Fix**: Set `.writingToolsBehavior = .default` for full experience + +### 6. Missing Writing Tools State Checks (MEDIUM) +**Pattern**: Text mutations without checking `isWritingToolsActive` +**Issue**: Can interfere with Writing Tools operation +**Fix**: Check `isWritingToolsActive` before modifying text + +## Audit Process + +### Step 1: Find All Swift Files + +Use Glob tool to find Swift files: +- Pattern: `**/*.swift` + +### Step 2: Search for TextKit Anti-Patterns + +**Direct layoutManager Access** (Fallback Trigger): +```bash +# Direct access without textLayoutManager check +grep -rn "\.layoutManager\b" --include="*.swift" | grep -v "textLayoutManager" +grep -rn "textView\.layoutManager" --include="*.swift" + +# Look for proper TextKit 2 checks (should be common) +grep -rn "textLayoutManager" --include="*.swift" +``` + +**NSLayoutManager Usage** (TextKit 1): +```bash +# NSLayoutManager class usage +grep -rn "NSLayoutManager" --include="*.swift" + +# NSLayoutManagerDelegate conformance +grep -rn ": NSLayoutManagerDelegate" --include="*.swift" +``` + +**Glyph APIs** (Deprecated): +```bash +# Glyph count queries +grep -rn "numberOfGlyphs" --include="*.swift" + +# Glyph range queries +grep -rn "glyphRange" --include="*.swift" + +# Glyph index queries +grep -rn "glyphIndex" --include="*.swift" + +# Character-to-glyph mapping (broken for complex scripts) +grep -rn "characterIndex(forGlyphAt:" --include="*.swift" +grep -rn "glyphIndexForCharacter" --include="*.swift" + +# Glyph rect queries +grep -rn "rectForGlyph" --include="*.swift" +grep -rn "boundingRectForGlyphRange" --include="*.swift" +``` + +**NSRange with TextKit 2**: +```bash +# NSRange used with TextKit 2 APIs +grep -rn "NSTextLayoutManager.*NSRange" --include="*.swift" +grep -rn "textLayoutManager.*NSRange" --include="*.swift" +``` + +**Missing Writing Tools Integration**: +```bash +# Find text views +grep -rn "UITextView\|NSTextView" --include="*.swift" + +# Check for Writing Tools configuration (should match text view count) +grep -rn "writingToolsBehavior" --include="*.swift" + +# Check for Writing Tools state awareness +grep -rn "isWritingToolsActive" --include="*.swift" +``` + +### Step 3: Categorize by Severity + +**CRITICAL** (Breaks Writing Tools, incorrect complex script handling): +- Direct `.layoutManager` access (fallback trigger) +- NSLayoutManager usage (TextKit 1 only) +- Glyph APIs (data corruption with Arabic, Kannada, etc.) + +**HIGH** (Wrong paradigm): +- NSRange with TextKit 2 APIs (should use NSTextRange) + +**MEDIUM** (Missing modern features): +- Missing `writingToolsBehavior` property +- Missing `isWritingToolsActive` checks + +## Output Format + +```markdown +# TextKit Audit Results + +## Summary +- **CRITICAL Issues**: [count] (TextKit 1 fallback, data corruption risk) +- **HIGH Issues**: [count] (Wrong paradigm) +- **MEDIUM Issues**: [count] (Missing modern features) + +## TextKit Version: [TextKit 1 / TextKit 2 / Mixed] + +## CRITICAL Issues + +### TextKit 1 Fallback Triggers +- `src/Views/EditorView.swift:42` - `textView.layoutManager` accessed directly + - **Risk**: One-way fallback to TextKit 1, loses Writing Tools support + - **Fix**: Check `textLayoutManager` first + ```swift + // ❌ BAD: Immediate fallback to TextKit 1 + if let layoutManager = textView.layoutManager { + // TextKit 1 code + } + + // ✅ GOOD: Use TextKit 2 when available + if let textLayoutManager = textView.textLayoutManager { + // TextKit 2 code + } else if let layoutManager = textView.layoutManager { + // TextKit 1 fallback only for old OS + } + ``` + +### NSLayoutManager Usage (TextKit 1 Only) +- `src/Helpers/TextMeasure.swift:67` - NSLayoutManager class used + - **Risk**: No Writing Tools, incorrect handling of Arabic/Kannada text + - **Fix**: Migrate to NSTextLayoutManager + ```swift + // TextKit 2 replacement for line counting + var lineCount = 0 + textLayoutManager.enumerateTextLayoutFragments( + from: textLayoutManager.documentRange.location, + options: [.ensuresLayout] + ) { fragment in + lineCount += fragment.textLineFragments.count + return true + } + ``` + +### Glyph API Usage (Data Corruption Risk) +- `src/Helpers/LineCounter.swift:89` - `numberOfGlyphs` used + - **Risk**: Incorrect count for complex scripts (Arabic: 1 char = 2+ glyphs, Kannada: 1 char splits) + - **Why broken**: Glyph ≠ character for ligatures, combining marks, right-to-left text + - **Fix**: Use TextKit 2 fragment enumeration + ```swift + // TextKit 2 - no glyph APIs + textLayoutManager.enumerateTextLayoutFragments(...) { fragment in + // Use fragment.textLineFragments for measurement + } + ``` + +## HIGH Issues + +### NSRange with TextKit 2 APIs +- `src/Views/SelectionHandler.swift:123` - NSRange used with NSTextLayoutManager + - **Risk**: Wrong paradigm, breaks with structured documents + - **Fix**: Convert to NSTextRange via NSTextContentManager + ```swift + // Convert NSRange → NSTextRange + let startLocation = textContentManager.location( + textContentManager.documentRange.location, + offsetBy: nsRange.location + )! + let endLocation = textContentManager.location( + startLocation, + offsetBy: nsRange.length + )! + let textRange = NSTextRange(location: startLocation, end: endLocation) + ``` + +## MEDIUM Issues + +### Missing Writing Tools Integration +- `src/Views/NotesEditor.swift:34` - UITextView without writingToolsBehavior property + - **Impact**: No Writing Tools support (iOS 18+) + - **Fix**: Add Writing Tools configuration + ```swift + textView.writingToolsBehavior = .default // Full experience + textView.writingToolsResultOptions = [.richText, .list] + ``` + +### Missing Writing Tools State Checks +- `src/Services/SyncService.swift:201` - Text mutations without isWritingToolsActive check + - **Impact**: Can interfere with Writing Tools operation + - **Fix**: Check before modifying text + ```swift + func syncChanges() { + guard !textView.isWritingToolsActive else { return } + // Sync logic + } + ``` + +## TextKit Version Assessment + +**Current State**: [Describe which version is in use] +- TextKit 2: [List TextKit 2 usage] +- TextKit 1: [List TextKit 1 usage] +- Mixed: [Describe if both are used] + +**Recommendation**: +- If iOS 16+ only: Migrate fully to TextKit 2 +- If supporting iOS 15-: Use TextKit 2 with TextKit 1 fallback pattern +- Writing Tools requires TextKit 2 (iOS 18+) + +## Next Steps + +1. **Fix CRITICAL issues first** - Prevents data corruption with complex scripts +2. **Migrate to TextKit 2** - Required for Writing Tools (iOS 18+) +3. **Test with complex scripts** - Arabic, Hebrew, Thai, Hindi, Kannada +4. **Test Writing Tools** - iOS 18+ only + +## Testing Recommendations + +After fixes: +```bash +# Test with complex scripts +1. Enter Arabic text: "مرحبا" +2. Enter Kannada text: "ಅಕ್ಟೋಬರ್" +3. Verify: No crashes, correct rendering + +# Test Writing Tools (iOS 18+) +1. Select text in UITextView +2. Tap "Writing Tools" in context menu +3. Verify: Full inline experience (not panel-only) + +# Debug TextKit 1 fallback +1. Set breakpoint on _UITextViewEnablingCompatibilityMode (UIKit) +2. Subscribe to willSwitchToNSLayoutManagerNotification (AppKit) +3. Run app and check if fallback occurs +``` + +## For Detailed TextKit 2 Guidance + +Use `/skill axiom:textkit-ref` for complete TextKit 2 architecture reference, migration patterns from TextKit 1, Writing Tools integration guide, and SwiftUI TextEditor + AttributedString patterns. +``` + +## Audit Guidelines + +1. Run all searches for comprehensive coverage +2. Provide file:line references to make it easy to find issues +3. Include code examples showing both wrong and correct patterns +4. Categorize by severity to help prioritize fixes +5. Assess TextKit version to determine migration path + +## When Issues Found + +If CRITICAL issues found: +- Emphasize data corruption risk with complex scripts +- Warn about Writing Tools loss +- Recommend TextKit 2 migration +- Provide exact fix code + +If NO issues found: +- Report "No TextKit violations detected" +- Note current TextKit version in use +- Suggest Writing Tools integration if iOS 18+ + +## False Positives + +These are acceptable (not issues): +- TextKit 1 code behind OS version checks (iOS 15 fallback) +- `layoutManager` mentioned in comments +- TextKit 1 in migration code with proper guards + +## Migration Priority + +**High Priority** (if targeting iOS 18+): +1. Fix fallback triggers (`.layoutManager` access) +2. Remove glyph APIs (data corruption risk) +3. Integrate Writing Tools + +**Medium Priority** (if supporting iOS 16-17): +1. Use TextKit 2 with TextKit 1 fallback +2. Plan migration to TextKit 2 when dropping iOS 15 + +**Low Priority** (if iOS 15 only): +- Stay on TextKit 1 until dropping iOS 15 support + +## Complex Script Examples + +**Why glyph APIs are dangerous:** + +**Arabic** (right-to-left): +- Visual: "مرحبا" (5 characters) +- Glyphs: 7+ glyphs (ligatures, position forms) +- Glyph index ≠ character index + +**Kannada** ("October"): +- Character 4: single vowel +- Glyphs: 2 glyphs (split vowel) +- Glyphs reorder during shaping +- No 1:1 mapping + +**TextKit 2 Solution**: Abstracts glyphs away, uses NSTextLocation for positions. + +## Summary + +This audit scans for: +- **3 CRITICAL patterns** that break Writing Tools and complex scripts +- **1 HIGH pattern** using wrong paradigm +- **2 MEDIUM patterns** missing modern features + +**Fix time**: TextKit 2 migration typically 2-4 hours for simple editors, 1-2 days for complex implementations. + +**When to run**: Before iOS 18 release, after adding text editing features, quarterly for technical debt tracking. diff --git a/.claude/skills/axiom-audit-textkit/agents/openai.yaml b/.claude/skills/axiom-audit-textkit/agents/openai.yaml new file mode 100644 index 0000000..66a54d0 --- /dev/null +++ b/.claude/skills/axiom-audit-textkit/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit TextKit" + short_description: "The user mentions TextKit review, text layout issues, Writing Tools integration, or UITextView/NSTextView code review." diff --git a/.claude/skills/axiom-audit-ux-flow/.openskills.json b/.claude/skills/axiom-audit-ux-flow/.openskills.json new file mode 100644 index 0000000..63d9586 --- /dev/null +++ b/.claude/skills/axiom-audit-ux-flow/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-audit-ux-flow", + "installedAt": "2026-04-12T08:05:49.856Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-audit-ux-flow/SKILL.md b/.claude/skills/axiom-audit-ux-flow/SKILL.md new file mode 100644 index 0000000..5ffe7d2 --- /dev/null +++ b/.claude/skills/axiom-audit-ux-flow/SKILL.md @@ -0,0 +1,272 @@ +--- +name: axiom-audit-ux-flow +description: Use when the user mentions UX flow issues, dead-end views, dismiss traps, missing empty states, broken user journeys, or wants a UX audit of their iOS app. +license: MIT +disable-model-invocation: true +--- +# UX Flow Auditor Agent + +You are an expert at detecting user journey defects in iOS apps (SwiftUI and UIKit) — both known anti-patterns AND missing/incomplete flows that cause user frustration, support tickets, and abandonment. + +## Your Mission + +Run a comprehensive UX flow audit using 5 phases: map the user journey architecture, detect known UX defects, reason about what flows are missing or incomplete, correlate compound issues, and score journey health. Report all issues with: +- File:line references +- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW) +- Fix recommendations with code examples +- Cross-auditor correlation notes + +**This agent checks user journeys, not code patterns.** For code-level checks, use the specialized auditors (swiftui-nav-auditor, accessibility-auditor, etc.). + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Phase 1: Map User Journey Architecture + +Before checking individual patterns, build a mental model of the app's user journey surface. + +### Step 1: Identify Entry Points + +``` +Glob: **/App.swift, **/*App.swift, **/SceneDelegate.swift, **/AppDelegate.swift +Grep for: + - `.onOpenURL` — deep link entry points + - `widgetURL` — widget entry points + - `UNUserNotificationCenter` — notification entry points + - `application(_:open:`, `application(_:continue:` — URL/activity entry points +``` + +### Step 2: Map Navigation Structure + +``` +Grep for: + - `NavigationStack`, `NavigationSplitView` — navigation containers + - `TabView`, `UITabBarController` — tab structure + - `.sheet`, `.fullScreenCover` — modal presentations + - `.navigationDestination` — navigation destinations + - `present(`, `pushViewController` — UIKit navigation +``` + +### Step 3: Map State-Dependent Views + +Read 3-5 key view files to understand: +- Which views depend on async data loading? +- Which views have empty/error/loading state handling? +- Where are the critical user flows? (onboarding, purchase, settings, content creation) + +### Output + +Write a brief **Journey Architecture Map** (8-12 lines) summarizing: +- App entry points (main, deep links, widgets, notifications) +- Navigation structure (tabs, stacks, modals) +- Critical user flows identified +- State-dependent views (async data, conditional content) + +Present this map in the output before proceeding. + +## Phase 2: Detect Known UX Defects + +Run all 11 existing detection categories. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification. + +### 1. Dead-End Views (CRITICAL) + +**Pattern**: Views that are navigation destinations but have no actions, navigation, or completion state +**Search**: Views in `.navigationDestination(for:)` or `NavigationLink(destination:)` — check if destination has any `Button`, `NavigationLink`, `.sheet`, `.fullScreenCover`, or dismiss action. UIKit: View controllers with no `IBAction`, no `addTarget`, no `pushViewController`/`present` calls +**Issue**: Users land on a screen with nothing to do +**Fix**: Add clear next action or completion path + +### 2. Dismiss Traps (CRITICAL) + +**Pattern**: Modal presentations without escape +**Search**: `.fullScreenCover` without `@Environment(\.dismiss)` or dismiss button; `.sheet` with `.interactiveDismissDisabled(true)` without alternative dismiss; `.alert`/`.confirmationDialog` without cancel action. UIKit: `present(_:animated:)` with `.fullScreen` where presented VC has no close button; `isModalInPresentation = true` without dismiss path +**Issue**: Users are trapped in a modal with no way out +**Fix**: Add dismiss button or cancel action + +### 3. Buried CTAs (HIGH) + +**Pattern**: Primary actions hidden or hard to find +**Search**: Root tab views — check if first visible content has a clear primary action; `ScrollView` content — check if primary `Button` is near top vs below fold; `.toolbar` items using `.secondaryAction` placement for primary functionality; Actions only inside `DisclosureGroup` or `Menu` +**Issue**: Users can't find the main action +**Fix**: Surface primary action prominently + +### 4. Promise-Scope Mismatch (HIGH) + +**Pattern**: Labels/titles that don't match content +**Search**: `.navigationTitle()` text vs view content; `NavigationLink` label vs destination content; `TabView` tab labels vs tab content +**Issue**: Users expect one thing, get another +**Fix**: Align title/label with actual content + +### 5. Deep Link Dead Ends (HIGH) + +**Pattern**: URLs that open to broken/empty views +**Search**: `.onOpenURL` handlers — check if destination view validates the linked entity exists; deep link routes that push views without checking data availability; no fallback view when linked content is unavailable +**Issue**: External link opens app to blank/broken screen +**Fix**: Validate linked content, show fallback for missing data + +### 6. Missing Empty States (HIGH) + +**Pattern**: Data views with no empty handling +**Search**: `List` or `ForEach` over arrays/queries without empty check; `@Query` results used in `ForEach` without `if results.isEmpty` guard; search results without "no results" UI; `LazyVGrid`/`LazyVStack` without empty state overlay +**Issue**: Users see a blank screen with no guidance +**Fix**: Add ContentUnavailableView or empty state overlay + +### 7. Missing Loading/Error States (HIGH) + +**Pattern**: Async operations without user feedback +**Search**: `.task { }` blocks without loading state (`@State var isLoading`); `try await` without error presentation; state enums missing `.loading`/`.error` cases. UIKit: `URLSession` calls without `UIActivityIndicatorView`; completion handlers that don't update UI on error +**Issue**: Users don't know if something is loading or broken +**Fix**: Add loading indicator and error presentation + +### 8. Accessibility Dead Ends (HIGH) + +**Pattern**: Flows unreachable via assistive technology +**Search**: `.onLongPressGesture` / `DragGesture` without `.accessibilityAction` equivalent; custom controls without `.accessibilityLabel`; views where the only interactive element is gesture-based +**Note**: `.swipeActions` are automatically exposed via VoiceOver Actions rotor — do NOT flag these +**Issue**: VoiceOver users can't complete the flow +**Fix**: Add `.accessibilityAction` equivalents for gesture-only interactions + +### 9. Onboarding Gaps (MEDIUM) + +**Pattern**: First-launch experience issues +**Search**: `@AppStorage` for first-launch flag — check the gated view for completeness; onboarding flows with more than 5 screens; onboarding requiring sign-up before showing app value +**Issue**: Users abandon onboarding before seeing value +**Fix**: Show value early, keep onboarding under 5 screens + +### 10. Broken Data Paths (MEDIUM) + +**Pattern**: State/binding wiring issues +**Search**: `@Binding` parameters initialized with `.constant()` in non-preview production code; `@Environment` keys used but not provided in view hierarchy; `@Observable` objects created with `@State` when they should be passed via environment +**Note**: Read 3-5 lines above and below. If there's a comment explaining intent (e.g., `// Staged refactor`, `// Intentional`), downgrade to LOW or skip. +**Issue**: User actions don't propagate, UI is disconnected +**Fix**: Wire bindings correctly, inject environment objects + +### 11. Platform Parity Gaps (MEDIUM) + +**Pattern**: Missing iPad/landscape/Mac adaptivity +**Search**: `NavigationStack` without `NavigationSplitView` for iPad; no `.horizontalSizeClass` usage in adaptive layouts; fixed heights that break in landscape +**Issue**: iPad/landscape users have degraded experience +**Fix**: Use NavigationSplitView, check size classes + +**Scan systematically**: When you find a pattern in one file, grep the entire codebase for the same pattern. A single instance usually indicates a codebase-wide habit. Report the full count and list all affected files. + +## Phase 3: Reason About Journey Completeness + +Using the Journey Architecture Map from Phase 1 and your domain knowledge, check for what's *missing* — not just what's wrong with individual screens. + +| Question | What it detects | Why it matters | +|----------|----------------|----------------| +| Can users complete every critical flow (onboarding, purchase, content creation) from start to finish without dead ends? | Incomplete critical flows | Users abandon the app at dead ends in core journeys | +| Does every modal presentation have a clear exit path, including when async operations fail mid-flow? | Missing error recovery in modals | Users get stuck in sheets when network calls fail | +| Are there screens that load async data but have no way to retry on failure? | Missing retry affordance | Users must kill and restart the app to try again | +| Do deep links, widgets, and notifications all land on screens that validate their data? | Unvalidated entry points | External entry points assume data exists, show broken state | +| Is there a consistent state pattern (loading/content/empty/error) applied to all data-dependent views? | Inconsistent state handling | Some screens handle empty gracefully, others show blank | +| Can VoiceOver users complete every flow that sighted users can? | Inaccessible critical paths | Gesture-only features exclude assistive technology users | +| Do destructive actions (delete, cancel subscription, sign out) have confirmation and undo paths? | Missing safety nets | Users lose data/state with no way to recover | +| Are there flows where the back button or swipe-to-dismiss loses user input? | Data loss on navigation | Users lose form data or draft content when navigating away | + +For each finding, explain what's missing and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code. + +## Phase 4: Cross-Reference Findings + +When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations: + +| Finding A | + Finding B | = Compound | Severity | +|-----------|------------|-----------|----------| +| Dead-end view | No NavigationPath management | User trapped with no programmatic exit | CRITICAL | +| Gesture-only action | No .accessibilityAction | Flow unreachable for VoiceOver users | CRITICAL | +| Missing loading state | Unhandled async error | User sees blank screen on failure | CRITICAL | +| Missing empty state | Deep link to list view | Deep link opens to blank screen | CRITICAL | +| Dismiss trap in sheet | Async operation in progress | User stuck while operation runs | HIGH | +| Missing error state | No retry button | User must kill app to retry | HIGH | +| Buried CTA | Onboarding flow | New users never find primary action | HIGH | +| Broken data path | Critical flow (purchase, auth) | Core transaction silently broken | HIGH | + +Also note overlaps with other auditors: +- Dead end + no NavigationPath → compound with swiftui-nav-auditor +- Gesture-only + no accessibilityAction → compound with accessibility-auditor +- Missing loading + unhandled error → compound with concurrency-auditor + +## Phase 5: UX Journey Health Score + +Calculate and present a health score: + +```markdown +## UX Journey Health Score + +| Metric | Value | +|--------|-------| +| Critical flow coverage | N critical flows identified, M complete start-to-finish (Z%) | +| State handling | N data-dependent views, M with loading/empty/error states (Z%) | +| Modal safety | N modal presentations, M with clear dismiss path (Z%) | +| Entry point validation | N external entries (deep link, widget, notification), M validate data (Z%) | +| Accessibility reach | N interactive flows, M reachable via VoiceOver (Z%) | +| **Health** | **SMOOTH / ROUGH EDGES / BROKEN JOURNEYS** | +``` + +Scoring: +- **SMOOTH**: No CRITICAL issues, all critical flows complete, >80% state handling coverage, all modals have dismiss paths +- **ROUGH EDGES**: No CRITICAL issues, most critical flows complete, some missing states or entry point validation gaps +- **BROKEN JOURNEYS**: Any CRITICAL issues (dead ends, dismiss traps), or critical flows incomplete, or <50% state handling + +## Output Format + +```markdown +# UX Flow Audit Results + +## Journey Architecture Map +[8-12 line summary from Phase 1] + +## Summary +- CRITICAL: [N] issues +- HIGH: [N] issues +- MEDIUM: [N] issues +- LOW: [N] issues +- Phase 2 (defect detection): [N] issues +- Phase 3 (completeness reasoning): [N] issues +- Phase 4 (compound findings): [N] issues + +## UX Journey Health Score +[Phase 5 table] + +## Enhanced Rating Table (CRITICAL and HIGH only) + +| Finding | Urgency | Blast Radius | Fix Effort | ROI | +|---------|---------|-------------|-----------|-----| +| [description] | Ship-blocker/Next release/Backlog | All users/Specific flow/Edge case | [time] | Critical/High/Medium | + +## Issues by Severity + +### [SEVERITY] [Category]: [Description] +**File**: path/to/file.swift:line +**Phase**: [2: Detection | 3: Completeness | 4: Compound] +**Issue**: What's wrong or missing +**Impact**: What users experience +**Fix**: Code example showing the fix +**Cross-Auditor Notes**: [if overlapping with another auditor] + +## Recommendations +1. [Immediate actions — CRITICAL fixes (dead ends, dismiss traps)] +2. [Short-term — HIGH fixes (missing states, entry point validation)] +3. [Long-term — journey improvements from Phase 3 findings] +``` + +## Output Limits + +If >50 issues in one category: Show top 10, provide total count, list top 3 files +If >100 total issues: Summarize by category, show only CRITICAL/HIGH details + +## False Positives (Not Issues) + +- Views intentionally designed as static informational screens (About, Legal, Licenses) +- `.fullScreenCover` with dismiss handled by parent view callback +- Empty states handled by a shared container/wrapper view +- Deep links not implemented by design choice (documented) +- iPad-only or iPhone-only apps (no platform parity expected) +- `.swipeActions` on List rows (automatically exposed via VoiceOver Actions rotor) + +## Related + +For navigation architecture: `axiom-swiftui-nav` skill +For accessibility compliance: `axiom-accessibility-diag` skill +For UX principles: `axiom-ux-flow-audit` skill diff --git a/.claude/skills/axiom-audit-ux-flow/agents/openai.yaml b/.claude/skills/axiom-audit-ux-flow/agents/openai.yaml new file mode 100644 index 0000000..3c92f1e --- /dev/null +++ b/.claude/skills/axiom-audit-ux-flow/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Audit UX Flow" + short_description: "The user mentions UX flow issues, dead-end views, dismiss traps, missing empty states, broken user journeys, or wants..." diff --git a/.claude/skills/axiom-auto-layout-debugging/.openskills.json b/.claude/skills/axiom-auto-layout-debugging/.openskills.json new file mode 100644 index 0000000..de48504 --- /dev/null +++ b/.claude/skills/axiom-auto-layout-debugging/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-auto-layout-debugging", + "installedAt": "2026-04-12T08:05:52.943Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-auto-layout-debugging/SKILL.md b/.claude/skills/axiom-auto-layout-debugging/SKILL.md new file mode 100644 index 0000000..12bb09e --- /dev/null +++ b/.claude/skills/axiom-auto-layout-debugging/SKILL.md @@ -0,0 +1,630 @@ +--- +name: axiom-auto-layout-debugging +description: Use when encountering "Unable to simultaneously satisfy constraints" errors, constraint conflicts, ambiguous layout warnings, or views positioned incorrectly - systematic debugging workflow for Auto Layout issues in iOS +license: MIT +metadata: + version: "1.0.0" +--- + +# Auto Layout Debugging + +## When to Use This Skill + +Use when: +- Seeing "Unable to simultaneously satisfy constraints" errors in console +- Views positioned incorrectly or not appearing +- Constraint warnings during app launch or navigation +- Ambiguous layout errors +- Views appearing at unexpected sizes +- Debug View Hierarchy shows misaligned views +- Storyboard/XIB constraints behaving differently at runtime + +## Overview + +**Core Principle**: Auto Layout constraint errors follow predictable patterns. Systematic debugging with proper tools identifies issues in minutes instead of hours. + +**Time Savings**: Typical constraint debugging without this workflow: 30-60 minutes. With systematic approach: 5-10 minutes. + +--- + +## Quick Decision Tree + +``` +Constraint error in console? +├─ Can't identify which views? +│ └─ Use Symbolic Breakpoint + Memory Address Identification +├─ Constraint conflicts shown? +│ └─ Use Constraint Priority Resolution +├─ Ambiguous layout (multiple solutions)? +│ └─ Use _autolayoutTrace to find missing constraints +└─ Views positioned incorrectly but no errors? + └─ Use Debug View Hierarchy + Show Constraints +``` + +--- + +## Understanding Constraint Error Messages + +### Anatomy of Error Message + +``` +Unable to simultaneously satisfy constraints. +Probably at least one of the constraints in the following list you don't need. + +( + "", + "", + "", + "" +) + +Will attempt to recover by breaking constraint + +``` + +**Key Components**: +1. **Memory addresses** — `0x7f8b9c4...` identifies views and constraints +2. **Visual Format** — Human-readable constraint description +3. **`(active)` status** — Constraint is currently enforced +4. **Recovery action** — Which constraint system will break (usually lowest priority) + +### System-Generated Constraints + +**UIView-Encapsulated-Layout-Width/Height**: +- Created by UIKit for cells, system views +- Often source of conflicts +- Usually correct; your constraints are the problem + +**Autoresizing Mask Constraints**: +- Format: `h=--&` or `v=&--` +- `-` = fixed dimension +- `&` = flexible dimension +- Example: `h=--&` = fixed left margin and width, flexible right margin + +--- + +## Debugging Workflow + +### Step 1: Set Up Symbolic Breakpoint (One-Time Setup) + +**Purpose**: Break when constraint conflict occurs, before system breaks constraint. + +**Setup**: +1. Open Breakpoint Navigator (⌘+7 or ⌘+8) +2. Click `+` → "Symbolic Breakpoint" +3. **Symbol**: `UIViewAlertForUnsatisfiableConstraints` +4. (Optional) Add **Action** → "Sound" → select sound +5. (Optional) Check "Automatically continue after evaluating actions" + +**Why this works**: Pauses execution at exact moment of constraint conflict, giving you debugger access to all views and constraints. + +--- + +### Step 2: Identify Views from Memory Addresses + +When breakpoint hits, console shows memory addresses like `UILabel:0x7f8b9c4...` + +#### Technique 1: Use %rbx Register (When Breakpoint Hits) + +```lldb +# Print all involved views and constraints +po $arg1 + +# Or on older Xcode versions +po $rbx +``` + +**Output**: NSArray containing all conflicting constraints and affected views. + +#### Technique 2: Set View Background Color + +```lldb +# Set background color on suspected view +expr ((UIView *)0x7f8b9c4...).backgroundColor = [UIColor redColor] + +# Continue execution to see which view turned red +``` + +**Result**: Visually identifies which view corresponds to memory address. + +#### Technique 3: Print View Hierarchy + +**Objective-C projects**: +```lldb +po [[UIWindow keyWindow] _autolayoutTrace] +``` + +**Swift projects**: +```lldb +expr -l objc++ -O -- [[UIWindow keyWindow] _autolayoutTrace] +``` + +**Output**: Entire view hierarchy with `*` marking ambiguous layouts. + +**Example**: +``` +* +| +``` + +The `*` indicates this UIView has ambiguous constraints. + +#### Technique 4: Print Constraints for Specific View + +```lldb +# Horizontal constraints (axis: 0) +po [0x7f8b9c4... constraintsAffectingLayoutForAxis:0] + +# Vertical constraints (axis: 1) +po [0x7f8b9c4... constraintsAffectingLayoutForAxis:1] +``` + +**Output**: All constraints affecting that view's layout. + +--- + +### Step 3: Use Debug View Hierarchy + +**When to use**: Views positioned incorrectly, constraints not visible in code. + +**Workflow**: +1. **Trigger the issue** — Navigate to screen with constraint problems +2. **Pause execution** — Click "Debug View Hierarchy" button in debug bar (or Debug → View Debugging → Capture View Hierarchy) +3. **Inspect 3D view** — Rotate view hierarchy to see layering +4. **Enable "Show Constraints"** — Shows all constraints as lines +5. **Select view** — Right panel shows all constraints affecting selected view + +**Key Features**: +- **Show Clipped Content** — Reveals views positioned off-screen +- **Show Constraints** — Visualizes constraint relationships +- **Filter Bar** — Search for specific views by class or memory address + +**Finding Issues**: +- Purple constraints = satisfied +- Orange/red constraints = conflicts +- Select constraint → see both views it connects + +--- + +### Step 4: Name Your Constraints (Prevention) + +**Why**: Makes error messages readable instead of cryptic memory addresses. + +#### In Interface Builder (Storyboards/XIBs) + +1. Select constraint in Document Outline +2. Open Attributes Inspector +3. Set **Identifier** field (e.g., "ProfileImageWidthConstraint") + +**Before**: +``` + +``` + +**After**: +``` + +``` + +#### Programmatically + +```swift +let widthConstraint = imageView.widthAnchor.constraint(equalToConstant: 100) +widthConstraint.identifier = "ProfileImageWidthConstraint" +widthConstraint.isActive = true +``` + +**Impact**: Instantly know which constraint is breaking without hunting through code. + +--- + +### Step 5: Name Your Views (Prevention) + +**Why**: Error messages show view class AND your custom label. + +#### In Interface Builder + +1. Select view in Document Outline +2. Open Identity Inspector +3. Set **Label** field (e.g., "Profile Image View") + +**Before**: +``` + +``` + +**After**: +``` + +``` + +#### Programmatically + +```swift +imageView.accessibilityIdentifier = "ProfileImageView" +``` + +**Note**: Xcode automatically uses textual components (UILabel text, UIButton titles) as identifiers when available. + +--- + +## Common Constraint Conflict Patterns + +### Pattern 1: Conflicting Fixed Widths + +**Symptom**: +``` +Container width: 375 +Child width: 300 +Child leading: 20 +Child trailing: 20 +// 20 + 300 + 20 = 340 ≠ 375 +``` + +**❌ WRONG**: +```swift +// Conflicting constraints +imageView.widthAnchor.constraint(equalToConstant: 300).isActive = true +imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20).isActive = true +imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20).isActive = true +// Over-constrained: width + leading + trailing = 3 horizontal constraints (only need 2) +``` + +**✅ CORRECT Option 1** (Remove fixed width): +```swift +// Let width be calculated from leading + trailing +imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20).isActive = true +imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20).isActive = true +// Width will be container width - 40 +``` + +**✅ CORRECT Option 2** (Use priorities): +```swift +let widthConstraint = imageView.widthAnchor.constraint(equalToConstant: 300) +widthConstraint.priority = .defaultHigh // 750 (can be broken if needed) +widthConstraint.isActive = true + +imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20).isActive = true +imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20).isActive = true +// Required constraints (1000) will break lower-priority width constraint if needed +``` + +--- + +### Pattern 2: UIView-Encapsulated-Layout Conflicts + +**Symptom**: Table cells or collection view cells conflicting with `UIView-Encapsulated-Layout-Width`. + +**Why it happens**: System sets cell width based on table/collection view. Your constraints fight it. + +**❌ WRONG**: +```swift +// In UITableViewCell +contentLabel.widthAnchor.constraint(equalToConstant: 320).isActive = true +// Conflicts with system-determined cell width +``` + +**✅ CORRECT**: +```swift +// Use relative constraints, not fixed widths +contentLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16).isActive = true +contentLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16).isActive = true +// Width adapts to cell width automatically +``` + +--- + +### Pattern 3: Autoresizing Mask Conflicts + +**Symptom**: Mixing Auto Layout with `autoresizingMask` or not setting `translatesAutoresizingMaskIntoConstraints = false`. + +**❌ WRONG**: +```swift +let imageView = UIImageView() +view.addSubview(imageView) + +// Forgot to disable autoresizing mask +imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true +// Conflicts with autoresizing mask constraints +``` + +**✅ CORRECT**: +```swift +let imageView = UIImageView() +imageView.translatesAutoresizingMaskIntoConstraints = false // ← CRITICAL +view.addSubview(imageView) + +imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true +``` + +**Why**: `translatesAutoresizingMaskIntoConstraints = true` creates automatic constraints that conflict with your explicit constraints. + +--- + +### Pattern 4: Ambiguous Layout (Missing Constraints) + +**Symptom**: View appears, but position shifts unexpectedly or `_autolayoutTrace` shows `*` (ambiguous). + +**Problem**: Not enough constraints to determine unique position/size. + +**❌ WRONG** (Ambiguous X position): +```swift +imageView.topAnchor.constraint(equalTo: view.topAnchor, constant: 20).isActive = true +imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true +imageView.heightAnchor.constraint(equalToConstant: 100).isActive = true +// Missing: horizontal position (leading/trailing/centerX) +``` + +**✅ CORRECT**: +```swift +imageView.topAnchor.constraint(equalTo: view.topAnchor, constant: 20).isActive = true +imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true // ← Added +imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true +imageView.heightAnchor.constraint(equalToConstant: 100).isActive = true +``` + +**Rule**: Every view needs: +- **Horizontal**: 2 constraints (e.g., leading + width, OR leading + trailing, OR centerX + width) +- **Vertical**: 2 constraints (e.g., top + height, OR top + bottom, OR centerY + height) + +--- + +### Pattern 5: Priority Conflicts + +**Symptom**: Unexpected constraint breaks, but all constraints seem correct. + +**Problem**: Multiple constraints at same priority competing. + +**❌ WRONG**: +```swift +// Both required (priority 1000) +imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true +imageView.widthAnchor.constraint(greaterThanOrEqualToConstant: 150).isActive = true +// Impossible: width can't be 100 AND >= 150 +``` + +**✅ CORRECT**: +```swift +let preferredWidth = imageView.widthAnchor.constraint(equalToConstant: 100) +preferredWidth.priority = .defaultHigh // 750 +preferredWidth.isActive = true + +let minWidth = imageView.widthAnchor.constraint(greaterThanOrEqualToConstant: 150) +minWidth.priority = .required // 1000 +minWidth.isActive = true + +// Result: width will be 150 (required constraint wins) +``` + +**Priority levels** (higher = stronger): +- `.required` (1000) — Must be satisfied +- `.defaultHigh` (750) — Strong preference +- `.defaultLow` (250) — Weak preference +- Custom: any value 1-999 + +--- + +## Debugging Checklist + +### Before Debugging +- [ ] Read full error message in console (don't ignore it) +- [ ] Note which constraints are listed as conflicting +- [ ] Check if error is consistent or intermittent + +### During Debugging +- [ ] Set symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints +- [ ] Identify views using memory addresses (background color technique) +- [ ] Use Debug View Hierarchy to visualize constraints +- [ ] Check _autolayoutTrace for ambiguous layouts +- [ ] Verify translatesAutoresizingMaskIntoConstraints = false for programmatic views + +### After Fixing +- [ ] Test on multiple device sizes (iPhone SE, iPhone Pro Max) +- [ ] Test orientation changes (portrait/landscape) +- [ ] Test with Dynamic Type sizes +- [ ] Verify no console warnings during transitions +- [ ] Add constraint identifiers for future debugging + +--- + +## Advanced Techniques + +### Constraint Priority Strategy + +**Use case**: View that should be certain size, but can shrink if needed. + +```swift +// Preferred size: 200x200 +let widthConstraint = imageView.widthAnchor.constraint(equalToConstant: 200) +widthConstraint.priority = .defaultHigh // 750 +widthConstraint.isActive = true + +let heightConstraint = imageView.heightAnchor.constraint(equalToConstant: 200) +heightConstraint.priority = .defaultHigh // 750 +heightConstraint.isActive = true + +// But never smaller than 100x100 +imageView.widthAnchor.constraint(greaterThanOrEqualToConstant: 100).isActive = true +imageView.heightAnchor.constraint(greaterThanOrEqualToConstant: 100).isActive = true + +// And never larger than container +imageView.widthAnchor.constraint(lessThanOrEqualTo: containerView.widthAnchor).isActive = true +imageView.heightAnchor.constraint(lessThanOrEqualTo: containerView.heightAnchor).isActive = true +``` + +**Result**: Image is 200x200 when space available, shrinks to fit container (min 100x100). + +--- + +### Content Hugging and Compression Resistance + +**Content Hugging** (resist expanding): +```swift +// Label should not stretch beyond its text width +label.setContentHuggingPriority(.defaultHigh, for: .horizontal) +``` + +**Compression Resistance** (resist shrinking): +```swift +// Label should not truncate if possible +label.setContentCompressionResistancePriority(.required, for: .horizontal) +``` + +**Common pattern**: +```swift +// In horizontal stack: priorityLabel (hugs) + spacer + valueLabel (hugs) +priorityLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) +valueLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + +// Spacer fills remaining space (low hugging priority) +spacerView.setContentHuggingPriority(.defaultLow, for: .horizontal) +``` + +--- + +### Debugging Transformed Views + +**Problem**: View transformations (rotate, scale) don't affect Auto Layout. + +**Gotcha**: +```swift +imageView.transform = CGAffineTransform(rotationAngle: .pi / 4) // 45° rotation +// Auto Layout still uses original (un-rotated) frame for calculations +``` + +**Solution**: Auto Layout works correctly, but visual debugging can be confusing. Use original frame for constraint debugging. + +--- + +## Troubleshooting + +### Issue: Breakpoint Never Hits + +**Check**: +1. Symbolic breakpoint symbol is exactly `UIViewAlertForUnsatisfiableConstraints` +2. Breakpoint is enabled (checkmark visible) +3. Constraint conflict actually exists (check console for error message) + +--- + +### Issue: Can't Identify View from Memory Address + +**Solution 1**: Use background color technique +```lldb +expr ((UIView *)0x7f8b9c4...).backgroundColor = [UIColor redColor] +continue +``` + +**Solution 2**: Print recursive description +```lldb +po [0x7f8b9c4... recursiveDescription] +``` + +**Solution 3**: Check view's class +```lldb +po [0x7f8b9c4... class] +``` + +--- + +### Issue: Debug View Hierarchy Shows No Constraints + +**Check**: +1. Click "Show Constraints" button in debug bar (looks like constraint icon) +2. Select specific view to see its constraints in right panel +3. Constraints may be satisfied (purple) vs conflicting (orange/red) + +--- + +### Issue: Constraints Change at Runtime + +**Check**: +1. UIKit system constraints (UIView-Encapsulated-Layout) added for cells/system views +2. Dynamic Type changes (font size changes = size invalidation) +3. Orientation changes triggering new constraints +4. View controller lifecycle (viewDidLoad vs viewWillLayoutSubviews) + +--- + +## Common Mistakes + +### ❌ Ignoring Console Warnings + +**Wrong**: Seeing constraint warning, continuing anyway. + +**Correct**: Fix every constraint warning immediately. They compound and cause unpredictable layout later. + +--- + +### ❌ Not Setting Identifiers + +**Wrong**: Debugging constraints by memory address. + +**Correct**: Always set constraint identifiers. 30 seconds now saves 30 minutes later. + +--- + +### ❌ Over-Constraining + +**Wrong**: Setting leading + trailing + width. + +**Correct**: Use 2 of 3 (leading + trailing, OR leading + width, OR trailing + width). + +--- + +### ❌ Mixing Auto Layout and Frames + +**Wrong**: +```swift +imageView.frame = CGRect(x: 50, y: 50, width: 100, height: 100) // Manual frame +imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true // Auto Layout +``` + +**Correct**: Choose one approach. If using Auto Layout, set `translatesAutoresizingMaskIntoConstraints = false` and let constraints determine position/size. + +--- + +## Real-World Impact + +**Before** (no systematic approach): +- 30-60 minutes per constraint conflict +- Trial-and-error constraint changes +- Frustration from cryptic error messages +- Breaking working constraints to fix new ones + +**After** (systematic debugging): +- 5-10 minutes per constraint conflict +- Targeted fixes with Debug View Hierarchy +- Named constraints = instant identification +- Symbolic breakpoint catches issues immediately + +--- + +## Related Skills + +- For Xcode environment issues: See `axiom-xcode-debugging` skill +- For SwiftUI layout issues: See `axiom-swiftui-performance` skill +- For testing UI: See `axiom-ui-testing` skill + +--- + +## Resources + +**Docs**: /library/archive/documentation/userexperience/conceptual/autolayoutpg/debuggingtricksandtips + +--- + +## Key Takeaways + +1. **Name everything** — Constraints and views with identifiers save hours of debugging +2. **Use symbolic breakpoint** — Catch constraint conflicts at source, not after recovery +3. **Debug View Hierarchy** — Visualize constraints instead of guessing +4. **Memory address → View** — Background color technique instantly identifies mystery views +5. **Two constraints per axis** — Avoid over-constraining (leading + trailing + width = conflict) +6. **Priorities matter** — Use .required (1000) for must-haves, .defaultHigh (750) for preferences +7. **Systematic wins** — Following workflow saves 30-50 minutes per conflict + +--- + +**Last Updated**: 2024 +**Minimum Requirements**: Xcode 12+, iOS 11+ (symbolic breakpoints work on all versions) diff --git a/.claude/skills/axiom-auto-layout-debugging/agents/openai.yaml b/.claude/skills/axiom-auto-layout-debugging/agents/openai.yaml new file mode 100644 index 0000000..7a65416 --- /dev/null +++ b/.claude/skills/axiom-auto-layout-debugging/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Auto Layout Debugging" + short_description: "Encountering \"Unable to simultaneously satisfy constraints\" errors, constraint conflicts, ambiguous layout warnings, ..." diff --git a/.claude/skills/axiom-avfoundation-ref/.openskills.json b/.claude/skills/axiom-avfoundation-ref/.openskills.json new file mode 100644 index 0000000..123344b --- /dev/null +++ b/.claude/skills/axiom-avfoundation-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-avfoundation-ref", + "installedAt": "2026-04-12T08:05:53.813Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-avfoundation-ref/SKILL.md b/.claude/skills/axiom-avfoundation-ref/SKILL.md new file mode 100644 index 0000000..9843355 --- /dev/null +++ b/.claude/skills/axiom-avfoundation-ref/SKILL.md @@ -0,0 +1,643 @@ +--- +name: axiom-avfoundation-ref +description: Reference — AVFoundation audio APIs, AVAudioSession categories/modes, AVAudioEngine pipelines, bit-perfect DAC output, iOS 26+ spatial audio capture, ASAF/APAC, Audio Mix with Cinematic framework +license: MIT +metadata: + version: "1.0.0" +--- + +# AVFoundation Audio Reference + +## Quick Reference + +```swift +// AUDIO SESSION SETUP +import AVFoundation + +try AVAudioSession.sharedInstance().setCategory( + .playback, // or .playAndRecord, .ambient + mode: .default, // or .voiceChat, .measurement + options: [.mixWithOthers, .allowBluetooth] +) +try AVAudioSession.sharedInstance().setActive(true) + +// AUDIO ENGINE PIPELINE +let engine = AVAudioEngine() +let player = AVAudioPlayerNode() +engine.attach(player) +engine.connect(player, to: engine.mainMixerNode, format: nil) +try engine.start() +player.scheduleFile(audioFile, at: nil) +player.play() + +// INPUT PICKER (iOS 26+) +import AVKit +let picker = AVInputPickerInteraction() +picker.delegate = self +myButton.addInteraction(picker) +// In button action: picker.present() + +// AIRPODS HIGH QUALITY (iOS 26+) +try AVAudioSession.sharedInstance().setCategory( + .playAndRecord, + options: [.bluetoothHighQualityRecording, .allowBluetoothA2DP] +) +``` + +--- + +## AVAudioSession + +### Categories + +| Category | Use Case | Silent Switch | Background | +|----------|----------|---------------|------------| +| `.ambient` | Game sounds, not primary | Silences | No | +| `.soloAmbient` | Default, interrupts others | Silences | No | +| `.playback` | Music player, podcast | Ignores | Yes | +| `.record` | Voice recorder | — | Yes | +| `.playAndRecord` | VoIP, voice chat | Ignores | Yes | +| `.multiRoute` | DJ apps, multiple outputs | Ignores | Yes | + +### Modes + +| Mode | Use Case | +|------|----------| +| `.default` | General audio | +| `.voiceChat` | VoIP, reduces echo | +| `.videoChat` | FaceTime-style | +| `.gameChat` | Voice chat in games | +| `.videoRecording` | Camera recording | +| `.measurement` | Flat response, no processing | +| `.moviePlayback` | Video playback | +| `.spokenAudio` | Podcasts, audiobooks | + +### Options + +```swift +// Mixing +.mixWithOthers // Play with other apps +.duckOthers // Lower other audio while playing +.interruptSpokenAudioAndMixWithOthers // Pause podcasts, mix music + +// Bluetooth +.allowBluetooth // HFP (calls) +.allowBluetoothA2DP // High quality stereo +.bluetoothHighQualityRecording // iOS 26+ AirPods recording + +// Routing +.defaultToSpeaker // Route to speaker (not receiver) +.allowAirPlay // Enable AirPlay +``` + +### Interruption Handling + +```swift +NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, + object: nil, + queue: .main +) { notification in + guard let userInfo = notification.userInfo, + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { + return + } + + switch type { + case .began: + // Pause playback + player.pause() + + case .ended: + guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return } + let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) + if options.contains(.shouldResume) { + player.play() + } + + @unknown default: + break + } +} +``` + +### Route Change Handling + +```swift +NotificationCenter.default.addObserver( + forName: AVAudioSession.routeChangeNotification, + object: nil, + queue: .main +) { notification in + guard let userInfo = notification.userInfo, + let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { + return + } + + switch reason { + case .oldDeviceUnavailable: + // Headphones unplugged — pause playback + player.pause() + + case .newDeviceAvailable: + // New device connected + break + + case .categoryChange: + // Category changed by system or another app + break + + default: + break + } +} +``` + +--- + +## AVAudioEngine + +### Basic Pipeline + +```swift +let engine = AVAudioEngine() + +// Create nodes +let player = AVAudioPlayerNode() +let reverb = AVAudioUnitReverb() +reverb.loadFactoryPreset(.largeHall) +reverb.wetDryMix = 50 + +// Attach to engine +engine.attach(player) +engine.attach(reverb) + +// Connect: player → reverb → mixer → output +engine.connect(player, to: reverb, format: nil) +engine.connect(reverb, to: engine.mainMixerNode, format: nil) + +// Start +engine.prepare() +try engine.start() + +// Play file +let url = Bundle.main.url(forResource: "audio", withExtension: "m4a")! +let file = try AVAudioFile(forReading: url) +player.scheduleFile(file, at: nil) +player.play() +``` + +### Node Types + +| Node | Purpose | +|------|---------| +| `AVAudioPlayerNode` | Plays audio files/buffers | +| `AVAudioInputNode` | Mic input (engine.inputNode) | +| `AVAudioOutputNode` | Speaker output (engine.outputNode) | +| `AVAudioMixerNode` | Mix multiple inputs | +| `AVAudioUnitEQ` | Equalizer | +| `AVAudioUnitReverb` | Reverb effect | +| `AVAudioUnitDelay` | Delay effect | +| `AVAudioUnitDistortion` | Distortion effect | +| `AVAudioUnitTimePitch` | Time stretch / pitch shift | + +### Installing Taps (Audio Analysis) + +```swift +let inputNode = engine.inputNode +let format = inputNode.outputFormat(forBus: 0) + +inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, time in + // Process audio buffer + guard let channelData = buffer.floatChannelData?[0] else { return } + let frameLength = Int(buffer.frameLength) + + // Calculate RMS level + var sum: Float = 0 + for i in 0..UIBackgroundModes +// audio + +// 3. Set Now Playing info (recommended) +let nowPlayingInfo: [String: Any] = [ + MPMediaItemPropertyTitle: "Song Title", + MPMediaItemPropertyArtist: "Artist", + MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime, + MPMediaItemPropertyPlaybackDuration: duration +] +MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo +``` + +### Ducking Other Audio + +```swift +try AVAudioSession.sharedInstance().setCategory( + .playback, + options: .duckOthers +) + +// When done, restore others +try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) +``` + +### Bluetooth Device Handling + +```swift +// Allow all Bluetooth +try AVAudioSession.sharedInstance().setCategory( + .playAndRecord, + options: [.allowBluetooth, .allowBluetoothA2DP] +) + +// Check current Bluetooth route +let route = AVAudioSession.sharedInstance().currentRoute +let hasBluetoothOutput = route.outputs.contains { + $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP +} +``` + +--- + +## Anti-Patterns + +### Wrong Category + +```swift +// WRONG — music player using ambient (silenced by switch) +try AVAudioSession.sharedInstance().setCategory(.ambient) + +// CORRECT — music needs .playback +try AVAudioSession.sharedInstance().setCategory(.playback) +``` + +### Missing Interruption Handling + +```swift +// WRONG — no interruption observer +// Audio stops on phone call and never resumes + +// CORRECT — always handle interruptions +NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, + // ... handle began/ended +) +``` + +### Tap Memory Leaks + +```swift +// WRONG — tap installed, never removed +engine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { ... } + +// CORRECT — remove tap when done +deinit { + engine.inputNode.removeTap(onBus: 0) +} +``` + +### Format Mismatch Crashes + +```swift +// WRONG — connecting nodes with incompatible formats +engine.connect(playerNode, to: mixerNode, format: wrongFormat) // Crash! + +// CORRECT — use nil for automatic format negotiation, or match exactly +engine.connect(playerNode, to: mixerNode, format: nil) +``` + +### Forgetting to Activate Session + +```swift +// WRONG — configure but don't activate +try AVAudioSession.sharedInstance().setCategory(.playback) +// Audio doesn't work! + +// CORRECT — always activate +try AVAudioSession.sharedInstance().setCategory(.playback) +try AVAudioSession.sharedInstance().setActive(true) +``` + +--- + +## Resources + +**WWDC**: 2025-251, 2025-403, 2019-510 + +**Docs**: /avfoundation, /avkit, /cinematic + +--- + +**Targets:** iOS 12+ (core), iOS 26+ (spatial features) +**Frameworks:** AVFoundation, AVKit, Cinematic (iOS 26+) +**History:** See git log for changes diff --git a/.claude/skills/axiom-avfoundation-ref/agents/openai.yaml b/.claude/skills/axiom-avfoundation-ref/agents/openai.yaml new file mode 100644 index 0000000..3aa11e1 --- /dev/null +++ b/.claude/skills/axiom-avfoundation-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "AVFoundation Reference" + short_description: "Reference — AVFoundation audio APIs, AVAudioSession categories/modes, AVAudioEngine pipelines, bit-perfect DAC output..." diff --git a/.claude/skills/axiom-axe-ref/.openskills.json b/.claude/skills/axiom-axe-ref/.openskills.json new file mode 100644 index 0000000..f0a3479 --- /dev/null +++ b/.claude/skills/axiom-axe-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-axe-ref", + "installedAt": "2026-04-12T08:05:54.694Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-axe-ref/SKILL.md b/.claude/skills/axiom-axe-ref/SKILL.md new file mode 100644 index 0000000..47e18c8 --- /dev/null +++ b/.claude/skills/axiom-axe-ref/SKILL.md @@ -0,0 +1,409 @@ +--- +name: axiom-axe-ref +description: Use when automating iOS Simulator UI interactions beyond simctl capabilities. Reference for AXe CLI covering accessibility-based tapping, gestures, text input, screenshots, video recording, and UI tree inspection. +license: MIT +metadata: + version: "1.0.0" +--- + +# AXe Reference (iOS Simulator UI Automation) + +AXe is a CLI tool for interacting with iOS Simulators using Apple's Accessibility APIs and HID functionality. Single binary, no daemon required. + +## Installation + +```bash +brew install cameroncooke/axe/axe + +# Verify installation +axe --version +``` + +## Critical Best Practice: describe_ui First + +**ALWAYS run `describe_ui` before UI interactions.** Never guess coordinates from screenshots. + +**Best practice:** Use describe-ui to get precise element coordinates prior to using x/y parameters (don't guess from screenshots). + +```bash +# 1. FIRST: Get the UI tree with frame coordinates +axe describe-ui --udid $UDID + +# 2. THEN: Tap by accessibility ID (preferred) +axe tap --id "loginButton" --udid $UDID + +# 3. OR: Tap by label +axe tap --label "Login" --udid $UDID + +# 4. LAST RESORT: Tap by coordinates from describe-ui output +axe tap -x 200 -y 400 --udid $UDID +``` + +**Priority order for targeting elements:** +1. `--id` (accessibilityIdentifier) - most stable +2. `--label` (accessibility label) - stable but may change with localization +3. `-x -y` coordinates from `describe-ui` - fragile, use only when no identifier + +## Core Concept: Accessibility-First + +**AXe's key advantage**: Tap elements by accessibility identifier or label, not just coordinates. + +```bash +# Coordinate-based (fragile - breaks with layout changes) +axe tap -x 200 -y 400 --udid $UDID + +# Accessibility-based (stable - survives UI changes) +axe tap --id "loginButton" --udid $UDID +axe tap --label "Login" --udid $UDID +``` + +**Always prefer `--id` or `--label` over coordinates.** + +## Getting the Simulator UDID + +AXe requires the simulator UDID for most commands: + +```bash +# Get booted simulator UDID +UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid' | head -1) + +# List all simulators +axe list-simulators +``` + +## Touch & Tap Commands + +### Tap by Accessibility Identifier (Recommended) + +```bash +# Tap element with accessibilityIdentifier +axe tap --id "loginButton" --udid $UDID + +# Tap element with accessibility label +axe tap --label "Submit" --udid $UDID +``` + +### Tap by Coordinates + +```bash +# Basic tap +axe tap -x 200 -y 400 --udid $UDID + +# Tap with timing controls +axe tap -x 200 -y 400 --pre-delay 0.5 --post-delay 0.3 --udid $UDID + +# Long press (hold duration in seconds) +axe tap -x 200 -y 400 --duration 1.0 --udid $UDID +``` + +### Low-Level Touch Events + +```bash +# Touch down (finger press) +axe touch down -x 200 -y 400 --udid $UDID + +# Touch up (finger release) +axe touch up -x 200 -y 400 --udid $UDID +``` + +## Swipe & Gesture Commands + +### Custom Swipe + +```bash +# Swipe from point A to point B +axe swipe --start-x 200 --start-y 600 --end-x 200 --end-y 200 --udid $UDID + +# Swipe with duration (slower = more visible) +axe swipe --start-x 200 --start-y 600 --end-x 200 --end-y 200 --duration 0.5 --udid $UDID +``` + +### Gesture Presets + +```bash +# Scrolling +axe gesture scroll-up --udid $UDID # Scroll content up (swipe down) +axe gesture scroll-down --udid $UDID # Scroll content down (swipe up) +axe gesture scroll-left --udid $UDID +axe gesture scroll-right --udid $UDID + +# Edge swipes (navigation) +axe gesture swipe-from-left-edge --udid $UDID # Back navigation +axe gesture swipe-from-right-edge --udid $UDID +axe gesture swipe-from-top-edge --udid $UDID # Notification Center +axe gesture swipe-from-bottom-edge --udid $UDID # Home indicator/Control Center +``` + +## Text Input + +### Type Text + +```bash +# Type text (element must be focused) +axe type "user@example.com" --udid $UDID + +# Type with delay between characters +axe type "password123" --char-delay 0.1 --udid $UDID + +# Type from stdin +echo "Hello World" | axe type --stdin --udid $UDID + +# Type from file +axe type --file /tmp/input.txt --udid $UDID +``` + +### Keyboard Keys + +```bash +# Press specific key by HID keycode +axe key 40 --udid $UDID # Return/Enter + +# Common keycodes: +# 40 = Return/Enter +# 41 = Escape +# 42 = Backspace/Delete +# 43 = Tab +# 44 = Space +# 79 = Right Arrow +# 80 = Left Arrow +# 81 = Down Arrow +# 82 = Up Arrow + +# Key sequence with timing +axe key-sequence 40 43 40 --delay 0.2 --udid $UDID +``` + +## Hardware Buttons + +```bash +# Home button +axe button home --udid $UDID + +# Lock/Power button +axe button lock --udid $UDID + +# Long press power (shutdown dialog) +axe button lock --duration 3.0 --udid $UDID + +# Side button (iPhone X+) +axe button side-button --udid $UDID + +# Siri +axe button siri --udid $UDID + +# Apple Pay +axe button apple-pay --udid $UDID +``` + +## Screenshots + +```bash +# Screenshot to auto-named file +axe screenshot --udid $UDID +# Output: screenshot_2026-01-11_143052.png + +# Screenshot to specific file +axe screenshot --output /tmp/my-screenshot.png --udid $UDID + +# Screenshot to stdout (for piping) +axe screenshot --stdout --udid $UDID > screenshot.png +``` + +## Video Recording & Streaming + +### Record Video + +```bash +# Start recording (Ctrl+C to stop) +axe record-video --output /tmp/recording.mp4 --udid $UDID + +# Record with quality settings +axe record-video --output /tmp/recording.mp4 --quality high --udid $UDID + +# Record with scale (reduce file size) +axe record-video --output /tmp/recording.mp4 --scale 0.5 --udid $UDID +``` + +### Stream Video + +```bash +# Stream at 10 FPS (default) +axe stream-video --udid $UDID + +# Stream at specific framerate (1-30 FPS) +axe stream-video --fps 30 --udid $UDID + +# Stream formats +axe stream-video --format mjpeg --udid $UDID # MJPEG (default) +axe stream-video --format jpeg --udid $UDID # Individual JPEGs +axe stream-video --format ffmpeg --udid $UDID # FFmpeg compatible +axe stream-video --format bgra --udid $UDID # Raw BGRA +``` + +## UI Inspection (describe-ui) + +**Critical for finding accessibility identifiers and labels.** + +### Full Screen UI Tree + +```bash +# Get complete accessibility tree +axe describe-ui --udid $UDID + +# Output includes: +# - Element type (Button, TextField, StaticText, etc.) +# - Accessibility identifier +# - Accessibility label +# - Frame (position and size) +# - Enabled/disabled state +``` + +### Point-Specific UI Info + +```bash +# Get element at specific coordinates +axe describe-ui --point 200,400 --udid $UDID +``` + +### Example Output + +```json +{ + "type": "Button", + "identifier": "loginButton", + "label": "Login", + "frame": {"x": 150, "y": 380, "width": 100, "height": 44}, + "enabled": true, + "focused": false +} +``` + +## Common Workflows + +### Login Flow + +```bash +UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid' | head -1) + +# Tap email field and type +axe tap --id "emailTextField" --udid $UDID +axe type "user@example.com" --udid $UDID + +# Tap password field and type +axe tap --id "passwordTextField" --udid $UDID +axe type "password123" --udid $UDID + +# Tap login button +axe tap --id "loginButton" --udid $UDID + +# Wait and screenshot +sleep 2 +axe screenshot --output /tmp/login-result.png --udid $UDID +``` + +### Discover Elements Before Automating + +```bash +# 1. Get the UI tree +axe describe-ui --udid $UDID > /tmp/ui-tree.json + +# 2. Find elements (search for identifiers) +cat /tmp/ui-tree.json | jq '.[] | select(.identifier != null) | {identifier, label, type}' + +# 3. Use discovered identifiers in automation +axe tap --id "discoveredIdentifier" --udid $UDID +``` + +### Scroll to Find Element + +```bash +# Scroll down until element appears (pseudo-code pattern) +for i in {1..5}; do + if axe describe-ui --udid $UDID | grep -q "targetElement"; then + axe tap --id "targetElement" --udid $UDID + break + fi + axe gesture scroll-down --udid $UDID + sleep 0.5 +done +``` + +### Screenshot on Error + +```bash +# Automation with error capture +if ! axe tap --id "submitButton" --udid $UDID; then + axe screenshot --output /tmp/error-state.png --udid $UDID + axe describe-ui --udid $UDID > /tmp/error-ui-tree.json + echo "Failed to tap submitButton - see error-state.png" +fi +``` + +## Timing Controls + +Most commands support timing options: + +| Option | Description | +|--------|-------------| +| `--pre-delay` | Wait before action (seconds) | +| `--post-delay` | Wait after action (seconds) | +| `--duration` | Action duration (for taps, button presses) | +| `--char-delay` | Delay between characters (for type) | + +```bash +# Example with full timing control +axe tap --id "button" --pre-delay 0.5 --post-delay 0.3 --udid $UDID +``` + +## AXe vs simctl + +| Capability | simctl | AXe | +|------------|--------|-----| +| Device lifecycle | ✅ | ❌ | +| Permissions | ✅ | ❌ | +| Push notifications | ✅ | ❌ | +| Status bar | ✅ | ❌ | +| Deep links | ✅ | ❌ | +| Screenshots | ✅ | ✅ (PNG) | +| Video recording | ✅ | ✅ (H.264) | +| Video streaming | ❌ | ✅ | +| UI tap/swipe | ❌ | ✅ | +| Type text | ❌ | ✅ | +| Hardware buttons | ❌ | ✅ | +| Accessibility tree | ❌ | ✅ | + +**Use both together**: simctl for device control, AXe for UI automation. + +## Troubleshooting + +### Element Not Found + +1. Run `axe describe-ui` to see available elements +2. Check element has `accessibilityIdentifier` set in code +3. Ensure element is visible (not off-screen) + +### Tap Doesn't Work + +1. Check element is enabled (`"enabled": true` in describe-ui) +2. Try adding `--pre-delay 0.5` for slow-loading UI +3. Verify correct UDID with `axe list-simulators` + +### Type Not Working + +1. Ensure text field is focused first: `axe tap --id "textField"` +2. Check keyboard is visible +3. Try `--char-delay 0.05` for reliability + +### Permission Denied + +AXe uses private APIs - ensure you're running on a Mac with Xcode installed and proper entitlements. + +## Resources + +**GitHub**: https://github.com/cameroncooke/AXe + +**Related**: xcsentinel (build orchestration) + +**Skills**: axiom-xctest-automation, axiom-ui-testing + +**Agents**: simulator-tester, test-runner diff --git a/.claude/skills/axiom-axe-ref/agents/openai.yaml b/.claude/skills/axiom-axe-ref/agents/openai.yaml new file mode 100644 index 0000000..ead26a6 --- /dev/null +++ b/.claude/skills/axiom-axe-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Axe Reference" + short_description: "Automating iOS Simulator UI interactions beyond simctl capabilities" diff --git a/.claude/skills/axiom-background-processing-diag/.openskills.json b/.claude/skills/axiom-background-processing-diag/.openskills.json new file mode 100644 index 0000000..505d47c --- /dev/null +++ b/.claude/skills/axiom-background-processing-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-background-processing-diag", + "installedAt": "2026-04-12T08:05:56.179Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-background-processing-diag/SKILL.md b/.claude/skills/axiom-background-processing-diag/SKILL.md new file mode 100644 index 0000000..9f522be --- /dev/null +++ b/.claude/skills/axiom-background-processing-diag/SKILL.md @@ -0,0 +1,441 @@ +--- +name: axiom-background-processing-diag +description: Symptom-based background task troubleshooting - decision trees for 'task never runs', 'task terminates early', 'works in dev not prod', 'handler not called', with time-cost analysis for each diagnosis path +license: MIT +metadata: + version: "1.0.0" +--- + +# Background Processing Diagnostics + +Symptom-based troubleshooting for background task issues. + +**Related skills**: `axiom-background-processing` (patterns, checklists), `axiom-background-processing-ref` (API reference) + +--- + +## Symptom 1: Task Never Runs + +Handler never called despite successful `submit()`. + +### Quick Diagnosis (5 minutes) + +``` +Task never runs? +│ +├─ Step 1: Check Info.plist (2 min) +│ ├─ BGTaskSchedulerPermittedIdentifiers contains EXACT identifier? +│ │ └─ NO → Add identifier, rebuild +│ ├─ UIBackgroundModes includes "fetch" or "processing"? +│ │ └─ NO → Add required mode +│ └─ Identifiers case-sensitive match code? +│ └─ NO → Fix typo, rebuild +│ +├─ Step 2: Check registration timing (2 min) +│ ├─ Registered in didFinishLaunchingWithOptions? +│ │ └─ NO → Move registration before return true +│ └─ Registration before first submit()? +│ └─ NO → Ensure register() precedes submit() +│ +└─ Step 3: Check app state (1 min) + ├─ App swiped away from App Switcher? + │ └─ YES → No background until user opens app + └─ Background App Refresh disabled in Settings? + └─ YES → Enable or inform user +``` + +### Time-Cost Analysis + +| Approach | Time | Success Rate | +|----------|------|--------------| +| Check Info.plist + registration | 5 min | 70% (catches most issues) | +| Add console logging | 15 min | 90% | +| LLDB simulate launch | 5 min | 95% (confirms handler works) | +| Random code changes | 2+ hours | Low | + +### LLDB Quick Test + +Verify handler is correctly registered: + +```lldb +e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourapp.refresh"] +``` + +If breakpoint hits → Registration correct, issue is scheduling/system factors. +If nothing happens → Registration broken. + +--- + +## Symptom 2: Task Terminates Unexpectedly + +Handler called but work doesn't complete before termination. + +### Quick Diagnosis (5 minutes) + +``` +Task terminates early? +│ +├─ Step 1: Check expiration handler (1 min) +│ ├─ Expiration handler set FIRST in handler? +│ │ └─ NO → Move to very first line +│ └─ Expiration handler actually cancels work? +│ └─ NO → Add cancellation logic +│ +├─ Step 2: Check setTaskCompleted (2 min) +│ ├─ Called in success path? +│ ├─ Called in failure path? +│ ├─ Called after expiration? +│ └─ ANY path missing → Task never signals completion +│ +├─ Step 3: Check work duration (2 min) +│ ├─ BGAppRefreshTask work > 30 seconds? +│ │ └─ YES → Chunk work or use BGProcessingTask +│ └─ BGProcessingTask work > system limit? +│ └─ YES → Save progress, resume on next launch +``` + +### Common Causes + +| Cause | Fix | +|-------|-----| +| Missing expiration handler | Set handler as first line | +| setTaskCompleted not called | Add to ALL code paths | +| Work takes too long | Chunk and checkpoint | +| Network timeout > task time | Use background URLSession | +| Async callback after expiration | Check shouldContinue flag | + +### Test Expiration Handling + +```lldb +// First simulate launch +e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourapp.refresh"] + +// Then force expiration +e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.yourapp.refresh"] +``` + +Verify expiration handler runs and work stops gracefully. + +--- + +## Symptom 3: Background URLSession Delegate Not Called + +Download completes but `didFinishDownloadingTo` never fires. + +### Quick Diagnosis (5 minutes) + +``` +URLSession delegate not called? +│ +├─ Step 1: Check session configuration (2 min) +│ ├─ Using URLSessionConfiguration.background()? +│ │ └─ NO → Must use background config +│ ├─ Session identifier unique? +│ │ └─ NO → Use unique bundle-prefixed ID +│ └─ sessionSendsLaunchEvents = true? +│ └─ NO → Set for app relaunch on completion +│ +├─ Step 2: Check AppDelegate handler (2 min) +│ ├─ handleEventsForBackgroundURLSession implemented? +│ │ └─ NO → Required for session events +│ └─ Completion handler stored and called later? +│ └─ NO → Store handler, call after events processed +│ +└─ Step 3: Check delegate assignment (1 min) + ├─ Session created with delegate? + └─ Delegate not nil when task completes? +``` + +### Required AppDelegate Code + +```swift +// Store completion handler +var backgroundSessionCompletionHandler: (() -> Void)? + +func application(_ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void) { + backgroundSessionCompletionHandler = completionHandler +} + +// Call after all events processed +func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + DispatchQueue.main.async { + self.backgroundSessionCompletionHandler?() + self.backgroundSessionCompletionHandler = nil + } +} +``` + +--- + +## Symptom 4: Works in Development, Not Production + +Task runs with debugger but fails in release builds or for users. + +### Quick Diagnosis (10 minutes) + +``` +Works in dev, not prod? +│ +├─ Step 1: Check system constraints (3 min) +│ ├─ Low Power Mode enabled? +│ │ └─ Check ProcessInfo.isLowPowerModeEnabled +│ ├─ Background App Refresh disabled? +│ │ └─ Check UIApplication.backgroundRefreshStatus +│ └─ Battery < 20%? +│ └─ System pauses discretionary work +│ +├─ Step 2: Check app state (2 min) +│ ├─ App force-quit from App Switcher? +│ │ └─ YES → No background until foreground launch +│ └─ App recently used? +│ └─ Rarely used apps get lower priority +│ +├─ Step 3: Check build differences (3 min) +│ ├─ Debug vs Release optimization differences? +│ ├─ #if DEBUG code excluding production? +│ └─ Different bundle identifier in release? +│ +└─ Step 4: Add production logging (2 min) + └─ Log task schedule/launch/complete to analytics +``` + +### The 7 Scheduling Factors + +All affect task execution in production: + +| Factor | Check | +|--------|-------| +| Critically Low Battery | Battery < 20%? | +| Low Power Mode | ProcessInfo.isLowPowerModeEnabled | +| App Usage | User opens app frequently? | +| App Switcher | App NOT swiped away? | +| Background App Refresh | Settings enabled? | +| System Budgets | Many recent background launches? | +| Rate Limiting | Requests too frequent? | + +### Production Debugging + +Add logging to track what's happening: + +```swift +func scheduleRefresh() { + let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh") + do { + try BGTaskScheduler.shared.submit(request) + Analytics.log("background_task_scheduled") + } catch { + Analytics.log("background_task_schedule_failed", error: error) + } +} + +func handleRefresh(task: BGAppRefreshTask) { + Analytics.log("background_task_started") + // ... work ... + Analytics.log("background_task_completed") + task.setTaskCompleted(success: true) +} +``` + +--- + +## Symptom 5: Inconsistent Task Scheduling + +Task runs sometimes but not predictably. + +### Quick Diagnosis (5 minutes) + +``` +Inconsistent scheduling? +│ +├─ Step 1: Understand earliestBeginDate (2 min) +│ ├─ This is MINIMUM delay, not scheduled time +│ │ └─ System runs when convenient AFTER this date +│ └─ Set too far in future (> 1 week)? +│ └─ System may skip task entirely +│ +├─ Step 2: Check scheduling pattern (2 min) +│ ├─ Scheduling same task multiple times? +│ │ └─ Call getPendingTaskRequests to check +│ └─ Scheduling in handler for continuity? +│ └─ Required for continuous refresh +│ +└─ Step 3: Understand system behavior (1 min) + ├─ BGAppRefreshTask runs based on USER patterns + │ └─ User rarely opens app = rare runs + └─ BGProcessingTask runs when charging + └─ User doesn't charge overnight = no runs +``` + +### Expected Behavior + +| Task Type | Scheduling Behavior | +|-----------|---------------------| +| BGAppRefreshTask | Runs before predicted app usage times | +| BGProcessingTask | Runs when charging + idle (typically overnight) | +| Silent Push | Rate-limited; 14 pushes may = 7 launches | + +**Key insight**: You request a time window. System decides when (or if) to run. + +--- + +## Symptom 6: App Crashes on Background Launch + +App crashes when launched by system for background task. + +### Quick Diagnosis (5 minutes) + +``` +Crash on background launch? +│ +├─ Step 1: Check launch initialization (2 min) +│ ├─ UI setup before task handler? +│ │ └─ Background launch may not have UI context +│ ├─ Accessing files before first unlock? +│ │ └─ Use completeUntilFirstUserAuthentication protection +│ └─ Force unwrapping optionals that may be nil? +│ └─ Guard against nil in background context +│ +├─ Step 2: Check handler safety (2 min) +│ ├─ Handler captures self strongly? +│ │ └─ Use [weak self] to prevent retain cycles +│ └─ Handler accesses UI on non-main thread? +│ └─ Dispatch UI work to main queue +│ +└─ Step 3: Check data protection (1 min) + └─ Files accessible when device locked? + └─ Use .completeUnlessOpen or .completeUntilFirstUserAuthentication +``` + +### File Protection for Background Tasks + +```swift +// Set appropriate protection when creating files +try data.write(to: url, options: .completeFileProtectionUntilFirstUserAuthentication) + +// Or configure in entitlements for entire app +``` + +### Safe Handler Pattern + +```swift +BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.app.refresh", + using: nil +) { [weak self] task in + guard let self = self else { + task.setTaskCompleted(success: false) + return + } + + // Don't access UI + // Use background-safe APIs only + self.performBackgroundWork(task: task) +} +``` + +--- + +## Symptom 7: Task Runs Multiple Times + +Same task appears to run repeatedly or in parallel. + +### Quick Diagnosis (5 minutes) + +``` +Task runs multiple times? +│ +├─ Step 1: Check scheduling logic (2 min) +│ ├─ Scheduling on every app launch? +│ │ └─ Check getPendingTaskRequests first +│ ├─ Scheduling in handler AND elsewhere? +│ │ └─ Consolidate to single location +│ └─ Using same identifier for different purposes? +│ └─ Use unique identifiers per task type +│ +├─ Step 2: Check for duplicate submissions (2 min) +│ └─ Multiple submit() calls queued? +│ └─ System may batch into single execution +│ +└─ Step 3: Check handler execution (1 min) + └─ setTaskCompleted called promptly? + └─ Delay may cause system to think task hung +``` + +### Prevent Duplicate Scheduling + +```swift +func scheduleRefreshIfNeeded() { + BGTaskScheduler.shared.getPendingTaskRequests { requests in + let alreadyScheduled = requests.contains { + $0.identifier == "com.app.refresh" + } + + if !alreadyScheduled { + self.scheduleRefresh() + } + } +} +``` + +--- + +## Quick Diagnostic Checklist + +### 30-Second Check + +- [ ] Info.plist has identifier? +- [ ] Registration in didFinishLaunchingWithOptions? +- [ ] App not swiped away? + +### 5-Minute Check + +- [ ] Identifiers exactly match (case-sensitive)? +- [ ] Background mode enabled (fetch/processing)? +- [ ] setTaskCompleted called in all paths? +- [ ] Expiration handler set first? + +### 15-Minute Investigation + +- [ ] LLDB simulate launch works? +- [ ] LLDB simulate expiration handled? +- [ ] Console shows registration/scheduling logs? +- [ ] Real device (not just simulator)? +- [ ] Release build (not just debug)? +- [ ] Background App Refresh enabled in Settings? + +--- + +## Console Log Filters + +``` +// All background task events +subsystem:com.apple.backgroundtaskscheduler + +// Specific to your app +subsystem:com.apple.backgroundtaskscheduler message:"com.yourapp" +``` + +### Expected Log Sequence + +1. "Registered handler for task with identifier" +2. "Scheduling task with identifier" +3. "Starting task with identifier" +4. (your work executes) +5. "Task completed with identifier" + +Missing any step = issue at that stage. + +--- + +## Resources + +**WWDC**: 2019-707 (debugging commands), 2020-10063 (7 factors) + +**Skills**: axiom-background-processing, axiom-background-processing-ref + +--- + +**Last Updated**: 2025-12-31 +**Platforms**: iOS 13+ diff --git a/.claude/skills/axiom-background-processing-diag/agents/openai.yaml b/.claude/skills/axiom-background-processing-diag/agents/openai.yaml new file mode 100644 index 0000000..b083e1d --- /dev/null +++ b/.claude/skills/axiom-background-processing-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Background Processing Diagnostics" + short_description: "Symptom-based background task troubleshooting" diff --git a/.claude/skills/axiom-background-processing-ref/.openskills.json b/.claude/skills/axiom-background-processing-ref/.openskills.json new file mode 100644 index 0000000..d75499a --- /dev/null +++ b/.claude/skills/axiom-background-processing-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-background-processing-ref", + "installedAt": "2026-04-12T08:05:56.911Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-background-processing-ref/SKILL.md b/.claude/skills/axiom-background-processing-ref/SKILL.md new file mode 100644 index 0000000..071b2bb --- /dev/null +++ b/.claude/skills/axiom-background-processing-ref/SKILL.md @@ -0,0 +1,778 @@ +--- +name: axiom-background-processing-ref +description: Complete background task API reference - BGTaskScheduler, BGAppRefreshTask, BGProcessingTask, BGContinuedProcessingTask (iOS 26), beginBackgroundTask, background URLSession, with all WWDC code examples +license: MIT +metadata: + version: "1.0.0" +--- + +# Background Processing Reference + +Complete API reference for iOS background execution, with code examples from WWDC sessions. + +**Related skills**: `axiom-background-processing` (decision trees, patterns), `axiom-background-processing-diag` (troubleshooting) + +--- + +## Part 1: BGTaskScheduler Registration + +### Info.plist Configuration + +```xml + +BGTaskSchedulerPermittedIdentifiers + + com.yourapp.refresh + com.yourapp.maintenance + + com.yourapp.export.* + + + +UIBackgroundModes + + + fetch + + processing + +``` + +### Register Handler + +```swift +import BackgroundTasks + +func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? +) -> Bool { + + // Register BEFORE returning from didFinishLaunching + BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.yourapp.refresh", + using: nil // nil = system creates serial background queue + ) { task in + self.handleAppRefresh(task: task as! BGAppRefreshTask) + } + + BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.yourapp.maintenance", + using: nil + ) { task in + self.handleMaintenance(task: task as! BGProcessingTask) + } + + return true +} +``` + +**Parameters**: +- `forTaskWithIdentifier`: Must match Info.plist exactly (case-sensitive) +- `using`: DispatchQueue for handler callback; nil = system creates one +- `launchHandler`: Called when task is launched; receives BGTask subclass + +### Registration Timing + +From WWDC 2019-707: +> "You do this by registering a launch handler **before your application finishes launching**" + +Register in: +- ✅ `application(_:didFinishLaunchingWithOptions:)` before `return true` +- ❌ Not in viewDidLoad, button handlers, or async callbacks + +--- + +## Part 2: BGAppRefreshTask + +### Purpose + +Keep app content fresh throughout the day. System launches app based on **user usage patterns**. + +### Runtime + +~30 seconds (same as legacy background fetch) + +### Scheduling + +```swift +func scheduleAppRefresh() { + let request = BGAppRefreshTaskRequest(identifier: "com.yourapp.refresh") + + // earliestBeginDate = MINIMUM delay (not exact time) + // System decides actual time based on usage patterns + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + + do { + try BGTaskScheduler.shared.submit(request) + } catch BGTaskScheduler.Error.notPermitted { + // Background App Refresh disabled in Settings + } catch BGTaskScheduler.Error.tooManyPendingTaskRequests { + // Too many pending requests for this identifier + } catch BGTaskScheduler.Error.unavailable { + // Background tasks not available (Simulator, etc.) + } catch { + print("Schedule failed: \(error)") + } +} + +// Schedule when app enters background +func applicationDidEnterBackground(_ application: UIApplication) { + scheduleAppRefresh() +} +``` + +### Handler + +```swift +func handleAppRefresh(task: BGAppRefreshTask) { + // 1. Set expiration handler FIRST + task.expirationHandler = { [weak self] in + self?.currentOperation?.cancel() + } + + // 2. Schedule NEXT refresh (continuous pattern) + scheduleAppRefresh() + + // 3. Perform work + let operation = fetchLatestContentOperation() + currentOperation = operation + + operation.completionBlock = { + // 4. Signal completion (REQUIRED) + task.setTaskCompleted(success: !operation.isCancelled) + } + + operationQueue.addOperation(operation) +} +``` + +### BGAppRefreshTaskRequest Properties + +| Property | Type | Description | +|----------|------|-------------| +| `identifier` | String | Must match Info.plist | +| `earliestBeginDate` | Date? | Minimum delay before execution | + +--- + +## Part 3: BGProcessingTask + +### Purpose + +Deferrable maintenance work (database cleanup, ML training, backups). Runs at **system-friendly times**, typically overnight when charging. + +### Runtime + +Several minutes (significantly longer than refresh tasks) + +### Scheduling with Constraints + +```swift +func scheduleMaintenanceIfNeeded() { + // Only schedule if work is needed + guard Date().timeIntervalSince(lastMaintenanceDate) > 7 * 24 * 3600 else { + return + } + + let request = BGProcessingTaskRequest(identifier: "com.yourapp.maintenance") + + // CRITICAL for CPU-intensive work + request.requiresExternalPower = true + + // Optional: Need network for cloud sync + request.requiresNetworkConnectivity = true + + // Keep within 1 week — longer may be skipped + // request.earliestBeginDate = ... + + do { + try BGTaskScheduler.shared.submit(request) + } catch { + print("Schedule failed: \(error)") + } +} +``` + +### Handler with Progress Checkpointing + +```swift +func handleMaintenance(task: BGProcessingTask) { + var shouldContinue = true + + task.expirationHandler = { + shouldContinue = false + } + + Task { + for item in workItems { + guard shouldContinue else { + // Expiration called — save progress and exit + saveProgress() + break + } + + await processItem(item) + saveProgress() // Checkpoint after each item + } + + task.setTaskCompleted(success: shouldContinue) + } +} +``` + +### BGProcessingTaskRequest Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `identifier` | String | — | Must match Info.plist | +| `earliestBeginDate` | Date? | nil | Minimum delay | +| `requiresNetworkConnectivity` | Bool | false | Wait for network | +| `requiresExternalPower` | Bool | false | Wait for charging | + +### CPU Monitor Disabling + +> "For the first time ever, we're giving you the ability to turn that off for the duration of your processing task so you can take full advantage of the hardware while the device is plugged in." + +When `requiresExternalPower = true`, CPU Monitor (which normally terminates CPU-heavy background apps) is disabled. + +--- + +## Part 4: BGContinuedProcessingTask (iOS 26+) + +### Purpose + +Continue **user-initiated work** after app backgrounds, with system UI showing progress. From WWDC 2025-227. + +**NOT for**: Automatic tasks, maintenance, syncing — user must explicitly initiate. + +### Use Cases + +- Photo/video export +- Publishing content +- Updating connected accessories +- File compression + +### Info.plist (Wildcard Pattern) + +```xml +BGTaskSchedulerPermittedIdentifiers + + + com.yourapp.export.* + +``` + +### Dynamic Registration + +Unlike other tasks, register **when user initiates action**: + +```swift +func userTappedExportButton() { + // Register dynamically + BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.yourapp.export.photos" + ) { task in + let continuedTask = task as! BGContinuedProcessingTask + self.handleExport(task: continuedTask) + } + + // Submit immediately + submitExportRequest() +} +``` + +### Submission with Progress UI + +```swift +func submitExportRequest() { + let request = BGContinuedProcessingTaskRequest( + identifier: "com.yourapp.export.photos", + title: "Exporting Photos", // Shown in system UI + subtitle: "0 of 100 photos complete" // Shown in system UI + ) + + // Strategy: .fail = reject if can't start now; .enqueue = queue (default) + request.strategy = .fail + + do { + try BGTaskScheduler.shared.submit(request) + } catch { + // Show error — can't run in background now + showError("Cannot export in background right now") + } +} +``` + +### Handler with Mandatory Progress Reporting + +```swift +func handleExport(task: BGContinuedProcessingTask) { + var shouldContinue = true + + task.expirationHandler = { + shouldContinue = false + } + + // MANDATORY: Report progress + // Tasks with no progress updates are AUTO-EXPIRED + task.progress.totalUnitCount = Int64(photos.count) + task.progress.completedUnitCount = 0 + + Task { + for (index, photo) in photos.enumerated() { + guard shouldContinue else { break } + + await exportPhoto(photo) + + // Update progress — system displays to user + task.progress.completedUnitCount = Int64(index + 1) + } + + task.setTaskCompleted(success: shouldContinue) + } +} +``` + +### BGContinuedProcessingTaskRequest Properties + +| Property | Type | Description | +|----------|------|-------------| +| `identifier` | String | With wildcard, can have dynamic suffix | +| `title` | String | Shown in system progress UI | +| `subtitle` | String | Shown in system progress UI | +| `strategy` | Strategy | `.fail` or `.enqueue` (default) | + +### Strategy Options + +```swift +// .fail — Reject if can't start immediately +request.strategy = .fail + +// .enqueue — Queue if can't start (default) +// Task may run later +``` + +### GPU Access (iOS 26+) + +```swift +// Check if GPU available for background task +let supportedResources = BGTaskScheduler.shared.supportedResources +if supportedResources.contains(.gpu) { + // GPU is available +} +``` + +--- + +## Part 5: beginBackgroundTask + +### Purpose + +Finish critical work (~30 seconds) when app transitions to background. For state saving, completing uploads. + +### Basic Pattern + +```swift +var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid + +func applicationDidEnterBackground(_ application: UIApplication) { + backgroundTaskID = application.beginBackgroundTask(withName: "Save State") { + // Expiration handler — clean up immediately + self.saveProgress() + application.endBackgroundTask(self.backgroundTaskID) + self.backgroundTaskID = .invalid + } + + // Do critical work + saveEssentialState { [weak self] in + guard let self = self, + self.backgroundTaskID != .invalid else { return } + + // End task AS SOON AS work completes + UIApplication.shared.endBackgroundTask(self.backgroundTaskID) + self.backgroundTaskID = .invalid + } +} +``` + +### Key Points + +- Call `endBackgroundTask` immediately when done — don't wait for expiration +- Failing to end may cause system to terminate app +- ~30 seconds max, not guaranteed +- Use for finalization, not ongoing work + +### SwiftUI / SceneDelegate + +```swift +.onChange(of: scenePhase) { newPhase in + if newPhase == .background { + startBackgroundTask() + } +} +``` + +--- + +## Part 6: Background URLSession + +### Purpose + +Large downloads/uploads that continue **even after app termination**. Work handed off to system daemon. + +### Configuration + +```swift +lazy var backgroundSession: URLSession = { + let config = URLSessionConfiguration.background( + withIdentifier: "com.yourapp.downloads" + ) + + // App relaunched when task completes + config.sessionSendsLaunchEvents = true + + // System chooses optimal time (WiFi, charging) + config.isDiscretionary = true + + // Timeout for requests (not the download itself) + config.timeoutIntervalForRequest = 60 + + return URLSession(configuration: config, delegate: self, delegateQueue: nil) +}() +``` + +### Starting Download + +```swift +func downloadFile(from url: URL) { + let task = backgroundSession.downloadTask(with: url) + task.resume() + // Work continues even if app terminates +} +``` + +### AppDelegate Handler + +```swift +var backgroundSessionCompletionHandler: (() -> Void)? + +func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void +) { + // Store — call after all events processed + backgroundSessionCompletionHandler = completionHandler +} +``` + +### URLSessionDelegate Implementation + +```swift +extension AppDelegate: URLSessionDelegate, URLSessionDownloadDelegate { + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + // MUST move file immediately — temp location deleted after return + let destination = getDestinationURL(for: downloadTask) + try? FileManager.default.moveItem(at: location, to: destination) + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + // All events processed — call stored completion handler + DispatchQueue.main.async { + self.backgroundSessionCompletionHandler?() + self.backgroundSessionCompletionHandler = nil + } + } +} +``` + +### Configuration Properties + +| Property | Default | Description | +|----------|---------|-------------| +| `sessionSendsLaunchEvents` | false | Relaunch app on completion | +| `isDiscretionary` | false | Wait for optimal conditions | +| `allowsCellularAccess` | true | Allow cellular network | +| `allowsExpensiveNetworkAccess` | true | Allow expensive networks | +| `allowsConstrainedNetworkAccess` | true | Allow Low Data Mode | + +--- + +## Part 7: Testing Background Tasks + +### LLDB Debugging Commands + +Pause app in debugger, then execute: + +```lldb +// Trigger task launch +e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourapp.refresh"] + +// Trigger task expiration +e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.yourapp.refresh"] +``` + +### Testing Workflow + +1. Set breakpoint in task handler +2. Run app, let it enter background +3. Pause execution (Debug → Pause) +4. Execute simulate launch command +5. Resume — breakpoint should hit +6. Test expiration handling with simulate expiration + +### Console Logging + +Filter Console.app: + +``` +subsystem:com.apple.backgroundtaskscheduler +``` + +### getPendingTaskRequests + +Check what's scheduled: + +```swift +BGTaskScheduler.shared.getPendingTaskRequests { requests in + for request in requests { + print("Pending: \(request.identifier)") + print(" Earliest: \(request.earliestBeginDate ?? Date())") + } +} +``` + +--- + +## Part 8: Throttling & System Constraints + +### The 7 Scheduling Factors + +| Factor | How to Check | Impact | +|--------|--------------|--------| +| Critically Low Battery | Battery < ~20% | Discretionary work paused | +| Low Power Mode | `ProcessInfo.isLowPowerModeEnabled` | Limited activity | +| App Usage | User opens app frequently? | Higher priority | +| App Switcher | Not swiped away? | Swiped = no background | +| Background App Refresh | `backgroundRefreshStatus` | Off = no BGAppRefresh | +| System Budgets | Many recent launches? | Budget depletes, refills daily | +| Rate Limiting | Requests too frequent? | System spaces launches | + +### Checking Constraints + +```swift +// Low Power Mode +if ProcessInfo.processInfo.isLowPowerModeEnabled { + // Reduce background work +} + +// Listen for changes +NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange) + .sink { _ in + // Adapt behavior + } + +// Background App Refresh status +switch UIApplication.shared.backgroundRefreshStatus { +case .available: + // Can schedule tasks +case .denied: + // User disabled — prompt in Settings +case .restricted: + // MDM or parental controls — cannot enable +@unknown default: + break +} +``` + +### Thermal State + +```swift +switch ProcessInfo.processInfo.thermalState { +case .nominal: + break // Normal operation +case .fair: + // Reduce intensive work +case .serious: + // Minimize all background activity +case .critical: + // Stop non-essential work immediately +@unknown default: + break +} + +NotificationCenter.default.publisher(for: ProcessInfo.thermalStateDidChangeNotification) + .sink { _ in + // Respond to thermal changes + } +``` + +--- + +## Part 9: Push Notifications for Background + +### Silent Push Payload + +```json +{ + "aps": { + "content-available": 1 + }, + "custom-data": "your-payload" +} +``` + +### APNS Priority + +``` +apns-priority: 5 // Discretionary — energy efficient (recommended) +apns-priority: 10 // Immediate — only for time-sensitive +``` + +### Handler + +```swift +func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void +) { + guard userInfo["aps"] as? [String: Any] != nil else { + completionHandler(.noData) + return + } + + Task { + do { + let hasNewData = try await fetchLatestData() + completionHandler(hasNewData ? .newData : .noData) + } catch { + completionHandler(.failed) + } + } +} +``` + +### Rate Limiting Behavior + +> "Receiving 14 pushes in a window may result in only 7 launches, maintaining a ~15-minute interval." + +Silent pushes are rate-limited. Don't expect launch on every push. + +--- + +## Part 10: SwiftUI Integration + +### backgroundTask Modifier + +```swift +@main +struct MyApp: App { + @Environment(\.scenePhase) var scenePhase + + var body: some Scene { + WindowGroup { + ContentView() + } + .onChange(of: scenePhase) { newPhase in + if newPhase == .background { + scheduleAppRefresh() + } + } + // App refresh handler + .backgroundTask(.appRefresh("com.yourapp.refresh")) { + scheduleAppRefresh() // Schedule next + await fetchLatestContent() + // Task completes when closure returns (no setTaskCompleted needed) + } + // Background URLSession handler + .backgroundTask(.urlSession("com.yourapp.downloads")) { + await processDownloadedFiles() + } + } +} +``` + +### Cancellation with Swift Concurrency + +```swift +.backgroundTask(.appRefresh("com.yourapp.refresh")) { + await withTaskCancellationHandler { + // Normal work + try await fetchData() + } onCancel: { + // Called when task expires + // Keep lightweight — runs synchronously + } +} +``` + +### Background URLSession with SwiftUI + +```swift +.backgroundTask(.urlSession("com.yourapp.weather")) { + // Called when background URLSession completes + // Handle completed downloads +} +``` + +--- + +## Quick Reference + +### Task Types + +| Type | Runtime | API | Use Case | +|------|---------|-----|----------| +| BGAppRefreshTask | ~30s | submit(BGAppRefreshTaskRequest) | Fresh content | +| BGProcessingTask | Minutes | submit(BGProcessingTaskRequest) | Maintenance | +| BGContinuedProcessingTask | Extended | submit(BGContinuedProcessingTaskRequest) | User-initiated | +| beginBackgroundTask | ~30s | beginBackgroundTask(withName:) | State saving | +| Background URLSession | As needed | URLSessionConfiguration.background | Downloads | +| Silent Push | ~30s | didReceiveRemoteNotification | Server trigger | + +### Required Info.plist + +```xml +BGTaskSchedulerPermittedIdentifiers + + your.identifiers.here + + +UIBackgroundModes + + fetch + processing + +``` + +### LLDB Commands + +```lldb +// Launch +e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"ID"] + +// Expire +e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"ID"] +``` + +--- + +## Resources + +**WWDC**: 2019-707, 2020-10063, 2022-10142, 2023-10170, 2025-227 + +**Docs**: /backgroundtasks, /backgroundtasks/bgtaskscheduler, /foundation/urlsessionconfiguration + +**Skills**: axiom-background-processing, axiom-background-processing-diag + +--- + +**Last Updated**: 2025-12-31 +**Platforms**: iOS 13+, iOS 26+ (BGContinuedProcessingTask) diff --git a/.claude/skills/axiom-background-processing-ref/agents/openai.yaml b/.claude/skills/axiom-background-processing-ref/agents/openai.yaml new file mode 100644 index 0000000..e62ecb4 --- /dev/null +++ b/.claude/skills/axiom-background-processing-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Background Processing Reference" + short_description: "Complete background task API reference" diff --git a/.claude/skills/axiom-background-processing/.openskills.json b/.claude/skills/axiom-background-processing/.openskills.json new file mode 100644 index 0000000..9c53601 --- /dev/null +++ b/.claude/skills/axiom-background-processing/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-background-processing", + "installedAt": "2026-04-12T08:05:55.443Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-background-processing/SKILL.md b/.claude/skills/axiom-background-processing/SKILL.md new file mode 100644 index 0000000..de885d6 --- /dev/null +++ b/.claude/skills/axiom-background-processing/SKILL.md @@ -0,0 +1,1011 @@ +--- +name: axiom-background-processing +description: Use when implementing BGTaskScheduler, debugging background tasks that never run, understanding why tasks terminate early, or testing background execution - systematic task lifecycle management with proper registration, expiration handling, and Swift 6 cancellation patterns +license: MIT +metadata: + version: "1.0.0" +--- + +# Background Processing + +## Overview + +Background execution is a **privilege**, not a right. iOS actively limits background work to protect battery life and user experience. **Core principle**: Treat background tasks as discretionary jobs — you request a time window, the system decides when (or if) to run your code. + +**Key insight**: Most "my task never runs" issues stem from registration mistakes or misunderstanding the 7 scheduling factors that govern execution. This skill provides systematic debugging, not guesswork. + +**Energy optimization**: For reducing battery impact of background tasks, see `axiom-energy` skill. This skill focuses on task **mechanics** — making tasks run correctly and complete reliably. + +**Requirements**: iOS 13+ (BGTaskScheduler), iOS 26+ (BGContinuedProcessingTask), Xcode 15+ + +## Example Prompts + +Real questions developers ask that this skill answers: + +#### 1. "My background task never runs. I register it, schedule it, but nothing happens." +→ The skill covers the registration checklist and debugging decision tree for "task never runs" issues + +#### 2. "How do I test background tasks? They don't seem to trigger in the simulator." +→ The skill covers LLDB debugging commands and simulator limitations + +#### 3. "My task gets terminated before it completes. How do I extend the time?" +→ The skill covers task types (BGAppRefresh 30s vs BGProcessing minutes), expiration handlers, and incremental progress saving + +#### 4. "Should I use BGAppRefreshTask or BGProcessingTask? What's the difference?" +→ The skill provides decision tree for choosing the correct task type based on work duration and system requirements + +#### 5. "How do I integrate Swift 6 concurrency with background task expiration?" +→ The skill covers withTaskCancellationHandler patterns for bridging BGTask expiration to structured concurrency + +#### 6. "My background task works in development but not in production." +→ The skill covers the 7 scheduling factors, throttling behavior, and production debugging + +--- + +## Red Flags — Task Won't Run or Terminates + +If you see ANY of these, suspect registration or scheduling issues: + +- **Task never runs**: Handler never called despite successful `submit()` +- **Task terminates immediately**: Handler called but work doesn't complete +- **Works in dev, not prod**: Task runs with debugger but not in release builds +- **Console shows no launch**: No "BackgroundTask" entries in unified logging +- **Identifier mismatch errors**: Task identifier not matching Info.plist +- **"No handler registered"**: Handler not registered before first scheduling + +#### Difference from energy issues +- **Energy issue**: Task runs but drains battery (see `axiom-energy` skill) +- **This skill**: Task doesn't run, or terminates before completing work + +--- + +## Mandatory First Steps + +**ALWAYS verify these before debugging code**: + +### Step 1: Verify Info.plist Configuration (2 minutes) + +```xml + +BGTaskSchedulerPermittedIdentifiers + + com.yourapp.refresh + com.yourapp.processing + + + +UIBackgroundModes + + fetch + + + + + fetch + processing + +``` + +**Common mistake**: Identifier in code doesn't EXACTLY match Info.plist. Check for typos, case sensitivity. + +### Step 2: Verify Registration Timing (2 minutes) + +Registration MUST happen before app finishes launching: + +```swift +// ✅ CORRECT: Register in didFinishLaunchingWithOptions +func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.yourapp.refresh", + using: nil + ) { task in + // Safe force cast: identifier guarantees BGAppRefreshTask type + self.handleAppRefresh(task: task as! BGAppRefreshTask) + } + + return true // Register BEFORE returning +} + +// ❌ WRONG: Registering after launch or on-demand +func someButtonTapped() { + // TOO LATE - registration won't work + BGTaskScheduler.shared.register(...) +} +``` + +**Exception**: BGContinuedProcessingTask (iOS 26+) uses dynamic registration when user initiates the action. + +### Step 3: Check Console Logs (5 minutes) + +Filter Console.app for background task events: + +``` +subsystem:com.apple.backgroundtaskscheduler +``` + +Look for: +- "Registered handler for task with identifier" +- "Scheduling task with identifier" +- "Starting task with identifier" +- "Task completed with identifier" +- Error messages about missing handlers or identifiers + +### Step 4: Verify App Not Swiped Away (1 minute) + +**Critical**: If user force-quits app from App Switcher, NO background tasks will run. + +Check in App Switcher: Is your app still visible? Swiping away = no background execution until user launches again. + +--- + +## Background Task Decision Tree + +``` +Need to run code in the background? +│ +├─ User initiated the action explicitly (button tap)? +│ ├─ iOS 26+? → BGContinuedProcessingTask (Pattern 4) +│ └─ iOS 13-25? → beginBackgroundTask + save progress (Pattern 5) +│ +├─ Keep content fresh throughout the day? +│ ├─ Runtime needed ≤ 30 seconds? → BGAppRefreshTask (Pattern 1) +│ └─ Need several minutes? → BGProcessingTask with constraints (Pattern 2) +│ +├─ Deferrable maintenance work (DB cleanup, ML training)? +│ └─ BGProcessingTask with requiresExternalPower (Pattern 2) +│ +├─ Large downloads/uploads? +│ └─ Background URLSession (Pattern 6) +│ +├─ Triggered by server data changes? +│ └─ Silent push notification → fetch data → complete handler (Pattern 7) +│ +└─ Short critical work when app backgrounds? + └─ beginBackgroundTask (Pattern 5) +``` + +### Task Type Comparison + +| Type | Runtime | When Runs | Use Case | +|------|---------|-----------|----------| +| BGAppRefreshTask | ~30 seconds | Based on user app usage patterns | Fetch latest content | +| BGProcessingTask | Several minutes | Device charging, idle (typically overnight) | Maintenance, ML training | +| BGContinuedProcessingTask | Extended | System-managed with progress UI | User-initiated export/publish | +| beginBackgroundTask | ~30 seconds | Immediately when backgrounding | Save state, finish upload | +| Background URLSession | As needed | System-friendly time, even after termination | Large transfers | + +--- + +## Common Patterns + +### Pattern 1: BGAppRefreshTask — Keep Content Fresh + +**Use when**: You need to fetch new content so app feels fresh when user opens it. + +**Runtime**: ~30 seconds + +**When system runs it**: Predicted based on user's app usage patterns. If user opens app every morning, system learns and refreshes before then. + +#### Registration (at app launch) + +```swift +func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.yourapp.refresh", + using: nil + ) { task in + self.handleAppRefresh(task: task as! BGAppRefreshTask) + } + + return true +} +``` + +#### Scheduling (when app backgrounds) + +```swift +func scheduleAppRefresh() { + let request = BGAppRefreshTaskRequest(identifier: "com.yourapp.refresh") + + // earliestBeginDate = MINIMUM delay, not exact time + // System may run hours later based on usage patterns + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // At least 15 min + + do { + try BGTaskScheduler.shared.submit(request) + } catch { + print("Failed to schedule refresh: \(error)") + } +} + +// Call when app enters background +func applicationDidEnterBackground(_ application: UIApplication) { + scheduleAppRefresh() +} + +// Or with SceneDelegate / SwiftUI +.onChange(of: scenePhase) { newPhase in + if newPhase == .background { + scheduleAppRefresh() + } +} +``` + +#### Handler + +```swift +func handleAppRefresh(task: BGAppRefreshTask) { + // 1. IMMEDIATELY set expiration handler + task.expirationHandler = { [weak self] in + // Cancel any in-progress work + self?.currentOperation?.cancel() + } + + // 2. Schedule NEXT refresh (continuous refresh pattern) + scheduleAppRefresh() + + // 3. Do the work + fetchLatestContent { [weak self] result in + switch result { + case .success: + task.setTaskCompleted(success: true) + case .failure: + task.setTaskCompleted(success: false) + } + } +} +``` + +**Key points**: +- Set expiration handler FIRST +- Schedule next refresh inside handler (continuous pattern) +- Call `setTaskCompleted` in ALL code paths (success AND failure) +- Keep work under 30 seconds + +--- + +### Pattern 2: BGProcessingTask — Deferrable Maintenance + +**Use when**: Maintenance work that can wait for optimal system conditions (charging, WiFi, idle). + +**Runtime**: Several minutes + +**When system runs it**: Typically overnight when device is charging. May not run daily. + +#### Registration + +```swift +BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.yourapp.maintenance", + using: nil +) { task in + self.handleMaintenance(task: task as! BGProcessingTask) +} +``` + +#### Scheduling with Constraints + +```swift +func scheduleMaintenanceIfNeeded() { + // Be conscientious — only schedule when work is actually needed + guard needsMaintenance() else { return } + + let request = BGProcessingTaskRequest(identifier: "com.yourapp.maintenance") + + // CRITICAL: Set requiresExternalPower for CPU-intensive work + request.requiresExternalPower = true + + // Optional: Require network for cloud sync + request.requiresNetworkConnectivity = true + + // Don't set earliestBeginDate too far — max ~1 week + // If user doesn't return to app, task won't run + + do { + try BGTaskScheduler.shared.submit(request) + } catch BGTaskScheduler.Error.unavailable { + print("Background processing not available") + } catch { + print("Failed to schedule: \(error)") + } +} +``` + +#### Handler with Progress Checkpointing + +```swift +func handleMaintenance(task: BGProcessingTask) { + var shouldContinue = true + + task.expirationHandler = { [weak self] in + shouldContinue = false + self?.saveProgress() // Save partial progress! + } + + Task { + do { + // Process in chunks, checking for expiration + for chunk in workChunks { + guard shouldContinue else { + // Expiration called — stop gracefully + break + } + + try await processChunk(chunk) + saveProgress() // Checkpoint after each chunk + } + + task.setTaskCompleted(success: true) + } catch { + task.setTaskCompleted(success: false) + } + } +} +``` + +**Key points**: +- Set `requiresExternalPower = true` for CPU-intensive work (prevents battery drain) +- Save progress incrementally — task may be interrupted +- Work may never run if user doesn't charge device +- Don't set `earliestBeginDate` more than a week ahead + +--- + +### Pattern 3: SwiftUI backgroundTask Modifier + +**Use when**: SwiftUI app using modern async/await patterns. + +```swift +@main +struct MyApp: App { + @Environment(\.scenePhase) var scenePhase + + var body: some Scene { + WindowGroup { + ContentView() + } + .onChange(of: scenePhase) { newPhase in + if newPhase == .background { + scheduleAppRefresh() + } + } + // Handle app refresh + .backgroundTask(.appRefresh("com.yourapp.refresh")) { + // Schedule next refresh + scheduleAppRefresh() + + // Async work — task completes when closure returns + await fetchLatestContent() + } + // Handle background URLSession events + .backgroundTask(.urlSession("com.yourapp.downloads")) { + // Called when background URLSession completes + await processDownloadedFiles() + } + } +} +``` + +**SwiftUI advantages**: +- Implicit task completion when closure returns (no `setTaskCompleted` needed) +- Native Swift Concurrency support +- Task automatically cancelled on expiration + +--- + +### Pattern 4: BGContinuedProcessingTask (iOS 26+) + +**Use when**: User explicitly initiates work (button tap) that should continue after backgrounding, with visible progress. + +**NOT for**: Automatic tasks, maintenance, syncing + +```swift +// 1. Info.plist — use wildcard for dynamic suffix +// BGTaskSchedulerPermittedIdentifiers: +// "com.yourapp.export.*" + +// 2. Register WHEN user initiates action (not at launch) +func userTappedExportButton() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.yourapp.export.photos" + ) { task in + let continuedTask = task as! BGContinuedProcessingTask + self.handleExport(task: continuedTask) + } + + // Submit immediately + let request = BGContinuedProcessingTaskRequest( + identifier: "com.yourapp.export.photos", + title: "Exporting Photos", + subtitle: "0 of 100 photos" + ) + + // Optional: Fail if can't start immediately + request.strategy = .fail // or .enqueue (default) + + do { + try BGTaskScheduler.shared.submit(request) + } catch { + showError("Cannot export in background right now") + } +} + +// 3. Handler with mandatory progress reporting +func handleExport(task: BGContinuedProcessingTask) { + var shouldContinue = true + + task.expirationHandler = { + shouldContinue = false + } + + // MANDATORY: Report progress (tasks with no progress auto-expire) + task.progress.totalUnitCount = 100 + task.progress.completedUnitCount = 0 + + Task { + for (index, photo) in photos.enumerated() { + guard shouldContinue else { break } + + await exportPhoto(photo) + + // Update progress — system shows this to user + task.progress.completedUnitCount = Int64(index + 1) + } + + task.setTaskCompleted(success: shouldContinue) + } +} +``` + +**Key points**: +- Dynamic registration (when user acts, not at launch) +- Progress reporting is MANDATORY — tasks with no updates auto-expire +- User can monitor and cancel from system UI +- Use `.fail` strategy when work is only useful if it starts immediately + +--- + +### Pattern 5: beginBackgroundTask — Short Critical Work + +**Use when**: App is backgrounding and you need ~30 seconds to finish critical work (save state, complete upload). + +```swift +var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid + +func applicationDidEnterBackground(_ application: UIApplication) { + // Start background task + backgroundTaskID = application.beginBackgroundTask(withName: "Save State") { [weak self] in + // Expiration handler — clean up and end task + self?.saveProgress() + if let taskID = self?.backgroundTaskID { + application.endBackgroundTask(taskID) + } + self?.backgroundTaskID = .invalid + } + + // Do critical work + saveEssentialState { [weak self] in + // End task as soon as done — DON'T wait for expiration + if let taskID = self?.backgroundTaskID, taskID != .invalid { + UIApplication.shared.endBackgroundTask(taskID) + self?.backgroundTaskID = .invalid + } + } +} +``` + +**Key points**: +- Call `endBackgroundTask` AS SOON as work completes (not just in expiration handler) +- Failing to end task may cause system to terminate your app and impact future launches +- ~30 seconds max, not guaranteed +- Use for state saving, not ongoing work + +--- + +### Pattern 6: Background URLSession + +**Use when**: Large downloads/uploads that should continue even if app terminates. + +```swift +// 1. Create background configuration +lazy var backgroundSession: URLSession = { + let config = URLSessionConfiguration.background( + withIdentifier: "com.yourapp.downloads" + ) + config.sessionSendsLaunchEvents = true // App relaunched when complete + config.isDiscretionary = true // System chooses optimal time + + return URLSession(configuration: config, delegate: self, delegateQueue: nil) +}() + +// 2. Start download +func downloadFile(from url: URL) { + let task = backgroundSession.downloadTask(with: url) + task.resume() +} + +// 3. Handle app relaunch for session events (AppDelegate) +func application(_ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void) { + + // Store completion handler — call after processing events + backgroundSessionCompletionHandler = completionHandler + + // Session delegate methods will be called +} + +// 4. URLSessionDelegate +func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + // All events processed — call stored completion handler + DispatchQueue.main.async { + self.backgroundSessionCompletionHandler?() + self.backgroundSessionCompletionHandler = nil + } +} + +func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL) { + // Move file from temp location before returning + let destinationURL = getDestinationURL(for: downloadTask) + try? FileManager.default.moveItem(at: location, to: destinationURL) +} +``` + +**Key points**: +- Work handed off to system daemon (`nsurlsessiond`) — continues after app termination +- `isDiscretionary = true` for non-urgent (system waits for WiFi, charging) +- Must handle `handleEventsForBackgroundURLSession` for app relaunch +- Move downloaded files immediately — temp location deleted after delegate returns + +--- + +### Pattern 7: Silent Push Notification Trigger + +**Use when**: Server needs to wake app to fetch new data. + +#### Server Payload + +```json +{ + "aps": { + "content-available": 1 + }, + "custom-data": "fetch-new-messages" +} +``` + +Use `apns-priority: 5` (not 10) for energy efficiency. + +#### App Handler + +```swift +func application(_ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + + Task { + do { + let hasNewData = try await fetchLatestData() + completionHandler(hasNewData ? .newData : .noData) + } catch { + completionHandler(.failed) + } + } +} +``` + +**Key points**: +- Silent pushes are rate-limited — don't expect launch on every push +- System coalesces multiple pushes (14 pushes may result in 7 launches) +- Budget depletes with each launch and refills throughout day +- ~30 seconds runtime per launch + +> For silent push notification patterns (content-available payload, throttling limits, APNs setup), see axiom-push-notifications. + +--- + +## Swift 6 Cancellation Integration + +When using structured concurrency, bridge BGTask expiration to task cancellation: + +```swift +func handleAppRefresh(task: BGAppRefreshTask) { + // Create a Task that respects expiration + let workTask = Task { + try await withTaskCancellationHandler { + // Your async work + try await fetchAndProcessData() + task.setTaskCompleted(success: true) + } onCancel: { + // Called synchronously when task.cancel() is invoked + // Note: Runs on arbitrary thread, keep lightweight + } + } + + // Bridge expiration to cancellation + task.expirationHandler = { + workTask.cancel() // Triggers onCancel block + } +} + +// Checking cancellation in your work +func fetchAndProcessData() async throws { + for item in items { + // Check if we should stop + try Task.checkCancellation() + + // Or non-throwing check + guard !Task.isCancelled else { + saveProgress() + return + } + + try await process(item) + } +} +``` + +**Key points**: +- `withTaskCancellationHandler` handles cancellation while task is suspended +- `Task.checkCancellation()` throws `CancellationError` if cancelled +- `Task.isCancelled` for non-throwing check +- Cancellation is cooperative — your code must check and respond + +--- + +## Testing Background Tasks + +### Simulator Limitations + +Background tasks **do not run automatically** in simulator. You must manually trigger them. + +### LLDB Debugging Commands + +While app is running with debugger attached, pause execution and run: + +```lldb +// Trigger task launch +e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourapp.refresh"] + +// Trigger task expiration (test expiration handler) +e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.yourapp.refresh"] +``` + +### Testing Workflow + +1. Set breakpoint in task handler +2. Run app, let it background +3. Pause in debugger +4. Run `_simulateLaunchForTaskWithIdentifier` command +5. Resume — breakpoint should hit +6. Test expiration with `_simulateExpirationForTaskWithIdentifier` + +### Testing Checklist + +- [ ] Task handler breakpoint hits when simulated? +- [ ] Expiration handler called when simulated? +- [ ] `setTaskCompleted` called in all code paths? +- [ ] Works on real device (not just simulator)? +- [ ] Works in release build (not just debug)? +- [ ] App not swiped away from App Switcher? + +--- + +## The 7 Scheduling Factors + +From WWDC 2020-10063 "Background execution demystified": + +| Factor | Description | Impact | +|--------|-------------|--------| +| **Critically Low Battery** | <20% battery | All discretionary work paused | +| **Low Power Mode** | User-enabled | Background activity limited | +| **App Usage** | How often user launches app | More usage = higher priority | +| **App Switcher** | App still visible? | Swiped away = no background | +| **Background App Refresh** | System setting | Off = no BGAppRefresh tasks | +| **System Budgets** | Energy/data budgets | Deplete with launches, refill over day | +| **Rate Limiting** | System spacing | Prevents too-frequent launches | + +### Responding to System Constraints + +```swift +// Check Low Power Mode +if ProcessInfo.processInfo.isLowPowerModeEnabled { + // Reduce background work +} + +// Listen for changes +NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange) + .sink { _ in + // Adapt behavior + } + +// Check Background App Refresh status +let status = UIApplication.shared.backgroundRefreshStatus +switch status { +case .available: + break // Good to schedule +case .denied: + // User disabled — prompt to enable in Settings +case .restricted: + // Parental controls or MDM — can't enable +} +``` + +--- + +## Audit Checklists + +### Registration Checklist + +- [ ] Identifier in Info.plist exactly matches code (case-sensitive)? +- [ ] Correct background mode enabled (`fetch`, `processing`)? +- [ ] Registration happens in `didFinishLaunchingWithOptions` BEFORE return? +- [ ] Not registering same identifier multiple times? +- [ ] Handler closure doesn't capture self strongly? + +### Scheduling Checklist + +- [ ] Scheduling on main queue or background queue (if performance sensitive)? +- [ ] `earliestBeginDate` not too far in future (max ~1 week)? +- [ ] Handling `submit()` errors? +- [ ] Not scheduling duplicate tasks (check `getPendingTaskRequests`)? + +### Handler Checklist + +- [ ] Expiration handler set IMMEDIATELY at start of handler? +- [ ] `setTaskCompleted(success:)` called in ALL code paths? +- [ ] Next task scheduled (for continuous patterns)? +- [ ] Progress saved incrementally for long operations? +- [ ] Expiration handler actually cancels ongoing work? + +### Production Readiness + +- [ ] Tested on real device, not just simulator? +- [ ] Tested in release build, not just debug? +- [ ] Tested with Low Power Mode enabled? +- [ ] Tested after force-quit from App Switcher (should NOT run)? +- [ ] Console logs show expected "Task completed" messages? + +--- + +## Pressure Scenarios + +### Scenario 1: "Just poll the server every 30 seconds in background" + +**The temptation**: "Polling is simpler than push notifications. We need real-time updates." + +**The reality**: +- iOS will NOT give you 30-second background intervals +- BGAppRefreshTask runs based on USER behavior patterns, not your schedule +- If user rarely opens app, task may run once per day or less +- Polling burns budget quickly — fewer total launches + +**Time cost comparison**: +- Implement polling: 30 minutes (won't work as expected) +- Understand why it doesn't work: 2-4 hours debugging +- Implement proper push notifications: 3-4 hours + +**What actually works**: +- Silent push notifications (server triggers, not polling) +- BGAppRefreshTask for predicted user behavior (not real-time) +- BGProcessingTask for deferrable work (overnight) + +**Pushback template**: "iOS background execution doesn't support polling intervals. BGAppRefreshTask runs based on when iOS predicts the user will open our app, not on a fixed schedule. For real-time updates, we need server-side push notifications. Let me show you Apple's documentation on this." + +--- + +### Scenario 2: "My task needs 5 minutes, not 30 seconds" + +**The temptation**: "I'll just use beginBackgroundTask and do all my work." + +**The reality**: +- beginBackgroundTask: ~30 seconds max +- BGAppRefreshTask: ~30 seconds +- BGProcessingTask: Several minutes, but only when charging +- No API gives you guaranteed 5-minute foreground-quality runtime + +**What actually works**: +1. **Chunk your work** — Break into 30-second pieces, save progress +2. **Use BGProcessingTask** with `requiresExternalPower = true` (runs overnight) +3. **iOS 26+**: Use BGContinuedProcessingTask for user-initiated work + +**Pushback template**: "iOS limits background runtime to protect battery. For work that needs several minutes, we have two options: (1) BGProcessingTask runs overnight when charging — great for maintenance, (2) Break work into chunks that complete in 30 seconds, saving progress between runs. Which fits our use case better?" + +--- + +### Scenario 3: "It works on my device but not for users" + +**The temptation**: "The code is correct — it must be a user device issue." + +**The reality**: Debug builds with Xcode attached behave differently than release builds in the wild. + +**Common causes**: +1. **Low Power Mode enabled** — limits background activity +2. **Background App Refresh disabled** — user or parental controls +3. **App swiped away** — kills all background tasks +4. **Budget exhausted** — too many recent launches +5. **Rarely used app** — system deprioritizes + +**Debugging steps**: +1. Check `backgroundRefreshStatus` at launch, log it +2. Log when tasks are scheduled and completed +3. Use MetricKit to monitor background launches in production +4. Ask users: "Did you force-quit the app from App Switcher?" + +**Pushback template**: "Background execution depends on 7 system factors including battery level, user app usage patterns, and whether they force-quit the app. Let me add logging to understand what's happening for affected users." + +--- + +### Scenario 4: "Ship now, add background tasks later" + +**The temptation**: "Background work is a nice-to-have feature." + +**The reality**: +- Users expect content to be fresh when they open the app +- Competing apps that refresh in background feel more responsive +- Adding background tasks later requires careful registration timing +- First impression of stale content drives retention + +**Time cost comparison**: +- Add BGAppRefreshTask now: 1-2 hours +- Retrofit later with proper testing: 4-6 hours +- Debug "why doesn't it work" issues: Additional hours + +**Minimum viable background**: +```swift +// In didFinishLaunchingWithOptions +BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.yourapp.refresh", + using: nil +) { task in + task.setTaskCompleted(success: true) // Placeholder + self.scheduleRefresh() +} +``` + +**Pushback template**: "Background refresh is a core expectation for [type of app]. The minimum implementation is 20 lines of code. If we ship without it and add later, we risk registration timing bugs. Let me add the scaffolding now so we can enhance it post-launch." + +--- + +## Real-World Examples + +### Example 1: Task Never Runs — Identifier Mismatch + +**Symptom**: `submit()` succeeds but handler never called. + +**Diagnosis**: +```swift +// Code uses: +BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.myapp.Refresh", // Capital R + ... +) + +// Info.plist has: +// "com.myapp.refresh" // lowercase r +``` + +**Fix**: Identifiers must EXACTLY match (case-sensitive). + +**Time wasted**: 2 hours debugging code logic when issue was typo. + +--- + +### Example 2: Task Terminates — Missing setTaskCompleted + +**Symptom**: Handler runs, work appears to complete, but next scheduled task never runs. + +**Diagnosis**: +```swift +func handleRefresh(task: BGAppRefreshTask) { + fetchData { result in + switch result { + case .success: + task.setTaskCompleted(success: true) // ✅ Called + case .failure: + // ❌ Missing setTaskCompleted! + print("Failed") + } + } +} +``` + +**Fix**: Call `setTaskCompleted` in ALL code paths including errors. + +```swift +case .failure: + task.setTaskCompleted(success: false) // ✅ Now called +``` + +**Impact**: Failing to call setTaskCompleted may cause system to penalize app's background budget. + +--- + +### Example 3: Works in Dev, Not Production — Force Quit + +**Symptom**: Users report background sync doesn't work. Developer can't reproduce. + +**Diagnosis**: +``` +User: "I close my apps every night to save battery." +Developer: "How do you close them?" +User: "Swipe up in the app switcher." +``` + +**Reality**: Swiping away from App Switcher = force quit = no background tasks until user opens app again. + +**Fix**: +1. Educate users (not ideal) +2. Accept this is iOS behavior +3. Ensure good first-launch experience when app reopens + +--- + +### Example 4: BGProcessingTask Never Runs — Missing Power Requirement + +**Symptom**: BGProcessingTask scheduled but never executes. + +**Diagnosis**: User has phone plugged in at night, but task has `requiresExternalPower = true` and user uses wireless charger. + +Wait, that's not the issue. Real issue: +```swift +let request = BGProcessingTaskRequest(identifier: "com.app.maintenance") +// Missing: request.requiresExternalPower = true +``` + +Without `requiresExternalPower`, system STILL waits for charging but has less certainty. Setting it explicitly gives system clear signal. + +Also: User must have launched app in foreground within ~2 weeks for processing tasks to be eligible. + +--- + +## Quick Reference + +### LLDB Debugging Commands + +```lldb +// Trigger task +e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"IDENTIFIER"] + +// Trigger expiration +e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"IDENTIFIER"] +``` + +### Console Filter + +``` +subsystem:com.apple.backgroundtaskscheduler +``` + +### Task Type Summary + +| Need | Use | Runtime | +|------|-----|---------| +| Keep content fresh | BGAppRefreshTask | ~30s | +| Heavy maintenance | BGProcessingTask + requiresExternalPower | Minutes | +| User-initiated continuation | BGContinuedProcessingTask (iOS 26) | Extended | +| Finish on background | beginBackgroundTask | ~30s | +| Large downloads | Background URLSession | As needed | +| Server-triggered | Silent push notification | ~30s | + +--- + +## Resources + +**WWDC**: 2019-707, 2020-10063, 2022-10142, 2023-10170, 2025-227 + +**Docs**: /backgroundtasks/bgtaskscheduler, /backgroundtasks/starting-and-terminating-tasks-during-development + +**Skills**: axiom-background-processing-ref, axiom-background-processing-diag, axiom-energy, axiom-push-notifications + +--- + +**Last Updated**: 2025-12-31 +**Platforms**: iOS 13+, iOS 26+ (BGContinuedProcessingTask) +**Status**: Production-ready background task patterns diff --git a/.claude/skills/axiom-background-processing/agents/openai.yaml b/.claude/skills/axiom-background-processing/agents/openai.yaml new file mode 100644 index 0000000..42aa2ac --- /dev/null +++ b/.claude/skills/axiom-background-processing/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Background Processing" + short_description: "Implementing BGTaskScheduler, debugging background tasks that never run, understanding why tasks terminate early, or ..." diff --git a/.claude/skills/axiom-build-debugging/.openskills.json b/.claude/skills/axiom-build-debugging/.openskills.json new file mode 100644 index 0000000..df58545 --- /dev/null +++ b/.claude/skills/axiom-build-debugging/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-build-debugging", + "installedAt": "2026-04-12T08:05:57.598Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-build-debugging/SKILL.md b/.claude/skills/axiom-build-debugging/SKILL.md new file mode 100644 index 0000000..763430e --- /dev/null +++ b/.claude/skills/axiom-build-debugging/SKILL.md @@ -0,0 +1,514 @@ +--- +name: axiom-build-debugging +description: Use when encountering dependency conflicts, CocoaPods/SPM resolution failures, "Multiple commands produce" errors, or framework version mismatches - systematic dependency and build configuration debugging for iOS projects. Includes pressure scenario guidance for resisting quick fixes under time constraints +license: MIT +metadata: + version: "1.1.0" + last-updated: "TDD-tested with production crisis scenarios" +--- + +# Build Debugging + +## Overview + +Check dependencies BEFORE blaming code. **Core principle** 80% of persistent build failures are dependency resolution issues (CocoaPods, SPM, framework conflicts), not code bugs. + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "I added a Swift Package but I'm getting 'No such module' errors. The package is in my Xcode project but won't compile." +→ The skill covers SPM resolution workflows, package cache clearing, and framework search path diagnostics + +#### 2. "The build is failing with 'Multiple commands produce' the same output file. How do I figure out which files are duplicated?" +→ The skill shows how to identify duplicate target membership and resolve file conflicts in build settings + +#### 3. "CocoaPods installed dependencies successfully but the build still fails. How do I debug CocoaPods issues?" +→ The skill covers Podfile.lock conflict resolution, linking errors, and version constraint debugging + +#### 4. "My build works on my Mac but fails on the CI server. Both machines have the latest Xcode. What's different?" +→ The skill explains dependency caching differences, environment-specific paths, and reproducible build strategies + +#### 5. "I'm getting framework version conflicts and I don't know which dependency is causing it. How do I resolve this?" +→ The skill demonstrates dependency graph analysis and version constraint resolution strategies for complex dependency trees + +--- + +## Red Flags — Dependency/Build Issues + +If you see ANY of these, suspect dependency problem: +- "No such module" after adding package +- "Multiple commands produce" same output file +- Build succeeds on one machine, fails on another +- CocoaPods install succeeds but build fails +- SPM resolution takes forever or times out +- Framework version conflicts in error logs + +## Quick Decision Tree + +``` +Build failing? +├─ "No such module XYZ"? +│ ├─ After adding SPM package? +│ │ └─ Clean build folder + reset package caches +│ ├─ After pod install? +│ │ └─ Check Podfile.lock conflicts +│ └─ Framework not found? +│ └─ Check FRAMEWORK_SEARCH_PATHS +├─ "Multiple commands produce"? +│ └─ Duplicate files in target membership +├─ SPM resolution hangs? +│ └─ Clear package caches + derived data +└─ Version conflicts? + └─ Use dependency resolution strategies below +``` + +## Common Build Issues + +### Issue 1: SPM Package Not Found + +**Symptom**: "No such module PackageName" after adding Swift Package + +**❌ WRONG**: +```bash +# Rebuilding without cleaning +xcodebuild build +``` + +**✅ CORRECT**: +```bash +# Reset package caches first +rm -rf ~/Library/Developer/Xcode/DerivedData +rm -rf ~/Library/Caches/org.swift.swiftpm + +# Reset packages in project +xcodebuild -resolvePackageDependencies + +# Clean build +xcodebuild clean build -scheme YourScheme +``` + +### Issue 2: CocoaPods Conflicts + +**Symptom**: Pod install succeeds but build fails with framework errors + +**Check Podfile.lock**: +```bash +# See what versions were actually installed +cat Podfile.lock | grep -A 2 "PODS:" + +# Compare with Podfile requirements +cat Podfile | grep "pod " +``` + +**Fix version conflicts**: +```ruby +# Podfile - be explicit about versions +pod 'Alamofire', '~> 5.8.0' # Not just 'Alamofire' +pod 'SwiftyJSON', '5.0.1' # Exact version if needed +``` + +**Clean reinstall**: +```bash +# Remove all pods +rm -rf Pods/ +rm Podfile.lock + +# Reinstall +pod install + +# Open workspace (not project!) +open YourApp.xcworkspace +``` + +### Issue 3: Multiple Commands Produce Error + +**Symptom**: "Multiple commands produce '/path/to/file'" + +**Cause**: Same file added to multiple targets or build phases + +**Fix**: +1. Open Xcode +2. Select file in navigator +3. File Inspector → Target Membership +4. Uncheck duplicate targets +5. Or: Build Phases → Copy Bundle Resources → remove duplicates + +### Issue 4: Framework Search Paths + +**Symptom**: "Framework not found" or "Linker command failed" + +**Check build settings**: +```bash +# Show all build settings +xcodebuild -showBuildSettings -scheme YourScheme | grep FRAMEWORK_SEARCH_PATHS +``` + +**Fix in Xcode**: +1. Target → Build Settings +2. Search "Framework Search Paths" +3. Add path: `$(PROJECT_DIR)/Frameworks` (recursive) +4. Or: `$(inherited)` to inherit from project + +### Issue 5: SPM Version Conflicts + +**Symptom**: Package resolution fails with version conflicts + +**See dependency graph**: +```bash +# In project directory +swift package show-dependencies + +# Or see resolved versions +cat Package.resolved +``` + +**Fix conflicts**: +```swift +// Package.swift - be explicit +.package(url: "https://github.com/owner/repo", exact: "1.2.3") // Exact version +.package(url: "https://github.com/owner/repo", from: "1.2.0") // Minimum version +.package(url: "https://github.com/owner/repo", .upToNextMajor(from: "1.0.0")) // SemVer +``` + +**Reset resolution**: +```bash +# Clear package caches +rm -rf .build +rm Package.resolved + +# Re-resolve +swift package resolve +``` + +## Dependency Resolution Strategies + +### Strategy 1: Lock to Specific Versions + +When stability matters more than latest features: + +**CocoaPods**: +```ruby +pod 'Alamofire', '5.8.0' # Exact version +pod 'SwiftyJSON', '~> 5.0.0' # Any 5.0.x +``` + +**SPM**: +```swift +.package(url: "...", exact: "1.2.3") +``` + +### Strategy 2: Use Version Ranges + +When you want bug fixes but not breaking changes: + +**CocoaPods**: +```ruby +pod 'Alamofire', '~> 5.8' # 5.8.x but not 5.9 +pod 'SwiftyJSON', '>= 5.0', '< 6.0' # Range +``` + +**SPM**: +```swift +.package(url: "...", from: "1.2.0") // 1.2.0 and higher +.package(url: "...", .upToNextMajor(from: "1.0.0")) // 1.x.x but not 2.0.0 +``` + +### Strategy 3: Fork and Pin + +When you need custom modifications: + +```bash +# Fork repo on GitHub +# Clone your fork +git clone https://github.com/yourname/package.git + +# In Package.swift, use your fork +.package(url: "https://github.com/yourname/package", branch: "custom-fixes") +``` + +### Strategy 4: Exclude Transitive Dependencies + +When a dependency's dependency conflicts: + +**SPM (not directly supported, use workarounds)**: +```swift +// Instead of this: +.package(url: "https://github.com/problematic/package") + +// Fork it and remove the conflicting dependency from its Package.swift +``` + +**CocoaPods**: +```ruby +# Exclude specific subspecs +pod 'Firebase/Core' # Not all of Firebase +pod 'Firebase/Analytics' +``` + +## Build Configuration Issues + +### Debug vs Release Differences + +**Symptom**: Builds in Debug, fails in Release (or vice versa) + +**Check optimization settings**: +```bash +# Compare Debug and Release settings +xcodebuild -showBuildSettings -configuration Debug > debug.txt +xcodebuild -showBuildSettings -configuration Release > release.txt +diff debug.txt release.txt +``` + +**Common culprits**: +- SWIFT_OPTIMIZATION_LEVEL (-Onone vs -O) +- ENABLE_TESTABILITY (YES in Debug, NO in Release) +- DEBUG preprocessor flag +- Code signing settings + +### Workspace vs Project + +**Always open workspace with CocoaPods**: +```bash +# ❌ WRONG +open YourApp.xcodeproj + +# ✅ CORRECT +open YourApp.xcworkspace +``` + +**Check which you're building**: +```bash +# For workspace +xcodebuild -workspace YourApp.xcworkspace -scheme YourScheme build + +# For project only (no CocoaPods) +xcodebuild -project YourApp.xcodeproj -scheme YourScheme build +``` + +## Pressure Scenarios: When to Resist "Quick Fix" Advice + +### The Problem + +Under deadline pressure, senior engineers and teammates provide "quick fixes" based on pattern-matching: +- "Just regenerate the lock file" +- "Increment the build number" +- "Delete DerivedData and rebuild" + +These feel safe because they come from experience. **But if the diagnosis is wrong, the fix wastes time you don't have.** + +**Critical insight** Time pressure makes authority bias STRONGER. You're more likely to trust advice when stressed. + +### Red Flags — STOP Before Acting + +If you hear ANY of these, pause 5 minutes before executing: + +- ❌ **"This smells like..."** (pattern-matching, not diagnosis) +- ❌ **"Just..."** (underestimating complexity) +- ❌ **"This usually fixes it"** (worked once ≠ works always) +- ❌ **"You have plenty of time"** (overconfidence about 24-hour turnaround) +- ❌ **"This is safe"** (regenerating lock files CAN break things) + +**Your brain under pressure** Trusts these phrases because they sound confident. Doesn't ask "but do they have evidence THIS is the root cause?" + +### Mandatory Diagnosis Before "Quick Fix" + +When someone senior suggests a fix under time pressure: + +#### Step 1: Ask (Don't argue) +``` +"I understand the pressure. Before we regenerate lock files, +can we spend 5 minutes comparing the broken build to our +working build? I want to know what we're fixing." +``` + +#### Step 2: Demand Evidence +- "What makes you think it's a lock file issue?" +- "What changed between our last successful build and this failure?" +- "Can we see the actual error from App Store build vs our build?" + +#### Step 3: Document the Gamble +``` +If we try "pod install": +- Time to execute: 10 minutes +- Time to learn it failed: 24 hours (next submission cycle) +- Remaining time if it fails: 6 days +- Alternative: Spend 1-2 hours diagnosing first + +Cost of being wrong with quick fix: High +Cost of spending 1 hour on diagnosis: Low +``` + +#### Step 4: Push Back Professionally +``` +"I want to move fast too. A 1-hour diagnosis now means we +won't waste another 24-hour cycle. Let's document what we're +testing before we submit." +``` + +#### Why this works +- You're not questioning their expertise +- You're asking for evidence (legitimate request) +- You're showing you understand the pressure +- You're making the time math visible + +### Real-World Example: App Store Review Blocker + +**Scenario** App rejected in App Store build, passes locally. + +**Senior says** "Regenerate lock file and resubmit (7 days buffer)" + +#### What you do +1. ❌ WRONG: Execute immediately, fail after 24 hours, now 6 days left +2. ✅ RIGHT: Spend 1 hour comparing builds first + +#### Comparison checklist +``` +Local build that works: +- Pod versions in Podfile.lock: [list them] +- Xcode version: [version] +- Derived Data: [timestamp] +- CocoaPods version: [version] + +App Store build that fails: +- Pod versions used: [from error message] +- Build system: [App Store's environment] +- Differences: [explicitly document] +``` + +#### After comparison +- If versions match: Lock file isn't the issue. Skip the quick fix. +- If versions differ: Now you understand what to fix. + +**Time saved** 24 hours of wasted iteration. + +### When to Trust Quick Fixes (Rare) + +Quick fixes are safe ONLY when: + +- [ ] You've seen this EXACT error before (not "similar") +- [ ] You know the root cause (not "this usually works") +- [ ] You can reproduce it locally (so you know if fix worked) +- [ ] You have >48 hours buffer (so failure costs less) +- [ ] You documented the fix in case you need to explain it later + +#### In production crises, NONE of these are usually true. + +--- + +## Testing Checklist + +### When Adding Dependencies +- [ ] Specify exact versions or ranges (not just latest) +- [ ] Check for known conflicts with existing deps +- [ ] Test clean build after adding +- [ ] Commit lockfile (Podfile.lock or Package.resolved) + +### When Builds Fail +- [ ] Run mandatory environment checks (xcode-debugging skill) +- [ ] Check dependency lockfiles for changes +- [ ] Verify using correct workspace/project file +- [ ] Compare working vs broken build settings + +### Before Shipping +- [ ] Test both Debug and Release builds +- [ ] Verify all dependencies have compatible licenses +- [ ] Check binary size impact of dependencies +- [ ] Test on clean machine or CI + +## Common Mistakes + +### ❌ Not Committing Lockfiles +```bash +# ❌ BAD: .gitignore includes lockfiles +Podfile.lock +Package.resolved +``` + +**Why**: Team members get different versions, builds differ + +### ❌ Using "Latest" Version +```ruby +# ❌ BAD: No version specified +pod 'Alamofire' +``` + +**Why**: Breaking changes when dependency updates + +### ❌ Mixing Package Managers +``` +Project uses both: +- CocoaPods (Podfile) +- Carthage (Cartfile) +- SPM (Package.swift) +``` + +**Why**: Conflicts are inevitable, pick one primary manager + +### ❌ Not Cleaning After Dependency Changes +```bash +# ❌ BAD: Just rebuild +xcodebuild build + +# ✅ GOOD: Clean first +xcodebuild clean build +``` + +### ❌ Opening Project Instead of Workspace +When using CocoaPods, always open .xcworkspace not .xcodeproj + +## Command Reference + +```bash +# CocoaPods +pod install # Install dependencies +pod update # Update to latest versions +pod update PodName # Update specific pod +pod outdated # Check for updates +pod deintegrate # Remove CocoaPods from project + +# Swift Package Manager +swift package resolve # Resolve dependencies +swift package update # Update dependencies +swift package show-dependencies # Show dependency tree +swift package reset # Reset package cache +xcodebuild -resolvePackageDependencies # Xcode's SPM resolve + +# Carthage +carthage update # Update dependencies +carthage bootstrap # Download pre-built frameworks +carthage build --platform iOS # Build for specific platform + +# Xcode Build +xcodebuild clean # Clean build folder +xcodebuild -list # List schemes and targets +xcodebuild -showBuildSettings # Show all build settings +``` + +## Real-World Impact + +**Before** (trial-and-error with dependencies): +- Dependency issue: 2-4 hours debugging +- Clean builds not run consistently +- Version conflicts surprise team +- CI failures from dependency mismatches + +**After** (systematic dependency management): +- Dependency issue: 15-30 minutes (check lockfile → resolve) +- Clean builds mandatory after dep changes +- Explicit version constraints prevent surprises +- CI matches local builds (committed lockfiles) + +**Key insight** Lock down dependency versions early. Flexibility causes more problems than it solves. + +## Resources + +**Docs**: swift.org/package-manager, /xcode/build-system + +**GitHub**: Carthage/Carthage + +**Skills**: axiom-xcode-debugging + +--- + +**History:** See git log for changes diff --git a/.claude/skills/axiom-build-debugging/agents/openai.yaml b/.claude/skills/axiom-build-debugging/agents/openai.yaml new file mode 100644 index 0000000..1f77047 --- /dev/null +++ b/.claude/skills/axiom-build-debugging/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Build Debugging" + short_description: "Encountering dependency conflicts, CocoaPods/SPM resolution failures, \"Multiple commands produce\" errors, or framewor..." diff --git a/.claude/skills/axiom-build-performance/.openskills.json b/.claude/skills/axiom-build-performance/.openskills.json new file mode 100644 index 0000000..afa617c --- /dev/null +++ b/.claude/skills/axiom-build-performance/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-build-performance", + "installedAt": "2026-04-12T08:05:58.249Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-build-performance/SKILL.md b/.claude/skills/axiom-build-performance/SKILL.md new file mode 100644 index 0000000..17fc914 --- /dev/null +++ b/.claude/skills/axiom-build-performance/SKILL.md @@ -0,0 +1,769 @@ +--- +name: axiom-build-performance +description: Use when build times are slow, investigating build performance, analyzing Build Timeline, identifying type checking bottlenecks, enabling compilation caching, or optimizing incremental builds - comprehensive build optimization workflows including Xcode 26 compilation caching +license: MIT +compatibility: iOS 14+, macOS 11+, iPadOS 14+, tvOS 14+, watchOS 7+, axiom-visionOS 1.0+. Xcode 14+ (Xcode 26+ for compilation caching and explicit modules) +metadata: + version: "2.0" + last-updated: "2026-01-01" + wwdc-sessions: "[2018-408, 2022-110364, 2024-10171, 2025-247]" +--- + +# Build Performance Optimization + +## Overview + +Systematic Xcode build performance analysis and optimization. **Core principle**: Measure before optimizing, then optimize the critical path first. + +## When to Use This Skill + +- Build times have increased significantly +- Incremental builds taking too long +- Want to analyze Build Timeline +- Need to identify slow-compiling Swift code +- Optimizing CI/CD build times +- Build performance regression investigation +- Enabling Xcode 26 compilation caching +- Reducing module variants in explicitly built modules +- Understanding the three-phase build process (scan → modules → compile) + +## Quick Win: Run the Agent First + +For automated scanning and quick wins: +```bash +/axiom:optimize-build +``` + +The build-optimizer agent scans for common issues and provides immediate fixes. Use this skill for deep analysis. + +## The Build Performance Workflow + +### Step 1: Measure Baseline (Required) + +**Why**: You can't improve what you don't measure. Baseline prevents placebo optimizations. + +```bash +# Clean build (eliminates all caching) +xcodebuild clean build -scheme YourScheme + +# Measure time +time xcodebuild build -scheme YourScheme + +# Or use Xcode UI +Product → Perform Action → Build with Timing Summary +``` + +**Record**: +- Total build time +- Incremental build time (change one file, rebuild) +- Which phase takes longest (compilation vs linking vs scripts) + +**Example baseline**: +``` +Clean build: 247 seconds +Incremental (1 file change): 12 seconds +Longest phase: Compile Swift sources (189s) +``` + +### Step 2: Analyze Build Timeline (Xcode 14+) + +**Access**: +1. Build your project (Cmd+B) +2. Open Report Navigator (Cmd+9) +3. Select latest build +4. Show Assistant Editor (Cmd+Option+Return) +5. Build Timeline appears alongside build log + +**What to look for**: + +#### Critical Path (The Build's Speed Limit) +The **critical path** is the shortest possible build time with unlimited CPU cores. It's defined by the longest chain of dependent tasks. + +``` +┌─────────────────────────────────────────┐ +│ Critical Path: A → B → C → D (120s) │ +│ │ +│ Task A: 30s ─────────┐ │ +│ Task B: 40s ├─→ D: 20s │ +│ Task C: 30s ─────────┘ │ +│ │ +│ Even with 100 CPUs, build takes 120s │ +└─────────────────────────────────────────┘ +``` + +**Goal**: Shorten the critical path by breaking dependencies. + +#### Timeline Red Flags + +**Empty vertical space**: Tasks waiting for inputs +``` +Timeline: +████████░░░░░░░░████████ ← Bad: idle cores waiting +████████████████████████ ← Good: continuous work +``` + +**Long horizontal bars**: Slow individual tasks +``` +Task A: ████████████████████ (45 seconds) ← Investigate +Task B: ███ (3 seconds) ← Fine +``` + +**Serial target builds**: Targets waiting unnecessarily +``` +Framework: ████████░░░░░░░░░░ ← Waiting +App: ░░░░░░░░░░████████ ← Delayed + +Better (parallel): +Framework: ████████ +App: ░░░░████████████ +``` + +### Step 3: Identify Bottlenecks (Decision Tree) + +**Is compilation the slowest phase?** +├─ YES → Check type checking performance (Step 4) +└─ NO → Is linking slow? + ├─ YES → Check link dependencies (Step 5) + └─ NO → Are scripts slow? + ├─ YES → Optimize build phase scripts (Step 6) + └─ NO → Check parallelization (Step 7) + +## Optimization Patterns + +### Pattern 1: Type Checking Performance (MEDIUM-HIGH IMPACT) + +**Symptom**: "Compile Swift sources" takes >50% of build time. + +**Diagnosis**: + +Enable compiler warnings to find slow functions: + +```swift +// Add to Debug build settings → Other Swift Flags +-warn-long-function-bodies 100 +-warn-long-expression-type-checking 100 +``` + +Build → Xcode shows warnings: +``` +MyView.swift:42: Function body took 247ms to type-check (limit: 100ms) +LoginViewModel.swift:18: Expression took 156ms to type-check (limit: 100ms) +``` + +**Fix slow type checking**: + +```swift +// ❌ SLOW - Complex type inference (247ms) +func calculateTotal(items: [Item]) -> Double { + return items + .filter { $0.isActive } + .map { $0.price * $0.quantity } + .reduce(0, +) +} + +// ✅ FAST - Explicit types (12ms) +func calculateTotal(items: [Item]) -> Double { + let activeItems: [Item] = items.filter { $0.isActive } + let prices: [Double] = activeItems.map { $0.price * $0.quantity } + let total: Double = prices.reduce(0, +) + return total +} +``` + +**Common slow patterns**: +- Complex chained operations without intermediate types +- Deeply nested closures +- Large literals (dictionaries, arrays) +- Operator overloading in complex expressions + +**Expected impact**: 10-30% faster compilation for affected files. + +--- + +### Pattern 2: Build Phase Script Optimization (HIGH IMPACT) + +**Symptom**: Build Timeline shows long script phases in Debug builds. + +**Common culprits**: +- dSYM/Crashlytics uploads running in Debug +- Asset processing on every build +- Code generation scripts without caching + +**Fix**: Make scripts conditional + +```bash +# ❌ BAD - Runs in ALL configurations (adds 6+ seconds to debug builds) +#!/bin/bash +firebase crashlytics upload-symbols + +# ✅ GOOD - Skip in Debug +#!/bin/bash +if [ "${CONFIGURATION}" = "Release" ]; then + firebase crashlytics upload-symbols +fi + +# Example savings: 6.3 seconds per incremental debug build +``` + +**Script Phase Sandboxing** (Xcode 14+) + +Enable to prevent data races and improve parallelization: + +``` +Build Settings → User Script Sandboxing → YES +``` + +**Why**: Forces you to declare inputs/outputs explicitly, enabling parallel execution. + +```bash +# Script phase with proper inputs/outputs +Input Files: + $(SRCROOT)/input.txt + $(DERIVED_FILE_DIR)/checksum.txt + +Output Files: + $(DERIVED_FILE_DIR)/output.html + +# Now Xcode knows dependencies and can parallelize safely +``` + +**Parallel Script Execution**: + +``` +Build Settings → FUSE_BUILD_SCRIPT_PHASES → YES +``` + +**⚠️ WARNING**: Only enable if ALL scripts have correct inputs/outputs declared. Otherwise you'll get data races. + +**Expected impact**: 5-10 seconds saved per incremental debug build. + +--- + +### Pattern 3: Compilation Mode Settings (CRITICAL) + +**Symptom**: Incremental builds recompile entire modules. + +**Check current settings**: + +```bash +# In project.pbxproj +grep "SWIFT_COMPILATION_MODE" project.pbxproj +``` + +**Optimal configuration**: + +| Configuration | Setting | Why | +|---|---|---| +| **Debug** | `singlefile` (Incremental) | Only recompiles changed files | +| **Release** | `wholemodule` | Maximum optimization | + +```swift +// ❌ BAD - Whole module in Debug +SWIFT_COMPILATION_MODE = wholemodule; // ALL configs + +// ✅ GOOD - Incremental for Debug +Debug: SWIFT_COMPILATION_MODE = singlefile; +Release: SWIFT_COMPILATION_MODE = wholemodule; +``` + +**How to fix**: +1. Project → Build Settings +2. Filter: "Compilation Mode" +3. Set Debug to "Incremental" +4. Set Release to "Whole Module" + +**Expected impact**: 40-60% faster incremental debug builds. + +--- + +### Pattern 4: Build Active Architecture Only (HIGH IMPACT) + +**Symptom**: Debug builds compile for multiple architectures (x86_64 + arm64). + +**Check**: +```bash +grep "ONLY_ACTIVE_ARCH" project.pbxproj +``` + +**Fix**: + +| Configuration | Setting | Why | +|---|---|---| +| **Debug** | `YES` | Only build for current device (arm64 OR x86_64) | +| **Release** | `NO` | Build universal binary | + +**How to fix**: +1. Build Settings → "Build Active Architecture Only" +2. Set Debug to YES +3. Keep Release as NO + +**Expected impact**: 40-50% faster debug builds (half the architectures). + +--- + +### Pattern 5: Debug Information Format (MEDIUM IMPACT) + +**Symptom**: Debug builds generating dSYMs unnecessarily. + +**Optimal configuration**: + +| Configuration | Setting | Why | +|---|---|---| +| **Debug** | `dwarf` | Embedded debug info, faster | +| **Release** | `dwarf-with-dsym` | Separate dSYM for crash reporting | + +```bash +# Check current +grep "DEBUG_INFORMATION_FORMAT" project.pbxproj +``` + +**How to fix**: +1. Build Settings → "Debug Information Format" +2. Set Debug to "DWARF" +3. Set Release to "DWARF with dSYM File" + +**Expected impact**: 3-5 seconds saved per debug build. + +--- + +### Pattern 6: Target Parallelization (WWDC 2018-408) + +**Symptom**: Build Timeline shows targets building sequentially when they could be parallel. + +**Check scheme configuration**: +1. Product → Scheme → Edit Scheme +2. Build tab +3. Check "Parallelize Build" checkbox +4. Verify target order allows parallelization + +**Dependency graph example**: + +``` +App ──┬──→ Framework A + └──→ Framework B + +Framework A ──→ Utilities +Framework B ──→ Utilities +``` + +**Timeline (bad - serial)**: +``` +Utilities: ████████░░░░░░░░░░░░░░ +Framework A: ░░░░░░░░████████░░░░░░ +Framework B: ░░░░░░░░░░░░░░░░████████ +App: ░░░░░░░░░░░░░░░░░░░░░░████ +``` + +**Timeline (good - parallel)**: +``` +Utilities: ████████ +Framework A: ░░░░░░░░████████ +Framework B: ░░░░░░░░████████ +App: ░░░░░░░░░░░░░░░░████ +``` + +**Expected impact**: Proportional to number of independent targets (e.g., 2 parallel targets = ~2x faster). + +--- + +### Pattern 7: Emit Module Optimization (Xcode 14+, Swift 5.7+) + +**What it is**: Swift modules are produced separately from compilation, unblocking downstream targets faster. + +**Before (Xcode 13)**: +``` +Framework: Compile ████████████ → Emit Module █ +App: ░░░░░░░░░░░░░░░░░░░░░░░░░█████████ + ↑ + Waiting for Framework compilation to finish +``` + +**After (Xcode 14+)**: +``` +Framework: Compile ████████████ + Emit Module ███ +App: ░░░░░░███████████ + ↑ + Starts as soon as module emitted +``` + +**Automatic**: No configuration needed, works in Xcode 14+ with Swift 5.7+. + +**Expected impact**: Reduces idle time in multi-target builds by 20-40%. + +--- + +### Pattern 8: Eager Linking (Xcode 14+) + +**What it is**: Linking can start before all compilation finishes if the module is ready. + +**Impact**: Further reduces critical path in dependency chains. + +**Automatic**: Works in Xcode 14+ automatically. + +--- + +### Pattern 9: Compilation Caching (Xcode 26+, CRITICAL) + +**What it is**: Xcode 26 introduces compilation caching that reuses previously compiled artifacts across clean builds. + +**Build Settings**: + +``` +Build Settings → COMPILATION_CACHE_ENABLE_CACHING → YES +``` + +**How it works**: +- Caches compilation results based on input file content and compiler flags +- Works across clean builds — even after `xcodebuild clean`, cached artifacts can be reused +- Significantly reduces CI/CD build times where clean builds are common + +**When to enable**: +- CI/CD pipelines with frequent clean builds +- Teams sharing build artifacts +- Projects with stable dependencies + +**Verification**: +```bash +# Build with caching enabled +xcodebuild build -scheme YourScheme \ + COMPILATION_CACHE_ENABLE_CACHING=YES + +# Check build log for cache information +``` + +**Current limitations** (Xcode 26): +- Swift Package Manager dependencies not yet cacheable +- CompileStoryboard, CompileXIB, DataModelCompile, Ld tasks not cacheable +- Cache requires time to populate on first run + +**Expected impact**: 20-40% faster clean builds after initial cache population (up to 70%+ for favorable projects). + +--- + +### Pattern 10: Explicitly Built Modules (Xcode 16+, HIGH IMPACT) + +**What it is**: Xcode splits module compilation into explicit build tasks instead of implicit on-demand compilation. **Enabled by default for Swift in Xcode 26.** + +**The Problem with Implicit Modules (Pre-Xcode 16)**: + +When a compiler encounters an import, it builds the module on-demand: +``` +Compile A.swift ─── needs UIKit ───→ (builds UIKit.pcm) ───→ continues +Compile B.swift ─── needs UIKit ───→ (waits for A to finish) ───→ uses cached +Compile C.swift ─── needs UIKit ───→ (waits) ───→ uses cached +``` + +Problems: +- One task blocks others waiting for the same module +- Non-deterministic: whoever gets there first builds it +- Build failures hard to reproduce (depends on task order) + +**Explicitly Built Modules Solution**: + +Xcode now separates compilation into three phases: + +``` +Phase 1: SCAN Phase 2: BUILD MODULES Phase 3: COMPILE +┌──────────────────┐ ┌──────────────────────┐ ┌──────────────────┐ +│ Scan A.swift │ │ Build UIKit.pcm │ │ Compile A.swift │ +│ Scan B.swift │ → │ Build Foundation.pcm │ → │ Compile B.swift │ +│ Scan C.swift │ │ Build SwiftUI.pcm │ │ Compile C.swift │ +└──────────────────┘ └──────────────────────┘ └──────────────────┘ + (fast) (parallel) (parallel) +``` + +**Benefits**: +- **More reliable builds**: Precise dependencies, deterministic build graphs +- **More efficient scheduling**: Build system knows exactly what's needed +- **Better debugging**: Debugger reuses built modules (no separate rebuild) +- **Visible module tasks**: See "Compile Clang Module" and "Compile Swift Module" in build log + +**Enable/Disable** (if needed): +``` +Build Settings → Explicitly Built Modules → YES (default in Xcode 26 for Swift) +``` + +**Module Variants** (WWDC 2024-10171) + +The same module may be built multiple times with different settings: + +``` +Build Log: + Compile Clang module 'UIKit' (hash: abc123) ← Variant 1 + Compile Clang module 'UIKit' (hash: def456) ← Variant 2 + Compile Swift module 'UIKit' (hash: ghi789) ← Variant 3 +``` + +**Common causes of variants**: +- Different preprocessor macros between targets +- Mixed C and Objective-C language modes +- Different C language versions (C11 vs C17) +- Disabling ARC on some targets + +**Diagnose variants**: +1. Build with Timing Summary: `Product → Perform Action → Build with Timing Summary` +2. Filter build log: Type "modules report" in filter box +3. View Clang and Swift module reports showing variant counts + +**Reduce variants** (unify settings at project/workspace level): +```bash +# Check for macro differences +grep "GCC_PREPROCESSOR_DEFINITIONS" project.pbxproj + +# Move target-specific macros to project level where possible +Project → Build Settings → Preprocessor Macros → [unify here] +``` + +**Example** (from WWDC 2024-10171): +``` +Before: 4 UIKit variants (2 Swift × 2 Clang) +After: 2 UIKit variants (unified settings) +Impact: Fewer module builds = faster incremental builds +``` + +**Expected impact**: 10-30% faster builds by reducing duplicate module compilation. + +**Note: Swift Build** (Xcode 26+): Xcode now uses Swift Build, Apple's open-source build engine. This provides more predictable builds, better SPM integration, and cross-platform support (Linux, Windows, Android). No configuration needed. + +--- + +## Measurement & Verification + +### Before and After Comparison + +**Required steps**: + +1. **Baseline** (before changes): + ```bash + xcodebuild clean build -scheme YourScheme 2>&1 | tee baseline.log + ``` + +2. **Apply ONE optimization at a time** + +3. **Measure improvement**: + ```bash + xcodebuild clean build -scheme YourScheme 2>&1 | tee optimized.log + ``` + +4. **Compare**: + ```bash + # Extract build time from logs + grep "Build succeeded" baseline.log + grep "Build succeeded" optimized.log + ``` + +**Example**: +``` +Baseline: Build succeeded (247.3 seconds) +Optimized: Build succeeded (156.8 seconds) +Improvement: 90.5 seconds (36.6% faster) +``` + +### Build Timeline Visual Verification + +**Before optimization**: +- Look for empty vertical space (idle cores) +- Long horizontal bars (slow tasks) +- Serial target builds + +**After optimization**: +- Timeline should be more "filled" +- Shorter horizontal bars +- Parallel target builds + +**Critical path**: Should be visibly shorter. + +--- + +## Real-World Optimization Examples + +### Example 1: Large iOS App (50+ source files) + +**Baseline**: +- Clean build: 247 seconds +- Incremental (1 file): 12 seconds + +**Optimizations applied**: +1. Debug compilation mode: singlefile (saved 89s) +2. Build Active Architecture: YES (saved 45s) +3. Conditional dSYM upload script (saved 6.3s per incremental) + +**Result**: +- Clean build: 156 seconds (36% faster) +- Incremental: 5.7 seconds (52% faster) + +--- + +### Example 2: Multi-Framework Project + +**Baseline**: +- 5 frameworks built serially +- Total: 189 seconds + +**Optimizations applied**: +1. Enabled parallel builds in scheme +2. Fixed unnecessary dependencies +3. Emit module optimization (automatic in Xcode 14) + +**Result**: +- Total: 94 seconds (50% faster) +- Critical path reduced from 189s to 94s + +--- + +## Common Pitfalls + +### Pitfall 1: Optimizing Without Measuring + +**Mistake**: "I think this will help" → make change → no measurement. + +**Why bad**: Placebo improvements, wasted time, actual regressions unnoticed. + +**Fix**: Always measure before → change one thing → measure after. + +--- + +### Pitfall 2: Optimizing Release Builds for Speed + +**Mistake**: Set Release to incremental compilation for "faster builds". + +**Why bad**: Release builds should optimize for runtime performance, not build speed. You ship Release builds to users. + +**Fix**: Only optimize Debug builds for speed. Keep Release optimized for runtime. + +--- + +### Pitfall 3: Breaking Dependencies for Parallelization + +**Mistake**: Remove legitimate dependencies to "make builds parallel". + +**Why bad**: Build errors, undefined behavior, race conditions. + +**Fix**: Only parallelize truly independent targets. Use Build Timeline to identify safe opportunities. + +--- + +### Pitfall 4: Enabling FUSE_BUILD_SCRIPT_PHASES Without Sandboxing + +**Mistake**: Enable parallel scripts but don't declare inputs/outputs. + +**Why bad**: Data races, non-deterministic build failures, incorrect builds. + +**Fix**: First enable `ENABLE_USER_SCRIPT_SANDBOXING = YES`, fix all errors, THEN enable `FUSE_BUILD_SCRIPT_PHASES`. + +--- + +## Troubleshooting + +### Problem: Builds Still Slow After Optimizations + +**Check**: +1. Did you clean before measuring? (`xcodebuild clean`) +2. Are you measuring the right build? (Debug vs Release) +3. Is your machine thermal throttling? (Activity Monitor → CPU tab) +4. Are other apps using CPU? (Quit Xcode, Docker, VMs during measurement) + +--- + +### Problem: Build Timeline Shows No Parallelization + +**Check**: +1. Scheme → Parallelize Build checked? +2. Are targets actually independent? (Check dependency graph) +3. Do targets have unnecessary explicit dependencies? + +--- + +### Problem: Type Checking Warnings Don't Appear + +**Check**: +1. Added flags to correct configuration? (Debug, not Release) +2. Syntax correct? `-warn-long-function-bodies 100` (with hyphen) +3. Building the right scheme? +4. Clean build to force recompilation + +--- + +## Advanced: Analyzing Build Logs + +### Extract Compilation Times + +```bash +# Find slowest files to compile +xcodebuild -workspace YourApp.xcworkspace \ + -scheme YourScheme \ + clean build \ + OTHER_SWIFT_FLAGS="-Xfrontend -debug-time-function-bodies" 2>&1 | \ + grep ".[0-9]ms" | \ + sort -nr | \ + head -20 +``` + +**Output**: +``` +247.3ms MyViewModel.swift:42:1 func calculateTotal +156.8ms LoginView.swift:18:3 var body +89.2ms NetworkManager.swift:67:1 func handleResponse +... +``` + +**Action**: Add explicit types to slowest functions. + +--- + +### Extract Build Phase Times + +```bash +# From build log +Build target 'MyApp' (project 'MyApp') + Compile Swift source files (128.4 seconds) + Link MyApp (12.3 seconds) + Run custom shell script (6.7 seconds) +``` + +**Action**: Optimize the longest phase first. + +--- + +## Checklist: Build Performance Audit + +Before considering your build optimized: + +**Measurement** +- [ ] Measured baseline (clean + incremental) +- [ ] Verified improvement in Build Timeline +- [ ] Documented baseline → optimized comparison + +**Compilation Settings** +- [ ] Debug uses incremental compilation +- [ ] Build Active Architecture = YES (Debug only) +- [ ] Debug uses DWARF (not dSYM) +- [ ] Type checking warnings enabled +- [ ] Fixed slow type-checking functions (>100ms) + +**Parallelization** +- [ ] Parallelize Build enabled in scheme +- [ ] No unnecessary target dependencies +- [ ] Build phase scripts are conditional (skip in Debug when possible) +- [ ] Enabled script sandboxing if using parallel scripts + +**Xcode 26+ (if applicable)** +- [ ] Compilation caching enabled for CI/CD (`COMPILATION_CACHE_ENABLE_CACHING`) +- [ ] Checked module variants (Modules Report in build log, see Pattern 10) +- [ ] Unified build settings at project level to reduce module variants +- [ ] Explicitly Built Modules enabled (default for Swift in Xcode 26) + +--- + +## Resources + +**WWDC**: 2018-408, 2022-110364, 2024-10171, 2025-247 + +**Docs**: /xcode/improving-the-speed-of-incremental-builds, /xcode/building-your-project-with-explicit-module-dependencies + +**Tools**: Xcode Build Timeline (Xcode 14+), Build with Timing Summary (Product → Perform Action), Modules Report (Xcode 16+), Instruments Time Profiler + +--- + +**Remember**: Build performance optimization is about systematic measurement and targeted improvements. Optimize the critical path first, measure everything, and verify improvements in the Build Timeline. diff --git a/.claude/skills/axiom-build-performance/agents/openai.yaml b/.claude/skills/axiom-build-performance/agents/openai.yaml new file mode 100644 index 0000000..6a16aa4 --- /dev/null +++ b/.claude/skills/axiom-build-performance/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Build Performance" + short_description: "Build times are slow, investigating build performance, analyzing Build Timeline, identifying type checking bottleneck..." diff --git a/.claude/skills/axiom-camera-capture-diag/.openskills.json b/.claude/skills/axiom-camera-capture-diag/.openskills.json new file mode 100644 index 0000000..b60f459 --- /dev/null +++ b/.claude/skills/axiom-camera-capture-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-camera-capture-diag", + "installedAt": "2026-04-12T08:05:59.537Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-camera-capture-diag/SKILL.md b/.claude/skills/axiom-camera-capture-diag/SKILL.md new file mode 100644 index 0000000..3587c52 --- /dev/null +++ b/.claude/skills/axiom-camera-capture-diag/SKILL.md @@ -0,0 +1,587 @@ +--- +name: axiom-camera-capture-diag +description: camera freezes, preview rotated wrong, capture slow, session interrupted, black preview, front camera mirrored, camera not starting, AVCaptureSession errors, startRunning blocks, phone call interrupts camera +license: MIT +compatibility: iOS 17+, iPadOS 17+, macOS 14+, tvOS 17+ +metadata: + version: "1.0.0" + last-updated: "2026-01-03" +--- + +# Camera Capture Diagnostics + +Systematic troubleshooting for AVFoundation camera issues: frozen preview, wrong rotation, slow capture, session interruptions, and permission problems. + +## Overview + +**Core Principle**: When camera doesn't work, the problem is usually: +1. **Threading** (session work on main thread) - 35% +2. **Session lifecycle** (not started, interrupted, not configured) - 25% +3. **Rotation** (deprecated APIs, missing coordinator) - 20% +4. **Permissions** (denied, not requested) - 15% +5. **Configuration** (wrong preset, missing input/output) - 5% + +**Always check threading and session state BEFORE debugging capture logic.** + +## Red Flags + +Symptoms that indicate camera-specific issues: + +| Symptom | Likely Cause | +|---------|--------------| +| Preview shows black screen | Session not started, permission denied, no camera input | +| UI freezes when opening camera | `startRunning()` called on main thread | +| Camera freezes on phone call | No interruption handling | +| Preview rotated 90° wrong | Not using RotationCoordinator (iOS 17+) | +| Captured photo rotated wrong | Rotation angle not applied to output connection | +| Front camera photo not mirrored | This is correct! (preview mirrors, photo does not) | +| "Camera in use by another app" | Another app has exclusive access | +| Capture takes 2+ seconds | `photoQualityPrioritization` set to `.quality` | +| Session won't start on iPad | Split View - camera unavailable | +| Crash on older iOS | Using iOS 17+ APIs without availability check | + +## Mandatory First Steps + +Before investigating code, run these diagnostics: + +### Step 1: Check Session State + +```swift +print("📷 Session state:") +print(" isRunning: \(session.isRunning)") +print(" inputs: \(session.inputs.count)") +print(" outputs: \(session.outputs.count)") + +for input in session.inputs { + if let deviceInput = input as? AVCaptureDeviceInput { + print(" Input: \(deviceInput.device.localizedName)") + } +} + +for output in session.outputs { + print(" Output: \(type(of: output))") +} +``` + +**Expected output**: +- ✅ isRunning: true, inputs ≥ 1, outputs ≥ 1 → Session working +- ⚠️ isRunning: false → Session not started or interrupted +- ❌ inputs: 0 → Camera not added (permission? configuration?) + +### Step 2: Check Threading + +```swift +print("🧵 Thread check:") + +// When setting up session +sessionQueue.async { + print(" Setup thread: \(Thread.isMainThread ? "❌ MAIN" : "✅ Background")") +} + +// When starting session +sessionQueue.async { + print(" Start thread: \(Thread.isMainThread ? "❌ MAIN" : "✅ Background")") +} +``` + +**Expected output**: +- ✅ All background → Correct +- ❌ Any main thread → UI will freeze + +### Step 3: Check Permissions + +```swift +let status = AVCaptureDevice.authorizationStatus(for: .video) +print("🔐 Camera permission: \(status.rawValue)") + +switch status { +case .authorized: print(" ✅ Authorized") +case .notDetermined: print(" ⚠️ Not yet requested") +case .denied: print(" ❌ Denied by user") +case .restricted: print(" ❌ Restricted (parental controls?)") +@unknown default: print(" ❓ Unknown") +} +``` + +### Step 4: Check for Interruptions + +```swift +// Add temporary observer to see interruptions +NotificationCenter.default.addObserver( + forName: .AVCaptureSessionWasInterrupted, + object: session, + queue: .main +) { notification in + if let reason = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int { + print("🚨 Interrupted: reason \(reason)") + } +} +``` + +## Decision Tree + +``` +Camera not working as expected? +│ +├─ Black/frozen preview? +│ ├─ Check Step 1 (session state) +│ │ ├─ isRunning = false → See Pattern 1 (session not started) +│ │ ├─ inputs = 0 → See Pattern 2 (no camera input) +│ │ └─ isRunning = true, inputs > 0 → See Pattern 3 (preview layer) +│ +├─ UI freezes when opening camera? +│ └─ Check Step 2 (threading) +│ └─ Main thread → See Pattern 4 (move to session queue) +│ +├─ Camera freezes during use? +│ ├─ After phone call → See Pattern 5 (interruption handling) +│ ├─ In Split View (iPad) → See Pattern 6 (multitasking) +│ └─ Random freezes → See Pattern 7 (thermal pressure) +│ +├─ Preview/photo rotated wrong? +│ ├─ Preview rotated → See Pattern 8 (RotationCoordinator preview) +│ ├─ Captured photo rotated → See Pattern 9 (capture rotation) +│ └─ Front camera "wrong" → See Pattern 10 (mirroring expected) +│ +├─ Capture too slow? +│ ├─ 2+ seconds delay → See Pattern 11 (quality prioritization) +│ └─ Slight delay → See Pattern 12 (deferred processing) +│ +├─ Permission issues? +│ ├─ Status: notDetermined → See Pattern 13 (request permission) +│ └─ Status: denied → See Pattern 14 (settings prompt) +│ +└─ Crash on some devices? + └─ See Pattern 15 (API availability) +``` + +## Diagnostic Patterns + +### Pattern 1: Session Not Started + +**Symptom**: Black preview, `isRunning = false` + +**Common causes**: +1. `startRunning()` never called +2. `startRunning()` called but session has no inputs +3. Session stopped and never restarted + +**Diagnostic**: +```swift +// Check if startRunning was called +print("isRunning before start: \(session.isRunning)") +session.startRunning() +print("isRunning after start: \(session.isRunning)") +``` + +**Fix**: +```swift +// Ensure session is started on session queue +func startSession() { + sessionQueue.async { [self] in + guard !session.isRunning else { return } + + // Verify we have inputs before starting + guard !session.inputs.isEmpty else { + print("❌ Cannot start - no inputs configured") + return + } + + session.startRunning() + } +} +``` + +**Time to fix**: 10 min + +### Pattern 2: No Camera Input + +**Symptom**: `session.inputs.count = 0` + +**Common causes**: +1. Camera permission denied +2. `AVCaptureDeviceInput` creation failed +3. `canAddInput()` returned false +4. Configuration not committed + +**Diagnostic**: +```swift +// Step through input setup +guard let camera = AVCaptureDevice.default(for: .video) else { + print("❌ No camera device found") + return +} +print("✅ Camera: \(camera.localizedName)") + +do { + let input = try AVCaptureDeviceInput(device: camera) + print("✅ Input created") + + if session.canAddInput(input) { + print("✅ Can add input") + } else { + print("❌ Cannot add input - check session preset compatibility") + } +} catch { + print("❌ Input creation failed: \(error)") +} +``` + +**Fix**: Ensure permission is granted BEFORE creating input, and wrap in configuration block: +```swift +session.beginConfiguration() +// Add input here +session.commitConfiguration() +``` + +**Time to fix**: 15 min + +### Pattern 3: Preview Layer Not Connected + +**Symptom**: `isRunning = true`, inputs configured, but preview is black + +**Common causes**: +1. Preview layer session not set +2. Preview layer not in view hierarchy +3. Preview layer frame is zero + +**Diagnostic**: +```swift +print("Preview layer session: \(previewLayer.session != nil)") +print("Preview layer superlayer: \(previewLayer.superlayer != nil)") +print("Preview layer frame: \(previewLayer.frame)") +print("Preview layer connection: \(previewLayer.connection != nil)") +``` + +**Fix**: +```swift +// Ensure preview layer is properly configured +previewLayer.session = session +previewLayer.videoGravity = .resizeAspectFill + +// Ensure frame is set (common in SwiftUI) +previewLayer.frame = view.bounds +``` + +**Time to fix**: 10 min + +### Pattern 4: Main Thread Blocking + +**Symptom**: UI freezes for 1-3 seconds when camera opens + +**Root cause**: `startRunning()` is a blocking call executed on main thread + +**Diagnostic**: +```swift +// If this prints on main thread, that's the problem +print("startRunning on thread: \(Thread.current)") +session.startRunning() +``` + +**Fix**: +```swift +// Create dedicated serial queue +private let sessionQueue = DispatchQueue(label: "camera.session") + +func startSession() { + sessionQueue.async { [self] in + session.startRunning() + } +} +``` + +**Time to fix**: 15 min + +### Pattern 5: Phone Call Interruption + +**Symptom**: Camera works, then freezes when phone call comes in + +**Root cause**: Session interrupted but no handling/UI feedback + +**Diagnostic**: +```swift +// Check if session is still running after returning from call +print("Session running: \(session.isRunning)") +// Will be false during active call, true after call ends +``` + +**Fix**: Add interruption observers (see camera-capture skill Pattern 5) + +**Key point**: Session AUTOMATICALLY resumes after interruption ends. You don't need to call `startRunning()` again. Just update your UI. + +**Time to fix**: 30 min + +### Pattern 6: Split View Camera Unavailable + +**Symptom**: Camera stops working when iPad enters Split View + +**Root cause**: Camera not available with multiple foreground apps + +**Diagnostic**: +```swift +// Check interruption reason +// InterruptionReason.videoDeviceNotAvailableWithMultipleForegroundApps +``` + +**Fix**: Show appropriate UI message and resume when user exits Split View: +```swift +case .videoDeviceNotAvailableWithMultipleForegroundApps: + showMessage("Camera unavailable in Split View. Use full screen.") +``` + +**Time to fix**: 15 min + +### Pattern 7: Thermal Pressure + +**Symptom**: Camera stops randomly, especially after prolonged use + +**Root cause**: Device getting hot, system reducing resources + +**Diagnostic**: +```swift +// Check thermal state +print("Thermal state: \(ProcessInfo.processInfo.thermalState.rawValue)") +// 0 = nominal, 1 = fair, 2 = serious, 3 = critical +``` + +**Fix**: Reduce quality or show cooling message: +```swift +case .videoDeviceNotAvailableDueToSystemPressure: + // Reduce quality + session.sessionPreset = .medium + showMessage("Camera quality reduced due to device temperature") +``` + +**Time to fix**: 20 min + +### Pattern 8: Preview Rotation Wrong + +**Symptom**: Preview is rotated 90° from expected + +**Root cause**: Not using RotationCoordinator (iOS 17+) or not observing updates + +**Diagnostic**: +```swift +print("Preview connection rotation: \(previewLayer.connection?.videoRotationAngle ?? -1)") +``` + +**Fix**: +```swift +// Create and observe RotationCoordinator +let coordinator = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: previewLayer) + +// Set initial rotation +previewLayer.connection?.videoRotationAngle = coordinator.videoRotationAngleForHorizonLevelPreview + +// Observe changes +observation = coordinator.observe(\.videoRotationAngleForHorizonLevelPreview) { [weak previewLayer] coord, _ in + DispatchQueue.main.async { + previewLayer?.connection?.videoRotationAngle = coord.videoRotationAngleForHorizonLevelPreview + } +} +``` + +**Time to fix**: 30 min + +### Pattern 9: Captured Photo Rotation Wrong + +**Symptom**: Preview looks correct, but captured photo is rotated + +**Root cause**: Rotation angle not applied to photo output connection + +**Diagnostic**: +```swift +if let connection = photoOutput.connection(with: .video) { + print("Photo connection rotation: \(connection.videoRotationAngle)") +} +``` + +**Fix**: +```swift +func capturePhoto() { + // Apply current rotation to capture + if let connection = photoOutput.connection(with: .video) { + connection.videoRotationAngle = rotationCoordinator.videoRotationAngleForHorizonLevelCapture + } + + photoOutput.capturePhoto(with: settings, delegate: self) +} +``` + +**Time to fix**: 15 min + +### Pattern 10: Front Camera Mirroring + +**Symptom**: Designer says "front camera photo doesn't match preview" + +**Reality**: This is CORRECT behavior, not a bug. + +**Explanation**: +- Preview is mirrored (like looking in a mirror - user expectation) +- Captured photo is NOT mirrored (text reads correctly when shared) +- This matches the system Camera app behavior + +**If business requires mirrored photos** (selfie apps): +```swift +func mirrorImage(_ image: UIImage) -> UIImage? { + guard let cgImage = image.cgImage else { return nil } + return UIImage(cgImage: cgImage, scale: image.scale, orientation: .upMirrored) +} +``` + +**Time to fix**: 5 min (explanation) or 15 min (if mirroring required) + +### Pattern 11: Slow Capture (Quality Priority) + +**Symptom**: Photo capture takes 2+ seconds + +**Root cause**: `photoQualityPrioritization = .quality` (default for some devices) + +**Diagnostic**: +```swift +print("Max quality prioritization: \(photoOutput.maxPhotoQualityPrioritization.rawValue)") +// Check what you're requesting in AVCapturePhotoSettings +``` + +**Fix**: +```swift +var settings = AVCapturePhotoSettings() + +// For fast capture (social/sharing) +settings.photoQualityPrioritization = .speed + +// For balanced (general use) +settings.photoQualityPrioritization = .balanced + +// Only use .quality when image quality is critical +``` + +**Time to fix**: 5 min + +### Pattern 12: Deferred Processing + +**Symptom**: Want maximum responsiveness (zero-shutter-lag) + +**Solution**: Enable deferred processing (iOS 17+) +```swift +photoOutput.isAutoDeferredPhotoDeliveryEnabled = true + +// Then handle proxy in delegate: +// - didFinishProcessingPhoto gives proxy for immediate display +// - didFinishCapturingDeferredPhotoProxy gives final image later +``` + +**Time to fix**: 30 min + +### Pattern 13: Permission Not Requested + +**Symptom**: `authorizationStatus = .notDetermined` + +**Fix**: +```swift +// Must request before setting up session +Task { + let granted = await AVCaptureDevice.requestAccess(for: .video) + if granted { + setupSession() + } +} +``` + +**Time to fix**: 10 min + +### Pattern 14: Permission Denied + +**Symptom**: `authorizationStatus = .denied` + +**Fix**: Show settings prompt +```swift +func showSettingsPrompt() { + let alert = UIAlertController( + title: "Camera Access Required", + message: "Please enable camera access in Settings to use this feature.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Settings", style: .default) { _ in + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + present(alert, animated: true) +} +``` + +**Time to fix**: 15 min + +### Pattern 15: API Availability Crash + +**Symptom**: Crash on iOS 16 or earlier + +**Root cause**: Using iOS 17+ APIs without availability check + +**Fix**: +```swift +if #available(iOS 17.0, *) { + // Use RotationCoordinator + let coordinator = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: preview) +} else { + // Fallback to deprecated videoOrientation + if let connection = previewLayer.connection { + connection.videoOrientation = .portrait + } +} +``` + +**Time to fix**: 20 min + +## Quick Reference Table + +| Symptom | Check First | Likely Pattern | +|---------|-------------|----------------| +| Black preview | Step 1 (session state) | 1, 2, or 3 | +| UI freezes | Step 2 (threading) | 4 | +| Freezes on call | Step 4 (interruptions) | 5 | +| Wrong rotation | Print rotation angle | 8 or 9 | +| Slow capture | Print quality setting | 11 | +| Denied access | Step 3 (permissions) | 14 | +| Crash on old iOS | Check @available | 15 | + +## Checklist + +Before escalating camera issues: + +**Basics**: +- ☑ Session has at least one input +- ☑ Session has at least one output +- ☑ Session isRunning = true +- ☑ Preview layer connected to session +- ☑ Preview layer has non-zero frame + +**Threading**: +- ☑ All session work on sessionQueue +- ☑ startRunning() on background thread +- ☑ UI updates on main thread + +**Permissions**: +- ☑ Authorization status checked +- ☑ Permission requested if notDetermined +- ☑ Graceful UI for denied state + +**Rotation**: +- ☑ RotationCoordinator created with device AND previewLayer +- ☑ Observation set up for preview angle changes +- ☑ Capture angle applied when taking photos + +**Interruptions**: +- ☑ Interruption observer registered +- ☑ UI feedback for interrupted state +- ☑ Tested with incoming phone call + +## Resources + +**WWDC**: 2021-10247, 2023-10105 + +**Docs**: /avfoundation/avcapturesession, /avfoundation/avcapturesessionwasinterruptednotification + +**Skills**: axiom-camera-capture, axiom-camera-capture-ref diff --git a/.claude/skills/axiom-camera-capture-diag/agents/openai.yaml b/.claude/skills/axiom-camera-capture-diag/agents/openai.yaml new file mode 100644 index 0000000..1229809 --- /dev/null +++ b/.claude/skills/axiom-camera-capture-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Camera Capture Diagnostics" + short_description: "Camera freezes, preview rotated wrong, capture slow, session interrupted, black preview, front camera mirrored, camer..." diff --git a/.claude/skills/axiom-camera-capture-ref/.openskills.json b/.claude/skills/axiom-camera-capture-ref/.openskills.json new file mode 100644 index 0000000..3916955 --- /dev/null +++ b/.claude/skills/axiom-camera-capture-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-camera-capture-ref", + "installedAt": "2026-04-12T08:06:00.200Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-camera-capture-ref/SKILL.md b/.claude/skills/axiom-camera-capture-ref/SKILL.md new file mode 100644 index 0000000..11a3ed2 --- /dev/null +++ b/.claude/skills/axiom-camera-capture-ref/SKILL.md @@ -0,0 +1,779 @@ +--- +name: axiom-camera-capture-ref +description: Reference — AVCaptureSession, AVCapturePhotoSettings, AVCapturePhotoOutput, RotationCoordinator, photoQualityPrioritization, deferred processing, AVCaptureMovieFileOutput, session presets, capture device APIs +license: MIT +metadata: + version: "1.0.0" +--- + +# Camera Capture API Reference + +## Quick Reference + +```swift +// SESSION SETUP +import AVFoundation + +let session = AVCaptureSession() +let sessionQueue = DispatchQueue(label: "camera.session") + +sessionQueue.async { + session.beginConfiguration() + session.sessionPreset = .photo + + guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), + let input = try? AVCaptureDeviceInput(device: camera), + session.canAddInput(input) else { return } + session.addInput(input) + + let photoOutput = AVCapturePhotoOutput() + if session.canAddOutput(photoOutput) { + session.addOutput(photoOutput) + } + + session.commitConfiguration() + session.startRunning() +} + +// CAPTURE PHOTO +var settings = AVCapturePhotoSettings() +settings.photoQualityPrioritization = .balanced +photoOutput.capturePhoto(with: settings, delegate: self) + +// ROTATION (iOS 17+) +let coordinator = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: previewLayer) +previewLayer.connection?.videoRotationAngle = coordinator.videoRotationAngleForHorizonLevelPreview +``` + +--- + +## AVCaptureSession + +Central coordinator for capture data flow. + +### Session Presets + +| Preset | Resolution | Use Case | +|--------|------------|----------| +| `.photo` | Optimal for photos | Photo capture | +| `.high` | Highest device quality | Video recording | +| `.medium` | VGA quality | Preview, lower storage | +| `.low` | CIF quality | Minimal storage | +| `.hd1280x720` | 720p | HD video | +| `.hd1920x1080` | 1080p | Full HD video | +| `.hd4K3840x2160` | 4K | Ultra HD video | +| `.inputPriority` | Use device format | Custom configuration | + +### Session Configuration + +```swift +// Batch configuration (atomic) +session.beginConfiguration() +defer { session.commitConfiguration() } + +// Check preset support +if session.canSetSessionPreset(.hd4K3840x2160) { + session.sessionPreset = .hd4K3840x2160 +} + +// Add input/output +if session.canAddInput(input) { + session.addInput(input) +} + +if session.canAddOutput(output) { + session.addOutput(output) +} +``` + +### Session Lifecycle + +```swift +// Start (ALWAYS on background queue) +sessionQueue.async { + session.startRunning() // Blocking call +} + +// Stop +sessionQueue.async { + session.stopRunning() +} + +// Check state +session.isRunning // true/false +session.isInterrupted // true during phone calls, etc. +``` + +### Session Notifications + +```swift +// Session started +NotificationCenter.default.addObserver( + forName: .AVCaptureSessionDidStartRunning, + object: session, queue: .main) { _ in } + +// Session stopped +NotificationCenter.default.addObserver( + forName: .AVCaptureSessionDidStopRunning, + object: session, queue: .main) { _ in } + +// Session interrupted (phone call, etc.) +NotificationCenter.default.addObserver( + forName: .AVCaptureSessionWasInterrupted, + object: session, queue: .main) { notification in + let reason = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int + } + +// Interruption ended +NotificationCenter.default.addObserver( + forName: .AVCaptureSessionInterruptionEnded, + object: session, queue: .main) { _ in } + +// Runtime error +NotificationCenter.default.addObserver( + forName: .AVCaptureSessionRuntimeError, + object: session, queue: .main) { notification in + let error = notification.userInfo?[AVCaptureSessionErrorKey] as? Error + } +``` + +### Interruption Reasons + +| Reason | Value | Cause | +|--------|-------|-------| +| `.videoDeviceNotAvailableInBackground` | 1 | App went to background | +| `.audioDeviceInUseByAnotherClient` | 2 | Another app using audio | +| `.videoDeviceInUseByAnotherClient` | 3 | Another app using camera | +| `.videoDeviceNotAvailableWithMultipleForegroundApps` | 4 | Split View (iPad) | +| `.videoDeviceNotAvailableDueToSystemPressure` | 5 | Thermal throttling | + +--- + +## AVCaptureDevice + +Represents a physical capture device (camera, microphone). + +### Getting Devices + +```swift +// Default back camera +AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) + +// Default front camera +AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) + +// Default microphone +AVCaptureDevice.default(for: .audio) + +// Discovery session for all cameras +let discoverySession = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera, .builtInUltraWideCamera, .builtInTelephotoCamera], + mediaType: .video, + position: .unspecified +) +let cameras = discoverySession.devices +``` + +### Device Types + +| Type | Description | +|------|-------------| +| `.builtInWideAngleCamera` | Standard camera (1x) | +| `.builtInUltraWideCamera` | Ultra-wide camera (0.5x) | +| `.builtInTelephotoCamera` | Telephoto camera (2x, 3x) | +| `.builtInDualCamera` | Wide + telephoto | +| `.builtInDualWideCamera` | Wide + ultra-wide | +| `.builtInTripleCamera` | Wide + ultra-wide + telephoto | +| `.builtInTrueDepthCamera` | Front TrueDepth (Face ID) | +| `.builtInLiDARDepthCamera` | LiDAR depth | + +### Device Configuration + +```swift +do { + try device.lockForConfiguration() + defer { device.unlockForConfiguration() } + + // Focus + if device.isFocusModeSupported(.continuousAutoFocus) { + device.focusMode = .continuousAutoFocus + } + + // Exposure + if device.isExposureModeSupported(.continuousAutoExposure) { + device.exposureMode = .continuousAutoExposure + } + + // Torch (flashlight) + if device.hasTorch && device.isTorchModeSupported(.on) { + device.torchMode = .on + } + + // Zoom + device.videoZoomFactor = 2.0 // 2x zoom + +} catch { + print("Failed to configure device: \(error)") +} +``` + +### Switching Cameras + +```swift +// Switch between front and back during active session +func switchCamera() { + sessionQueue.async { [self] in + session.beginConfiguration() + defer { session.commitConfiguration() } + + // Remove current camera input + if let currentInput = session.inputs.first(where: { ($0 as? AVCaptureDeviceInput)?.device.hasMediaType(.video) == true }) as? AVCaptureDeviceInput { + session.removeInput(currentInput) + + // Get opposite camera + let newPosition: AVCaptureDevice.Position = currentInput.device.position == .back ? .front : .back + guard let newDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: newPosition), + let newInput = try? AVCaptureDeviceInput(device: newDevice) else { return } + + if session.canAddInput(newInput) { + session.addInput(newInput) + } + } + } +} +``` + +**Important**: Always switch on the session queue, within beginConfiguration/commitConfiguration. + +### Authorization + +```swift +// Check status +let status = AVCaptureDevice.authorizationStatus(for: .video) + +switch status { +case .authorized: break +case .notDetermined: + await AVCaptureDevice.requestAccess(for: .video) +case .denied, .restricted: + // Show settings prompt +@unknown default: break +} +``` + +--- + +## AVCaptureDevice.RotationCoordinator (iOS 17+) + +Automatically tracks device orientation and provides rotation angles. + +### Setup + +```swift +// Create with device and preview layer +let coordinator = AVCaptureDevice.RotationCoordinator( + device: captureDevice, + previewLayer: previewLayer +) +``` + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `videoRotationAngleForHorizonLevelPreview` | CGFloat | Rotation for preview layer | +| `videoRotationAngleForHorizonLevelCapture` | CGFloat | Rotation for captured output | + +### Observation + +```swift +// KVO observation for preview updates +let observation = coordinator.observe( + \.videoRotationAngleForHorizonLevelPreview, + options: [.new] +) { [weak previewLayer] coordinator, _ in + DispatchQueue.main.async { + previewLayer?.connection?.videoRotationAngle = coordinator.videoRotationAngleForHorizonLevelPreview + } +} + +// Set initial value +previewLayer.connection?.videoRotationAngle = coordinator.videoRotationAngleForHorizonLevelPreview +``` + +### Applying to Capture + +```swift +func capturePhoto() { + if let connection = photoOutput.connection(with: .video) { + connection.videoRotationAngle = coordinator.videoRotationAngleForHorizonLevelCapture + } + photoOutput.capturePhoto(with: settings, delegate: self) +} +``` + +--- + +## AVCapturePhotoOutput + +Output for capturing still photos. + +### Configuration + +```swift +let photoOutput = AVCapturePhotoOutput() + +// High resolution +photoOutput.isHighResolutionCaptureEnabled = true + +// Max quality prioritization +photoOutput.maxPhotoQualityPrioritization = .quality + +// Deferred processing (iOS 17+) +photoOutput.isAutoDeferredPhotoDeliveryEnabled = true + +// Live Photo +photoOutput.isLivePhotoCaptureEnabled = true + +// Depth +photoOutput.isDepthDataDeliveryEnabled = true + +// Portrait Effects Matte +photoOutput.isPortraitEffectsMatteDeliveryEnabled = true +``` + +### Supported Features + +```swift +// Check support before enabling +photoOutput.isHighResolutionCaptureEnabled && photoOutput.isHighResolutionCaptureSupported +photoOutput.isLivePhotoCaptureSupported +photoOutput.isDepthDataDeliverySupported +photoOutput.isPortraitEffectsMatteDeliverySupported +photoOutput.maxPhotoQualityPrioritization // .speed, .balanced, .quality +``` + +### Responsive Capture APIs (iOS 17+) + +```swift +// Zero Shutter Lag - uses ring buffer for instant capture +photoOutput.isZeroShutterLagSupported +photoOutput.isZeroShutterLagEnabled // true by default for iOS 17+ apps + +// Responsive Capture - overlapping captures +photoOutput.isResponsiveCaptureSupported +photoOutput.isResponsiveCaptureEnabled + +// Fast Capture Prioritization - adapts quality for burst-like capture +photoOutput.isFastCapturePrioritizationSupported +photoOutput.isFastCapturePrioritizationEnabled + +// Deferred Processing - proxy + background processing +photoOutput.isAutoDeferredPhotoDeliverySupported +photoOutput.isAutoDeferredPhotoDeliveryEnabled +``` + +--- + +## AVCapturePhotoOutputReadinessCoordinator (iOS 17+) + +Provides synchronous shutter button state updates. + +### Setup + +```swift +let coordinator = AVCapturePhotoOutputReadinessCoordinator(photoOutput: photoOutput) +coordinator.delegate = self +``` + +### Tracking Captures + +```swift +// Call BEFORE capturePhoto() +coordinator.startTrackingCaptureRequest(using: settings) +photoOutput.capturePhoto(with: settings, delegate: self) +``` + +### Delegate + +```swift +func readinessCoordinator(_ coordinator: AVCapturePhotoOutputReadinessCoordinator, + captureReadinessDidChange captureReadiness: AVCapturePhotoOutput.CaptureReadiness) { + switch captureReadiness { + case .ready: // Can capture immediately + case .notReadyMomentarily: // Brief delay, prevent double-tap + case .notReadyWaitingForCapture: // Flash firing, sensor reading + case .notReadyWaitingForProcessing: // Processing previous photo + case .sessionNotRunning: // Session stopped + @unknown default: break + } +} +``` + +--- + +## AVCapturePhotoSettings + +Configuration for a single photo capture. + +### Basic Settings + +```swift +// Standard JPEG +var settings = AVCapturePhotoSettings() + +// HEIF format +settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc]) + +// RAW +settings = AVCapturePhotoSettings(rawPixelFormatType: kCVPixelFormatType_14Bayer_BGGR) + +// RAW + JPEG +settings = AVCapturePhotoSettings( + rawPixelFormatType: kCVPixelFormatType_14Bayer_BGGR, + processedFormat: [AVVideoCodecKey: AVVideoCodecType.jpeg] +) +``` + +### Quality Prioritization + +| Value | Speed | Quality | Use Case | +|-------|-------|---------|----------| +| `.speed` | Fastest | Lower | Social sharing, rapid capture | +| `.balanced` | Medium | Good | General photography | +| `.quality` | Slowest | Best | Professional, documents | + +```swift +settings.photoQualityPrioritization = .speed +``` + +### Flash + +```swift +settings.flashMode = .auto // .off, .on, .auto +``` + +### Apple ProRAW and HDR + +```swift +// Check ProRAW support +if photoOutput.isAppleProRAWSupported { + photoOutput.isAppleProRAWEnabled = true + + // Capture ProRAW + let query = photoOutput.isAppleProRAWEnabled + ? AVCapturePhotoOutput.AppleProRAWQuery(photoOutput) + : nil + if let rawType = query?.availableRawPixelFormatTypes.first { + let settings = AVCapturePhotoSettings( + rawPixelFormatType: rawType, + processedFormat: [AVVideoCodecKey: AVVideoCodecType.hevc] + ) + } +} + +// HDR configuration +settings.photoQualityPrioritization = .quality // Enables computational photography/HDR +// HDR is automatic with .balanced or .quality — no separate toggle needed +``` + +**Note**: ProRAW requires iPhone 12 Pro or later. HDR is automatic with quality prioritization — Apple's Deep Fusion and Smart HDR are controlled by the system based on the quality setting. + +### Resolution + +```swift +// High resolution still image +settings.isHighResolutionPhotoEnabled = true + +// Max dimensions (limit resolution) +settings.maxPhotoDimensions = CMVideoDimensions(width: 4032, height: 3024) +``` + +### Preview/Thumbnail + +```swift +// Preview for immediate display +settings.previewPhotoFormat = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA +] + +// Thumbnail +settings.embeddedThumbnailPhotoFormat = [ + AVVideoCodecKey: AVVideoCodecType.jpeg, + AVVideoWidthKey: 160, + AVVideoHeightKey: 120 +] +``` + +### Important Notes + +```swift +// Settings cannot be reused +// Each capture needs a NEW settings instance +let settings1 = AVCapturePhotoSettings() // Use once +let settings2 = AVCapturePhotoSettings() // Use for second capture + +// Copy settings for similar captures +let settings2 = AVCapturePhotoSettings(from: settings1) +``` + +--- + +## AVCapturePhotoCaptureDelegate + +Delegate for photo capture events. + +```swift +extension CameraManager: AVCapturePhotoCaptureDelegate { + + // Photo capture will begin + func photoOutput(_ output: AVCapturePhotoOutput, + willBeginCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings) { + // Show shutter animation + } + + // Photo capture finished + func photoOutput(_ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error?) { + guard error == nil else { + print("Capture error: \(error!)") + return + } + + // Get JPEG data + if let data = photo.fileDataRepresentation() { + savePhoto(data) + } + + // Or get raw pixel buffer + if let pixelBuffer = photo.pixelBuffer { + processBuffer(pixelBuffer) + } + } + + // Deferred processing proxy (iOS 17+) + func photoOutput(_ output: AVCapturePhotoOutput, + didFinishCapturingDeferredPhotoProxy deferredPhotoProxy: AVCaptureDeferredPhotoProxy, + error: Error?) { + guard error == nil, let data = deferredPhotoProxy.fileDataRepresentation() else { return } + replaceThumbnailWithFinal(data) + } +} +``` + +--- + +## AVCaptureMovieFileOutput + +Output for recording video to file. + +### Setup + +```swift +let movieOutput = AVCaptureMovieFileOutput() + +if session.canAddOutput(movieOutput) { + session.addOutput(movieOutput) +} + +// Add audio input +if let microphone = AVCaptureDevice.default(for: .audio), + let audioInput = try? AVCaptureDeviceInput(device: microphone), + session.canAddInput(audioInput) { + session.addInput(audioInput) +} +``` + +### Recording + +```swift +// Start recording +let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("mov") + +// Apply rotation +if let connection = movieOutput.connection(with: .video) { + connection.videoRotationAngle = rotationCoordinator.videoRotationAngleForHorizonLevelCapture +} + +movieOutput.startRecording(to: outputURL, recordingDelegate: self) + +// Stop recording +movieOutput.stopRecording() + +// Check state +movieOutput.isRecording +movieOutput.recordedDuration +movieOutput.recordedFileSize +``` + +### Delegate + +```swift +extension CameraManager: AVCaptureFileOutputRecordingDelegate { + + func fileOutput(_ output: AVCaptureFileOutput, + didStartRecordingTo fileURL: URL, + from connections: [AVCaptureConnection]) { + // Recording started + } + + func fileOutput(_ output: AVCaptureFileOutput, + didFinishRecordingTo outputFileURL: URL, + from connections: [AVCaptureConnection], + error: Error?) { + if let error = error { + print("Recording failed: \(error)") + return + } + + // Video saved to outputFileURL + saveToPhotoLibrary(outputFileURL) + } +} +``` + +--- + +## AVCaptureVideoPreviewLayer + +Layer for displaying camera preview. + +### Setup + +```swift +let previewLayer = AVCaptureVideoPreviewLayer(session: session) +previewLayer.videoGravity = .resizeAspectFill +previewLayer.frame = view.bounds +view.layer.addSublayer(previewLayer) +``` + +### Video Gravity + +| Value | Behavior | +|-------|----------| +| `.resizeAspect` | Fit entire image, may letterbox | +| `.resizeAspectFill` | Fill layer, may crop edges | +| `.resize` | Stretch to fill (distorts) | + +### SwiftUI Integration + +```swift +struct CameraPreview: UIViewRepresentable { + let session: AVCaptureSession + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + view.previewLayer.session = session + view.previewLayer.videoGravity = .resizeAspectFill + return view + } + + func updateUIView(_ uiView: PreviewView, context: Context) {} + + class PreviewView: UIView { + override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } + } +} +``` + +--- + +## Common Code Patterns + +### Complete Camera Manager + +```swift +import AVFoundation + +@MainActor +class CameraManager: NSObject, ObservableObject { + let session = AVCaptureSession() + let photoOutput = AVCapturePhotoOutput() + private let sessionQueue = DispatchQueue(label: "camera.session") + private var rotationCoordinator: AVCaptureDevice.RotationCoordinator? + private var rotationObservation: NSKeyValueObservation? + + @Published var isSessionRunning = false + + func setup() async -> Bool { + guard await AVCaptureDevice.requestAccess(for: .video) else { return false } + + return await withCheckedContinuation { continuation in + sessionQueue.async { [self] in + session.beginConfiguration() + defer { session.commitConfiguration() } + + session.sessionPreset = .photo + + guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), + let input = try? AVCaptureDeviceInput(device: camera), + session.canAddInput(input) else { + continuation.resume(returning: false) + return + } + session.addInput(input) + + guard session.canAddOutput(photoOutput) else { + continuation.resume(returning: false) + return + } + session.addOutput(photoOutput) + photoOutput.maxPhotoQualityPrioritization = .quality + + continuation.resume(returning: true) + } + } + } + + func start() { + sessionQueue.async { [self] in + session.startRunning() + DispatchQueue.main.async { + self.isSessionRunning = self.session.isRunning + } + } + } + + func stop() { + sessionQueue.async { [self] in + session.stopRunning() + DispatchQueue.main.async { + self.isSessionRunning = false + } + } + } + + func capturePhoto() { + var settings = AVCapturePhotoSettings() + settings.photoQualityPrioritization = .balanced + + if let connection = photoOutput.connection(with: .video), + let angle = rotationCoordinator?.videoRotationAngleForHorizonLevelCapture { + connection.videoRotationAngle = angle + } + + photoOutput.capturePhoto(with: settings, delegate: self) + } +} + +extension CameraManager: AVCapturePhotoCaptureDelegate { + nonisolated func photoOutput(_ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error?) { + guard let data = photo.fileDataRepresentation() else { return } + // Handle photo data + } +} +``` + +--- + +## Resources + +**Docs**: /avfoundation/avcapturesession, /avfoundation/avcapturedevice, /avfoundation/avcapturephotosettings, /avfoundation/avcapturedevice/rotationcoordinator + +**Skills**: axiom-camera-capture, axiom-camera-capture-diag diff --git a/.claude/skills/axiom-camera-capture-ref/agents/openai.yaml b/.claude/skills/axiom-camera-capture-ref/agents/openai.yaml new file mode 100644 index 0000000..0314e6b --- /dev/null +++ b/.claude/skills/axiom-camera-capture-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Camera Capture Reference" + short_description: "Reference — AVCaptureSession, AVCapturePhotoSettings, AVCapturePhotoOutput, RotationCoordinator, photoQualityPrioriti..." diff --git a/.claude/skills/axiom-camera-capture/.openskills.json b/.claude/skills/axiom-camera-capture/.openskills.json new file mode 100644 index 0000000..2e464a8 --- /dev/null +++ b/.claude/skills/axiom-camera-capture/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-camera-capture", + "installedAt": "2026-04-12T08:05:58.892Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-camera-capture/SKILL.md b/.claude/skills/axiom-camera-capture/SKILL.md new file mode 100644 index 0000000..2aa08d9 --- /dev/null +++ b/.claude/skills/axiom-camera-capture/SKILL.md @@ -0,0 +1,890 @@ +--- +name: axiom-camera-capture +description: AVCaptureSession, camera preview, photo capture, video recording, RotationCoordinator, session interruptions, deferred processing, capture responsiveness, zero-shutter-lag, photoQualityPrioritization, front camera mirroring +license: MIT +compatibility: iOS 17+, iPadOS 17+, macOS 14+, tvOS 17+, axiom-visionOS 1+ +metadata: + version: "1.0.0" + last-updated: "2026-01-03" +--- + +# Camera Capture with AVFoundation + +Guides you through implementing camera capture: session setup, photo capture, video recording, responsive capture UX, rotation handling, and session lifecycle management. + +## When to Use This Skill + +Use when you need to: +- ☑ Build a custom camera UI (not system picker) +- ☑ Capture photos with quality/speed tradeoffs +- ☑ Record video with audio +- ☑ Handle device rotation correctly (RotationCoordinator) +- ☑ Make capture feel responsive (zero-shutter-lag) +- ☑ Handle session interruptions (phone calls, multitasking) +- ☑ Switch between front/back cameras +- ☑ Configure capture quality and resolution + +## Example Prompts + +"How do I set up a camera preview in SwiftUI?" +"My camera freezes when I get a phone call" +"The photo preview is rotated wrong on front camera" +"How do I make photo capture feel instant?" +"Should I use deferred processing?" +"My camera takes too long to capture" +"How do I switch between front and back cameras?" +"How do I record video with audio?" + +## Red Flags + +Signs you're making this harder than it needs to be: + +- ❌ Calling `startRunning()` on main thread (blocks UI for seconds) +- ❌ Using deprecated `videoOrientation` instead of RotationCoordinator (iOS 17+) +- ❌ Not observing session interruptions (app freezes on phone call) +- ❌ Creating new AVCaptureSession for each capture (expensive) +- ❌ Using `.photo` preset for video (wrong format) +- ❌ Ignoring `photoQualityPrioritization` (slow captures) +- ❌ Not handling `.notAuthorized` permission state +- ❌ Modifying session without `beginConfiguration()`/`commitConfiguration()` +- ❌ Using UIImagePickerController for custom camera UI (limited control) + +## Mandatory First Steps + +Before implementing any camera feature: + +### 1. Choose Your Capture Mode + +``` +What do you need? + +┌─ Just let user pick a photo? +│ └─ Don't use AVFoundation - use PHPicker or PhotosPicker +│ See: /skill axiom-photo-library +│ +├─ Simple photo/video capture with system UI? +│ └─ UIImagePickerController (but limited customization) +│ +├─ Custom camera UI with photo capture? +│ └─ AVCaptureSession + AVCapturePhotoOutput +│ → Continue with this skill +│ +├─ Custom camera UI with video recording? +│ └─ AVCaptureSession + AVCaptureMovieFileOutput +│ → Continue with this skill +│ +└─ Both photo and video in same session? + └─ AVCaptureSession + both outputs + → Continue with this skill +``` + +### 2. Request Camera Permission + +```swift +import AVFoundation + +func requestCameraAccess() async -> Bool { + let status = AVCaptureDevice.authorizationStatus(for: .video) + + switch status { + case .authorized: + return true + case .notDetermined: + return await AVCaptureDevice.requestAccess(for: .video) + case .denied, .restricted: + // Show settings prompt + return false + @unknown default: + return false + } +} +``` + +**Info.plist required**: +```xml +NSCameraUsageDescription +Take photos and videos +``` + +For audio (video recording): +```xml +NSMicrophoneUsageDescription +Record audio with video +``` + +### 3. Understand Session Architecture + +``` +AVCaptureSession + ├─ Inputs + │ ├─ AVCaptureDeviceInput (camera) + │ └─ AVCaptureDeviceInput (microphone, for video) + │ + ├─ Outputs + │ ├─ AVCapturePhotoOutput (photos) + │ ├─ AVCaptureMovieFileOutput (video files) + │ └─ AVCaptureVideoDataOutput (raw frames) + │ + └─ Connections (automatic between compatible input/output) +``` + +**Key rule**: All session configuration happens on a **dedicated serial queue**, never main thread. + +## Core Patterns + +### Pattern 1: Basic Session Setup + +**Use case**: Set up camera preview with photo capture capability. + +```swift +import AVFoundation + +class CameraManager: NSObject { + let session = AVCaptureSession() + let photoOutput = AVCapturePhotoOutput() + + // CRITICAL: Dedicated serial queue for session work + private let sessionQueue = DispatchQueue(label: "camera.session") + + func setupSession() { + sessionQueue.async { [self] in + session.beginConfiguration() + defer { session.commitConfiguration() } + + // 1. Set session preset + session.sessionPreset = .photo + + // 2. Add camera input + guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, + for: .video, + position: .back), + let input = try? AVCaptureDeviceInput(device: camera), + session.canAddInput(input) else { + return + } + session.addInput(input) + + // 3. Add photo output + guard session.canAddOutput(photoOutput) else { return } + session.addOutput(photoOutput) + + // 4. Configure photo output + photoOutput.isHighResolutionCaptureEnabled = true + photoOutput.maxPhotoQualityPrioritization = .quality + } + } + + func startSession() { + sessionQueue.async { [self] in + if !session.isRunning { + session.startRunning() // Blocking call - never on main thread! + } + } + } + + func stopSession() { + sessionQueue.async { [self] in + if session.isRunning { + session.stopRunning() + } + } + } +} +``` + +**Cost**: 30 min implementation + +### Pattern 2: SwiftUI Camera Preview + +**Use case**: Display camera preview in SwiftUI view. + +```swift +import SwiftUI +import AVFoundation + +struct CameraPreview: UIViewRepresentable { + let session: AVCaptureSession + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + view.previewLayer.session = session + view.previewLayer.videoGravity = .resizeAspectFill + return view + } + + func updateUIView(_ uiView: PreviewView, context: Context) {} + + class PreviewView: UIView { + override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } + } +} + +// Usage in SwiftUI +struct CameraView: View { + @StateObject private var camera = CameraManager() + + var body: some View { + CameraPreview(session: camera.session) + .ignoresSafeArea() + .onAppear { camera.startSession() } + .onDisappear { camera.stopSession() } + } +} +``` + +**Cost**: 20 min implementation + +### Pattern 3: Rotation Handling with RotationCoordinator (iOS 17+) + +**Use case**: Keep preview and captured photos correctly oriented regardless of device rotation. + +**Why RotationCoordinator**: Deprecated `videoOrientation` requires manual observation of device orientation. RotationCoordinator automatically tracks gravity and provides angles. + +```swift +import AVFoundation + +class CameraManager { + private var rotationCoordinator: AVCaptureDevice.RotationCoordinator? + private var rotationObservation: NSKeyValueObservation? + + func setupRotationCoordinator(device: AVCaptureDevice, previewLayer: AVCaptureVideoPreviewLayer) { + // Create coordinator with device and preview layer + rotationCoordinator = AVCaptureDevice.RotationCoordinator( + device: device, + previewLayer: previewLayer + ) + + // Observe preview rotation changes + rotationObservation = rotationCoordinator?.observe( + \.videoRotationAngleForHorizonLevelPreview, + options: [.new] + ) { [weak previewLayer] coordinator, _ in + // Update preview layer rotation on main thread + DispatchQueue.main.async { + previewLayer?.connection?.videoRotationAngle = coordinator.videoRotationAngleForHorizonLevelPreview + } + } + + // Set initial rotation + previewLayer.connection?.videoRotationAngle = rotationCoordinator!.videoRotationAngleForHorizonLevelPreview + } + + func captureRotationAngle() -> CGFloat { + // Use this angle when capturing photos + rotationCoordinator?.videoRotationAngleForHorizonLevelCapture ?? 0 + } +} +``` + +**When capturing**: +```swift +func capturePhoto() { + let settings = AVCapturePhotoSettings() + + // Apply rotation angle from coordinator + if let connection = photoOutput.connection(with: .video) { + connection.videoRotationAngle = captureRotationAngle() + } + + photoOutput.capturePhoto(with: settings, delegate: self) +} +``` + +**Cost**: 45 min implementation, prevents 2+ hours debugging rotation issues + +### Pattern 4: Responsive Capture Pipeline (iOS 17+) + +**Use case**: Make photo capture feel instant with zero-shutter-lag, overlapping captures, and responsive button states. + +**iOS 17+ introduces four complementary APIs** that work together for maximum responsiveness: + +#### 4a. Zero Shutter Lag + +Uses a ring buffer of recent frames to "time travel" back to the exact moment you tapped the shutter. Enabled automatically for iOS 17+ apps. + +```swift +// Check if supported for current format +if photoOutput.isZeroShutterLagSupported { + // Enabled by default for apps linking iOS 17+ + // Opt out if causing issues: + // photoOutput.isZeroShutterLagEnabled = false +} +``` + +**Why it matters**: Without ZSL, there's a delay between tap and frame capture. For action shots, the moment is already over. + +**Requirements**: iPhone XS and newer. Does NOT apply to flash captures, manual exposure, bracketed captures, or constituent photo delivery. + +#### 4b. Responsive Capture (Overlapping Captures) + +Allows a new capture to start while the previous one is still processing: + +```swift +// Check support first +if photoOutput.isZeroShutterLagSupported { + photoOutput.isZeroShutterLagEnabled = true // Required for responsive capture + + if photoOutput.isResponsiveCaptureSupported { + photoOutput.isResponsiveCaptureEnabled = true + } +} +``` + +**Tradeoff**: Increases peak memory usage. If your app is memory-constrained, consider leaving disabled. + +**Requirements**: A12 Bionic (iPhone XS) and newer. + +#### 4c. Fast Capture Prioritization + +Automatically adapts quality when taking multiple photos rapidly (like burst mode): + +```swift +if photoOutput.isFastCapturePrioritizationSupported { + photoOutput.isFastCapturePrioritizationEnabled = true + // When enabled, rapid captures use "balanced" quality instead of "quality" + // to maintain consistent shot-to-shot time +} +``` + +**When to enable**: User-facing toggle ("Prioritize Faster Shooting" in Camera.app). Off by default because it reduces quality. + +#### 4d. Readiness Coordinator (Button State Management) + +**Critical for UX**: Provides synchronous updates for shutter button state without async lag. + +```swift +class CameraManager { + private var readinessCoordinator: AVCapturePhotoOutputReadinessCoordinator! + + func setupReadinessCoordinator() { + readinessCoordinator = AVCapturePhotoOutputReadinessCoordinator(photoOutput: photoOutput) + readinessCoordinator.delegate = self + } + + func capturePhoto() { + var settings = AVCapturePhotoSettings() + settings.photoQualityPrioritization = .balanced + + // Tell coordinator to track this capture BEFORE calling capturePhoto + readinessCoordinator.startTrackingCaptureRequest(using: settings) + + photoOutput.capturePhoto(with: settings, delegate: self) + } +} + +extension CameraManager: AVCapturePhotoOutputReadinessCoordinatorDelegate { + func readinessCoordinator(_ coordinator: AVCapturePhotoOutputReadinessCoordinator, + captureReadinessDidChange captureReadiness: AVCapturePhotoOutput.CaptureReadiness) { + DispatchQueue.main.async { + switch captureReadiness { + case .ready: + self.shutterButton.isEnabled = true + self.shutterButton.alpha = 1.0 + + case .notReadyMomentarily: + // Brief delay - disable to prevent double-tap + self.shutterButton.isEnabled = false + + case .notReadyWaitingForCapture: + // Flash is firing - dim button + self.shutterButton.alpha = 0.5 + + case .notReadyWaitingForProcessing: + // Processing previous photo - show spinner + self.showProcessingIndicator() + + case .sessionNotRunning: + self.shutterButton.isEnabled = false + + @unknown default: + break + } + } + } +} +``` + +**Why use Readiness Coordinator**: Without it, you'd need to track capture state manually and users might spam the shutter button during processing. + +#### Quality Prioritization (Baseline) + +Still useful even without the new APIs: + +```swift +func capturePhoto() { + var settings = AVCapturePhotoSettings() + + // Speed vs Quality tradeoff + // .speed - Fastest capture, lower quality + // .balanced - Good default + // .quality - Best quality, may have delay + settings.photoQualityPrioritization = .speed + + // For specific use cases: + // - Social sharing: .speed (users expect instant) + // - Document scanning: .quality (accuracy matters) + // - General photography: .balanced + + photoOutput.capturePhoto(with: settings, delegate: self) +} +``` + +**Deferred Processing (iOS 17+)**: + +For maximum responsiveness, capture returns immediately with proxy image, full Deep Fusion processing happens in background: + +```swift +// Check support and enable deferred processing +if photoOutput.isAutoDeferredPhotoDeliverySupported { + photoOutput.isAutoDeferredPhotoDeliveryEnabled = true +} +``` + +**Delegate callbacks with deferred processing**: + +```swift +// Called for BOTH regular photos AND deferred proxies +func photoOutput(_ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error?) { + guard error == nil else { return } + + // Non-deferred photo - save directly + if !photo.isRawPhoto, let data = photo.fileDataRepresentation() { + savePhotoToLibrary(data) + } +} + +// Called ONLY for deferred proxies - save to PhotoKit for later processing +func photoOutput(_ output: AVCapturePhotoOutput, + didFinishCapturingDeferredPhotoProxy deferredPhotoProxy: AVCaptureDeferredPhotoProxy, + error: Error?) { + guard error == nil else { return } + + // CRITICAL: Save proxy to library ASAP before app is backgrounded + // App may be force-quit if memory pressure is high during backgrounding + guard let proxyData = deferredPhotoProxy.fileDataRepresentation() else { return } + + Task { + try await PHPhotoLibrary.shared().performChanges { + let request = PHAssetCreationRequest.forAsset() + // Use .photoProxy resource type - triggers deferred processing in Photos + request.addResource(with: .photoProxy, data: proxyData, options: nil) + } + } +} +``` + +**When final processing happens**: +- On-demand when image is requested from PhotoKit +- Or automatically when device is idle (plugged in, not in use) + +**Fetching images with deferred processing awareness**: + +```swift +// Request with secondary degraded image for smoother UX +let options = PHImageRequestOptions() +options.allowSecondaryDegradedImage = true // New in iOS 17 + +PHImageManager.default().requestImage( + for: asset, + targetSize: targetSize, + contentMode: .aspectFill, + options: options +) { image, info in + let isDegraded = info?[PHImageResultIsDegradedKey] as? Bool ?? false + + if isDegraded { + // First: Low quality (immediate) + // Second: Medium quality (new - while processing) + // Third callback will be final quality + self.showTemporaryImage(image) + } else { + // Final quality - processing complete + self.showFinalImage(image) + } +} +``` + +**Requirements**: iPhone 11 Pro and newer. Not used for flash captures or formats that don't benefit from extended processing. + +**Important considerations**: +- Can't apply pixel buffer customizations (filters, metadata changes) to deferred photos +- Use PhotoKit adjustments after processing for edits +- Get proxy into library ASAP - limited time when backgrounded + +**Cost**: 1 hour implementation, prevents "camera feels slow" complaints + +### Pattern 5: Session Interruption Handling + +**Use case**: Handle phone calls, multitasking, system camera usage. + +```swift +class CameraManager { + private var interruptionObservers: [NSObjectProtocol] = [] + + func setupInterruptionHandling() { + // Session was interrupted + let interruptedObserver = NotificationCenter.default.addObserver( + forName: .AVCaptureSessionWasInterrupted, + object: session, + queue: .main + ) { [weak self] notification in + guard let reason = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int, + let interruptionReason = AVCaptureSession.InterruptionReason(rawValue: reason) else { + return + } + + switch interruptionReason { + case .videoDeviceNotAvailableInBackground: + // App went to background - normal, will resume + self?.showPausedOverlay() + + case .audioDeviceInUseByAnotherClient: + // Another app using audio + self?.showInterruptedBanner("Audio in use by another app") + + case .videoDeviceInUseByAnotherClient: + // Another app using camera + self?.showInterruptedBanner("Camera in use by another app") + + case .videoDeviceNotAvailableWithMultipleForegroundApps: + // Split View/Slide Over - camera not available + self?.showInterruptedBanner("Camera unavailable in Split View") + + case .videoDeviceNotAvailableDueToSystemPressure: + // Thermal state - reduce quality or stop + self?.handleThermalPressure() + + @unknown default: + self?.showInterruptedBanner("Camera interrupted") + } + } + interruptionObservers.append(interruptedObserver) + + // Session interruption ended + let endedObserver = NotificationCenter.default.addObserver( + forName: .AVCaptureSessionInterruptionEnded, + object: session, + queue: .main + ) { [weak self] _ in + self?.hideInterruptedBanner() + self?.hidePausedOverlay() + // Session automatically resumes - no need to call startRunning() + } + interruptionObservers.append(endedObserver) + } + + deinit { + interruptionObservers.forEach { NotificationCenter.default.removeObserver($0) } + } +} +``` + +**Cost**: 30 min implementation, prevents "camera freezes" bug reports + +### Pattern 6: Camera Switching (Front/Back) + +**Use case**: Toggle between front and back cameras. + +```swift +func switchCamera() { + sessionQueue.async { [self] in + guard let currentInput = session.inputs.first as? AVCaptureDeviceInput else { + return + } + + let currentPosition = currentInput.device.position + let newPosition: AVCaptureDevice.Position = currentPosition == .back ? .front : .back + + guard let newDevice = AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: newPosition + ) else { + return + } + + session.beginConfiguration() + defer { session.commitConfiguration() } + + // Remove old input + session.removeInput(currentInput) + + // Add new input + do { + let newInput = try AVCaptureDeviceInput(device: newDevice) + if session.canAddInput(newInput) { + session.addInput(newInput) + + // Update rotation coordinator for new device + if let previewLayer = previewLayer { + setupRotationCoordinator(device: newDevice, previewLayer: previewLayer) + } + } else { + // Fallback: restore old input + session.addInput(currentInput) + } + } catch { + session.addInput(currentInput) + } + } +} +``` + +**Front camera mirroring**: Front camera preview is mirrored by default (matches user expectation). Captured photos are NOT mirrored (correct for sharing). This is intentional. + +**Cost**: 20 min implementation + +### Pattern 7: Video Recording + +**Use case**: Record video with audio to file. + +```swift +class CameraManager: NSObject { + let movieOutput = AVCaptureMovieFileOutput() + private var currentRecordingURL: URL? + + func setupVideoRecording() { + sessionQueue.async { [self] in + session.beginConfiguration() + defer { session.commitConfiguration() } + + // Set video preset + session.sessionPreset = .high // Or .hd1920x1080, .hd4K3840x2160 + + // Add microphone input + if let microphone = AVCaptureDevice.default(for: .audio), + let audioInput = try? AVCaptureDeviceInput(device: microphone), + session.canAddInput(audioInput) { + session.addInput(audioInput) + } + + // Add movie output + if session.canAddOutput(movieOutput) { + session.addOutput(movieOutput) + } + } + } + + func startRecording() { + guard !movieOutput.isRecording else { return } + + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("mov") + + currentRecordingURL = outputURL + + // Apply rotation + if let connection = movieOutput.connection(with: .video) { + connection.videoRotationAngle = captureRotationAngle() + } + + movieOutput.startRecording(to: outputURL, recordingDelegate: self) + } + + func stopRecording() { + guard movieOutput.isRecording else { return } + movieOutput.stopRecording() + } +} + +extension CameraManager: AVCaptureFileOutputRecordingDelegate { + func fileOutput(_ output: AVCaptureFileOutput, + didFinishRecordingTo outputFileURL: URL, + from connections: [AVCaptureConnection], + error: Error?) { + if let error = error { + print("Recording error: \(error)") + return + } + + // Video saved to outputFileURL + saveVideoToPhotoLibrary(outputFileURL) + } +} +``` + +**Cost**: 45 min implementation + +## Anti-Patterns + +### Anti-Pattern 1: Session Work on Main Thread + +**Wrong**: +```swift +func startCamera() { + session.startRunning() // Blocks UI for 1-3 seconds! +} +``` + +**Right**: +```swift +func startCamera() { + sessionQueue.async { [self] in + session.startRunning() + } +} +``` + +**Why it matters**: `startRunning()` is blocking. On main thread, UI freezes. + +### Anti-Pattern 2: Using Deprecated videoOrientation + +**Wrong** (pre-iOS 17): +```swift +// Manually tracking orientation +NotificationCenter.default.addObserver( + forName: UIDevice.orientationDidChangeNotification, + object: nil, + queue: .main +) { _ in + // Manual rotation logic... +} +``` + +**Right** (iOS 17+): +```swift +let coordinator = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: preview) +// Automatically tracks gravity, provides angles +``` + +**Why it matters**: RotationCoordinator handles edge cases (face-up, face-down) that manual tracking misses. + +### Anti-Pattern 3: Ignoring Session Interruptions + +**Wrong**: +```swift +// No interruption handling - camera freezes on phone call +``` + +**Right**: +```swift +NotificationCenter.default.addObserver( + forName: .AVCaptureSessionWasInterrupted, + object: session, + queue: .main +) { notification in + // Show UI feedback +} +``` + +**Why it matters**: Without handling, camera appears frozen when interrupted. + +### Anti-Pattern 4: Modifying Session Without Configuration Block + +**Wrong**: +```swift +session.removeInput(oldInput) +session.addInput(newInput) // May fail mid-stream +``` + +**Right**: +```swift +session.beginConfiguration() +session.removeInput(oldInput) +session.addInput(newInput) +session.commitConfiguration() // Atomic change +``` + +**Why it matters**: Without configuration block, session may enter invalid state between calls. + +## Pressure Scenarios + +### Scenario 1: "Just Make the Camera Work by Friday" + +**Context**: Product wants camera feature shipped. You're considering skipping interruption handling. + +**Pressure**: "It works when I test it, let's ship." + +**Reality**: First user who gets a phone call while using camera will see frozen UI. App Store review may catch this. + +**Correct action**: +1. Implement interruption handling (30 min) +2. Test by calling your test device during camera use +3. Verify UI shows appropriate feedback + +**Push-back template**: "Camera captures work, but the app freezes if a phone call comes in. I need 30 minutes to handle interruptions properly and avoid 1-star reviews." + +### Scenario 2: "The Camera is Too Slow" + +**Context**: QA reports photo capture feels sluggish. PM wants it "instant like the system camera." + +**Pressure**: "Just make it faster somehow." + +**Reality**: Default settings prioritize quality over speed. System camera uses deferred processing. + +**Correct action**: +1. Set `photoQualityPrioritization = .speed` for social/sharing use cases +2. Consider deferred processing for maximum responsiveness +3. Show capture animation immediately (before processing completes) + +**Push-back template**: "We're currently optimizing for image quality. I can make capture feel instant by prioritizing speed and showing the preview immediately while processing continues in background. This is what the system Camera app does." + +### Scenario 3: "Why is the Front Camera Photo Mirrored?" + +**Context**: Designer reports front camera photos look "wrong" - they're not mirrored like the preview. + +**Pressure**: "The preview shows it one way, the photo should match." + +**Reality**: Preview is mirrored (user expectation - like a mirror). Photo is NOT mirrored (correct for sharing - text reads correctly). This is intentional behavior matching system camera. + +**Correct action**: +1. Explain this is Apple's standard behavior +2. If business requires mirrored photos (selfie apps), manually mirror in post-processing +3. Never mirror the preview differently than expected + +**Push-back template**: "This is intentional Apple behavior. The preview is mirrored like a mirror so users can frame themselves, but the captured photo is unmirrored so text reads correctly when shared. We can add optional mirroring in post-processing if our use case requires it." + +## Checklist + +Before shipping camera features: + +**Session Setup**: +- ☑ All session work on dedicated serial queue +- ☑ `startRunning()` never called on main thread +- ☑ Session preset matches use case (`.photo` for photos, `.high` for video) +- ☑ Configuration changes wrapped in `beginConfiguration()`/`commitConfiguration()` + +**Permissions**: +- ☑ Camera permission requested before session setup +- ☑ `NSCameraUsageDescription` in Info.plist +- ☑ `NSMicrophoneUsageDescription` if recording audio +- ☑ Graceful handling of denied permission + +**Rotation**: +- ☑ RotationCoordinator used (not deprecated videoOrientation) +- ☑ Preview layer rotation updated via observation +- ☑ Capture rotation angle applied when taking photos +- ☑ Tested in all orientations (portrait, landscape, face-up) + +**Responsiveness**: +- ☑ photoQualityPrioritization set appropriately for use case +- ☑ Capture button shows immediate feedback +- ☑ Deferred processing considered for maximum speed + +**Interruptions**: +- ☑ Session interruption observer registered +- ☑ UI feedback shown when interrupted +- ☑ Tested with incoming phone call +- ☑ Tested in Split View (iPad) + +**Camera Switching**: +- ☑ Front/back switch updates rotation coordinator +- ☑ Switch happens on session queue +- ☑ Fallback if new camera unavailable + +**Video Recording** (if applicable): +- ☑ Microphone input added +- ☑ Recording delegate handles completion +- ☑ File cleanup for temporary recordings + +## Resources + +**WWDC**: 2021-10247, 2023-10105 + +**Docs**: /avfoundation/avcapturesession, /avfoundation/avcapturedevice/rotationcoordinator, /avfoundation/avcapturephotosettings, /avfoundation/avcapturephotooutputreadinesscoordinator + +**Skills**: axiom-camera-capture-ref, axiom-camera-capture-diag, axiom-photo-library diff --git a/.claude/skills/axiom-camera-capture/agents/openai.yaml b/.claude/skills/axiom-camera-capture/agents/openai.yaml new file mode 100644 index 0000000..da58d5e --- /dev/null +++ b/.claude/skills/axiom-camera-capture/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Camera Capture" + short_description: "AVCaptureSession, camera preview, photo capture, video recording, RotationCoordinator, session interruptions, deferre..." diff --git a/.claude/skills/axiom-cloud-sync-diag/.openskills.json b/.claude/skills/axiom-cloud-sync-diag/.openskills.json new file mode 100644 index 0000000..7f1a790 --- /dev/null +++ b/.claude/skills/axiom-cloud-sync-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-cloud-sync-diag", + "installedAt": "2026-04-12T08:06:01.371Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-cloud-sync-diag/SKILL.md b/.claude/skills/axiom-cloud-sync-diag/SKILL.md new file mode 100644 index 0000000..c864f85 --- /dev/null +++ b/.claude/skills/axiom-cloud-sync-diag/SKILL.md @@ -0,0 +1,562 @@ +--- +name: axiom-cloud-sync-diag +description: Use when debugging 'file not syncing', 'CloudKit error', 'sync conflict', 'iCloud upload failed', 'ubiquitous item error', 'data not appearing on other devices', 'CKError', 'quota exceeded' - systematic iCloud sync diagnostics for both CloudKit and iCloud Drive +license: MIT +metadata: + version: "1.0.0" + last-updated: "2025-12-12" +--- + +# iCloud Sync Diagnostics + +## Overview + +**Core principle** 90% of cloud sync problems stem from account/entitlement issues, network connectivity, or misunderstanding sync timing—not iCloud infrastructure bugs. + +iCloud (both CloudKit and iCloud Drive) handles billions of sync operations daily across all Apple devices. If your data isn't syncing, the issue is almost always configuration, connectivity, or timing expectations. + +## Red Flags — Suspect Cloud Sync Issue + +If you see ANY of these: +- Files/data not appearing on other devices +- "iCloud account not available" errors +- Persistent sync conflicts +- CloudKit quota exceeded +- Upload/download stuck at 0% +- Works on simulator but not device +- Works on WiFi but not cellular + +❌ **FORBIDDEN** "iCloud is broken, we should build our own sync" +- iCloud infrastructure handles trillions of operations +- Building reliable sync is incredibly complex +- 99% of issues are configuration or connectivity + +--- + +## Mandatory First Steps + +**ALWAYS check these FIRST** (before changing code): + +```swift +// 1. Check iCloud account status +func checkICloudStatus() async { + let status = FileManager.default.ubiquityIdentityToken + + if status == nil { + print("❌ Not signed into iCloud") + print("Settings → [Name] → iCloud → Sign in") + return + } + + print("✅ Signed into iCloud") + + // For CloudKit specifically + let container = CKContainer.default() + do { + let status = try await container.accountStatus() + switch status { + case .available: + print("✅ CloudKit available") + case .noAccount: + print("❌ No iCloud account") + case .restricted: + print("❌ iCloud restricted (parental controls?)") + case .couldNotDetermine: + print("⚠️ Could not determine status") + case .temporarilyUnavailable: + print("⚠️ Temporarily unavailable (retry)") + @unknown default: + print("⚠️ Unknown status") + } + } catch { + print("Error checking CloudKit: \(error)") + } +} + +// 2. Check entitlements +func checkEntitlements() { + // Verify iCloud container exists + if let containerURL = FileManager.default.url( + forUbiquityContainerIdentifier: nil + ) { + print("✅ iCloud container: \(containerURL)") + } else { + print("❌ No iCloud container") + print("Check Xcode → Signing & Capabilities → iCloud") + } +} + +// 3. Check network connectivity +func checkConnectivity() { + // Use NWPathMonitor or similar + print("Network: Check if device has internet") + print("Try on different networks (WiFi, cellular)") +} + +// 4. Check device storage +func checkStorage() { + let homeURL = FileManager.default.homeDirectoryForCurrentUser + if let values = try? homeURL.resourceValues(forKeys: [ + .volumeAvailableCapacityKey + ]) { + let available = values.volumeAvailableCapacity ?? 0 + print("Available space: \(available / 1_000_000) MB") + + if available < 100_000_000 { // <100 MB + print("⚠️ Low storage may prevent sync") + } + } +} +``` + +--- + +## Decision Tree + +### CloudKit Sync Issues + +``` +CloudKit data not syncing? + +├─ Account unavailable? +│ ├─ Check: await container.accountStatus() +│ ├─ .noAccount → User not signed into iCloud +│ ├─ .restricted → Parental controls or corporate restrictions +│ └─ .temporarilyUnavailable → Network issue or iCloud outage +│ +├─ CKError.quotaExceeded? +│ └─ User exceeded iCloud storage quota +│ → Prompt user to purchase more storage +│ → Or delete old data +│ +├─ CKError.networkUnavailable? +│ └─ No internet connection +│ → Check WiFi/cellular +│ → Test on different network +│ +├─ CKError.serverRecordChanged (conflict)? +│ └─ Concurrent modifications +│ → Implement conflict resolution +│ → Use savePolicy correctly +│ +└─ SwiftData not syncing? + ├─ Check ModelConfiguration CloudKit setup + ├─ Verify private database only (no public/shared) + └─ Check for @Attribute(.unique) (not supported with CloudKit) +``` + +### iCloud Drive Sync Issues + +``` +iCloud Drive files not syncing? + +├─ File not uploading? +│ ├─ Check: url.resourceValues(.ubiquitousItemIsUploadingKey) +│ ├─ Check: url.resourceValues(.ubiquitousItemUploadingErrorKey) +│ └─ Error details will indicate issue +│ +├─ File not downloading? +│ ├─ Not requested? → startDownloadingUbiquitousItem(at:) +│ ├─ Check: url.resourceValues(.ubiquitousItemDownloadingErrorKey) +│ └─ May need manual download trigger +│ +├─ File has conflicts? +│ ├─ Check: url.resourceValues(.ubiquitousItemHasUnresolvedConflictsKey) +│ └─ Resolve with NSFileVersion +│ +└─ Files not appearing on other device? + ├─ Check iCloud account on both devices (same account?) + ├─ Check entitlements match on both + ├─ Wait (sync not instant, can take minutes) + └─ Check Settings → iCloud → iCloud Drive → [App] is enabled +``` + +--- + +## Common CloudKit Errors + +### CKError.accountTemporarilyUnavailable + +**Cause**: iCloud servers temporarily unavailable or user signed out + +**Fix**: +```swift +if error.code == .accountTemporarilyUnavailable { + // Retry with exponential backoff + try await Task.sleep(for: .seconds(5)) + try await retryOperation() +} +``` + +### CKError.quotaExceeded + +**Cause**: User's iCloud storage full + +**Fix**: +```swift +if error.code == .quotaExceeded { + // Show alert to user + showAlert( + title: "iCloud Storage Full", + message: "Please free up space in Settings → [Name] → iCloud → Manage Storage" + ) +} +``` + +### CKError.serverRecordChanged + +**Cause**: Record modified on server since your last fetch. **Most common root cause**: saving a stale record without fetching the latest version first. + +**Diagnosis — check the simple fix FIRST**: +```swift +// ❌ WRONG: Saving without fetching latest version +// This causes serverRecordChanged on EVERY concurrent edit +let record = CKRecord(recordType: "Note", recordID: existingID) +record["title"] = "Updated" +try await database.save(record) // Overwrites server version → conflict + +// ✅ FIX: Fetch-then-modify-then-save (fixes 80% of cases) +let record = try await database.record(for: existingID) // Get latest +record["title"] = "Updated" // Modify the fetched record +try await database.save(record) // Save with correct changeTag +``` + +**If fetch-then-save doesn't fix it** (true concurrent edits from multiple devices): +```swift +if error.code == .serverRecordChanged, + let serverRecord = error.serverRecord, + let clientRecord = error.clientRecord { + // Merge records — only needed for real multi-device conflicts + let merged = mergeRecords(server: serverRecord, client: clientRecord) + try await database.save(merged) +} +``` + +### CKError.networkUnavailable + +**Cause**: No internet connection + +**Fix**: +```swift +if error.code == .networkUnavailable { + // Queue for retry when online + queueOperation(for: .whenOnline) + + // Or show offline indicator + showOfflineIndicator() +} +``` + +### Silent Data Loss in Batch Operations + +**Symptom**: Sync appears to work but records silently disappear or fail to save. + +**Common causes**: + +| Cause | Symptom | Fix | +|-------|---------|-----| +| Record size > 1 MB | Individual records silently dropped from batch | Split large data into CKAsset | +| Batch partial failure | Some records save, others fail silently | Check `perRecordSaveBlock` for per-record errors | +| Conflict auto-resolution | Last-writer-wins overwrites valid data | Implement merge-based conflict resolution | +| Asset download not triggered | Record syncs but CKAsset content missing | Call `fetchRecordZoneChanges` with `desiredKeys` | + +**Diagnosis**: +```swift +// ❌ WRONG: Batch save with no per-record error handling +let operation = CKModifyRecordsOperation(recordsToSave: records) +operation.modifyRecordsResultBlock = { result in + // Only catches operation-level failures — misses per-record errors +} + +// ✅ CORRECT: Check each record individually +let operation = CKModifyRecordsOperation(recordsToSave: records) +operation.perRecordSaveBlock = { recordID, result in + switch result { + case .success(let record): + print("✅ Saved: \(recordID)") + case .failure(let error): + print("❌ Failed: \(recordID) — \(error)") + // Log for retry — this record was silently lost otherwise + } +} +``` + +--- + +## Common iCloud Drive Errors + +### Upload Errors + +```swift +// ✅ Check upload error +func checkUploadError(url: URL) { + let values = try? url.resourceValues(forKeys: [ + .ubiquitousItemUploadingErrorKey + ]) + + if let error = values?.ubiquitousItemUploadingError { + print("Upload error: \(error.localizedDescription)") + + if (error as NSError).code == NSFileWriteOutOfSpaceError { + print("iCloud storage full") + } + } +} +``` + +### Download Errors + +```swift +// ✅ Check download error +func checkDownloadError(url: URL) { + let values = try? url.resourceValues(forKeys: [ + .ubiquitousItemDownloadingErrorKey + ]) + + if let error = values?.ubiquitousItemDownloadingError { + print("Download error: \(error.localizedDescription)") + + // Common errors: + // - Network unavailable + // - Account unavailable + // - File deleted on server + } +} +``` + +--- + +## Debugging Patterns + +### Pattern 1: CloudKit Operation Not Completing + +**Symptom**: Save/fetch never completes, no error + +**Diagnosis**: +```swift +// Add timeout +Task { + try await withTimeout(seconds: 30) { + try await database.save(record) + } +} + +// Log operation lifecycle +operation.database = database +operation.completionBlock = { + print("Operation completed") +} +operation.qualityOfService = .userInitiated + +// Check if operation was cancelled +if operation.isCancelled { + print("Operation was cancelled") +} +``` + +**Common causes**: +- No network connectivity +- Account issues +- Operation cancelled prematurely + +### Pattern 2: SwiftData CloudKit Not Syncing + +**Symptom**: SwiftData saves locally but doesn't sync + +**Diagnosis**: +```swift +// 1. Verify CloudKit configuration +let config = ModelConfiguration( + cloudKitDatabase: .private("iCloud.com.example.app") +) + +// 2. Check for incompatible attributes +// ❌ @Attribute(.unique) not supported with CloudKit +@Model +class Task { + @Attribute(.unique) var id: UUID // ← Remove this + var title: String +} + +// 3. Check all properties have defaults or are optional +@Model +class Task { + var title: String = "" // ✅ Has default + var dueDate: Date? // ✅ Optional +} +``` + +### Pattern 3: File Coordinator Deadlock + +**Symptom**: File operations hang + +**Diagnosis**: +```swift +// ❌ WRONG: Nested coordination can deadlock +coordinator.coordinate(writingItemAt: url, options: [], error: nil) { newURL in + // Don't create another coordinator here! + anotherCoordinator.coordinate(...) // ← Deadlock risk +} + +// ✅ CORRECT: Single coordinator per operation +coordinator.coordinate(writingItemAt: url, options: [], error: nil) { newURL in + // Direct file operations only + try data.write(to: newURL) +} +``` + +### Pattern 4: Conflicts Not Resolving + +**Symptom**: Conflicts persist even after resolution + +**Diagnosis**: +```swift +// ❌ WRONG: Not marking as resolved +let conflicts = NSFileVersion.unresolvedConflictVersionsOfItem(at: url) +for conflict in conflicts ?? [] { + // Missing: conflict.isResolved = true +} + +// ✅ CORRECT: Mark resolved and remove +for conflict in conflicts ?? [] { + conflict.isResolved = true +} +try NSFileVersion.removeOtherVersionsOfItem(at: url) +``` + +--- + +## Production Crisis Scenario + +**SYMPTOM**: Users report data not syncing after app update + +**DIAGNOSIS STEPS** (run in order): + +1. **Check account status** (2 min): + ```swift + // On affected device + let status = FileManager.default.ubiquityIdentityToken + // nil? → Not signed in + ``` + +2. **Verify entitlements unchanged** (5 min): + - Compare old vs new build entitlements + - Verify container IDs match + +3. **Check for breaking changes** (10 min): + - Did CloudKit schema change? + - Did ubiquitous container ID change? + - Are old and new versions compatible? + +4. **Test on clean device** (15 min): + - Factory reset device or use new test device + - Sign into iCloud + - Install app + - Does sync work on fresh install? + +**ROOT CAUSES** (90% of cases): +- Entitlements changed/corrupted in build +- CloudKit container ID mismatch +- Breaking schema changes +- Account restrictions (new parental controls, etc.) + +**FIX**: +- Verify entitlements in build +- Test migration path from old version +- Add better error handling and user messaging + +--- + +## Monitoring + +### CloudKit Console (recommended - WWDC 2024) + +**Access**: https://icloud.developer.apple.com/dashboard + +**Monitor**: +- Error rates by type +- Latency percentiles (p50, p95, p99) +- Quota usage +- Request volume + +**Set alerts for**: +- High error rate (>5%) +- Quota approaching limit (>80%) +- Latency spikes + +### Client-Side Logging + +```swift +// ✅ Log all CloudKit operations +extension CKDatabase { + func saveWithLogging(_ record: CKRecord) async throws { + print("Saving record: \(record.recordID)") + let start = Date() + + do { + try await self.save(record) + let duration = Date().timeIntervalSince(start) + print("✅ Saved in \(duration)s") + } catch let error as CKError { + print("❌ Save failed: \(error.code), \(error.localizedDescription)") + throw error + } + } +} +``` + +--- + +## Quick Diagnostic Checklist + +```swift +func diagnoseCloudSyncIssue() async { + print("=== Cloud Sync Diagnosis ===") + + // 1. Account + await checkICloudStatus() + + // 2. Entitlements + checkEntitlements() + + // 3. Network + checkConnectivity() + + // 4. Storage + checkStorage() + + // 5. For CloudKit + let container = CKContainer.default() + do { + let status = try await container.accountStatus() + print("CloudKit status: \(status)") + } catch { + print("CloudKit error: \(error)") + } + + // 6. For iCloud Drive + if let url = getICloudContainerURL() { + let values = try? url.resourceValues(forKeys: [ + .ubiquitousItemDownloadingErrorKey, + .ubiquitousItemUploadingErrorKey + ]) + print("Download error: \(values?.ubiquitousItemDownloadingError?.localizedDescription ?? "none")") + print("Upload error: \(values?.ubiquitousItemUploadingError?.localizedDescription ?? "none")") + } + + print("=== End Diagnosis ===") +} +``` + +--- + +## Related Skills + +- `axiom-cloudkit-ref` — CloudKit implementation details +- `axiom-icloud-drive-ref` — iCloud Drive implementation details +- `axiom-storage` — Choose sync approach + +--- + +**Last Updated**: 2025-12-12 +**Skill Type**: Diagnostic diff --git a/.claude/skills/axiom-cloud-sync-diag/agents/openai.yaml b/.claude/skills/axiom-cloud-sync-diag/agents/openai.yaml new file mode 100644 index 0000000..80df8bb --- /dev/null +++ b/.claude/skills/axiom-cloud-sync-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Cloud Sync Diagnostics" + short_description: "Debugging 'file not syncing', 'CloudKit error', 'sync conflict', 'iCloud upload failed', 'ubiquitous item error', 'da..." diff --git a/.claude/skills/axiom-cloud-sync/.openskills.json b/.claude/skills/axiom-cloud-sync/.openskills.json new file mode 100644 index 0000000..8c5f299 --- /dev/null +++ b/.claude/skills/axiom-cloud-sync/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-cloud-sync", + "installedAt": "2026-04-12T08:06:00.825Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-cloud-sync/SKILL.md b/.claude/skills/axiom-cloud-sync/SKILL.md new file mode 100644 index 0000000..affd1d3 --- /dev/null +++ b/.claude/skills/axiom-cloud-sync/SKILL.md @@ -0,0 +1,423 @@ +--- +name: axiom-cloud-sync +description: Use when choosing between CloudKit vs iCloud Drive, implementing reliable sync, handling offline-first patterns, or designing sync architecture - prevents common sync mistakes that cause data loss +license: MIT +compatibility: iOS 10+, macOS 10.12+ +metadata: + version: "1.0.0" + last-updated: "2025-12-25" +--- + +# Cloud Sync + +## Overview + +**Core principle**: Choose the right sync technology for the data shape, then implement offline-first patterns that handle network failures gracefully. + +Two fundamentally different sync approaches: +- **CloudKit** — Structured data (records with fields and relationships) +- **iCloud Drive** — File-based data (documents, images, any file format) + +## Quick Decision Tree + +``` +What needs syncing? + +├─ Structured data (records, relationships)? +│ ├─ Using SwiftData? → SwiftData + CloudKit (easiest, iOS 17+) +│ ├─ Need shared/public database? → CKSyncEngine or raw CloudKit +│ └─ Custom persistence (GRDB, SQLite)? → CKSyncEngine (iOS 17+) +│ +├─ Documents/files users expect in Files app? +│ └─ iCloud Drive (UIDocument or FileManager) +│ +├─ Large binary blobs (images, videos)? +│ ├─ Associated with structured data? → CKAsset in CloudKit +│ └─ Standalone files? → iCloud Drive +│ +└─ App settings/preferences? + └─ NSUbiquitousKeyValueStore (simple key-value, 1MB limit) +``` + +## CloudKit vs iCloud Drive + +| Aspect | CloudKit | iCloud Drive | +|--------|----------|--------------| +| **Data shape** | Structured records | Files/documents | +| **Query support** | Full query language | Filename only | +| **Relationships** | Native support | None (manual) | +| **Conflict resolution** | Record-level | File-level | +| **User visibility** | Hidden from user | Visible in Files app | +| **Sharing** | Record/database sharing | File sharing | +| **Offline** | Local cache required | Automatic download | + +## Red Flags + +If ANY of these appear, STOP and reconsider: + +- ❌ "Store JSON files in CloudKit" — Wrong tool. Use iCloud Drive for files +- ❌ "Build relationships manually in iCloud Drive" — Wrong tool. Use CloudKit +- ❌ "Assume sync is instant" — Network fails. Design offline-first +- ❌ "Skip conflict handling" — Conflicts WILL happen on multiple devices +- ❌ "Use CloudKit for user documents" — Users can't see them. Use iCloud Drive +- ❌ "Sync on app launch only" — Users expect continuous sync + +## Offline-First Pattern + +**MANDATORY**: All sync code must work offline first. + +```swift +// ✅ CORRECT: Offline-first architecture +class OfflineFirstSync { + private let localStore: LocalDatabase // GRDB, SwiftData, Core Data + private let syncEngine: CKSyncEngine + + // Write to LOCAL first, sync to cloud in background + func save(_ item: Item) async throws { + // 1. Save locally (instant) + try await localStore.save(item) + + // 2. Queue for sync (non-blocking) + syncEngine.state.add(pendingRecordZoneChanges: [ + .saveRecord(item.recordID) + ]) + } + + // Read from LOCAL (instant) + func fetch() async throws -> [Item] { + return try await localStore.fetchAll() + } +} + +// ❌ WRONG: Cloud-first (blocks on network) +func save(_ item: Item) async throws { + // Fails when offline, slow on bad network + try await cloudKit.save(item) + try await localStore.save(item) +} +``` + +## Conflict Resolution Strategies + +Conflicts occur when two devices edit the same data before syncing. + +### Strategy 1: Last-Writer-Wins (Simplest) + +```swift +// Server always has latest, client accepts it +func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord { + return server // Accept server version +} +``` + +**Use when**: Data is non-critical, user won't notice overwrites + +### Strategy 2: Merge (Most Common) + +```swift +// Combine changes from both versions +func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord { + let merged = server.copy() as! CKRecord + + // For each field, apply custom merge logic + merged["notes"] = mergeText( + local["notes"] as? String, + server["notes"] as? String + ) + merged["tags"] = mergeSets( + local["tags"] as? [String] ?? [], + server["tags"] as? [String] ?? [] + ) + + return merged +} +``` + +**Use when**: Both versions contain valuable changes + +### Strategy 3: User Choice + +```swift +// Present conflict to user +func resolveConflict(local: CKRecord, server: CKRecord) async -> CKRecord { + let choice = await presentConflictUI(local: local, server: server) + return choice == .keepLocal ? local : server +} +``` + +**Use when**: Data is critical, user must decide + +## Common Patterns + +### Pattern 1: SwiftData + CloudKit (Recommended for New Apps) + +```swift +import SwiftData + +// Automatic CloudKit sync with zero configuration +@Model +class Note { + var title: String + var content: String + var createdAt: Date + + init(title: String, content: String) { + self.title = title + self.content = content + self.createdAt = Date() + } +} + +// Container automatically syncs if CloudKit entitlement present +let container = try ModelContainer(for: Note.self) +``` + +**Limitations**: +- Private database only (no public/shared) +- Automatic sync (less control over timing) +- No custom conflict resolution +- `@Attribute(.unique)` not supported with CloudKit sync — remove if using CloudKit + +### Pattern 2: CKSyncEngine (Custom Persistence) + +```swift +// For GRDB, SQLite, or custom databases +class MySyncManager: CKSyncEngineDelegate { + private let engine: CKSyncEngine + private let database: GRDBDatabase + + func handleEvent(_ event: CKSyncEngine.Event) async { + switch event { + case .stateUpdate(let update): + // Persist sync state + await saveSyncState(update.stateSerialization) + + case .fetchedDatabaseChanges(let changes): + // Apply changes to local DB + for zone in changes.modifications { + await handleZoneChanges(zone) + } + + case .sentRecordZoneChanges(let sent): + // Mark records as synced + for saved in sent.savedRecords { + await markSynced(saved.recordID) + } + } + } +} +``` + +See `axiom-cloudkit-ref` for complete CKSyncEngine setup. + +### Pattern 3: iCloud Drive Documents + +```swift +import UIKit + +class MyDocument: UIDocument { + var content: Data? + + override func contents(forType typeName: String) throws -> Any { + return content ?? Data() + } + + override func load(fromContents contents: Any, ofType typeName: String?) throws { + content = contents as? Data + } +} + +// Save to iCloud Drive (visible in Files app) +let url = FileManager.default.url(forUbiquityContainerIdentifier: nil)? + .appendingPathComponent("Documents") + .appendingPathComponent("MyFile.txt") + +let doc = MyDocument(fileURL: url!) +doc.content = "Hello".data(using: .utf8) +doc.save(to: url!, for: .forCreating) +``` + +See `axiom-icloud-drive-ref` for NSFileCoordinator and conflict handling. + +## Anti-Patterns + +### 1. Ignoring Sync State + +```swift +// ❌ WRONG: No awareness of pending changes +var items: [Item] = [] // Are these synced? Pending? Conflicted? + +// ✅ CORRECT: Track sync state +struct SyncableItem { + let item: Item + let syncState: SyncState // .synced, .pending, .conflict +} +``` + +### 2. Blocking UI on Sync + +```swift +// ❌ WRONG: UI blocks until sync completes +func viewDidLoad() async { + items = try await cloudKit.fetchAll() // Spinner forever on airplane + tableView.reloadData() +} + +// ✅ CORRECT: Show local data immediately +func viewDidLoad() { + items = localStore.fetchAll() // Instant + tableView.reloadData() + + Task { + await syncEngine.fetchChanges() // Background update + } +} +``` + +### 3. CloudKit Schema Not Deployed to Production + +CloudKit has **separate schemas for Development and Production**. Your app in the App Store can only access the Production environment. If you add record types, fields, or indexes in Development but never deploy them, queries in Production return empty results with no error. + +``` +❌ Works in Xcode/TestFlight (Development) → empty results in App Store (Production) + Queries silently return zero results — no CKError, no crash, no clue. + +✅ Before every App Store submission: + 1. CloudKit Console → Select container + 2. "Deploy Schema Changes" → Review changes → Deploy + 3. Test with Production environment in Xcode scheme settings +``` + +**Time cost of skipping**: 3-7 days (rejection cycle + debugging "why does it work in TestFlight but not production?"). This is the #1 CloudKit gotcha for first-time submitters. + +### 4. No Retry Logic + +```swift +// ❌ WRONG: Single attempt +try await cloudKit.save(record) + +// ✅ CORRECT: Exponential backoff +func saveWithRetry(_ record: CKRecord, attempts: Int = 3) async throws { + for attempt in 0.. CKSyncEngine.RecordZoneChangeBatch? { + // Return pending local changes + let pendingChanges = getPendingLocalChanges() + return CKSyncEngine.RecordZoneChangeBatch( + pendingSaves: pendingChanges, + recordIDsToDelete: [] + ) + } +} +``` + +**Key concepts**: +- **State serialization**: Persist sync state between app launches +- **Events**: Delegate receives events for changes +- **Batches**: You provide pending changes, engine uploads them +- **Automatic conflict resolution**: Engine handles basic conflicts + +--- + +## Approach 3: Raw CloudKit APIs (Legacy) + +**When to use**: Only if CKSyncEngine doesn't fit (rare) + +**Core types**: +- `CKContainer` — Entry point +- `CKDatabase` — Public/private/shared scope +- `CKRecord` — Individual data record +- `CKRecordZone` — Logical grouping +- `CKAsset` — Binary file storage + +### Basic Operations + +```swift +// ✅ Container and database +let container = CKContainer.default() +let privateDatabase = container.privateCloudDatabase +let publicDatabase = container.publicCloudDatabase + +// ✅ Create record +let record = CKRecord(recordType: "Task") +record["title"] = "Buy groceries" +record["isCompleted"] = false +record["dueDate"] = Date() + +// ✅ Save record +try await privateDatabase.save(record) + +// ✅ Fetch record +let recordID = CKRecord.ID(recordName: "task-123") +let fetchedRecord = try await privateDatabase.record(for: recordID) + +// ✅ Query records +let predicate = NSPredicate(format: "isCompleted == NO") +let query = CKQuery(recordType: "Task", predicate: predicate) +let (matchResults, _) = try await privateDatabase.records(matching: query) + +for result in matchResults { + if case .success(let record) = result.1 { + print("Task: \(record["title"] as? String ?? "")") + } +} + +// ✅ Delete record +try await privateDatabase.deleteRecord(withID: recordID) +``` + +### Update Record + +```swift +// ✅ Fetch-then-modify-then-save (prevents serverRecordChanged errors) +let record = try await privateDatabase.record(for: recordID) +record["title"] = "Updated title" +record["isCompleted"] = true +try await privateDatabase.save(record) + +// ✅ Batch modify (save + delete in one operation) +let operation = CKModifyRecordsOperation( + recordsToSave: [updatedRecord1, updatedRecord2], + recordIDsToDelete: [deletedID] +) +operation.perRecordSaveBlock = { recordID, result in + switch result { + case .success: print("Saved: \(recordID)") + case .failure(let error): print("Failed: \(recordID) — \(error)") + } +} +try await privateDatabase.add(operation) +``` + +### Conflict Resolution + +```swift +// ✅ Handle conflicts with savePolicy +let operation = CKModifyRecordsOperation( + recordsToSave: [record], + recordIDsToDelete: nil +) + +// Save only if server version unchanged +operation.savePolicy = .ifServerRecordUnchanged + +// OR: Always overwrite server +operation.savePolicy = .changedKeys // Only changed fields + +operation.modifyRecordsResultBlock = { result in + switch result { + case .success: + print("Saved") + case .failure(let error as CKError): + if error.code == .serverRecordChanged { + // Conflict - merge manually + let serverRecord = error.serverRecord + let clientRecord = error.clientRecord + let merged = mergeRecords(server: serverRecord, client: clientRecord) + // Retry with merged record + } + } +} + +privateDatabase.add(operation) +``` + +--- + +## Database Scopes + +| Scope | Accessibility | SwiftData Support | Use Case | +|-------|---------------|-------------------|----------| +| **Private** | User only | ✅ Yes | Personal user data | +| **Public** | All users | ❌ No | Shared/public content | +| **Shared** | Invited users | ❌ No | Collaboration | + +### Private Database + +```swift +// ✅ Private database (most common) +let privateDB = CKContainer.default().privateCloudDatabase + +// User must be signed into iCloud +// Data syncs across user's devices +// Not visible to other users +``` + +### Public Database + +```swift +// ✅ Public database (for shared content) +let publicDB = CKContainer.default().publicCloudDatabase + +// Accessible to all app users +// Even unauthenticated users can read +// Writes require authentication +// Use for: Leaderboards, public content, discovery +``` + +### Shared Database + +```swift +// ✅ Shared database (collaboration) +let sharedDB = CKContainer.default().sharedCloudDatabase + +// For CKShare-based collaboration +// Users invited to specific record zones +// Use for: Shared documents, team data +``` + +--- + +## CloudKit Assets (Files) + +```swift +// ✅ Store files as CKAsset +let imageURL = saveImageToTempFile(image) // Must be file URL +let asset = CKAsset(fileURL: imageURL) + +let record = CKRecord(recordType: "Photo") +record["image"] = asset +record["caption"] = "Sunset" + +try await privateDatabase.save(record) + +// ✅ Retrieve asset +let fetchedRecord = try await privateDatabase.record(for: recordID) +if let asset = fetchedRecord["image"] as? CKAsset, + let fileURL = asset.fileURL { + let imageData = try Data(contentsOf: fileURL) + let image = UIImage(data: imageData) +} +``` + +**Important**: CKAsset requires a **file URL**, not Data. Write data to temp file first. + +--- + +## CloudKit Console (Monitoring - WWDC 2024) + +### Developer Notifications + +Set up alerts for: +- Schema changes +- Quota exceeded +- High error rates +- Custom thresholds + +### Telemetry + +Monitor: +- Request count +- Error rate +- Latency (p50, p95, p99) +- Bandwidth usage + +### Logs + +View: +- Individual requests +- Error details +- Performance bottlenecks + +**Access**: https://icloud.developer.apple.com/dashboard + +--- + +## Common Patterns + +### Pattern 1: Initial Sync + +```swift +// ✅ Fetch all records on first launch +func performInitialSync() async throws { + let predicate = NSPredicate(value: true) // All records + let query = CKQuery(recordType: "Task", predicate: predicate) + + let (results, _) = try await privateDatabase.records(matching: query) + + for result in results { + if case .success(let record) = result.1 { + saveToLocalDatabase(record) + } + } +} +``` + +### Pattern 2: Incremental Sync + +```swift +// ✅ Use CKServerChangeToken for incremental fetches +func fetchChanges(since token: CKServerChangeToken?) async throws { + let zoneID = CKRecordZone.ID(zoneName: "Tasks") + + let config = CKFetchRecordZoneChangesOperation.ZoneConfiguration( + previousServerChangeToken: token + ) + + let operation = CKFetchRecordZoneChangesOperation( + recordZoneIDs: [zoneID], + configurationsByRecordZoneID: [zoneID: config] + ) + + operation.recordWasChangedBlock = { recordID, result in + if case .success(let record) = result { + updateLocalDatabase(with: record) + } + } + + operation.recordWithIDWasDeletedBlock = { recordID, _ in + deleteFromLocalDatabase(recordID) + } + + operation.recordZoneFetchResultBlock = { zoneID, result in + if case .success(let (token, _, _)) = result { + saveChangeToken(token) // For next fetch + } + } + + try await privateDatabase.add(operation) +} +``` + +--- + +## Entitlements + +Required entitlements in Xcode: + +```xml + +com.apple.developer.icloud-services + + CloudKit + + + +com.apple.developer.icloud-container-identifiers + + iCloud.com.example.app + +``` + +**Setup**: +1. Xcode → Target → Signing & Capabilities +2. "+ Capability" → iCloud +3. Check "CloudKit" +4. Select or create container + +--- + +## Subscriptions (Push Notifications) + +### Database Subscription + +```swift +// ✅ Get notified of ANY change in private database +let subscription = CKDatabaseSubscription(subscriptionID: "all-changes") + +let notificationInfo = CKSubscription.NotificationInfo() +notificationInfo.shouldSendContentAvailable = true // Silent push +subscription.notificationInfo = notificationInfo + +try await privateDatabase.save(subscription) +``` + +### Query Subscription + +```swift +// ✅ Get notified when records matching a query change +let predicate = NSPredicate(format: "priority > 3") +let subscription = CKQuerySubscription( + recordType: "Task", + predicate: predicate, + subscriptionID: "high-priority-tasks", + options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] +) + +let notificationInfo = CKSubscription.NotificationInfo() +notificationInfo.alertBody = "High priority task changed" +notificationInfo.shouldBadge = true +subscription.notificationInfo = notificationInfo + +try await privateDatabase.save(subscription) +``` + +### Zone Subscription + +```swift +// ✅ Get notified of any change in a specific zone +let zoneID = CKRecordZone.ID(zoneName: "Tasks") +let subscription = CKRecordZoneSubscription( + zoneID: zoneID, + subscriptionID: "tasks-zone" +) + +let notificationInfo = CKSubscription.NotificationInfo() +notificationInfo.shouldSendContentAvailable = true +subscription.notificationInfo = notificationInfo + +try await privateDatabase.save(subscription) +``` + +### Handling Push Notifications + +```swift +// In AppDelegate +func application(_ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult { + let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) + + if notification.subscriptionID == "all-changes" { + try? await fetchChanges(since: savedChangeToken) + return .newData + } + return .noData +} +``` + +--- + +## Sharing Records + +### Create a Share + +```swift +// ✅ Share a record with other users +let record = try await privateDatabase.record(for: recordID) + +// Record must be in a custom zone (not default zone) +let share = CKShare(rootRecord: record) +share[CKShare.SystemFieldKey.title] = "Shared Task List" +share.publicPermission = .none // Invite-only + +// Save both the record and share together +let operation = CKModifyRecordsOperation( + recordsToSave: [record, share], + recordIDsToDelete: nil +) +try await privateDatabase.add(operation) +``` + +### Present Sharing UI + +```swift +import CloudKit +import UIKit + +// ✅ UIKit sharing controller +let sharingController = UICloudSharingController(share: share, container: container) +sharingController.delegate = self +present(sharingController, animated: true) + +// Delegate methods +extension ViewController: UICloudSharingControllerDelegate { + func cloudSharingController(_ csc: UICloudSharingController, + failedToSaveShareWithError error: Error) { + print("Share failed: \(error)") + } + + func itemTitle(for csc: UICloudSharingController) -> String? { + return "My Shared List" + } +} +``` + +### Manage Participants + +```swift +// ✅ Check participants +for participant in share.participants { + print("\(participant.userIdentity.nameComponents?.givenName ?? "Unknown")") + print(" Acceptance: \(participant.acceptanceStatus)") + print(" Permission: \(participant.permission)") + // .readOnly, .readWrite, .none +} + +// ✅ Remove participant +share.removeParticipant(participant) +try await privateDatabase.save(share) +``` + +### Accept a Share + +```swift +// In SceneDelegate or AppDelegate +func userDidAcceptCloudKitShareWith(_ cloudKitShareMetadata: CKShare.Metadata) { + let operation = CKAcceptSharesOperation(shareMetadatas: [cloudKitShareMetadata]) + operation.acceptSharesResultBlock = { result in + switch result { + case .success: print("Share accepted") + case .failure(let error): print("Accept failed: \(error)") + } + } + CKContainer(identifier: cloudKitShareMetadata.containerIdentifier) + .add(operation) +} +``` + +--- + +## Quick Reference + +| Task | Modern API (iOS 17+) | Legacy API | +|------|----------------------|------------| +| Structured data sync | SwiftData + CloudKit | CKSyncEngine or CKDatabase | +| Custom persistence sync | CKSyncEngine | CKDatabase | +| Conflict resolution | Automatic (SwiftData/CKSyncEngine) | Manual (savePolicy) | +| Account changes | Handled automatically | Manual detection | +| Monitoring | CloudKit Console telemetry | Manual logging | + +--- + +## Related Skills + +- `axiom-swiftdata` — SwiftData implementation details +- `axiom-storage` — Choose CloudKit vs iCloud Drive +- `axiom-icloud-drive-ref` — File-based iCloud sync +- `axiom-cloud-sync-diag` — Debug CloudKit sync issues + +--- + +**Last Updated**: 2025-12-12 +**Skill Type**: Reference +**Minimum iOS**: 10.0 (basic), 17.0 (CKSyncEngine, SwiftData integration) +**WWDC Sessions**: 2023-10188 (CKSyncEngine), 2024-10122 (CloudKit Console) diff --git a/.claude/skills/axiom-cloudkit-ref/agents/openai.yaml b/.claude/skills/axiom-cloudkit-ref/agents/openai.yaml new file mode 100644 index 0000000..5ea7113 --- /dev/null +++ b/.claude/skills/axiom-cloudkit-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "CloudKit Reference" + short_description: "Implementing 'CloudKit sync', 'CKSyncEngine', 'CKRecord', 'CKDatabase', 'SwiftData CloudKit', 'shared database', 'pub..." diff --git a/.claude/skills/axiom-codable/.openskills.json b/.claude/skills/axiom-codable/.openskills.json new file mode 100644 index 0000000..fce86bc --- /dev/null +++ b/.claude/skills/axiom-codable/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-codable", + "installedAt": "2026-04-12T08:06:02.173Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-codable/SKILL.md b/.claude/skills/axiom-codable/SKILL.md new file mode 100644 index 0000000..5cc8172 --- /dev/null +++ b/.claude/skills/axiom-codable/SKILL.md @@ -0,0 +1,832 @@ +--- +name: axiom-codable +description: Use when working with Codable protocol, JSON encoding/decoding, CodingKeys customization, enum serialization, date strategies, custom containers, or encountering "Type does not conform to Decodable/Encodable" errors - comprehensive Codable patterns and anti-patterns for Swift 6.x +license: MIT +metadata: + version: "1.0" +--- + +# Swift Codable Patterns + +Comprehensive guide to Codable protocol conformance for JSON and PropertyList encoding/decoding in Swift 6.x. + +## Quick Reference + +### Decision Tree: When to Use Each Approach + +``` +Has your type... +├─ All properties Codable? → Automatic synthesis (just add `: Codable`) +├─ Property names differ from JSON keys? → CodingKeys customization +├─ Needs to exclude properties? → CodingKeys customization +├─ Enum with associated values? → Check enum synthesis patterns +├─ Needs structural transformation? → Manual implementation + bridge types +├─ Needs data not in JSON? → DecodableWithConfiguration (iOS 15+) +└─ Complex nested JSON? → Manual implementation + nested containers +``` + +### Common Triggers + +| Error | Solution | +|-------|----------| +| "Type 'X' does not conform to protocol 'Decodable'" | Ensure all stored properties are Codable | +| "No value associated with key X" | Check CodingKeys match JSON keys | +| "Expected to decode X but found Y instead" | Type mismatch; check JSON structure or use bridge type | +| "keyNotFound" | JSON missing expected key; make property optional or provide default | +| "Date parsing failed" | Configure dateDecodingStrategy on decoder | + +--- + +## Part 1: Automatic Synthesis + +Swift automatically synthesizes Codable conformance when all stored properties are Codable. + +### Struct Synthesis + +```swift +// ✅ Automatic synthesis +struct User: Codable { + let id: UUID // Codable + var name: String // Codable + var membershipPoints: Int // Codable +} + +// JSON: {"id":"...", "name":"Alice", "membershipPoints":100} +``` + +**Requirements**: +- All stored properties must conform to Codable +- Properties use standard Swift types or other Codable types +- No custom initialization logic needed + +### Enum Synthesis Patterns + +#### Pattern 1: Raw Value Enums + +```swift +enum Direction: String, Codable { + case north, south, east, west +} + +// Encodes as: "north" +``` + +The raw value itself becomes the JSON representation. + +#### Pattern 2: Enums Without Associated Values + +```swift +enum Status: Codable { + case success + case failure + case pending +} + +// Encodes as: {"success":{}} +``` + +Each case becomes an object with the case name as the key and empty dictionary as value. + +#### Pattern 3: Enums With Associated Values + +```swift +enum APIResult: Codable { + case success(data: String, count: Int) + case error(code: Int, message: String) +} + +// success case encodes as: +// {"success":{"data":"example","count":5}} +``` + +**Gotcha**: Unlabeled associated values generate `_0`, `_1` keys: + +```swift +enum Command: Codable { + case store(String, Int) // ❌ Unlabeled +} + +// Encodes as: {"store":{"_0":"value","_1":42}} +``` + +**Fix**: Always label associated values for predictable JSON: + +```swift +enum Command: Codable { + case store(key: String, value: Int) // ✅ Labeled +} + +// Encodes as: {"store":{"key":"value","value":42}} +``` + +### When Synthesis Breaks + +Automatic synthesis fails when: +1. **Computed properties** - Only stored properties are encoded +2. **Non-Codable properties** - Custom types without Codable conformance +3. **Property wrappers** - `@Published`, `@State` (except `@AppStorage` with Codable types) +4. **Class inheritance** - Subclasses must implement `init(from:)` manually + +--- + +## Part 2: CodingKeys Customization + +Use `CodingKeys` enum to customize encoding/decoding without full manual implementation. + +### Renaming Keys + +```swift +struct Article: Codable { + let url: URL + let title: String + let body: String + + enum CodingKeys: String, CodingKey { + case url = "source_link" // JSON uses "source_link" + case title = "content_name" // JSON uses "content_name" + case body // Matches JSON key + } +} + +// JSON: {"source_link":"...", "content_name":"...", "body":"..."} +``` + +### Excluding Properties + +Omit properties from `CodingKeys` to exclude them from encoding/decoding: + +```swift +struct NoteCollection: Codable { + let name: String + let notes: [Note] + var localDrafts: [Note] = [] // ✅ Must have default value + + enum CodingKeys: CodingKey { + case name + case notes + // localDrafts omitted - not encoded/decoded + } +} +``` + +**Rule**: Excluded properties require default values or you must implement `init(from:)` manually. + +### Snake Case Conversion + +For consistent snake_case → camelCase conversion: + +```swift +let decoder = JSONDecoder() +decoder.keyDecodingStrategy = .convertFromSnakeCase + +// JSON: {"first_name":"Alice", "last_name":"Smith"} +// Decodes to: User(firstName: "Alice", lastName: "Smith") +``` + +### Enum Associated Value Keys + +Customize keys for enum associated values using `{CaseName}CodingKeys`: + +```swift +enum Command: Codable { + case store(key: String, value: Int) + case delete(key: String) + + enum StoreCodingKeys: String, CodingKey { + case key = "identifier" // Renames "key" to "identifier" + case value = "data" // Renames "value" to "data" + } + + enum DeleteCodingKeys: String, CodingKey { + case key = "identifier" + } +} + +// store case encodes as: {"store":{"identifier":"x","data":42}} +``` + +**Pattern**: `{CaseName}CodingKeys` with capitalized case name. + +--- + +## Part 3: Manual Implementation + +For structural differences between JSON and Swift models, implement `init(from:)` and `encode(to:)`. + +### Container Types + +| Container | When to Use | +|-----------|-------------| +| **Keyed** | Dictionary-like data with string keys | +| **Unkeyed** | Array-like sequential data | +| **Single-value** | Wrapper types that encode as a single value | +| **Nested** | Hierarchical JSON structures | + +### Nested Containers Example + +Flatten hierarchical JSON: + +```swift +// JSON: +// { +// "latitude": 37.7749, +// "longitude": -122.4194, +// "additionalInfo": { +// "elevation": 52 +// } +// } + +struct Coordinate { + var latitude: Double + var longitude: Double + var elevation: Double // Nested in JSON, flat in Swift + + enum CodingKeys: String, CodingKey { + case latitude, longitude, additionalInfo + } + + enum AdditionalInfoKeys: String, CodingKey { + case elevation + } +} + +extension Coordinate: Decodable { + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + latitude = try values.decode(Double.self, forKey: .latitude) + longitude = try values.decode(Double.self, forKey: .longitude) + + let additionalInfo = try values.nestedContainer( + keyedBy: AdditionalInfoKeys.self, + forKey: .additionalInfo + ) + elevation = try additionalInfo.decode(Double.self, forKey: .elevation) + } +} + +extension Coordinate: Encodable { + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(latitude, forKey: .latitude) + try container.encode(longitude, forKey: .longitude) + + var additionalInfo = container.nestedContainer( + keyedBy: AdditionalInfoKeys.self, + forKey: .additionalInfo + ) + try additionalInfo.encode(elevation, forKey: .elevation) + } +} +``` + +### Bridge Types for Structural Mismatches + +When JSON structure fundamentally differs from Swift model: + +```swift +// JSON: {"USD": 1.0, "EUR": 0.85, "GBP": 0.73} +// Want: [ExchangeRate] + +struct ExchangeRate { + let currency: String + let rate: Double +} + +// Bridge type for decoding +private extension ExchangeRate { + struct List: Decodable { + let values: [ExchangeRate] + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let dictionary = try container.decode([String: Double].self) + values = dictionary.map { ExchangeRate(currency: $0, rate: $1) } + } + } +} + +// Public interface +extension ExchangeRate { + static func decode(from data: Data) throws -> [ExchangeRate] { + let list = try JSONDecoder().decode(List.self, from: data) + return list.values + } +} +``` + +--- + +## Part 4: Date Handling + +### Built-in Strategies + +```swift +let decoder = JSONDecoder() + +// 1. ISO 8601 (recommended) +decoder.dateDecodingStrategy = .iso8601 +// Expects: "2024-02-15T17:00:00+01:00" + +// 2. Unix timestamp (seconds) +decoder.dateDecodingStrategy = .secondsSince1970 +// Expects: 1708012800 + +// 3. Unix timestamp (milliseconds) +decoder.dateDecodingStrategy = .millisecondsSince1970 +// Expects: 1708012800000 + +// 4. Custom formatter +let formatter = DateFormatter() +formatter.dateFormat = "yyyy-MM-dd" +formatter.locale = Locale(identifier: "en_US_POSIX") // ✅ Always set +formatter.timeZone = TimeZone(secondsFromGMT: 0) // ✅ Always set +decoder.dateDecodingStrategy = .formatted(formatter) + +// 5. Custom closure +decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + if let date = ISO8601DateFormatter().date(from: dateString) { + return date + } + + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Cannot decode date string \(dateString)" + ) +} +``` + +### ISO 8601 Nuances + +**Default**: `2024-02-15T17:00:00+01:00` +**Timezone required**: Without timezone offset, decoding may fail across regions + +```swift +// ❌ No timezone - parsing depends on device locale +"2024-02-15T17:00:00" + +// ✅ With timezone - unambiguous +"2024-02-15T17:00:00+01:00" +``` + +### Performance Consideration + +**Custom closures run for every date** - optimize expensive operations: + +```swift +// ❌ Creates new formatter for every date +decoder.dateDecodingStrategy = .custom { decoder in + let formatter = DateFormatter() // Expensive! + // ... +} + +// ✅ Reuse formatter +let sharedFormatter = DateFormatter() +sharedFormatter.dateFormat = "yyyy-MM-dd" + +decoder.dateDecodingStrategy = .custom { decoder in + // Use sharedFormatter +} +``` + +--- + +## Part 5: Type Transformation + +### StringBacked Wrapper + +Handle APIs that encode numbers as strings: + +```swift +protocol StringRepresentable: CustomStringConvertible { + init?(_ string: String) +} + +extension Int: StringRepresentable {} +extension Double: StringRepresentable {} + +struct StringBacked: Codable { + var value: Value + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + + guard let value = Value(string) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Cannot convert '\(string)' to \(Value.self)" + ) + } + + self.value = value + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(value.description) + } +} + +// Usage +struct Product: Codable { + let name: String + private let _price: StringBacked + + var price: Double { + get { _price.value } + set { _price = StringBacked(value: newValue) } + } + + enum CodingKeys: String, CodingKey { + case name + case _price = "price" + } +} + +// JSON: {"name":"Widget","price":"19.99"} +// Decodes to: Product(name: "Widget", price: 19.99) +``` + +### Type Coercion + +For loosely typed APIs that may return different types: + +```swift +struct FlexibleValue: Codable { + let stringValue: String + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let string = try? container.decode(String.self) { + stringValue = string + } else if let int = try? container.decode(Int.self) { + stringValue = String(int) + } else if let double = try? container.decode(Double.self) { + stringValue = String(double) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Cannot decode value to String, Int, or Double" + ) + } + } +} +``` + +**Warning**: Avoid this pattern unless the API is truly unpredictable. Prefer strict types. + +--- + +## Part 6: Advanced Patterns + +### DecodableWithConfiguration (iOS 15+) + +For types that need data unavailable in JSON: + +```swift +struct User: Encodable, DecodableWithConfiguration { + let id: UUID + var name: String + var favorites: Favorites // Not in JSON, injected via configuration + + enum CodingKeys: CodingKey { + case id, name + } + + init(from decoder: Decoder, configuration: Favorites) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + favorites = configuration // Injected + } +} + +// Usage (iOS 17+) +let favorites = try await fetchFavorites() +let user = try JSONDecoder().decode( + User.self, + from: data, + configuration: favorites +) +``` + +### userInfo Workaround (iOS 15-16) + +```swift +extension JSONDecoder { + private struct ConfigurationDecodingWrapper: Decodable { + var wrapped: T + + init(from decoder: Decoder) throws { + let config = decoder.userInfo[configurationUserInfoKey] as! T.DecodingConfiguration + wrapped = try T(from: decoder, configuration: config) + } + } + + func decode( + _ type: T.Type, + from data: Data, + configuration: T.DecodingConfiguration + ) throws -> T { + let decoder = JSONDecoder() + decoder.userInfo[Self.configurationUserInfoKey] = configuration + let wrapper = try decoder.decode(ConfigurationDecodingWrapper.self, from: data) + return wrapper.wrapped + } +} + +private let configurationUserInfoKey = CodingUserInfoKey(rawValue: "configuration")! +``` + +### Partial Decoding + +Decode only the fields you need: + +```swift +struct ArticlePreview: Decodable { + let id: UUID + let title: String + // Omit body, comments, etc. +} + +// JSON has many more fields, but we only decode id and title +``` + +--- + +## Part 7: Debugging + +### DecodingError Cases + +```swift +do { + let user = try decoder.decode(User.self, from: data) +} catch DecodingError.keyNotFound(let key, let context) { + print("Missing key '\(key)' at path: \(context.codingPath)") +} catch DecodingError.typeMismatch(let type, let context) { + print("Type mismatch for \(type) at path: \(context.codingPath)") +} catch DecodingError.valueNotFound(let type, let context) { + print("Value not found for \(type) at path: \(context.codingPath)") +} catch DecodingError.dataCorrupted(let context) { + print("Data corrupted at path: \(context.codingPath)") +} catch { + print("Other error: \(error)") +} +``` + +### Debugging Techniques + +**1. Pretty-print JSON** + +```swift +let encoder = JSONEncoder() +encoder.outputFormatting = [.prettyPrinted, .sortedKeys] +let jsonData = try encoder.encode(user) +print(String(data: jsonData, encoding: .utf8)!) +``` + +**2. Inspect coding path** + +```swift +// In custom init(from:) +print("Decoding at path: \(decoder.codingPath)") +``` + +**3. Validate JSON structure** + +```swift +// Quick check: Can it decode as Any? +let json = try JSONSerialization.jsonObject(with: data) +print(json) // See actual structure +``` + +--- + +## Anti-Patterns + +| Anti-Pattern | Cost | Better Approach | +|--------------|------|-----------------| +| **Manual JSON string building** | Injection vulnerabilities, escaping bugs, no type safety | Use `JSONEncoder` | +| **`try?` swallowing DecodingError** | Silent failures, debugging nightmares, data loss | Handle specific error cases | +| **Optional properties to avoid decode errors** | Runtime crashes, nil checks everywhere, masks structural issues | Fix JSON/model mismatch or use `DecodableWithConfiguration` | +| **Duplicating partial models** | 2-5 hours maintenance per change, sync issues, fragile | Use bridge types or configuration | +| **Ignoring date timezone** | Intermittent bugs across regions, data corruption | Always use ISO8601 with timezone or explicit UTC | +| **`JSONSerialization` for Codable types** | 3x more boilerplate, manual type casting, error-prone | Use `JSONDecoder`/`JSONEncoder` | +| **No locale on DateFormatter** | Parsing fails in non-US locales | Set `locale = Locale(identifier: "en_US_POSIX")` | + +### Why try? is Dangerous + +```swift +// ❌ Silent failure - production bug waiting to happen +let user = try? JSONDecoder().decode(User.self, from: data) +// If this fails, user is nil - why? No idea. + +// ✅ Explicit error handling +do { + let user = try JSONDecoder().decode(User.self, from: data) +} catch { + logger.error("Failed to decode user: \(error)") + // Now you know WHY it failed +} +``` + +--- + +## Pressure Scenarios + +### Scenario 1: "Just Use try? to Make It Compile" + +**Context**: API integration deadline tomorrow, decoder failing on some edge case. + +**Pressure**: "We can debug it later, just make it work now." + +**Why You'll Rationalize**: +- "It's only failing on 1% of requests" +- "We can add logging later" +- "Customers won't notice" + +**What Actually Happens**: +- Silent data loss for that 1% +- No logs, so you can't debug in production +- Customer complaints 3 months later +- You've forgotten the context by then + +**Discipline Response**: + +> "Using `try?` here means we'll lose data silently. Let me spend 5 minutes handling the specific error case. If it's truly rare, I'll log it so we can fix the root cause." + +**5-Minute Fix**: + +```swift +do { + return try decoder.decode(User.self, from: data) +} catch DecodingError.keyNotFound(let key, let context) { + logger.error("Missing key '\(key)' in API response", metadata: [ + "path": .string(context.codingPath.description), + "rawJSON": .string(String(data: data, encoding: .utf8) ?? "") + ]) + throw APIError.invalidResponse(reason: "Missing key: \(key)") +} catch { + logger.error("Failed to decode User", error: error) + throw APIError.decodingFailed(error) +} +``` + +**Result**: You discover the API sometimes omits the `email` field for deleted users. Fix: make `email` optional only for that case, not all users. + +--- + +### Scenario 2: "Dates Are Intermittent, Must Be Server Bug" + +**Context**: Date parsing works in your timezone but fails for European QA team. + +**Pressure**: "It works for me, QA must be doing something wrong." + +**Why You'll Rationalize**: +- "My tests pass locally" +- "The server is probably sending bad data" +- "It's their device settings" + +**What Actually Happens**: +- Server sends dates without timezone: `"2024-12-14T10:00:00"` +- Your device (PST) interprets as 10:00 PST +- QA device (CET) interprets as 10:00 CET +- Different absolute times, intermittent bugs + +**Discipline Response**: + +> "Intermittent date failures are almost always timezone issues. Let me check if we're using ISO8601 with timezone offsets." + +**Check**: + +```swift +// ❌ Current (fails across timezones) +decoder.dateDecodingStrategy = .iso8601 + +// Server sends: "2024-12-14T10:00:00" (no timezone) +// PST device: Dec 14, 10:00 PST +// CET device: Dec 14, 10:00 CET +// Bug: Different times! + +// ✅ Fix: Require server to send timezone +// "2024-12-14T10:00:00+00:00" +// OR: Explicitly parse as UTC +decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + let formatter = ISO8601DateFormatter() + formatter.timeZone = TimeZone(secondsFromGMT: 0) // Force UTC + + guard let date = formatter.date(from: dateString) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid ISO8601 date: \(dateString)" + ) + } + + return date +} +``` + +**Result**: Bug fixed, server adds timezone to API (or you parse explicitly as UTC). No more intermittent failures. + +--- + +### Scenario 3: "Just Make It Optional" + +**Context**: New API field causes decoding to fail. Product manager wants a fix in 1 hour. + +**Pressure**: "Can't you just make that field optional? We need this shipped." + +**Why You'll Rationalize**: +- "It's faster than fixing the API" +- "We can make it non-optional later" +- "Users won't notice" + +**What Actually Happens**: +- Field is actually required for the feature +- You add `user.email ?? ""` everywhere +- 3 months later: production crash because `email` was nil +- Now you can't remember why it was optional + +**Discipline Response**: + +> "Making it optional masks the real problem. Let me check if the API is wrong or our model is wrong. This will take 10 minutes." + +**Investigation**: + +```swift +// Step 1: Print raw JSON +do { + let json = try JSONSerialization.jsonObject(with: data) + print(json) +} catch { + print("Invalid JSON: \(error)") +} + +// Step 2: Check if key exists but value is null +// {"email": null} vs key missing entirely + +// Step 3: Check API docs - is email actually required? +``` + +**Common Outcomes**: +1. **API is wrong**: Field should be there → File bug, get hotfix +2. **Model is wrong**: Field is optional in some flows → Use proper optionality with clear documentation +3. **Structural mismatch**: Field is nested → Use nested container + +**Result**: You discover `email` is nested in `user.contact.email` in the new API version. Fix with nested container, not optionality. + +```swift +// ✅ Correct fix +struct User: Decodable { + let id: UUID + let email: String // Still required + + enum CodingKeys: CodingKey { + case id, contact + } + + enum ContactKeys: CodingKey { + case email + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + + let contact = try container.nestedContainer( + keyedBy: ContactKeys.self, + forKey: .contact + ) + email = try contact.decode(String.self, forKey: .email) + } +} +``` + +--- + +## Related Skills + +- **swift-concurrency** — Codable types crossing actor boundaries must be `Sendable` +- **swiftdata** — `@Model` types use Codable for CloudKit sync +- **networking** — `Coder` protocol wraps Codable for Network.framework +- **app-intents-ref** — `AppEnum` parameters use Codable serialization + +--- + +## Key Takeaways + +1. **Prefer automatic synthesis** — Add `: Codable` when structure matches JSON +2. **Use CodingKeys for simple mismatches** — Rename or exclude without manual code +3. **Manual implementation for structural differences** — Nested containers, bridge types +4. **Always set locale and timezone** — `DateFormatter` requires `en_US_POSIX` and explicit timezone +5. **Never swallow errors with try?** — Handle `DecodingError` cases explicitly +6. **Codable + Sendable** — Value types (structs/enums) are ideal for async networking + +**Core Principle**: Codable is Swift's universal serialization protocol. Master it once, use it everywhere. diff --git a/.claude/skills/axiom-codable/agents/openai.yaml b/.claude/skills/axiom-codable/agents/openai.yaml new file mode 100644 index 0000000..cb9c2ac --- /dev/null +++ b/.claude/skills/axiom-codable/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Codable" + short_description: "Working with Codable protocol, JSON encoding/decoding, CodingKeys customization, enum serialization, date strategies,..." diff --git a/.claude/skills/axiom-code-signing-diag/.openskills.json b/.claude/skills/axiom-code-signing-diag/.openskills.json new file mode 100644 index 0000000..91cfbac --- /dev/null +++ b/.claude/skills/axiom-code-signing-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-code-signing-diag", + "installedAt": "2026-04-12T08:06:03.332Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-code-signing-diag/SKILL.md b/.claude/skills/axiom-code-signing-diag/SKILL.md new file mode 100644 index 0000000..4d3b708 --- /dev/null +++ b/.claude/skills/axiom-code-signing-diag/SKILL.md @@ -0,0 +1,350 @@ +--- +name: axiom-code-signing-diag +description: Use when code signing fails during build, archive, or upload — certificate not found, provisioning profile mismatch, errSecInternalComponent in CI, ITMS-90035 invalid signature, ambiguous identity, entitlement mismatch. Covers certificate, profile, keychain, entitlement, and archive signing diagnostics. +license: MIT +--- + +# Code Signing Diagnostics + +Systematic troubleshooting for code signing failures: missing certificates, provisioning profile mismatches, Keychain issues in CI, entitlement conflicts, and App Store upload rejections. + +## Overview + +**Core Principle**: When code signing fails, the problem is usually: +1. **Certificate issues** (expired, missing, wrong type, revoked) — 30% +2. **Provisioning profile issues** (expired, missing cert, wrong App ID, missing capability) — 25% +3. **Entitlement mismatches** (capability in Xcode but not in profile, or vice versa) — 15% +4. **Keychain issues** (locked in CI, errSecInternalComponent, partition list) — 15% +5. **Archive/export issues** (wrong export method, wrong cert type for distribution) — 10% +6. **Ambiguous identity** (multiple matching certificates, Xcode picks wrong one) — 5% + +**Always verify certificate + profile + entitlements BEFORE rewriting build settings or regenerating everything.** + +## Red Flags + +Symptoms that indicate code signing–specific issues: + +| Symptom | Likely Cause | +|---------|--------------| +| "No signing certificate found" | Certificate expired, revoked, or not in keychain | +| "Provisioning profile doesn't include signing certificate" | Profile generated with different cert than the one in keychain | +| ITMS-90035 Invalid Signature | Signed with Development cert instead of Distribution | +| ITMS-90161 Invalid Provisioning Profile | Profile expired or doesn't match binary | +| errSecInternalComponent in CI | Keychain locked or `set-key-partition-list` not called | +| "Ambiguous — matches multiple" | Multiple valid certs with same name (dev + expired) | +| "Entitlement not allowed by profile" | Capability added in Xcode but profile not regenerated | +| "codesign wants to access key" dialog | Keychain access not granted to codesign | +| Build works locally, fails in CI | Missing keychain setup steps (create, unlock, partition list) | +| "Profile doesn't match bundle ID" | Bundle identifier mismatch between Xcode target and profile | +| Export fails after successful archive | ExportOptions.plist specifies wrong method or profile | +| App extension signing fails | Extension needs its own profile with matching team and prefix | + +## Anti-Rationalization + +| Rationalization | Why It Fails | Time Cost | +|----------------|--------------|-----------| +| "Certificate was fine yesterday" | Certificates expire and get revoked. Profiles auto-regenerate in portal changes. Always re-verify. | 30-60 min debugging build settings when cert expired overnight | +| "Let me regenerate everything" | Regenerating certificates revokes the old ones, breaking other team members and CI. Diagnose first. | 2-4 hours + broken teammates + CI pipeline down | +| "I'll reset my keychain" | Destroys ALL stored credentials (SSH keys, saved passwords, other certs). Diagnose the specific cert. | 1-2 hours restoring all credentials | +| "Just disable code signing for now" | Code signing can't be disabled for device builds or distribution. You'll hit the same issue later with less time. | Wasted time plus the original problem remains | +| "It's an Xcode bug, let me reinstall" | Code signing is configuration, not an Xcode bug. Reinstalling doesn't change your certificates or profiles. | 2-4 hours reinstalling Xcode while the config stays broken | +| "I'll use the team provisioning profile" | Xcode's auto-managed wildcard profile lacks specific entitlements (push, App Groups). It won't work for apps needing capabilities. | 30+ min discovering missing capabilities | +| "CI worked before, nothing changed on our side" | Apple revokes certificates for security reasons. CI runner macOS updates change keychain behavior. Provisioning profiles expire after 1 year. | Hours of "but we didn't change anything" while the cert is expired | +| "Let me check the code first" | Code signing errors are NEVER code bugs. They are 100% configuration — certificates, profiles, entitlements, and keychains. | Hours debugging working code while the profile is expired | +| "Set build.keychain as default" | `security default-keychain -s build.keychain` replaces the login keychain as default, breaking access to SSH keys, saved passwords, and other credentials. Use `list-keychains -s` instead. | 30+ min restoring default keychain + mysterious SSH/credential failures | + +## Mandatory First Steps + +Before changing build settings or regenerating certificates, run these diagnostics: + +### Step 1: Check Signing Identities + +```bash +security find-identity -v -p codesigning +``` + +**Expected output**: +- At least one valid identity with "Apple Development" or "Apple Distribution" +- Each shows SHA-1 hash + name + Team ID + +**Problems**: +- 0 valid identities → No certificates installed or all expired +- Only "Apple Development" but trying to archive → Need Distribution certificate +- Multiple entries with same name → Ambiguous identity (see Tree 5) + +### Step 2: Decode Provisioning Profile + +```bash +# Find the profile being used +find ~/Library/Developer/Xcode/DerivedData -name "embedded.mobileprovision" -newer . 2>/dev/null | head -3 + +# Decode it +security cms -D -i path/to/embedded.mobileprovision +``` + +**Check these fields**: +- `ExpirationDate` → Not expired? +- `DeveloperCertificates` → Contains your current certificate? +- `Entitlements` → Contains all capabilities your app uses? +- `ProvisionedDevices` → Contains your test device UDID? (Development/Ad Hoc only) +- `Name` → Matches what Xcode is configured to use? + +### Step 3: Extract and Compare Entitlements + +```bash +# What entitlements does the built app have? +codesign -d --entitlements - /path/to/MyApp.app + +# What entitlements does the profile grant? +security cms -D -i embedded.mobileprovision | plutil -extract Entitlements xml1 -o - - + +# What entitlements does Xcode's .entitlements file declare? +cat MyApp/MyApp.entitlements +``` + +**All three must agree.** Any mismatch → signing failure. + +### Step 4: Verify Certificate in Profile + +```bash +# Get certificate SHA-1 from keychain +security find-identity -v -p codesigning | grep "Apple Distribution" +# Output: 1) ABCDEF123... "Apple Distribution: Company (TEAMID)" + +# Check if that certificate is embedded in the profile +security cms -D -i embedded.mobileprovision | plutil -extract DeveloperCertificates xml1 -o - - +# Decode one of the base64 certificates: +echo "" | base64 -d | openssl x509 -inform DER -noout -fingerprint -sha1 +``` + +The SHA-1 from the profile must match the SHA-1 from `find-identity`. + +## Decision Trees + +### Tree 1: "No signing certificate found" + +```dot +digraph tree1 { + "No signing certificate?" [shape=diamond]; + "Run find-identity" [shape=box, label="security find-identity -v -p codesigning"]; + "0 identities?" [shape=diamond]; + "Has identities but wrong type?" [shape=diamond]; + "Certificate expired?" [shape=diamond]; + + "Import certificate" [shape=box, label="Import .p12 into keychain\nsecurity import cert.p12 -k login.keychain-db -P pass -T /usr/bin/codesign"]; + "Download from portal" [shape=box, label="Download certificate from\nApple Developer Portal\nor use Xcode > Preferences > Accounts"]; + "Use correct cert type" [shape=box, label="Archive needs Apple Distribution\nDebug needs Apple Development\nCheck CODE_SIGN_IDENTITY"]; + "Renew certificate" [shape=box, label="Revoke expired cert in portal\nCreate new cert\nUpdate profiles to use new cert"]; + "CI keychain issue" [shape=box, label="In CI: create keychain, import cert,\nunlock, set-key-partition-list\n(see code-signing-ref CI section)"]; + + "No signing certificate?" -> "Run find-identity"; + "Run find-identity" -> "0 identities?" [label="check output"]; + "0 identities?" -> "Import certificate" [label="yes, no certs at all"]; + "0 identities?" -> "Has identities but wrong type?" [label="no, has some"]; + "Has identities but wrong type?" -> "Use correct cert type" [label="yes, dev only but need dist"]; + "Has identities but wrong type?" -> "Certificate expired?" [label="no, correct type exists"]; + "Certificate expired?" -> "Renew certificate" [label="yes"]; + "Certificate expired?" -> "CI keychain issue" [label="no, cert valid but CI fails"]; + "Import certificate" -> "Download from portal" [label="don't have .p12"]; +} +``` + +### Tree 2: "Provisioning profile doesn't include signing certificate" + +```dot +digraph tree2 { + "Profile cert mismatch?" [shape=diamond]; + "Automatic signing?" [shape=diamond]; + + "Clean and retry" [shape=box, label="Xcode > Preferences > Accounts\n> Download Manual Profiles\nClean build folder (Cmd+Shift+K)"]; + "Regenerate profile" [shape=box, label="Apple Developer Portal:\n1. Edit profile\n2. Select current certificate\n3. Generate\n4. Download and install"]; + "Check cert match" [shape=box, label="Step 4: Verify certificate SHA-1\nin keychain matches SHA-1\nembedded in profile"]; + "Revoked cert" [shape=box, label="If someone regenerated the cert,\nall existing profiles are invalid.\nRegenerate profiles with new cert."]; + + "Profile cert mismatch?" -> "Automatic signing?" [label="check Xcode"]; + "Automatic signing?" -> "Clean and retry" [label="yes"]; + "Automatic signing?" -> "Check cert match" [label="no, manual signing"]; + "Check cert match" -> "Regenerate profile" [label="SHA-1 mismatch"]; + "Check cert match" -> "Revoked cert" [label="cert not in profile at all"]; +} +``` + +### Tree 3: ITMS-90035/90161 Invalid Signature/Profile + +```dot +digraph tree3 { + "Upload rejected?" [shape=diamond]; + "ITMS-90035?" [shape=diamond]; + "ITMS-90161?" [shape=diamond]; + "ITMS-90046?" [shape=diamond]; + + "Wrong cert" [shape=box, label="Signed with Development cert.\nRe-archive with Apple Distribution.\nCODE_SIGN_IDENTITY = Apple Distribution"]; + "Cert expired" [shape=box, label="Certificate expired between\narchive and upload.\nRenew cert, re-archive."]; + "Profile expired" [shape=box, label="Provisioning profile expired.\nRegenerate in portal,\nre-archive."]; + "Profile mismatch" [shape=box, label="Profile doesn't match binary.\nCheck bundle ID alignment.\nVerify ExportOptions.plist."]; + "Check entitlements" [shape=box, label="Entitlement not in profile.\nAdd capability in portal,\nregenerate profile, re-archive."]; + + "Upload rejected?" -> "ITMS-90035?" [label="check error code"]; + "Upload rejected?" -> "ITMS-90046?" [label="ITMS-90046"]; + "ITMS-90035?" -> "Wrong cert" [label="'Invalid Signature'"]; + "ITMS-90035?" -> "ITMS-90161?" [label="different error"]; + "ITMS-90046?" -> "Check entitlements" [label="'Invalid Entitlements'"]; + "ITMS-90161?" -> "Profile expired" [label="'Invalid Provisioning Profile'"]; + "ITMS-90161?" -> "Profile mismatch" [label="'profile doesn't match'"]; + "Wrong cert" -> "Cert expired" [label="cert IS Distribution but still fails"]; +} +``` + +### Tree 4: errSecInternalComponent / Keychain Locked in CI + +```dot +digraph tree4 { + "errSecInternalComponent?" [shape=diamond]; + "Keychain created?" [shape=diamond]; + "Keychain unlocked?" [shape=diamond]; + "Partition list set?" [shape=diamond]; + "Search list correct?" [shape=diamond]; + + "Create keychain" [shape=box, label="security create-keychain -p pass build.keychain"]; + "Unlock keychain" [shape=box, label="security unlock-keychain -p pass build.keychain"]; + "Set partition list" [shape=box, label="security set-key-partition-list\n-S apple-tool:,apple: -s\n-k pass build.keychain\n(MOST COMMON FIX)"]; + "Add to search list" [shape=box, label="security list-keychains -d user\n-s build.keychain login.keychain-db"]; + "Set timeout" [shape=box, label="security set-keychain-settings\n-t 3600 -l build.keychain\n(prevent lock during long builds)"]; + "Check runner image" [shape=box, label="Runner image may have changed.\nCheck GitHub Actions runner changelog.\nmacOS updates change keychain defaults."]; + + "errSecInternalComponent?" -> "Keychain created?" [label="CI environment"]; + "Keychain created?" -> "Create keychain" [label="no"]; + "Keychain created?" -> "Keychain unlocked?" [label="yes"]; + "Keychain unlocked?" -> "Unlock keychain" [label="no"]; + "Keychain unlocked?" -> "Partition list set?" [label="yes"]; + "Partition list set?" -> "Set partition list" [label="no — this is the #1 fix"]; + "Partition list set?" -> "Search list correct?" [label="yes"]; + "Search list correct?" -> "Add to search list" [label="no — keychain not in search path"]; + "Search list correct?" -> "Set timeout" [label="yes — try extending timeout"]; + "Set timeout" -> "Check runner image" [label="still failing"]; +} +``` + +### Tree 5: Ambiguous Identity / Multiple Certificates + +```dot +digraph tree5 { + "Ambiguous identity?" [shape=diamond]; + "Same name, different dates?" [shape=diamond]; + "Dev and Dist both present?" [shape=diamond]; + + "List all" [shape=box, label="security find-identity -v -p codesigning\nNote SHA-1 hashes and expiry dates"]; + "Delete expired" [shape=box, label="Open Keychain Access\nDelete expired certificate\n(check expiry with openssl x509 -enddate)"]; + "Use SHA-1" [shape=box, label="Specify exact identity by SHA-1:\nCODE_SIGN_IDENTITY = 'SHA1HASH'\nor codesign -s 'SHA1HASH'"]; + "Specify full name" [shape=box, label="Use full identity name:\nCODE_SIGN_IDENTITY =\n'Apple Distribution: Company (TEAMID)'"]; + + "Ambiguous identity?" -> "List all" [label="first"]; + "List all" -> "Same name, different dates?" [label="inspect"]; + "Same name, different dates?" -> "Delete expired" [label="yes, old + new cert"]; + "Same name, different dates?" -> "Dev and Dist both present?" [label="no"]; + "Dev and Dist both present?" -> "Specify full name" [label="yes, Xcode picks wrong one"]; + "Dev and Dist both present?" -> "Use SHA-1" [label="still ambiguous"]; +} +``` + +### Tree 6: Entitlement Mismatch / Missing Capability + +```dot +digraph tree6 { + "Entitlement error?" [shape=diamond]; + "In Xcode but not profile?" [shape=diamond]; + "In profile but not Xcode?" [shape=diamond]; + + "Run Step 3" [shape=box, label="Compare entitlements:\n1. codesign -d --entitlements - App\n2. Profile entitlements\n3. .entitlements file"]; + "Regenerate profile" [shape=box, label="Apple Developer Portal:\n1. App ID > Capabilities\n2. Enable missing capability\n3. Edit profile\n4. Generate and download"]; + "Add capability" [shape=box, label="Xcode > Target >\nSigning & Capabilities >\n+ Capability"]; + "Remove stale entitlement" [shape=box, label="Remove capability from\n.entitlements file that\nisn't supported by profile type"]; + + "Entitlement error?" -> "Run Step 3" [label="diagnose"]; + "Run Step 3" -> "In Xcode but not profile?" [label="compare"]; + "In Xcode but not profile?" -> "Regenerate profile" [label="yes, capability missing from profile"]; + "In Xcode but not profile?" -> "In profile but not Xcode?" [label="no"]; + "In profile but not Xcode?" -> "Add capability" [label="yes"]; + "In profile but not Xcode?" -> "Remove stale entitlement" [label="entitlement not valid for profile type"]; +} +``` + +## Quick Reference Table + +| Symptom | Check | Fix | +|---------|-------|-----| +| No signing certificate found | `security find-identity -v -p codesigning` | Import cert or download from portal | +| Provisioning profile doesn't include cert | Step 4: SHA-1 comparison | Regenerate profile with current cert | +| ITMS-90035 Invalid Signature | `codesign -dv` on archived app | Re-archive with Apple Distribution cert | +| ITMS-90161 Invalid Provisioning Profile | `security cms -D -i` on profile | Regenerate non-expired profile | +| ITMS-90046 Invalid Entitlements | Step 3: three-way comparison | Add capability in portal, regenerate profile | +| errSecInternalComponent | CI keychain setup | `set-key-partition-list` (most common fix) | +| Ambiguous identity | `security find-identity -v` count | Delete expired cert or use SHA-1 hash | +| Entitlement mismatch | Three-way entitlement comparison | Align Xcode, profile, and .entitlements | +| Profile expired | `security cms -D` check ExpirationDate | Download fresh profile from portal | +| Profile missing push | `grep aps-environment` in profile | Enable Push in portal, regenerate profile | +| Extension signing fails | Extension target signing config | Each extension needs own profile with matching team | +| Works locally, fails CI | CI keychain script completeness | Full setup: create, unlock, import, partition list, search list | +| "codesign wants to access key" | Keychain access settings | `security set-key-partition-list` or Keychain Access > Get Info > Access Control | +| App Groups entitlement error | Three-way comparison | Add App Group in portal App ID, regenerate profile | +| Build works, export fails | ExportOptions.plist | Verify method, profile name, team ID in plist | + +## Pressure Scenarios + +### Scenario 1: "Just regenerate everything" + +**Context**: Code signing fails. Team member suggests revoking all certificates and generating new ones to start fresh. + +**Pressure**: "It'll only take 5 minutes to regenerate everything." + +**Reality**: Revoking a distribution certificate invalidates ALL provisioning profiles that use it — across ALL team members and CI systems. Every developer needs new certificates. Every CI pipeline breaks. Every profile needs regeneration. The "5 minute fix" becomes a 2-4 hour team-wide outage. + +**Correct action**: Diagnose the specific issue with Steps 1-4. Most signing failures are a single expired or mismatched component, not a systemic problem. + +**Push-back template**: "Revoking certificates breaks signing for everyone on the team and all CI pipelines. Let me run the diagnostic steps first — 90% of signing issues are a single expired cert or mismatched profile, fixable in 5 minutes without affecting anyone else." + +### Scenario 2: "Xcode updated, signing broke — rollback" + +**Context**: After an Xcode update, code signing stopped working. Someone suggests rolling back Xcode. + +**Pressure**: "The build worked before the update, so the update broke it." + +**Reality**: Xcode updates sometimes invalidate managed signing caches or change how automatic signing selects profiles. But the certificates and profiles themselves don't change. Rolling back Xcode loses access to new SDK features and doesn't fix the underlying configuration. + +**Correct action**: Run Steps 1-2 to verify certificates and profiles are still valid. Check if Xcode's automatic signing is selecting a different profile. Try: Xcode → Preferences → Accounts → Download Manual Profiles. Clean build folder. + +**Push-back template**: "Xcode updates don't change our certificates or profiles. Let me check what Xcode's automatic signing is selecting now — it's likely picking a different profile than before. A 5-minute check will tell us exactly what changed." + +### Scenario 3: "The archive failed, let me re-archive — it's probably corruption" + +**Context**: Archive succeeded but export or upload failed. Developer wants to re-archive assuming the archive was corrupted. + +**Pressure**: "Just do it again, it'll probably work this time." + +**Reality**: Archive "corruption" is extremely rare. Export failures are almost always: wrong ExportOptions.plist method, wrong certificate type in the archive, or profile mismatch. Re-archiving with the same settings produces the same result. + +**Correct action**: Inspect the archive before re-building: +1. `codesign -dv` on the .app inside the .xcarchive to see what signed it +2. Check ExportOptions.plist method matches intent (app-store, ad-hoc, etc.) +3. Verify the profile specified in ExportOptions exists and isn't expired + +**Push-back template**: "Re-archiving with the same settings will produce the same result. Let me check what's actually in the archive — it takes 30 seconds with codesign -dv and will tell us exactly why export failed." + +## Checklist + +Before declaring a signing issue fixed: + +- [ ] `security find-identity -v -p codesigning` shows the expected identity +- [ ] Profile decoded with `security cms -D` — not expired, contains correct cert +- [ ] Three-way entitlement comparison agrees (binary, profile, .entitlements file) +- [ ] Build/archive succeeds with correct `CODE_SIGN_IDENTITY` +- [ ] If CI: keychain created, unlocked, partition list set, cert imported +- [ ] If CI: cleanup step runs on success AND failure + +## Resources + +**WWDC**: 2021-10204, 2022-110353 + +**Docs**: /security, /bundleresources/entitlements, /xcode/distributing-your-app + +**Skills**: axiom-code-signing, axiom-code-signing-ref diff --git a/.claude/skills/axiom-code-signing-diag/agents/openai.yaml b/.claude/skills/axiom-code-signing-diag/agents/openai.yaml new file mode 100644 index 0000000..7d45bc4 --- /dev/null +++ b/.claude/skills/axiom-code-signing-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Code Signing Diagnostics" + short_description: "Code signing fails during build, archive, or upload" diff --git a/.claude/skills/axiom-code-signing-ref/.openskills.json b/.claude/skills/axiom-code-signing-ref/.openskills.json new file mode 100644 index 0000000..da0f03e --- /dev/null +++ b/.claude/skills/axiom-code-signing-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-code-signing-ref", + "installedAt": "2026-04-12T08:06:03.961Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-code-signing-ref/SKILL.md b/.claude/skills/axiom-code-signing-ref/SKILL.md new file mode 100644 index 0000000..bd0d3a9 --- /dev/null +++ b/.claude/skills/axiom-code-signing-ref/SKILL.md @@ -0,0 +1,700 @@ +--- +name: axiom-code-signing-ref +description: Use when needing certificate CLI commands, provisioning profile inspection, entitlement extraction, Keychain management scripts, codesign verification, fastlane match setup, Xcode build settings for signing, or APNs .p8 vs .p12 decision. Covers complete code signing API and CLI surface. +license: MIT +--- + +# Code Signing API Reference + +Comprehensive CLI and API reference for iOS/macOS code signing: certificate management, provisioning profile inspection, entitlement extraction, Keychain operations, codesign verification, fastlane match, and Xcode build settings. + +## Quick Reference + +```bash +# Diagnostic flow — run these 3 commands first for any signing issue +security find-identity -v -p codesigning # List valid signing identities +security cms -D -i path/to/embedded.mobileprovision # Decode provisioning profile +codesign -d --entitlements - MyApp.app # Extract entitlements from binary +``` + +--- + +## Certificate Reference + +### Certificate Types + +| Type | Purpose | Validity | Max Per Account | +|------|---------|----------|-----------------| +| Apple Development | Debug builds on registered devices | 1 year | Unlimited (per developer) | +| Apple Distribution | App Store + TestFlight submission | 1 year | 3 per account | +| iOS Distribution (legacy) | App Store submission (pre-Xcode 11) | 1 year | 3 per account | +| iOS Development (legacy) | Debug builds (pre-Xcode 11) | 1 year | Unlimited | +| Developer ID Application | macOS distribution outside App Store | 5 years | 5 per account | +| Developer ID Installer | macOS package signing | 5 years | 5 per account | +| Apple Push Services | APNs .p12 certificate auth (legacy) | 1 year | 1 per App ID | + +### CSR Generation + +```bash +# Generate Certificate Signing Request +openssl req -new -newkey rsa:2048 -nodes \ + -keyout CertificateSigningRequest.key \ + -out CertificateSigningRequest.certSigningRequest \ + -subj "/emailAddress=dev@example.com/CN=Developer Name/C=US" +``` + +Or use Keychain Access: Certificate Assistant → Request a Certificate From a Certificate Authority. + +### Certificate Inspection + +```bash +# View certificate details (from .cer file) +openssl x509 -in certificate.cer -inform DER -text -noout + +# View certificate from .p12 +openssl pkcs12 -in certificate.p12 -nokeys -clcerts | openssl x509 -text -noout + +# List certificates in Keychain with SHA-1 hashes +security find-identity -v -p codesigning + +# Example output: +# 1) ABC123... "Apple Development: dev@example.com (TEAMID)" +# 2) DEF456... "Apple Distribution: Company Name (TEAMID)" +# 2 valid identities found + +# Find specific certificate by name +security find-certificate -c "Apple Distribution" login.keychain-db -p + +# Check certificate expiration (pipe PEM output to openssl) +security find-certificate -c "Apple Distribution" login.keychain-db -p | openssl x509 -noout -enddate +``` + +### Certificate Installation + +```bash +# Import .p12 into Keychain (interactive — prompts for password) +security import certificate.p12 -k ~/Library/Keychains/login.keychain-db -P "$P12_PASSWORD" -T /usr/bin/codesign + +# Import .cer into Keychain +security import certificate.cer -k ~/Library/Keychains/login.keychain-db + +# For CI: import into temporary keychain (see CI section below) +``` + +--- + +## Provisioning Profile Reference + +### Profile Types + +| Type | Contains | Use Case | +|------|----------|----------| +| Development | Dev cert + device UDIDs + App ID + entitlements | Debug builds on registered devices | +| Ad Hoc | Distribution cert + device UDIDs + App ID + entitlements | Testing on specific devices without TestFlight | +| App Store | Distribution cert + App ID + entitlements (no device list) | App Store + TestFlight submission | +| Enterprise | Enterprise cert + App ID + entitlements (no device list) | In-house distribution (Enterprise program only) | + +### Profile Contents + +A provisioning profile (.mobileprovision) is a signed plist containing: + +``` +├── AppIDName — App ID name +├── ApplicationIdentifierPrefix — Team ID +├── CreationDate — When profile was created +├── DeveloperCertificates — Embedded signing certificates (DER-encoded) +├── Entitlements — Granted entitlements +│ ├── application-identifier +│ ├── aps-environment (development|production) +│ ├── com.apple.developer.associated-domains +│ ├── keychain-access-groups +│ └── ... +├── ExpirationDate — When profile expires (1 year) +├── Name — Profile name in Apple Developer Portal +├── ProvisionedDevices — UDIDs (Development/Ad Hoc only) +├── TeamIdentifier — Team ID array +├── TeamName — Team display name +├── TimeToLive — Days until expiration +├── UUID — Unique profile identifier +└── Version — Profile version (1) +``` + +### Decode Provisioning Profile + +```bash +# Decode and display full contents +security cms -D -i path/to/embedded.mobileprovision + +# Extract specific fields +security cms -D -i embedded.mobileprovision | plutil -extract Entitlements xml1 -o - - + +# Check aps-environment (push notifications) +security cms -D -i embedded.mobileprovision | grep -A1 "aps-environment" + +# Check expiration +security cms -D -i embedded.mobileprovision | grep -A1 "ExpirationDate" + +# List provisioned devices (Development/Ad Hoc only) +security cms -D -i embedded.mobileprovision | grep -A100 "ProvisionedDevices" + +# Check team ID +security cms -D -i embedded.mobileprovision | grep -A1 "TeamIdentifier" +``` + +### Profile Installation Paths + +```bash +# Installed profiles (Xcode manages these) +~/Library/MobileDevice/Provisioning Profiles/ + +# List installed profiles +ls ~/Library/MobileDevice/Provisioning\ Profiles/ + +# Decode a specific installed profile +security cms -D -i ~/Library/MobileDevice/Provisioning\ Profiles/.mobileprovision + +# Find profile embedded in app bundle +find ~/Library/Developer/Xcode/DerivedData -name "embedded.mobileprovision" -newer . 2>/dev/null | head -5 + +# Install a profile manually (copy to managed directory) +cp MyProfile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/ +``` + +--- + +## Entitlements Reference + +### Common Entitlements + +| Entitlement | Key | Values | +|-------------|-----|--------| +| App ID | `application-identifier` | `TEAMID.com.example.app` | +| Push Notifications | `aps-environment` | `development` / `production` | +| App Groups | `com.apple.security.application-groups` | `["group.com.example.shared"]` | +| Keychain Sharing | `keychain-access-groups` | `["TEAMID.com.example.keychain"]` | +| Associated Domains | `com.apple.developer.associated-domains` | `["applinks:example.com"]` | +| iCloud | `com.apple.developer.icloud-container-identifiers` | Container IDs | +| HealthKit | `com.apple.developer.healthkit` | `true` | +| Apple Pay | `com.apple.developer.in-app-payments` | Merchant IDs | +| Network Extensions | `com.apple.developer.networking.networkextension` | Array of types | +| Siri | `com.apple.developer.siri` | `true` | +| Sign in with Apple | `com.apple.developer.applesignin` | `["Default"]` | + +### Entitlement .plist Format + +```xml + + + + + aps-environment + development + com.apple.security.application-groups + + group.com.example.shared + + com.apple.developer.associated-domains + + applinks:example.com + + + +``` + +### Extraction and Comparison + +```bash +# Extract entitlements from signed app binary +codesign -d --entitlements - /path/to/MyApp.app + +# Extract to file for comparison +codesign -d --entitlements entitlements.plist /path/to/MyApp.app + +# Compare entitlements between app and provisioning profile +diff <(codesign -d --entitlements - MyApp.app 2>/dev/null) \ + <(security cms -D -i embedded.mobileprovision | plutil -extract Entitlements xml1 -o - -) + +# Extract entitlements from .ipa +unzip -o MyApp.ipa -d /tmp/ipa_contents +codesign -d --entitlements - /tmp/ipa_contents/Payload/*.app + +# Verify entitlements match between build and profile +codesign -d --entitlements - --xml MyApp.app # XML format +``` + +--- + +## CLI Command Reference + +### `security` Commands + +```bash +# --- Identity & Certificate --- + +# List valid code signing identities +security find-identity -v -p codesigning +# -v: valid only, -p codesigning: code signing policy + +# Find certificate by common name +security find-certificate -c "Apple Distribution" login.keychain-db -p +# -c: common name substring, -p: output PEM, keychain is positional arg + +# Find certificate by SHA-1 hash +security find-certificate -Z -a login.keychain-db | grep -B5 "ABC123" + +# --- Import/Export --- + +# Import .p12 (with password, allow codesign access) +security import certificate.p12 -k login.keychain-db -P "$PASSWORD" -T /usr/bin/codesign -T /usr/bin/security + +# Import .cer +security import certificate.cer -k login.keychain-db + +# Export certificate to .p12 +security export -t identities -f pkcs12 -k login.keychain-db -P "$PASSWORD" -o exported.p12 + +# --- Provisioning Profile Decode --- + +# Decode provisioning profile (CMS/PKCS7 signed plist) +security cms -D -i embedded.mobileprovision + +# --- Keychain Management --- + +# Create temporary keychain (for CI) +security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + +# Set as default keychain +security default-keychain -s build.keychain + +# Add to search list (required for codesign to find certs) +security list-keychains -d user -s build.keychain login.keychain-db + +# Unlock keychain (required in CI before signing) +security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + +# Set keychain lock timeout (0 = never lock during session) +security set-keychain-settings -t 3600 -l build.keychain +# -t: timeout in seconds, -l: lock on sleep + +# Allow codesign to access keys without UI prompt (critical for CI) +security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain + +# Delete keychain (CI cleanup) +security delete-keychain build.keychain +``` + +### `codesign` Commands + +```bash +# --- Signing --- + +# Sign with specific identity +codesign -s "Apple Distribution: Company Name (TEAMID)" MyApp.app +# -s: signing identity (name or SHA-1 hash) + +# Sign with entitlements file +codesign -s "Apple Distribution" --entitlements entitlements.plist MyApp.app + +# Force re-sign (overwrite existing signature) +codesign -f -s "Apple Distribution" MyApp.app + +# Sign with timestamp (required for notarization) +codesign -s "Developer ID Application" --timestamp MyApp.app + +# Deep sign (sign all nested code — frameworks, extensions) +codesign --deep -s "Apple Distribution" MyApp.app +# Warning: --deep is unreliable for complex apps. Sign each component individually. + +# --- Verification --- + +# Verify signature is valid +codesign --verify --verbose=4 MyApp.app + +# Verify deep (check nested code) +codesign --verify --deep --strict MyApp.app + +# Display signing information +codesign -dv MyApp.app +# Shows: Identifier, Format, TeamIdentifier, Signing Authority chain + +# Display verbose signing info +codesign -dvvv MyApp.app + +# Extract entitlements from signed binary +codesign -d --entitlements - MyApp.app +codesign -d --entitlements - --xml MyApp.app # XML format +``` + +### `openssl` Commands + +> **Note**: macOS ships with LibreSSL, not OpenSSL. Some `openssl pkcs12` commands may fail with "MAC verification failed" on stock macOS. Install OpenSSL via `brew install openssl` if needed, then use the full path (`/opt/homebrew/opt/openssl/bin/openssl`). + +```bash +# --- Certificate Inspection --- + +# View .cer details +openssl x509 -in certificate.cer -inform DER -text -noout + +# View .pem details +openssl x509 -in certificate.pem -text -noout + +# Check certificate expiration +openssl x509 -in certificate.cer -inform DER -noout -enddate + +# Extract public key +openssl x509 -in certificate.cer -inform DER -pubkey -noout + +# --- PKCS12 (.p12) --- + +# Extract certificate from .p12 +openssl pkcs12 -in certificate.p12 -nokeys -clcerts -out cert.pem + +# Extract private key from .p12 +openssl pkcs12 -in certificate.p12 -nocerts -nodes -out key.pem + +# Create .p12 from cert + key +openssl pkcs12 -export -in cert.pem -inkey key.pem -out certificate.p12 + +# Verify .p12 contents +openssl pkcs12 -info -in certificate.p12 -nokeys +``` + +--- + +## Xcode Build Settings Reference + +| Setting | Key | Values | +|---------|-----|--------| +| Code Signing Style | `CODE_SIGN_STYLE` | `Automatic` / `Manual` | +| Signing Identity | `CODE_SIGN_IDENTITY` | `Apple Development` / `Apple Distribution` / `iPhone Distribution` | +| Development Team | `DEVELOPMENT_TEAM` | Team ID (10-char alphanumeric) | +| Provisioning Profile | `PROVISIONING_PROFILE_SPECIFIER` | Profile name or UUID | +| Provisioning Profile (legacy) | `PROVISIONING_PROFILE` | Profile UUID (deprecated, use SPECIFIER) | +| Other Code Signing Flags | `OTHER_CODE_SIGN_FLAGS` | `--timestamp` / `--options runtime` | +| Code Sign Entitlements | `CODE_SIGN_ENTITLEMENTS` | Path to .entitlements file | +| Enable Hardened Runtime | `ENABLE_HARDENED_RUNTIME` | `YES` / `NO` (macOS) | + +### xcodebuild Signing Overrides + +```bash +# Automatic signing +xcodebuild -scheme MyApp -configuration Release \ + CODE_SIGN_STYLE=Automatic \ + DEVELOPMENT_TEAM=YOURTEAMID + +# Manual signing +xcodebuild -scheme MyApp -configuration Release \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="Apple Distribution: Company Name (TEAMID)" \ + PROVISIONING_PROFILE_SPECIFIER="MyApp App Store Profile" + +# Archive for distribution +xcodebuild archive -scheme MyApp \ + -archivePath build/MyApp.xcarchive \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="Apple Distribution" \ + PROVISIONING_PROFILE_SPECIFIER="MyApp App Store" + +# Export .ipa from archive +xcodebuild -exportArchive \ + -archivePath build/MyApp.xcarchive \ + -exportOptionsPlist ExportOptions.plist \ + -exportPath build/ipa +``` + +### ExportOptions.plist + +```xml + + + + + method + app-store + teamID + YOURTEAMID + signingStyle + manual + signingCertificate + Apple Distribution + provisioningProfiles + + com.example.myapp + MyApp App Store Profile + + uploadSymbols + + + +``` + +Export method values: `app-store`, `ad-hoc`, `enterprise`, `development`, `developer-id`. + +--- + +## fastlane match Reference + +### Setup + +```bash +# Initialize match (interactive — choose storage type) +fastlane match init +# Options: git, google_cloud, s3, azure_blob + +# Generate certificates + profiles for all types +fastlane match development +fastlane match appstore +fastlane match adhoc +``` + +### Matchfile + +```ruby +# fastlane/Matchfile +git_url("https://github.com/your-org/certificates.git") +storage_mode("git") + +type("appstore") # Default type + +app_identifier(["com.example.app", "com.example.app.widget"]) +username("dev@example.com") +team_id("YOURTEAMID") + +# For multiple targets with different profiles +# for_lane(:beta) do +# type("adhoc") +# end +``` + +### Usage + +```bash +# Generate or fetch development certs + profiles +fastlane match development + +# Generate or fetch App Store certs + profiles +fastlane match appstore + +# CI: read-only mode (never create, only fetch) +fastlane match appstore --readonly + +# Force regenerate (revokes existing) +fastlane match nuke distribution # Revoke all distribution certs +fastlane match appstore # Generate fresh +# Also: nuke development, nuke enterprise +``` + +### Environment Variables for CI + +```bash +MATCH_GIT_URL="https://github.com/your-org/certificates.git" +MATCH_PASSWORD="encryption_password" # Encrypts the repo +MATCH_KEYCHAIN_NAME="fastlane_tmp" +MATCH_KEYCHAIN_PASSWORD="keychain_password" +MATCH_READONLY="true" # CI should never create certs +FASTLANE_USER="dev@example.com" +FASTLANE_TEAM_ID="YOURTEAMID" +``` + +### CI Fastfile Example + +```ruby +# fastlane/Fastfile +lane :release do + setup_ci # Creates temporary keychain + + match( + type: "appstore", + readonly: true, # Critical: never create certs in CI + keychain_name: "fastlane_tmp", + keychain_password: "" + ) + + build_app( + scheme: "MyApp", + export_method: "app-store" + ) + + upload_to_app_store(skip_metadata: true, skip_screenshots: true) +end +``` + +--- + +## Keychain Management for CI + +### Complete CI Keychain Setup Script + +```bash +#!/bin/bash +set -euo pipefail + +KEYCHAIN_NAME="ci-build.keychain-db" +KEYCHAIN_PASSWORD="ci-temporary-password" + +# 1. Create temporary keychain +security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" + +# 2. Add to search list (MUST include login.keychain-db or it disappears) +security list-keychains -d user -s "$KEYCHAIN_NAME" login.keychain-db + +# 3. Unlock keychain +security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" + +# 4. Prevent keychain from locking during build +security set-keychain-settings -t 3600 -l "$KEYCHAIN_NAME" + +# 5. Import signing certificate +security import "$P12_PATH" -k "$KEYCHAIN_NAME" -P "$P12_PASSWORD" \ + -T /usr/bin/codesign -T /usr/bin/security + +# 6. Allow codesign access without UI prompt (CRITICAL) +# Without this, CI gets errSecInternalComponent +security set-key-partition-list -S apple-tool:,apple: -s \ + -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" + +echo "Keychain ready for code signing" +``` + +### CI Keychain Cleanup Script + +```bash +#!/bin/bash +# Run in CI post-build (always, even on failure) + +KEYCHAIN_NAME="ci-build.keychain-db" + +# Delete temporary keychain +security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true + +# Restore default keychain search list +security list-keychains -d user -s login.keychain-db +``` + +### GitHub Actions Example + +```yaml +- name: Install signing certificate + env: + P12_BASE64: ${{ secrets.P12_BASE64 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + PROVISION_PROFILE_BASE64: ${{ secrets.PROVISION_PROFILE_BASE64 }} + run: | + # Decode certificate + echo "$P12_BASE64" | base64 --decode > certificate.p12 + + # Decode provisioning profile + echo "$PROVISION_PROFILE_BASE64" | base64 --decode > profile.mobileprovision + + # Create and configure keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security list-keychains -d user -s build.keychain login.keychain-db + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security set-keychain-settings -t 3600 -l build.keychain + security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" \ + -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple: -s \ + -k "$KEYCHAIN_PASSWORD" build.keychain + + # Install provisioning profile + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/ + +- name: Cleanup keychain + if: always() + run: security delete-keychain build.keychain 2>/dev/null || true +``` + +### Xcode Cloud + +Xcode Cloud manages signing automatically: +- Certificates are managed by Apple — no manual cert management needed +- Provisioning profiles are fetched from Developer Portal +- Configure signing in Xcode → cloud workflow settings +- Use `ci_post_clone.sh` for custom keychain operations if needed + +--- + +## APNs Authentication: .p8 vs .p12 + +| Aspect | .p8 (Token-Based) | .p12 (Certificate-Based) | +|--------|-------|-------------| +| Validity | Never expires (revoke to invalidate) | 1 year (must renew annually) | +| Scope | All apps in team | Single App ID | +| Max per team | 2 keys | 1 cert per App ID | +| Setup complexity | Lower (one key for all apps) | Higher (per-app certificate) | +| Server implementation | JWT token generation required | TLS client certificate | +| Recommended | Yes (Apple's current recommendation) | Legacy (still supported) | + +### .p8 Key Usage + +```bash +# Generate JWT for APNs (simplified — use a library in production) +# Header: {"alg": "ES256", "kid": "KEY_ID"} +# Payload: {"iss": "TEAM_ID", "iat": TIMESTAMP} +# Sign with .p8 private key + +# JWT is valid for 1 hour — cache and refresh before expiry +``` + +### .p12 Certificate Usage + +```bash +# Send push with certificate authentication +curl -v \ + --cert-type P12 --cert apns-cert.p12:password \ + --header "apns-topic: com.example.app" \ + --header "apns-push-type: alert" \ + --data '{"aps":{"alert":"Hello"}}' \ + --http2 https://api.sandbox.push.apple.com/3/device/$TOKEN +# Production: https://api.push.apple.com/3/device/$TOKEN +``` + +--- + +## Error Codes Reference + +### security Command Errors + +| Error | Code | Cause | +|-------|------|-------| +| errSecInternalComponent | -2070 | Keychain locked or `set-key-partition-list` not called | +| errSecItemNotFound | -25300 | Certificate/key not in searched keychains | +| errSecDuplicateItem | -25299 | Certificate already exists in keychain | +| errSecAuthFailed | -25293 | Wrong keychain password | +| errSecInteractionNotAllowed | -25308 | Keychain locked, no UI available (CI without unlock) | +| errSecMissingEntitlement | -34018 | App missing required entitlement for keychain access | + +### codesign Errors + +| Error | Cause | Fix | +|-------|-------|-----| +| `No signing certificate found` | No valid identity in keychain | Import cert or check expiration | +| `ambiguous (matches ...)` | Multiple matching identities | Specify full identity name or SHA-1 hash | +| `not valid for use in ...` | Cert type mismatch (dev vs dist) | Use correct certificate type | +| `a sealed resource is missing or invalid` | Modified resources after signing | Re-sign after all modifications | +| `invalid signature (code or signature have been modified)` | Binary tampered post-signing | Re-sign or rebuild | + +### ITMS (App Store Upload) Errors + +| Code | Error | Cause | Fix | +|------|-------|-------|-----| +| ITMS-90035 | Invalid Signature | Wrong certificate type or expired cert | Sign with valid Apple Distribution cert | +| ITMS-90161 | Invalid Provisioning Profile | Profile doesn't match app | Regenerate profile in Developer Portal | +| ITMS-90046 | Invalid Code Signing Entitlements | Entitlements not in profile | Add capability in portal, regenerate profile | +| ITMS-90056 | Missing Push Notification Entitlement | aps-environment not in profile | Enable Push Notifications capability | +| ITMS-90174 | Missing Provisioning Profile | No profile embedded | Archive with correct signing settings | +| ITMS-90283 | Invalid Provisioning Profile | Profile expired | Download fresh profile | +| ITMS-90426 | Invalid Swift Support | Swift libraries not signed correctly | Use Xcode's organizer to export (not manual) | +| ITMS-90474 | Missing Bundle Identifier | Bundle ID doesn't match profile | Align bundle ID across Xcode, portal, and profile | +| ITMS-90478 | Invalid Team ID | Team ID mismatch | Verify DEVELOPMENT_TEAM build setting | +| ITMS-90717 | Invalid App Store Distribution Certificate | Using Development cert for App Store | Switch to Apple Distribution certificate | + +## Resources + +**WWDC**: 2021-10204, 2022-110353 + +**Docs**: /security, /bundleresources/entitlements, /xcode/distributing-your-app + +**Skills**: axiom-code-signing, axiom-code-signing-diag diff --git a/.claude/skills/axiom-code-signing-ref/agents/openai.yaml b/.claude/skills/axiom-code-signing-ref/agents/openai.yaml new file mode 100644 index 0000000..9147a73 --- /dev/null +++ b/.claude/skills/axiom-code-signing-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Code Signing Reference" + short_description: "Needing certificate CLI commands, provisioning profile inspection, entitlement extraction, Keychain management script..." diff --git a/.claude/skills/axiom-code-signing/.openskills.json b/.claude/skills/axiom-code-signing/.openskills.json new file mode 100644 index 0000000..32c5d4b --- /dev/null +++ b/.claude/skills/axiom-code-signing/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-code-signing", + "installedAt": "2026-04-12T08:06:02.543Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-code-signing/SKILL.md b/.claude/skills/axiom-code-signing/SKILL.md new file mode 100644 index 0000000..c75dfe5 --- /dev/null +++ b/.claude/skills/axiom-code-signing/SKILL.md @@ -0,0 +1,455 @@ +--- +name: axiom-code-signing +description: Use when setting up code signing, managing certificates, configuring provisioning profiles, debugging signing errors, setting up CI/CD signing, or preparing distribution builds. Covers certificate lifecycle, automatic vs manual signing, entitlements, fastlane match, Keychain management, and App Store distribution signing. +license: MIT +--- + +# Code Signing + +Certificate management, provisioning profiles, entitlements configuration, CI/CD signing setup, and distribution build preparation for iOS/macOS apps. + +## When to Use This Skill + +Use when you need to: +- ☑ Set up code signing for a new project or CI/CD pipeline +- ☑ Debug any code signing error (certificate, profile, entitlement, Keychain) +- ☑ Configure signing for App Store, TestFlight, or Ad Hoc distribution +- ☑ Manage certificates and profiles across a team +- ☑ Set up fastlane match for team-wide certificate management +- ☑ Understand automatic vs manual signing tradeoffs +- ☑ Add a new capability that requires entitlement changes +- ☑ Fix CI/CD signing failures (errSecInternalComponent, locked keychain) + +## Example Prompts + +"How do I set up code signing for my app?" +"My build fails with 'No signing certificate found'" +"How do I set up fastlane match for my team?" +"errSecInternalComponent in GitHub Actions" +"ITMS-90035 when uploading to App Store" +"How do I add push notification entitlements?" +"Multiple certificates match — ambiguous identity" +"Code signing works locally but fails in CI" +"How do I sign for App Store distribution?" +"My provisioning profile expired, what do I do?" + +## Red Flags + +Signs you're making this harder than it needs to be: + +- ❌ Sharing certificates via Slack/email — .p12 files in chat create security risks and version confusion. Use fastlane match or Xcode's automatic signing. +- ❌ Disabling code signing to "fix" a build — Code signing can't be disabled for device or distribution builds. The error will return. +- ❌ Committing .p12 or .mobileprovision to git — These are secrets. Use CI secrets management (GitHub Secrets, environment variables). +- ❌ Regenerating all certificates "to be safe" — Revokes existing certs, breaking every team member and CI pipeline. +- ❌ Using a personal certificate for team/CI builds — When that person leaves or their cert expires, everything breaks. +- ❌ Ignoring "profile doesn't include signing certificate" warnings — This always becomes a build failure. Fix it now. +- ❌ Setting `CODE_SIGN_IDENTITY = ""` to suppress errors — Defers the problem to archive/export where it's harder to debug. +- ❌ Manually managing profiles for automatic-signing projects — Pick one approach. Mixing causes conflicts. + +## Mandatory First Steps + +Before configuring or debugging any code signing issue: + +### 1. List Available Signing Identities + +```bash +security find-identity -v -p codesigning +``` + +This tells you what certificates are installed, valid, and available for signing. + +### 2. Decode the Provisioning Profile + +```bash +# Find profile embedded in most recent build +find ~/Library/Developer/Xcode/DerivedData -name "embedded.mobileprovision" -newer . 2>/dev/null | head -3 + +# Decode it +security cms -D -i path/to/embedded.mobileprovision +``` + +Check: expiration, embedded certificates, entitlements, device list. + +### 3. Extract Entitlements from the Binary + +```bash +codesign -d --entitlements - /path/to/MyApp.app +``` + +Compare these against the profile's entitlements and your .entitlements file. All three must agree. + +### 4. Verify Certificate in Profile + +```bash +# Get SHA-1 from keychain +security find-identity -v -p codesigning | grep "Apple Distribution" +# Output: 1) ABCDEF123... "Apple Distribution: Company (TEAMID)" + +# Get SHA-1 from profile +security cms -D -i embedded.mobileprovision | plutil -extract DeveloperCertificates xml1 -o - - +echo "" | base64 -d | openssl x509 -inform DER -noout -fingerprint -sha1 +``` + +The SHA-1 hashes must match. If they don't, the profile was generated with a different certificate than the one in your keychain. + +## Automatic vs Manual Signing + +```dot +digraph signing_decision { + "New project or\nsmall team?" [shape=diamond]; + "CI/CD pipeline?" [shape=diamond]; + "Multiple targets\nwith different profiles?" [shape=diamond]; + "Team > 3 developers?" [shape=diamond]; + + "Automatic Signing" [shape=box, label="Use Automatic Signing\nXcode manages everything"]; + "Manual + match" [shape=box, label="Use Manual Signing\n+ fastlane match"]; + "Manual Signing" [shape=box, label="Use Manual Signing\nspecify profiles explicitly"]; + "Automatic + overrides" [shape=box, label="Use Automatic Signing\nwith xcodebuild overrides\nfor CI"]; + + "New project or\nsmall team?" -> "CI/CD pipeline?" [label="no, larger team"]; + "New project or\nsmall team?" -> "Automatic Signing" [label="yes, solo/2-3 devs"]; + "CI/CD pipeline?" -> "Team > 3 developers?" [label="yes, have CI"]; + "CI/CD pipeline?" -> "Multiple targets\nwith different profiles?" [label="no CI"]; + "Team > 3 developers?" -> "Manual + match" [label="yes"]; + "Team > 3 developers?" -> "Automatic + overrides" [label="no, small team + CI"]; + "Multiple targets\nwith different profiles?" -> "Manual Signing" [label="yes"]; + "Multiple targets\nwith different profiles?" -> "Automatic Signing" [label="no"]; +} +``` + +### Automatic Signing + +**Best for**: Solo developers, small teams, projects without CI. + +Xcode manages certificates and provisioning profiles automatically. You just select a team. + +``` +Xcode → Target → Signing & Capabilities → ✓ Automatically manage signing → Select Team +``` + +**How it works**: +- Xcode creates/downloads certificates as needed +- Generates provisioning profiles that match your capabilities +- Regenerates profiles when you add/remove capabilities +- Registers devices when you connect them + +**Limitations**: +- Only one developer's cert per machine (can conflict in teams) +- CI requires `xcodebuild` overrides or Xcode Cloud +- Can't share profiles across team members easily +- May select unexpected profile if multiple are available + +### Manual Signing + +**Best for**: Teams, CI/CD pipelines, apps with multiple targets/extensions. + +You explicitly specify which certificate and profile to use. + +``` +Xcode → Target → Signing & Capabilities → ✗ Automatically manage signing +→ Select Provisioning Profile for each configuration (Debug/Release) +``` + +**How it works**: +- You create certificates and profiles in Apple Developer Portal +- Download and install profiles manually (or use match) +- Specify exact profile in Xcode or xcodebuild +- Full control over which cert/profile combination is used + +**Required build settings**: +``` +CODE_SIGN_STYLE = Manual +CODE_SIGN_IDENTITY = Apple Distribution: Company Name (TEAMID) +PROVISIONING_PROFILE_SPECIFIER = MyApp App Store Profile +DEVELOPMENT_TEAM = YOURTEAMID +``` + +## Certificate Types and Lifecycle + +### Certificate Type Selection + +| Scenario | Certificate Type | Notes | +|----------|-----------------|-------| +| Debug build on device | Apple Development | Auto-created by Xcode | +| TestFlight / App Store | Apple Distribution | 3 max per account | +| Ad Hoc distribution | Apple Distribution | Same cert, different profile type | +| macOS outside App Store | Developer ID Application | 5-year validity | + +### Certificate Renewal Workflow + +Certificates expire after 1 year (5 years for Developer ID). When a cert expires: + +1. **Don't revoke the expired cert** — it's already expired; revoking removes it from the portal and invalidates any profiles still referencing it +2. Create a new certificate in Apple Developer Portal +3. Download and install the new certificate +4. Edit ALL provisioning profiles that used the old cert → select the new cert → regenerate +5. Download and install updated profiles +6. Update CI with the new .p12 and profiles +7. If using fastlane match: `fastlane match nuke [type]` then `fastlane match [type]` + +**Team coordination**: Notify the team before regenerating. Distribution certs are shared — regenerating one affects everyone. + +## Provisioning Profile Patterns + +### Development Profile + +For debug builds on registered devices: + +- Contains: Development certificate + registered device UDIDs + App ID + entitlements +- Created: Automatically by Xcode (automatic signing) or manually in portal +- Devices: Must be explicitly registered in portal (100 device limit per type per year) + +### Ad Hoc Profile + +For testing on specific devices without TestFlight: + +- Contains: Distribution certificate + registered device UDIDs + App ID + entitlements +- Use case: QA testing, client demos, beta testing without TestFlight +- Limitation: Same 100-device annual limit as Development + +### App Store Profile + +For TestFlight and App Store submission: + +- Contains: Distribution certificate + App ID + entitlements (NO device list) +- No device limit — TestFlight supports up to 10,000 testers +- Required for Xcode Organizer upload or `xcodebuild -exportArchive` + +### Enterprise Profile + +For in-house distribution (Apple Developer Enterprise Program only): + +- Contains: Enterprise certificate + App ID + entitlements (NO device list) +- Distribute internally without device registration +- Cannot submit to App Store +- Apple audits for compliance — misuse (distributing to public) results in program termination + +## Entitlements Configuration + +### Adding a New Capability + +1. **Apple Developer Portal**: App IDs → Select your App ID → Capabilities → Enable the capability +2. **Xcode**: Target → Signing & Capabilities → + Capability → Select capability +3. **Regenerate profile**: If using manual signing, edit the provisioning profile to include the new capability, then generate and download + +If using automatic signing, Xcode handles steps 1 and 3 automatically. + +### Common Capability → Entitlement Mapping + +| Capability | Entitlement Key | Profile Requirement | +|-----------|-----------------|---------------------| +| Push Notifications | `aps-environment` | Must be in profile | +| App Groups | `com.apple.security.application-groups` | Must be in profile | +| Associated Domains | `com.apple.developer.associated-domains` | Must be in profile | +| Sign in with Apple | `com.apple.developer.applesignin` | Must be in profile | +| HealthKit | `com.apple.developer.healthkit` | Must be in profile | +| iCloud | `com.apple.developer.icloud-*` | Must be in profile | +| In-App Purchase | Automatic | No profile change needed | +| Background Modes | `UIBackgroundModes` (Info.plist) | No profile change needed | +| Keychain Sharing | `keychain-access-groups` | Must be in profile | + +### Multi-Target Entitlements + +Each target (app, widget, extension, etc.) needs its own: +- App ID in the Developer Portal +- Provisioning profile +- .entitlements file + +All targets must share the same Team ID. App Groups enable data sharing between targets. + +``` +com.example.app → MyApp.entitlements +com.example.app.widget → Widget/Widget.entitlements +com.example.app.NotificationService → NotificationService/NotificationService.entitlements +``` + +## CI/CD Signing Setup + +### Without fastlane (raw scripts) + +See `axiom-code-signing-ref` for complete CI keychain scripts. The critical steps: + +1. **Create** temporary keychain +2. **Add to search list** (include login.keychain-db) — do NOT use `default-keychain -s` as it breaks access to login keychain credentials +3. **Unlock** keychain +4. **Import** .p12 certificate +5. **Set partition list** (prevents errSecInternalComponent) +6. **Install** provisioning profile to `~/Library/MobileDevice/Provisioning Profiles/` +7. **Build/archive** with explicit signing settings +8. **Cleanup** — delete temporary keychain (always, even on failure) + +### With fastlane match + +fastlane match manages certificates and profiles in a shared git repo (or cloud storage), encrypted with a passphrase. + +**Initial setup** (run once by a team admin): +```bash +fastlane match init # Choose storage (git, google_cloud, s3) +fastlane match development # Generate dev certs + profiles +fastlane match appstore # Generate distribution certs + profiles +``` + +**CI usage** (readonly — never generate in CI): +```ruby +# Fastfile +lane :release do + setup_ci # Creates temporary keychain + match(type: "appstore", readonly: true) + build_app(scheme: "MyApp", export_method: "app-store") +end +``` + +**Key rules**: +- CI must use `readonly: true` — never generate certificates from CI +- Set `MATCH_PASSWORD` as a CI secret (encrypts/decrypts the cert repo) +- Use `setup_ci` to create a temporary keychain (handles all keychain setup) +- Each app target needs its own `app_identifier` in Matchfile + +### Xcode Cloud + +Xcode Cloud handles signing automatically: +- No certificate management needed — Apple manages signing infrastructure +- Configure in Xcode → Product → Xcode Cloud → Manage Workflows +- Use `ci_post_clone.sh` for custom setup (SPM auth, certificates from custom sources) +- Distribution signing is handled during the "Archive" action + +## Anti-Patterns + +### Anti-Pattern 1: Sharing .p12 via Chat + +**Wrong**: +``` +Slack: "Hey, here's the distribution cert 📎 cert.p12, password is Company123" +``` + +**Right**: Use fastlane match (encrypted git repo) or Xcode's automatic signing with a shared team account. + +**Why it matters**: .p12 files shared in chat create security risks (credentials in chat history), version confusion (which cert is current?), and single-point-of-failure (if the sender's cert expires or is revoked). Time cost: 1-2 hours per team member when the shared cert breaks. + +### Anti-Pattern 2: Committing Secrets to Git + +**Wrong**: +``` +git add certificates/distribution.p12 +git add profiles/AppStore.mobileprovision +git commit -m "Add signing files" +``` + +**Right**: Use CI secrets management: +```bash +# GitHub Actions +echo "$P12_BASE64" | base64 --decode > certificate.p12 # From secrets +echo "$PROFILE_BASE64" | base64 --decode > profile.mobileprovision +``` + +**Why it matters**: Certificates and profiles are secrets — they allow anyone to sign apps as your team. Even in private repos, git history is permanent. Once committed, the credential is in every clone forever. + +**If already committed**: Scrub history with `git filter-repo --path path/to/cert.p12 --invert-paths`, then rotate the compromised certificate (revoke in portal, create new, update CI secrets). Every existing clone still has the old cert — treat it as compromised. + +### Anti-Pattern 3: "Fix" Signing by Disabling It + +**Wrong**: +``` +CODE_SIGN_IDENTITY = "" +CODE_SIGNING_REQUIRED = NO +CODE_SIGNING_ALLOWED = NO +``` + +**Right**: Diagnose the actual signing error with the mandatory diagnostic steps. + +**Why it matters**: Disabling code signing "works" for Simulator builds but fails for device builds, archives, and distribution. The problem is deferred to a point where it's harder to debug and closer to a deadline. Time cost: the original issue plus 30+ minutes of export/upload debugging. + +### Anti-Pattern 4: Using Personal Cert for Team/CI + +**Wrong**: +``` +# CI pipeline uses one developer's personal certificate +security import ~/charles-personal.p12 ... +CODE_SIGN_IDENTITY = "Apple Distribution: Charles Personal (ABC123)" +``` + +**Right**: Use a dedicated team certificate managed via fastlane match or a shared Apple Developer account: +```ruby +match(type: "appstore", readonly: true) +``` + +**Why it matters**: When that developer leaves, changes machines, or their cert expires, CI and all team distribution breaks. One person's personal cert should never be a shared infrastructure dependency. Time cost: 2-4 hours of emergency cert rotation when the person is unavailable. + +## Pressure Scenarios + +### Scenario 1: "Just disable code signing so we can ship today" + +**Context**: Build deadline approaching, code signing error blocking the archive. + +**Pressure**: "We don't need signing for testing, just disable it and we'll fix it after release." + +**Reality**: You can't submit to TestFlight or the App Store without valid code signing. Disabling it now defers a blocking issue to the most time-pressured moment — the actual submission. The diagnostic steps take 5-10 minutes and will identify the root cause. + +**Correct action**: Run Steps 1-4 from Mandatory First Steps. Most signing issues are a single expired or mismatched component. + +**Push-back template**: "We can't submit to TestFlight or the App Store without valid signing. The diagnostic takes 5 minutes and will tell us exactly what's wrong. Disabling it now means we'll hit the same blocker during submission with even less time." + +### Scenario 2: "Use my personal certificate for the team build" + +**Context**: CI is broken because the distribution certificate expired. A team member offers their personal cert as a quick fix. + +**Pressure**: "I have a working cert, just use mine for now." + +**Reality**: Using a personal certificate creates a single point of failure. When that person is unavailable, on vacation, or leaves the company, signing breaks with no path to recovery without their cooperation. The proper fix (renewing the team cert) takes the same amount of time. + +**Correct action**: Renew the team's distribution certificate in Apple Developer Portal. Update CI secrets. Regenerate provisioning profiles with the new cert. + +**Push-back template**: "Your cert would work short-term, but it makes you a single point of failure for all builds. Renewing the team cert takes the same 10 minutes and doesn't create a dependency on one person." + +### Scenario 3: "Commit the .p12 to the repo so CI has it" + +**Context**: Setting up CI/CD for the first time. Developer wants to commit the certificate to git for convenience. + +**Pressure**: "It's a private repo, nobody else can see it." + +**Reality**: Git history is permanent. Even in private repos, the .p12 (which contains the private key) lives in every clone, every fork, and every backup forever. If the repo is ever made public, open-sourced, or accessed by a contractor, the signing key is exposed. CI secrets management exists specifically for this. + +**Correct action**: Base64-encode the .p12 and store as a CI secret (GitHub Secrets, GitLab CI Variables, etc.). Decode at build time. + +**Push-back template**: "Git history is permanent — once committed, the certificate is in every clone forever. CI secrets management (GitHub Secrets) takes the same effort to set up and is designed for exactly this. Let me set it up properly." + +## Checklist + +Before archiving for distribution: + +**Certificates**: +- [ ] Distribution certificate valid (not expired, not revoked) +- [ ] `security find-identity -v -p codesigning` shows the expected identity +- [ ] Certificate matches what provisioning profile expects + +**Provisioning Profiles**: +- [ ] Profile not expired +- [ ] Profile type matches distribution method (App Store, Ad Hoc, Enterprise) +- [ ] Profile contains the certificate being used for signing +- [ ] Profile bundle ID matches target's bundle identifier + +**Entitlements**: +- [ ] All capabilities in Xcode match capabilities in profile +- [ ] .entitlements file doesn't contain capabilities not in profile +- [ ] App extensions have their own profiles with correct entitlements +- [ ] App Groups consistent across main app and extensions + +**Build Settings**: +- [ ] `CODE_SIGN_STYLE` matches intent (Automatic or Manual) +- [ ] `CODE_SIGN_IDENTITY` correct for build type (Development vs Distribution) +- [ ] `PROVISIONING_PROFILE_SPECIFIER` set for manual signing +- [ ] `DEVELOPMENT_TEAM` set to correct Team ID + +**CI/CD** (if applicable): +- [ ] Keychain created, unlocked, and partition list set +- [ ] Certificate imported into CI keychain +- [ ] Profile installed to `~/Library/MobileDevice/Provisioning Profiles/` +- [ ] Cleanup step runs on success and failure (`if: always()`) + +## Resources + +**WWDC**: 2021-10204, 2022-110353 + +**Docs**: /security, /bundleresources/entitlements, /xcode/distributing-your-app + +**Skills**: axiom-code-signing-ref, axiom-code-signing-diag diff --git a/.claude/skills/axiom-code-signing/agents/openai.yaml b/.claude/skills/axiom-code-signing/agents/openai.yaml new file mode 100644 index 0000000..b91168c --- /dev/null +++ b/.claude/skills/axiom-code-signing/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Code Signing" + short_description: "Setting up code signing, managing certificates, configuring provisioning profiles, debugging signing errors, setting ..." diff --git a/.claude/skills/axiom-combine-patterns/.openskills.json b/.claude/skills/axiom-combine-patterns/.openskills.json new file mode 100644 index 0000000..2d0c38e --- /dev/null +++ b/.claude/skills/axiom-combine-patterns/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-combine-patterns", + "installedAt": "2026-04-12T08:06:04.520Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-combine-patterns/SKILL.md b/.claude/skills/axiom-combine-patterns/SKILL.md new file mode 100644 index 0000000..cd9387a --- /dev/null +++ b/.claude/skills/axiom-combine-patterns/SKILL.md @@ -0,0 +1,642 @@ +--- +name: axiom-combine-patterns +description: Use when working with Combine publishers, AnyCancellable lifecycle, @Published properties, or bridging Combine with async/await. Covers reactive patterns, operator selection, memory management, and migration strategy. +license: MIT +metadata: + version: "1.0.0" + last-updated: "2026-03-12" +--- + +# Combine Patterns + +## Overview + +Combine remains embedded in massive production codebases — UIKit delegates, NotificationCenter bridging, KVO observation, and @Published properties are everywhere. New code prefers async/await, but interop and maintenance of existing Combine pipelines is daily work. This skill covers the decisions and pitfalls that matter: when to use Combine vs async/await, how to avoid memory leaks, and how to bridge between the two paradigms. + +**Core principle**: Combine is not dead — it's mature. The question isn't "should I use Combine?" but "is Combine the right tool for THIS specific data flow?" + +## When to Use This Skill + +- Working with existing Combine pipelines +- Deciding between Combine and async/await for a new data flow +- Debugging AnyCancellable memory leaks or silent pipeline failures +- Using @Published or ObservableObject +- Bridging Combine publishers with async/await code +- Working with Subjects (PassthroughSubject, CurrentValueSubject) + +## When NOT to Use This Skill + +- Timer.publish patterns → route via `axiom-ios-concurrency` to timer-patterns skill (dedicated timer lifecycle coverage) +- @Observable migration from ObservableObject → use `axiom-swift-concurrency` (modern observation) +- UIKit ↔ SwiftUI bridging → route via `axiom-ios-ui` (view wrapping, not data flow) +- General async/await patterns → use `axiom-swift-concurrency` + +## Example Prompts + +- "Should I use Combine or async/await for this?" +- "My Combine pipeline silently stops producing values" +- "How do I convert a publisher to an async sequence?" +- "AnyCancellable is leaking — where do I store it?" +- "What's the difference between combineLatest and zip?" +- "How do I debounce a text field with Combine?" +- "My @Published property update isn't reaching the view" +- "How do I bridge a Combine publisher into async/await code?" + +--- + +## Part 1: Combine vs async/await Decision Tree + +| Use Case | Combine | async/await | Why | +|----------|---------|-------------|-----| +| One-shot network call | No | Yes | async/await is simpler, no cancellable management | +| Stream of values over time | Yes | AsyncStream | Combine's operators (debounce, combineLatest) are richer | +| Debounce/throttle user input | Yes | Awkward | Combine has built-in debounce/throttle; AsyncStream requires manual implementation | +| Merge multiple sources | Yes | TaskGroup | Combine's merge/combineLatest handle heterogeneous streams naturally | +| Existing UIKit KVO/Notification | Yes | Bridge | publisher(for:) and NotificationCenter.default.publisher are idiomatic Combine | +| New project iOS 17+ | No | Yes | @Observable + async/await is the modern pattern | +| Existing codebase with Combine | Maintain | Migrate incrementally | Don't rewrite working pipelines — bridge at boundaries | + +### Quick Decision + +``` +Is it a one-shot operation (network call, file read)? +├─ Yes → async/await (simpler, no cancellable management) +│ +Does it need time-based operators (debounce, throttle, delay)? +├─ Yes → Combine (built-in operators, no manual implementation) +│ +Are you combining multiple ongoing streams? +├─ Yes → Combine (combineLatest, merge, zip are purpose-built) +│ +Is this new code on iOS 17+? +├─ Yes → async/await + @Observable (modern pattern) +│ +Is it existing Combine code that works? +└─ Yes → Keep it. Bridge at boundaries when async/await code needs the data. +``` + +--- + +## Part 2: Publisher/Subscriber Lifecycle + +### AnyCancellable Storage Rules + +AnyCancellable cancels its subscription when deallocated. If you don't store it, the pipeline is cancelled immediately after setup. + +#### ❌ Pipeline dies instantly + +```swift +func setupPipeline() { + publisher + .sink { value in + self.handle(value) // Never called + } + // AnyCancellable returned by sink is discarded → subscription cancelled +} +``` + +#### ✅ Store in Set + +```swift +private var cancellables = Set() + +func setupPipeline() { + publisher + .sink { [weak self] value in + self?.handle(value) + } + .store(in: &cancellables) +} +``` + +### Why Set, Not Array + +`Set` is the idiomatic choice because: +- `store(in:)` works with both `Set` and `RangeReplaceableCollection` (including `Array`), but `Set` is conventional +- Order doesn't matter for subscriptions +- Prevents accidental duplicates if setup runs twice + +### 4 Memory Leak Patterns + +#### Leak 1: Strong self in sink + +```swift +// ❌ LEAK: sink closure captures self strongly +publisher + .sink { value in + self.handle(value) // Strong capture → retain cycle + } + .store(in: &cancellables) + +// ✅ FIX: weak self +publisher + .sink { [weak self] value in + self?.handle(value) + } + .store(in: &cancellables) +``` + +#### Leak 2: Missing store(in:) + +```swift +// ❌ LEAK: cancellable assigned to local var, not stored +let cancellable = publisher.sink { handle($0) } +// cancellable deallocated at end of scope → pipeline cancelled + +// ✅ FIX: store in instance property +publisher.sink { [weak self] in self?.handle($0) } + .store(in: &cancellables) +``` + +#### Leak 3: Over-retained cancellables + +```swift +// ❌ LEAK: cancellables set never cleared, old pipelines accumulate +func refreshData() { + // Each call adds another subscription without removing the previous one + dataPublisher + .sink { [weak self] in self?.update($0) } + .store(in: &cancellables) +} + +// ✅ FIX: clear before re-subscribing +func refreshData() { + cancellables.removeAll() // Cancel previous subscriptions + dataPublisher + .sink { [weak self] in self?.update($0) } + .store(in: &cancellables) +} +``` + +#### Leak 4: assign(to:on:) strong capture + +`assign(to:on:)` captures the `on:` parameter **strongly**. When the target is `self`, you get a retain cycle: `self → cancellables → subscription → self`. + +```swift +// ❌ LEAK: assign(to:on:) retains self strongly — deinit never called +userPublisher + .map { $0.name } + .assign(to: \.displayName, on: self) + .store(in: &cancellables) + +// ✅ FIX: use assign(to:) with @Published projected value (iOS 14+) +userPublisher + .map { $0.name } + .assign(to: &$displayName) +// No store(in:) needed — subscription tied to @Published property lifetime +``` + +Key difference: `assign(to: &$prop)` does NOT return an `AnyCancellable` — the subscription is managed internally and cancelled when the `@Published` property's owner deallocates. No retain cycle, no cancellable storage needed. + +If you must support iOS 13, use `sink` with `[weak self]` instead. + +--- + +## Part 3: Essential Operators + +One canonical example per group. These cover 90% of real-world usage. + +### Transform + +```swift +// map: transform each value +publisher.map { $0.name } + +// compactMap: transform + filter nil +publisher.compactMap { Int($0) } + +// flatMap: one-to-many (each value produces a new publisher) +searchText + .flatMap { query in + api.search(query) // Returns a publisher + } +``` + +**flatMap gotcha**: Without `.switchToLatest()` or `maxPublishers: .max(1)`, flatMap creates a new inner publisher for every upstream value. For search-as-you-type, use `map` + `switchToLatest` instead: + +```swift +searchText + .map { query in api.search(query) } + .switchToLatest() // Cancels previous search when new query arrives +``` + +### Combine Multiple Sources + +```swift +// combineLatest: latest value from each, fires when ANY changes +Publishers.CombineLatest(namePublisher, agePublisher) + .map { name, age in "\(name), \(age)" } + +// merge: interleave values from same-type publishers +Publishers.Merge(localUpdates, remoteUpdates) + .sink { update in handle(update) } + +// zip: pairs values 1:1 (waits for both to produce) +Publishers.Zip(requestA, requestB) + .sink { responseA, responseB in /* both complete */ } +``` + +| Operator | Fires When | Use Case | +|----------|-----------|----------| +| combineLatest | Any input changes | Form validation (all fields) | +| merge | Any input produces | Combining event streams | +| zip | All inputs produce one value | Parallel requests that must complete together | + +### Time-Based + +```swift +// debounce: wait until values stop arriving (search-as-you-type) +searchTextPublisher + .debounce(for: .milliseconds(300), scheduler: RunLoop.main) + .sink { [weak self] query in self?.search(query) } + .store(in: &cancellables) + +// throttle: emit at most once per interval (scroll position) +scrollOffsetPublisher + .throttle(for: .milliseconds(100), scheduler: RunLoop.main, latest: true) + .sink { [weak self] offset in self?.updateHeader(offset) } + .store(in: &cancellables) +``` + +| Operator | Behavior | Use Case | +|----------|----------|----------| +| debounce | Waits for silence, then emits last value | Search fields, auto-save | +| throttle(latest: true) | Emits latest value at fixed intervals | Scroll tracking, sensor data | +| throttle(latest: false) | Emits first value at fixed intervals | Rate-limiting button taps | + +### Error Handling + +```swift +// tryMap: transform that can throw +publisher.tryMap { data in + try JSONDecoder().decode(Model.self, from: data) +} + +// mapError: convert error types +publisher.mapError { error in + AppError.network(error) +} + +// replaceError: provide fallback value (terminates error path) +publisher.replaceError(with: defaultValue) + +// retry: re-subscribe on failure +publisher.retry(3) // Retry up to 3 times before propagating error +``` + +**Error handling order matters**: `retry` should come before `replaceError`. Retry re-subscribes to the upstream publisher; replaceError terminates the error and makes the pipeline infallible. + +```swift +api.fetchData() + .retry(3) // Try 3 more times on failure + .replaceError(with: cached) // If all retries fail, use cache + .sink { data in update(data) } + .store(in: &cancellables) +``` + +**replaceError after flatMap kills the outer pipeline**: If `replaceError` is downstream of `flatMap`, a single inner publisher error terminates the entire pipeline — not just that one request. Move error handling inside `flatMap` so each inner publisher handles its own errors: + +```swift +// ❌ One API error kills the entire pipeline +$searchText + .flatMap { query in api.search(query) } + .replaceError(with: []) // Pipeline completes on first error + .sink { ... } + +// ✅ Each search handles its own errors independently +$searchText + .flatMap { query in + api.search(query) + .replaceError(with: []) // Only this search affected + } + .sink { ... } +``` + +--- + +## Part 4: @Published + ObservableObject + +### willSet Timing + +`@Published` fires its publisher in `willSet`, not `didSet`. This means subscribers see the new value before the property has actually been set on the object. + +```swift +class ViewModel: ObservableObject { + @Published var count = 0 + + init() { + $count.sink { newValue in + // 'newValue' is the incoming value + // BUT self.count is still the OLD value here + print("New: \(newValue), Current: \(self.count)") + // Prints "New: 1, Current: 0" when count is set to 1 + } + .store(in: &cancellables) + } +} +``` + +If you need to read the property's value after it's been set, don't subscribe to `$count` — use a `didSet` observer instead, or read `self.count` after a brief deferral. The `$` publisher is designed for reacting to the incoming value, not for reading post-mutation state. + +### Nested ObservableObject Trap + +SwiftUI does NOT observe nested ObservableObject changes. Only the top-level object's `objectWillChange` triggers view updates. + +```swift +// ❌ View won't update when settings.theme changes +class AppState: ObservableObject { + @Published var settings = Settings() // Settings is also ObservableObject +} + +class Settings: ObservableObject { + @Published var theme = "light" // Changes here don't propagate +} + +// ✅ FIX: Forward objectWillChange manually +class AppState: ObservableObject { + @Published var settings = Settings() + private var cancellables = Set() + + init() { + settings.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + } +} +``` + +**Better fix for iOS 17+**: Migrate to `@Observable`, which handles nested observation automatically. See `axiom-swift-concurrency` for migration patterns. + +### Thread Safety Warning + +`@Published` is NOT thread-safe. Setting a `@Published` property from a background thread triggers `objectWillChange` off the main thread, which can crash SwiftUI views. + +```swift +// ❌ CRASH: @Published set from background thread +class ViewModel: ObservableObject { + @Published var data: [Item] = [] + + func fetch() { + Task { + let items = await api.fetchItems() + data = items // Background thread → crash + } + } +} + +// ✅ FIX: Ensure main thread +@MainActor +class ViewModel: ObservableObject { + @Published var data: [Item] = [] + + func fetch() { + Task { + let items = await api.fetchItems() + data = items // Safe — @MainActor ensures main thread + } + } +} +``` + +--- + +## Part 5: Bridging Combine and async/await + +### Publisher → AsyncSequence + +Use `.values` to consume any publisher as an async sequence: + +```swift +let cancellable = notificationPublisher + .sink { notification in handle(notification) } + +// ✅ Modern equivalent using .values +for await notification in notificationPublisher.values { + handle(notification) +} +``` + +**Caveats with `.values`**: +- The `for await` loop runs indefinitely until the publisher completes or the Task is cancelled +- Errors thrown by the publisher terminate the loop +- Only one consumer — if two `for await` loops consume the same `.values`, behavior is undefined + +### async/await → Publisher + +Wrap an async function in `Future` for Combine consumption: + +```swift +func fetchUser(id: String) async throws -> User { ... } + +// Wrap as a Combine publisher +let userPublisher = Future { promise in + Task { + do { + let user = try await fetchUser(id: "123") + promise(.success(user)) + } catch { + promise(.failure(error)) + } + } +} +``` + +**Future executes immediately** — it runs its closure when created, not when subscribed. Wrap in `Deferred` if you need lazy execution: + +```swift +let lazyPublisher = Deferred { + Future { promise in + Task { + do { + let user = try await fetchUser(id: "123") + promise(.success(user)) + } catch { + promise(.failure(error)) + } + } + } +} +``` + +### Gradual Migration Strategy + +Don't rewrite working Combine code. Bridge at the boundary: + +``` +Combine pipeline → .values → async/await code + (bridge) + +async function → Future → Combine pipeline + (bridge) +``` + +**Migration priority**: +1. New code: write in async/await +2. Boundary: bridge with `.values` or `Future` +3. Existing Combine: leave working pipelines alone +4. Rewrite: only when the pipeline needs significant changes anyway + +--- + +## Part 6: Subjects + +### PassthroughSubject vs CurrentValueSubject + +| Feature | PassthroughSubject | CurrentValueSubject | +|---------|-------------------|-------------------| +| Initial value | None | Required | +| Late subscribers | Miss previous values | Get current value immediately | +| `.value` property | No | Yes (read current value) | +| Use case | Events (button taps, notifications) | State (current selection, loading status) | + +```swift +// Event-driven: no initial value, late subscribers miss past events +let taps = PassthroughSubject() +taps.send() + +// State-driven: always has a current value +let isLoading = CurrentValueSubject(false) +isLoading.value = true // Direct access +isLoading.send(false) // Also works +``` + +### Send-After-Completion Pitfall + +Once a Subject receives a completion event, all subsequent `send()` calls are silently ignored. No crash, no error — just silence. + +```swift +let subject = PassthroughSubject() + +subject.send(1) // Delivered +subject.send(completion: .finished) +subject.send(2) // Silently ignored — no crash, no warning + +// This is the most common cause of "my pipeline stopped working" +``` + +**Diagnosis**: If a pipeline silently stops producing values, check whether anything upstream sent a `.finished` or `.failure` completion. Once complete, the pipeline is dead. + +--- + +## Part 7: Cold vs Hot Publishers (share/multicast) + +Most Combine publishers are **cold** — they start work when subscribed and each subscriber gets its own independent execution. `URLSession.dataTaskPublisher` fires a new HTTP request per subscriber. + +```swift +// ❌ Two subscribers = two network requests +let publisher = URLSession.shared + .dataTaskPublisher(for: url) + .map(\.data) + .eraseToAnyPublisher() + +publisher.sink { cache.store($0) }.store(in: &cancellables) // Request 1 +publisher.sink { display($0) }.store(in: &cancellables) // Request 2 +``` + +### share() + +`.share()` makes a cold publisher hot — the first subscriber triggers the work, subsequent subscribers share the output: + +```swift +// ✅ One request, shared result +let publisher = URLSession.shared + .dataTaskPublisher(for: url) + .map(\.data) + .share() + .eraseToAnyPublisher() + +publisher.sink { cache.store($0) }.store(in: &cancellables) // Triggers request +publisher.sink { display($0) }.store(in: &cancellables) // Shares result +``` + +### share() Gotchas + +| Gotcha | Effect | Fix | +|--------|--------|-----| +| Late subscribers miss values | `share()` uses PassthroughSubject — no replay | Attach all subscribers before the first value arrives, or use `multicast` with `CurrentValueSubject` | +| Upstream completed before subscriber attaches | Late subscriber immediately gets `.finished` with no values | Ensure subscription order, or cache the result outside Combine | +| All subscribers cancel → upstream cancels | New subscriber after that triggers a NEW upstream execution | Expected behavior, but surprising if you assumed the result was cached | + +### When to use share() + +``` +Multiple subscribers to the same expensive publisher? +├─ No → Don't use share() (unnecessary complexity) +│ +├─ Yes, all subscribe at the same time? +│ └─ Yes → share() works +│ +└─ Yes, subscribers attach at different times? + └─ Use multicast(subject:) with CurrentValueSubject, or cache the result in a property +``` + +--- + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "Combine is dead, just use async/await" | Combine has no deprecation notice. Thousands of production apps use it. Rewriting working pipelines wastes time and introduces bugs. Bridge incrementally instead. | +| "I'll just use .sink everywhere" | Without `[weak self]` and proper `store(in:)`, every sink is a potential memory leak. The lifecycle rules in Part 2 prevent the top 4 leak patterns. | +| "assign(to:on:) is fine, it's the standard API" | It captures `on:` strongly — retain cycle if target is `self`. Use `assign(to: &$prop)` instead (Part 2, Leak 4). | +| "debounce and throttle are the same thing" | debounce waits for silence; throttle emits at intervals. Using the wrong one causes either delayed responses or missed events. Part 3 has the decision table. | +| "I know how @Published works" | @Published fires on willSet, not didSet. Nested ObservableObject doesn't propagate. Background thread access crashes. Part 4 covers all three traps. | +| "I'll migrate everything to async/await at once" | Full rewrites of working Combine code introduce bugs and waste time. Bridge at boundaries (Part 5). Rewrite only when the pipeline needs significant changes anyway. | + +--- + +## Pressure Scenarios + +### Scenario 1: "Let's migrate all Combine code to async/await" + +**Setup**: Tech lead wants to modernize the codebase. "Combine is legacy, let's rip it out." + +**Pressure**: Authority + scope creep. The entire data layer uses Combine publishers, @Published properties, and operator chains. + +**Expected with skill**: Push back with the gradual migration strategy (Part 5). New code uses async/await. Boundaries use `.values` and `Future`. Existing working pipelines stay until they need changes. Full rewrite is the most expensive option with the least benefit. + +**Pushback template**: "Combine isn't deprecated — Apple still ships it in every SDK. A full rewrite of working pipelines introduces bugs we don't have today. Let's bridge at boundaries: new code in async/await, `.values` to consume existing publishers, and we only rewrite a pipeline when we're already changing it significantly." + +--- + +### Scenario 2: "Pipeline silently stopped — just recreate it" + +**Setup**: A Combine pipeline stopped producing values after a refactor. No crash, no error. + +**Pressure**: Time pressure. "Just tear it down and rebuild." + +**Expected with skill**: Diagnose before rebuilding. Check: (1) Was a completion sent upstream? (send-after-completion, Part 6). (2) Is the AnyCancellable still alive? (storage rules, Part 2). (3) Did the publisher error without handling? (replaceError / catch, Part 3). These three causes cover 90% of silent pipeline failures. + +**Diagnostic checklist**: +1. Is the `AnyCancellable` still stored? (Set not cleared, not deallocated) +2. Did anything upstream send `.finished` or `.failure`? +3. Is there a `tryMap` or other throwing operator without error handling? +4. Was `switchToLatest` used where the outer publisher completed? + +**Pushback template**: "Before rebuilding, let me check four things: cancellable lifecycle, upstream completions, unhandled errors, and switchToLatest completion. One of these is almost always the cause. It takes 5 minutes to diagnose vs 30 minutes to rebuild and test." + +--- + +### Scenario 3: "Settings changes aren't updating the UI" + +**Setup**: A settings screen uses a nested ObservableObject. The parent `AppState` holds a `Settings` object. When the user changes `settings.theme`, the UI doesn't update. + +**Pressure**: "The binding works in isolation, it must be a SwiftUI bug. Let me just force a refresh with objectWillChange.send()." + +**Expected with skill**: Recognize the nested ObservableObject trap (Part 4). SwiftUI does NOT observe nested ObservableObject changes — only the top-level object's `objectWillChange` triggers view updates. The fix is either forwarding `objectWillChange` from the nested object, or migrating to `@Observable` (iOS 17+) which handles nesting automatically. + +**Anti-pattern without skill**: Sprinkling `objectWillChange.send()` calls throughout the code, adding `@Published` to every nested property (which doesn't help), or restructuring the model to flatten everything into one object (losing separation of concerns). + +**Pushback template**: "SwiftUI only observes the top-level ObservableObject. Nested objects need their objectWillChange forwarded to the parent. Part 4 has the exact pattern — it's a 5-line fix in the parent's init, not a SwiftUI bug." + +--- + +## Resources + +**WWDC**: 2019-722, 2019-721, 2020-10034 + +**Docs**: /combine, /combine/anycancellable, /combine/published + +**Skills**: swift-concurrency, memory-debugging diff --git a/.claude/skills/axiom-combine-patterns/agents/openai.yaml b/.claude/skills/axiom-combine-patterns/agents/openai.yaml new file mode 100644 index 0000000..17ff2f1 --- /dev/null +++ b/.claude/skills/axiom-combine-patterns/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Combine Patterns" + short_description: "Working with Combine publishers, AnyCancellable lifecycle, @Published properties, or bridging Combine with async/await" diff --git a/.claude/skills/axiom-concurrency-profiling/.openskills.json b/.claude/skills/axiom-concurrency-profiling/.openskills.json new file mode 100644 index 0000000..1673d2e --- /dev/null +++ b/.claude/skills/axiom-concurrency-profiling/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-concurrency-profiling", + "installedAt": "2026-04-12T08:06:05.046Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-concurrency-profiling/SKILL.md b/.claude/skills/axiom-concurrency-profiling/SKILL.md new file mode 100644 index 0000000..7895cd5 --- /dev/null +++ b/.claude/skills/axiom-concurrency-profiling/SKILL.md @@ -0,0 +1,249 @@ +--- +name: axiom-concurrency-profiling +description: Use when profiling async/await performance, diagnosing actor contention, or investigating thread pool exhaustion. Covers Swift Concurrency Instruments template, task visualization, actor contention analysis, thread pool debugging. +license: MIT +metadata: + version: "1.0.0" +--- + +# Concurrency Profiling — Instruments Workflows + +Profile and optimize Swift async/await code using Instruments. + +## When to Use + +✅ **Use when:** +- UI stutters during async operations +- Suspecting actor contention +- Tasks queued but not executing +- Main thread blocked during async work +- Need to visualize task execution flow + +❌ **Don't use when:** +- Issue is pure CPU performance (use Time Profiler) +- Memory issues unrelated to concurrency (use Allocations) +- Haven't confirmed concurrency is the bottleneck + +## Swift Concurrency Template + +### What It Shows + +| Track | Information | +|-------|-------------| +| **Swift Tasks** | Task lifetimes, parent-child relationships | +| **Swift Actors** | Actor access, contention visualization | +| **Thread States** | Blocked vs running vs suspended | + +### Statistics + +- **Running Tasks**: Tasks currently executing +- **Alive Tasks**: Tasks present at a point in time +- **Total Tasks**: Cumulative count created + +### Color Coding + +- **Blue**: Task executing +- **Red**: Task waiting (contention) +- **Gray**: Task suspended (awaiting) + +## Workflow 1: Diagnose Main Thread Blocking + +**Symptom**: UI freezes, main thread timeline full + +1. Profile with Swift Concurrency template +2. Look at main thread → "Swift Tasks" lane +3. Find long blue bars (task executing on main) +4. Check if work could be offloaded + +**Solution patterns**: + +```swift +// ❌ Heavy work on MainActor +@MainActor +class ViewModel: ObservableObject { + func process() { + let result = heavyComputation() // Blocks UI + self.data = result + } +} + +// ✅ Offload heavy work +@MainActor +class ViewModel: ObservableObject { + func process() async { + let result = await Task.detached { + heavyComputation() + }.value + self.data = result + } +} +``` + +## Workflow 2: Find Actor Contention + +**Symptom**: Tasks serializing unexpectedly, parallel work running sequentially + +1. Enable "Swift Actors" instrument +2. Look for serialized access patterns +3. Red = waiting, Blue = executing +4. High red:blue ratio = contention problem + +**Solution patterns**: + +```swift +// ❌ All work serialized through actor +actor DataProcessor { + func process(_ data: Data) -> Result { + heavyProcessing(data) // All callers wait + } +} + +// ✅ Mark heavy work as nonisolated +actor DataProcessor { + nonisolated func process(_ data: Data) -> Result { + heavyProcessing(data) // Runs in parallel + } + + func storeResult(_ result: Result) { + // Only actor state access serialized + } +} +``` + +**More fixes**: +- Split actor into multiple (domain separation) +- Use Mutex for hot paths (faster than actor hop) +- Reduce actor scope (fewer isolated properties) + +## Workflow 3: Thread Pool Exhaustion + +**Symptom**: Tasks queued but not executing, gaps in task execution + +**Cause**: Blocking calls exhaust cooperative pool + +1. Look for gaps in task execution across all threads +2. Check for blocking primitives +3. Replace with async equivalents + +**Common culprits**: + +```swift +// ❌ Blocks cooperative thread +Task { + semaphore.wait() // NEVER do this + // ... + semaphore.signal() +} + +// ❌ Synchronous file I/O in async context +Task { + let data = Data(contentsOf: fileURL) // Blocks +} + +// ✅ Use async APIs +Task { + let (data, _) = try await URLSession.shared.data(from: fileURL) +} +``` + +**Debug flag**: +``` +SWIFT_CONCURRENCY_COOPERATIVE_THREAD_BOUNDS=1 +``` +Detects unsafe blocking in async context. + +## Workflow 4: Priority Inversion + +**Symptom**: High-priority task waits for low-priority + +1. Inspect task priorities in Instruments +2. Follow wait chains +3. Ensure critical paths use appropriate priority + +```swift +// ✅ Explicit priority for critical work +Task(priority: .userInitiated) { + await criticalUIUpdate() +} +``` + +## Thread Pool Model + +Swift uses a **cooperative thread pool** matching CPU core count: + +| Aspect | GCD | Swift Concurrency | +|--------|-----|-------------------| +| Threads | Grows unbounded | Fixed to core count | +| Blocking | Creates new threads | Suspends, frees thread | +| Dependencies | Hidden | Runtime-tracked | +| Context switch | Full kernel switch | Lightweight continuation | + +**Why blocking is catastrophic**: +- Each blocked thread holds memory + kernel structures +- Limited threads means blocked = no progress +- Pool exhaustion deadlocks the app + +## Quick Checks (Before Profiling) + +Run these checks first: + +1. **Is work actually async?** + - Look for suspension points (`await`) + - Sync code in async function still blocks + +2. **Holding locks across await?** + ```swift + // ❌ Deadlock risk + mutex.withLock { + await something() // Never! + } + ``` + +3. **Tasks in tight loops?** + ```swift + // ❌ Overhead may exceed benefit + for item in items { + Task { process(item) } + } + + // ✅ Structured concurrency + await withTaskGroup(of: Void.self) { group in + for item in items { + group.addTask { process(item) } + } + } + ``` + +4. **DispatchSemaphore in async context?** + - Always unsafe — use `withCheckedContinuation` instead + +## Common Issues Summary + +| Issue | Symptom in Instruments | Fix | +|-------|------------------------|-----| +| MainActor overload | Long blue bars on main | `Task.detached`, `nonisolated` | +| Actor contention | High red:blue ratio | Split actors, use `nonisolated` | +| Thread exhaustion | Gaps in all threads | Remove blocking calls | +| Priority inversion | High-pri waits for low-pri | Check task priorities | +| Too many tasks | Task creation overhead | Use task groups | + +## Safe vs Unsafe Primitives + +**Safe with cooperative pool**: +- `await`, actors, task groups +- `os_unfair_lock`, `NSLock` (short critical sections) +- `Mutex` (iOS 18+) + +**Unsafe (violate forward progress)**: +- `DispatchSemaphore.wait()` +- `pthread_cond_wait` +- Sync file/network I/O +- `Thread.sleep()` in Task + +## Resources + +**WWDC**: 2022-110350, 2021-10254 + +**Docs**: /xcode/improving-app-responsiveness + +**Skills**: axiom-swift-concurrency, axiom-performance-profiling, axiom-synchronization, axiom-lldb (interactive thread state inspection) diff --git a/.claude/skills/axiom-concurrency-profiling/agents/openai.yaml b/.claude/skills/axiom-concurrency-profiling/agents/openai.yaml new file mode 100644 index 0000000..16240a4 --- /dev/null +++ b/.claude/skills/axiom-concurrency-profiling/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Concurrency Profiling" + short_description: "Profiling async/await performance, diagnosing actor contention, or investigating thread pool exhaustion" diff --git a/.claude/skills/axiom-contacts-ref/.openskills.json b/.claude/skills/axiom-contacts-ref/.openskills.json new file mode 100644 index 0000000..343aaf0 --- /dev/null +++ b/.claude/skills/axiom-contacts-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-contacts-ref", + "installedAt": "2026-04-12T08:06:05.993Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-contacts-ref/SKILL.md b/.claude/skills/axiom-contacts-ref/SKILL.md new file mode 100644 index 0000000..ad4f794 --- /dev/null +++ b/.claude/skills/axiom-contacts-ref/SKILL.md @@ -0,0 +1,609 @@ +--- +name: axiom-contacts-ref +description: Use when needing Contacts API details — CNContactStore, CNMutableContact, CNSaveRequest, CNContactFormatter, CNContactVCardSerialization, CNContactPickerViewController, ContactAccessButton, contactAccessPicker, ContactProvider extension, CNChangeHistoryFetchRequest, contact key descriptors, and CNError codes +license: MIT +--- + +# Contacts API Reference + +## Overview + +The Contacts framework provides programmatic access to the system contact database. ContactsUI provides system view controllers for contact selection and display. ContactProvider enables apps to expose their own contacts to the system. + +**Platform**: iOS 9.0+, iPadOS 9.0+, macOS 10.11+, Mac Catalyst 13.1+, watchOS 2.0+, visionOS 1.0+ + +--- + +# Part 1: CNContactStore + +The primary gateway for contact data. "Fetch methods perform I/O — avoid using the main thread." + +## Authorization + +```swift +// Check status (static method) +let status = CNContactStore.authorizationStatus(for: .contacts) +// Returns: .notDetermined, .restricted, .denied, .authorized, .limited (iOS 18+) + +// Request access +let store = CNContactStore() +try await store.requestAccess(for: .contacts) // Returns Bool +``` + +**Info.plist required**: `NSContactsUsageDescription` (crash without it). + +**Special entitlement**: `com.apple.developer.contacts.notes` — required to read/write `note` field. Requires Apple approval. + +## Fetching Contacts + +```swift +// Single contact by identifier +let contact = try store.unifiedContact( + withIdentifier: identifier, + keysToFetch: keys +) + +// Search by predicate +let contacts = try store.unifiedContacts( + matching: predicate, + keysToFetch: keys +) + +// Current user's card +let me = try store.unifiedMeContact(withKeysToFetch: keys) + +// Memory-efficient enumeration +let request = CNContactFetchRequest(keysToFetch: keys) +request.predicate = predicate // Optional filter +request.sortOrder = .userDefault // .none, .givenName, .familyName, .userDefault +try store.enumerateContacts(with: request) { contact, stop in + // stop.pointee = true to break early +} +``` + +## Built-in Predicates + +```swift +CNContact.predicateForContacts(matchingName: "John") +CNContact.predicateForContacts(matchingEmailAddress: "john@example.com") +CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: "+1555...")) +CNContact.predicateForContacts(withIdentifiers: [id1, id2]) +CNContact.predicateForContactsInGroup(withIdentifier: groupId) +CNContact.predicateForContactsInContainer(withIdentifier: containerId) +``` + +## Containers and Groups + +```swift +store.containers(matching: predicate) // [CNContainer] +store.groups(matching: predicate) // [CNGroup] +store.defaultContainerIdentifier // String (property, not method) +``` + +**CNContainer types**: `.local`, `.exchange`, `.cardDAV`, `.unassigned` + +## Change Tracking + +```swift +store.currentHistoryToken // Data? — save for incremental sync +``` + +## Change Notification + +```swift +NotificationCenter.default.addObserver( + forName: .CNContactStoreDidChange, object: nil, queue: .main +) { _ in + // Refetch visible contacts +} +``` + +## Save Operations + +```swift +let saveRequest = CNSaveRequest() +saveRequest.add(contact, toContainerWithIdentifier: nil) // nil = default +saveRequest.update(contact) +saveRequest.delete(contact.mutableCopy() as! CNMutableContact) +try store.execute(saveRequest) +``` + +--- + +# Part 2: CNContact Key Descriptors + +You MUST specify which properties to fetch. Accessing an unfetched property throws `CNContactPropertyNotFetchedException`. + +## Common Key Constants + +| Key | Property | +|-----|----------| +| `CNContactIdentifierKey` | `identifier` | +| `CNContactGivenNameKey` | `givenName` | +| `CNContactFamilyNameKey` | `familyName` | +| `CNContactMiddleNameKey` | `middleName` | +| `CNContactNamePrefixKey` | `namePrefix` | +| `CNContactNameSuffixKey` | `nameSuffix` | +| `CNContactNicknameKey` | `nickname` | +| `CNContactOrganizationNameKey` | `organizationName` | +| `CNContactJobTitleKey` | `jobTitle` | +| `CNContactDepartmentNameKey` | `departmentName` | +| `CNContactPhoneNumbersKey` | `phoneNumbers` | +| `CNContactEmailAddressesKey` | `emailAddresses` | +| `CNContactPostalAddressesKey` | `postalAddresses` | +| `CNContactUrlAddressesKey` | `urlAddresses` | +| `CNContactSocialProfilesKey` | `socialProfiles` | +| `CNContactInstantMessageAddressesKey` | `instantMessageAddresses` | +| `CNContactBirthdayKey` | `birthday` | +| `CNContactNonGregorianBirthdayKey` | `nonGregorianBirthday` | +| `CNContactDatesKey` | `dates` | +| `CNContactNoteKey` | `note` (requires entitlement) | +| `CNContactImageDataKey` | `imageData` | +| `CNContactThumbnailImageDataKey` | `thumbnailImageData` | +| `CNContactImageDataAvailableKey` | `imageDataAvailable` | +| `CNContactRelationsKey` | `contactRelations` | +| `CNContactTypeKey` | `contactType` (.person, .organization) | + +## Convenience Descriptors + +```swift +// All keys needed for name display (locale-aware) +CNContactFormatter.descriptorForRequiredKeys(for: .fullName) +CNContactFormatter.descriptorForRequiredKeys(for: .phoneticFullName) + +// All keys needed for vCard export +CNContactVCardSerialization.descriptorForRequiredKeys() +``` + +**Always prefer formatter descriptors** over manual key lists for name display. + +--- + +# Part 3: CNMutableContact + +Mutable subclass of `CNContact`. **Not thread-safe** — use immutable `CNContact` for cross-thread access. + +## Creating a Contact + +```swift +let contact = CNMutableContact() +contact.givenName = "Jane" +contact.familyName = "Appleseed" +contact.organizationName = "Apple Inc." +contact.jobTitle = "Engineer" + +// Phone numbers +contact.phoneNumbers = [ + CNLabeledValue(label: CNLabelPhoneNumberMobile, + value: CNPhoneNumber(stringValue: "+15551234567")), + CNLabeledValue(label: CNLabelWork, + value: CNPhoneNumber(stringValue: "+15559876543")) +] + +// Email addresses +contact.emailAddresses = [ + CNLabeledValue(label: CNLabelHome, value: "jane@example.com" as NSString), + CNLabeledValue(label: CNLabelWork, value: "jane@apple.com" as NSString) +] + +// Postal addresses +let address = CNMutablePostalAddress() +address.street = "1 Apple Park Way" +address.city = "Cupertino" +address.state = "CA" +address.postalCode = "95014" +address.country = "United States" +contact.postalAddresses = [CNLabeledValue(label: CNLabelWork, value: address)] + +// Birthday +contact.birthday = DateComponents(year: 1990, month: 6, day: 15) + +// Photo +contact.imageData = imageData +``` + +## Removing Values + +Set strings/arrays to empty, other properties to `nil`. + +## Constraint + +"You may modify only those properties whose values you fetched from the contacts database." + +--- + +# Part 4: CNSaveRequest + +Batch operations for contacts, groups, and subgroups. + +**Platform**: iOS 9.0+, iPadOS 9.0+, macOS 10.11+, Mac Catalyst 13.1+ (no watchOS) + +## Contact Operations + +```swift +let request = CNSaveRequest() +request.add(contact, toContainerWithIdentifier: containerId) // nil = default +request.update(contact) +request.delete(contact) +``` + +## Group Operations + +```swift +request.add(group, toContainerWithIdentifier: containerId) +request.update(group) +request.delete(group) +request.addMember(contact, to: group) +request.removeMember(contact, from: group) +request.addSubgroup(subgroup, to: parentGroup) +request.removeSubgroup(subgroup, from: parentGroup) +``` + +## Properties + +| Property | Type | Notes | +|----------|------|-------| +| `shouldRefetchContacts` | `Bool` | Refetch added/updated contacts post-execution | +| `transactionAuthor` | `String?` | Identifies who made the change (for change history filtering) | + +## Execution + +```swift +try store.execute(request) +``` + +**Concurrency**: "Last change wins" for overlapping concurrent changes. + +--- + +# Part 5: CNContactFormatter + +Locale-aware name formatting. + +```swift +let formatter = CNContactFormatter() + +// Format name +let name = formatter.string(from: contact) // String? +let name = CNContactFormatter.string(from: contact, style: .fullName) + +// Attributed string variants +let attributed = formatter.attributedString(from: contact) + +// Locale information +let order = CNContactFormatter.nameOrder(for: contact) // .givenNameFirst, .familyNameFirst +let delimiter = CNContactFormatter.delimiter(for: contact) // Locale-appropriate separator +``` + +## Styles + +| Style | Example | +|-------|---------| +| `.fullName` | "Jane Appleseed" or "Appleseed Jane" (per locale) | +| `.phoneticFullName` | Phonetic representation | + +--- + +# Part 6: CNContactVCardSerialization + +```swift +// Export contacts to vCard data +let data = try CNContactVCardSerialization.data(with: contacts) + +// Import contacts from vCard data +let contacts = try CNContactVCardSerialization.contacts(with: data) + +// Required keys for export +let keys = CNContactVCardSerialization.descriptorForRequiredKeys() +``` + +--- + +# Part 7: ContactsUI + +## CNContactPickerViewController (iOS 9+) + +Lets users pick contacts **without requiring app-level authorization**. App receives one-time snapshot. + +```swift +let picker = CNContactPickerViewController() +picker.delegate = self +picker.displayedPropertyKeys = [CNContactPhoneNumbersKey, CNContactEmailAddressesKey] +present(picker, animated: true) +``` + +### Predicates (set BEFORE presentation) + +```swift +// Which contacts are selectable +picker.predicateForEnablingContact = NSPredicate( + format: "phoneNumbers.@count > 0" +) + +// Auto-select whole contact (skip property selection) +picker.predicateForSelectionOfContact = NSPredicate( + format: "emailAddresses.@count > 0" +) + +// Which properties can be selected individually +picker.predicateForSelectionOfProperty = NSPredicate( + format: "key == 'phoneNumbers'" +) +``` + +**Gotcha**: Changing predicates only takes effect before the view is presented. + +### Delegate (CNContactPickerDelegate) + +```swift +func contactPicker(_ picker: CNContactPickerViewController, + didSelect contact: CNContact) { } +func contactPicker(_ picker: CNContactPickerViewController, + didSelect contacts: [CNContact]) { } // Multi-selection +func contactPicker(_ picker: CNContactPickerViewController, + didSelect contactProperty: CNContactProperty) { } +func contactPickerDidCancel(_ picker: CNContactPickerViewController) { } +``` + +## CNContactViewController (iOS 9+) + +Display a single contact with three initialization modes: + +```swift +// Existing contact +let vc = CNContactViewController(for: contact) + +// Unknown contact (partial data) +let vc = CNContactViewController(forUnknownContact: partialContact) + +// New contact +let vc = CNContactViewController(forNewContact: nil) + +// Display mode +vc.allowsEditing = true +vc.allowsActions = true // Call, message, email buttons +vc.displayedPropertyKeys = [CNContactPhoneNumbersKey] +vc.highlightProperty(withKey: CNContactPhoneNumbersKey, identifier: nil) +``` + +--- + +# Part 8: Contact Access Button (iOS 18+) + +SwiftUI component for privacy-conscious contact access. + +```swift +ContactAccessButton(queryString: searchText) { identifiers in + let contacts = await fetchContacts(withIdentifiers: identifiers) +} +``` + +### Caption Options + +| Value | Shows | +|-------|-------| +| `.defaultText` | Default text | +| `.email` | Email address | +| `.phone` | Phone number | + +### Modifiers + +```swift +.font(.system(weight: .bold)) +.foregroundStyle(.gray) +.tint(.green) +.contactAccessButtonCaption(.phone) +.contactAccessButtonStyle(ContactAccessButton.Style(imageWidth: 30)) +``` + +### Security + +Button only grants access when: +- **Legible**: Sufficient contrast between text and background +- **Unobstructed**: Entire button visible, not clipped +- **Validated tap**: Real user interaction, not simulated + +--- + +# Part 9: contactAccessPicker (iOS 18+) + +Modal sheet for managing limited access contact set. For bulk or non-immediate use cases. + +```swift +@State private var isPresented = false + +Button("Share More Contacts") { + isPresented.toggle() +} +.contactAccessPicker(isPresented: $isPresented) { identifiers in + // identifiers: [String] — newly permitted contacts only + let contacts = await fetchContacts(withIdentifiers: identifiers) +} +``` + +**Difference from CNContactPickerViewController**: `contactAccessPicker` changes persistent access. `CNContactPickerViewController` provides one-time snapshots. + +--- + +# Part 10: ContactProvider Framework (iOS 18+) + +Enables apps to expose contacts to the system Contacts ecosystem from third-party sources. + +## Architecture + +1. **Main app** controls the extension via `ContactProviderManager` +2. **Extension** enumerates contacts to the system +3. Communication via **App Group** shared container + +## ContactProviderManager (Main App Only) + +```swift +let manager = try ContactProviderManager(domainIdentifier: "com.myapp.contacts") + +try await manager.enable() // Async — may prompt user +try await manager.disable() // Deactivate +try await manager.reset() // Clear all provider contacts +try await manager.invalidate() // Terminate extension +try await manager.signalEnumerator(for: .default) // Trigger enumeration + +manager.isEnabled // Bool — activation state +``` + +**Cannot be used in app extensions** — main app only. + +## ContactProviderExtension Protocol + +```swift +@main +class Provider: ContactProviderExtension { + func configure(for domain: ContactProviderDomain) { + // Setup data access + } + + func enumerator(for collection: ContactItem.Identifier) + -> ContactItemEnumerator { + return MyEnumerator() + } + + func invalidate() async throws { + // Cleanup + } +} +``` + +**Info.plist**: Extension point `com.apple.contact.provider.extension` + +## Enumeration + +Two patterns: +1. **Content enumeration** — full initial sync via `ContactItemContentObserver` +2. **Change enumeration** — incremental updates via `ContactItemChangeObserver` and `ContactItemSyncAnchor` + +```swift +class MyEnumerator: ContactItemEnumerator { + func enumerateContent( + in page: ContactItemPage, + for observer: ContactItemContentObserver + ) { + let contact = CNMutableContact() + contact.givenName = "Jane" + contact.familyName = "Appleseed" + let item = ContactItem.contact(contact, ContactItem.Identifier("jane-001")) + observer.didEnumerate([item]) + observer.didFinishEnumeratingContent(upTo: generationMarker) + } + + func enumerateChanges( + startingAt anchor: ContactItemSyncAnchor, + for observer: ContactItemChangeObserver + ) { + // Incremental updates since anchor + observer.didFinishEnumeratingChanges(upTo: newAnchor) + } +} +``` + +## ContactProvider Errors + +| Code | Meaning | +|------|---------| +| `featureNotAvailable` | Framework not available | +| `deniedByUser` | User rejected | +| `extensionNotFound` | Extension not registered | +| `enumerationTimeout` | Extension too slow | +| `cannotEnumerate` | Enumeration failed | +| `pageExpired` | Content page expired | +| `changeAnchorExpired` | Sync anchor expired | +| `itemsLimitReached` | Too many contacts | + +--- + +# Part 11: Change History (TN3149) + +## CNChangeHistoryFetchRequest + +```swift +let request = CNChangeHistoryFetchRequest() +request.startingToken = savedToken // nil = full fetch +request.includeGroupChanges = false // Default NO +request.mutableObjects = false // Default NO +request.shouldUnifyResults = true // Default YES +request.additionalContactKeyDescriptors = [ + CNContactFormatter.descriptorForRequiredKeys(for: .fullName) +] +request.excludedTransactionAuthors = [Bundle.main.bundleIdentifier!] +``` + +## CNChangeHistoryEventVisitor Protocol + +```swift +// Required +func visit(_ event: CNChangeHistoryDropEverythingEvent) // Full re-sync +func visit(_ event: CNChangeHistoryAddContactEvent) // New contact +func visit(_ event: CNChangeHistoryUpdateContactEvent) // Modified +func visit(_ event: CNChangeHistoryDeleteContactEvent) // Deleted + +// Optional (when includeGroupChanges = true) +func visit(_ event: CNChangeHistoryAddGroupEvent) +func visit(_ event: CNChangeHistoryUpdateGroupEvent) +func visit(_ event: CNChangeHistoryDeleteGroupEvent) +func visit(_ event: CNChangeHistoryAddMemberToGroupEvent) +func visit(_ event: CNChangeHistoryRemoveMemberFromGroupEvent) +func visit(_ event: CNChangeHistoryAddSubgroupToGroupEvent) +func visit(_ event: CNChangeHistoryRemoveSubgroupFromGroupEvent) +``` + +**Must use visitor pattern** — do NOT use `isKindOfClass:` to determine event type. + +**Gotcha**: `enumeratorForChangeHistoryFetchRequest:error:` is **Objective-C only** — unavailable in Swift. + +**Token expiration**: Returns `DropEverything` + `Add` events for all contacts — same code handles full and incremental sync. + +**Transaction authors**: Use reverse-domain notation (bundle identifier). Filters results but doesn't provide attribution. + +--- + +# Part 12: Error Reference + +## CNError Codes + +| Category | Code | Meaning | +|----------|------|---------| +| Authorization | `authorizationDenied` | No permission | +| Authorization | `featureDisabledByUser` | Feature turned off | +| Data | `recordDoesNotExist` | Contact/group deleted | +| Data | `recordNotWritable` | Read-only contact | +| Data | `insertedRecordAlreadyExists` | Duplicate insert | +| Validation | `validationTypeMismatch` | Wrong value type | +| Validation | `validationMultipleErrors` | Multiple validation failures | +| History | `changeHistoryExpired` | Sync token expired | +| History | `changeHistoryInvalidAnchor` | Bad sync anchor | +| History | `changeHistoryInvalidFetchRequest` | Invalid request | + +Error `userInfo` provides: `affectedRecords`, `affectedRecordIdentifiers`, `keyPaths`. + +--- + +# Part 13: Platform Availability + +| API | iOS | macOS | watchOS | visionOS | +|-----|-----|-------|---------|----------| +| CNContactStore | 9.0+ | 10.11+ | 2.0+ | 1.0+ | +| Limited access | 18.0+ | — | — | — | +| CNContactPickerViewController | 9.0+ | (Catalyst 13.1+) | — | 1.0+ | +| CNContactViewController | 9.0+ | (Catalyst 13.1+) | — | 1.0+ | +| ContactAccessButton | 18.0+ | — | — | — | +| contactAccessPicker | 18.0+ | — | — | — | +| ContactProvider | 18.0+ | — | — | — | +| CNChangeHistoryFetchRequest | 13.0+ | 10.15+ | — | 1.0+ | +| CNSaveRequest | 9.0+ | 10.11+ | — | 1.0+ | + +--- + +## Resources + +**WWDC**: 2024-10121 + +**Docs**: /contacts, /contacts/cncontactstore, /contacts/cnmutablecontact, /contactsui, /contactsui/cncontactpickerviewcontroller, /contactprovider, /technotes/tn3149 + +**Skills**: contacts, eventkit-ref, privacy-ux diff --git a/.claude/skills/axiom-contacts-ref/agents/openai.yaml b/.claude/skills/axiom-contacts-ref/agents/openai.yaml new file mode 100644 index 0000000..2dfe8da --- /dev/null +++ b/.claude/skills/axiom-contacts-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Contacts Reference" + short_description: "Needing Contacts API details" diff --git a/.claude/skills/axiom-contacts/.openskills.json b/.claude/skills/axiom-contacts/.openskills.json new file mode 100644 index 0000000..ca84de6 --- /dev/null +++ b/.claude/skills/axiom-contacts/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-contacts", + "installedAt": "2026-04-12T08:06:05.528Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-contacts/SKILL.md b/.claude/skills/axiom-contacts/SKILL.md new file mode 100644 index 0000000..ff77d89 --- /dev/null +++ b/.claude/skills/axiom-contacts/SKILL.md @@ -0,0 +1,294 @@ +--- +name: axiom-contacts +description: Use when accessing ANY contact data, requesting Contacts permissions, choosing between picker and store access, implementing Contact Access Button, or migrating to iOS 18 limited access. Covers authorization levels, CNContactStore, ContactProvider, key fetching, incremental sync. +license: MIT +--- + +# Contacts — Discipline + +## Core Philosophy + +> "The contact access button is a powerful new way to manage access to contacts, right in your app. Instead of a full-screen picker, this button fits into your existing UI." + +**Mental model**: Contacts has four authorization levels. Most apps should use the Contact Access Button or CNContactPickerViewController, which require no authorization at all. Only request store access when you need persistent contact data. + +## When to Use This Skill + +Use this skill when: +- Reading or writing contacts +- Choosing between picker, Contact Access Button, or full store access +- Requesting Contacts permissions +- Implementing contact search or selection UIs +- Migrating to iOS 18 limited access +- Building a Contact Provider extension +- Implementing incremental contact sync + +Do NOT use this skill for: +- Calendar events or reminders (use **eventkit**) +- General privacy patterns (use **privacy-ux**) +- SwiftUI architecture (use **swiftui-architecture**) + +## Related Skills + +- **contacts-ref** — Complete Contacts/ContactsUI API reference +- **eventkit** — EventKit discipline skill +- **privacy-ux** — General iOS privacy and permission UX +- **keychain** — If storing contact-related credentials + +--- + +## Access Level Decision Tree + +```dot +digraph contacts_access { + rankdir=TB; + "What does your app need?" [shape=diamond]; + "One-time contact selection?" [shape=diamond]; + "Persistent access to specific contacts?" [shape=diamond]; + "Search + incremental discovery?" [shape=diamond]; + "Read entire contact database?" [shape=diamond]; + + "CNContactPickerViewController" [shape=box, label="No Auth Required\nCNContactPickerViewController\nOne-time snapshot"]; + "Contact Access Button" [shape=box, label="Limited or Not Determined\nContactAccessButton\nGrants access per-contact"]; + "contactAccessPicker" [shape=box, label="Limited Access\ncontactAccessPicker\nBulk contact selection"]; + "Full access" [shape=box, label="Full Access\nCNContactStore\nRead/write all contacts"]; + + "What does your app need?" -> "One-time contact selection?" [label="pick a contact"]; + "One-time contact selection?" -> "CNContactPickerViewController" [label="yes"]; + "One-time contact selection?" -> "Persistent access to specific contacts?" [label="no, need persistent"]; + "Persistent access to specific contacts?" -> "Search + incremental discovery?" [label="yes"]; + "Search + incremental discovery?" -> "Contact Access Button" [label="search flow\n(best UX)"]; + "Search + incremental discovery?" -> "contactAccessPicker" [label="bulk selection\n(friend matching)"]; + "Persistent access to specific contacts?" -> "Read entire contact database?" [label="no"]; + "Read entire contact database?" -> "Full access" [label="yes, core feature"]; +} +``` + +--- + +## The Four Authorization Levels + +### Not Determined + +App hasn't requested access yet. `CNContactStore` auto-prompts on first access attempt. `ContactAccessButton` works in this state — tapping it triggers a simplified limited-access prompt. + +### Limited Access (iOS 18+) + +User selected specific contacts to share. Your app sees only those contacts via `CNContactStore`. The API surface is identical to full access — only the visible contacts differ. + +**Your app always has access to contacts it creates**, regardless of authorization level. + +### Full Access + +Read/write access to all contacts. Users must explicitly choose "Full Access" in the two-stage prompt. Reserve this for apps where contacts are the core feature. + +### Denied + +No access to contact data. App cannot read, write, or enumerate contacts. + +--- + +## Contact Access Button (iOS 18+) + +The preferred way to give users control over which contacts your app can access. Shows search results for contacts your app doesn't yet have access to. One tap grants access. + +```swift +@State private var searchText = "" + +var body: some View { + VStack { + // Your app's own search results first + ForEach(myAppResults) { result in + ContactRow(result) + } + + // Contact Access Button for contacts not yet shared + if authStatus == .limited || authStatus == .notDetermined { + ContactAccessButton(queryString: searchText) { identifiers in + let contacts = await fetchContacts(withIdentifiers: identifiers) + // Use the newly accessible contacts + } + } + } +} +``` + +### Customization + +```swift +ContactAccessButton(queryString: searchText) + .font(.system(weight: .bold)) // Upper text + action label + .foregroundStyle(.gray) // Primary text color + .tint(.green) // Action label color + .contactAccessButtonCaption(.phone) // .defaultText, .email, .phone + .contactAccessButtonStyle( + ContactAccessButton.Style(imageWidth: 30) + ) +``` + +### Security Requirements + +The button only grants access when: +- Contents are clearly **legible** (sufficient contrast) +- Button is fully **unobstructed** (not clipped) +- Taps are **validated events** (not simulated) + +If any requirement fails, tapping the button does nothing. Always ensure adequate contrast and avoid clipping. + +--- + +## Anti-Patterns + +| Pattern | Time Cost | Why It's Wrong | Fix | +|---------|-----------|----------------|-----| +| Requesting full access for contact picking | 1-2 sprint days recovering denied users | Full access prompts denied 40%+ of the time | Use CNContactPickerViewController or ContactAccessButton | +| Accessing unfetched key on CNContact | 15-30 min debugging crash | `CNContactPropertyNotFetchedException` — no clear error message | Always specify `keysToFetch` | +| Using manual name key lists instead of formatter descriptor | 10-20 min debugging per locale | Different cultures use different name field combinations | Use `CNContactFormatter.descriptorForRequiredKeys(for:)` | +| Creating multiple CNContactStore instances | 30+ min debugging stale data | Objects from one store can't be used with another | Create one, reuse it | +| Fetching all keys "just in case" | App Store review risk | Overfetching triggers stricter privacy scrutiny | Fetch only the keys you need | +| Using `CNContactStore` on main thread | 1-2 hours debugging UI freezes | "Fetch methods perform I/O" — Apple docs | Run fetches on background thread | +| Missing `NSContactsUsageDescription` in Info.plist | 15 min debugging crash | App crashes on any contact store access attempt | Add the usage description | +| Mutating CNMutableContact across threads | 2-4 hours debugging corruption | "CNMutableContact objects are not thread-safe" | Use immutable CNContact for cross-thread access | +| Ignoring `.limited` status | 1-2 hours debugging "missing contacts" | App assumes full access but only sees subset | Check status and show ContactAccessButton | +| Not handling `note` field entitlement | 30 min debugging empty notes | `com.apple.developer.contacts.notes` required | Apply for entitlement from Apple | + +--- + +## Key Patterns + +### Minimal Key Fetching + +Always specify exactly which properties you need: + +```swift +let keys: [CNKeyDescriptor] = [ + CNContactGivenNameKey as CNKeyDescriptor, + CNContactFamilyNameKey as CNKeyDescriptor, + CNContactPhoneNumbersKey as CNKeyDescriptor, + CNContactFormatter.descriptorForRequiredKeys(for: .fullName) +] + +let request = CNContactFetchRequest(keysToFetch: keys) +``` + +**Rule**: You may only modify properties whose values you fetched. Accessing an unfetched property throws `CNContactPropertyNotFetchedException`. + +See **contacts-ref** for search predicates, save operations, name formatting, and vCard serialization. + +--- + +## Incremental Sync (Change History) + +For apps that cache contacts and need to detect changes: + +```swift +// Save the token after initial fetch +var changeToken = store.currentHistoryToken + +// Later, fetch changes since last token +let request = CNChangeHistoryFetchRequest() +request.startingToken = changeToken +request.shouldUnifyResults = true +request.additionalContactKeyDescriptors = [ + CNContactFormatter.descriptorForRequiredKeys(for: .fullName) +] + +// Process via visitor pattern (required) +class MyVisitor: NSObject, CNChangeHistoryEventVisitor { + func visit(_ event: CNChangeHistoryDropEverythingEvent) { + // Full re-sync needed — token expired or first fetch + } + func visit(_ event: CNChangeHistoryAddContactEvent) { + // New contact added + } + func visit(_ event: CNChangeHistoryUpdateContactEvent) { + // Contact modified + } + func visit(_ event: CNChangeHistoryDeleteContactEvent) { + // Contact deleted + } +} +``` + +**Gotcha**: `enumeratorForChangeHistoryFetchRequest:error:` is Objective-C only — unavailable in Swift. Use a bridging wrapper. + +**Token expiration**: When token expires, the system returns a `DropEverything` event followed by `Add` events for all contacts. Same code path handles full and incremental sync. + +--- + +## Contact Provider Extension (iOS 18+) + +Expose your app's contact graph to the system Contacts ecosystem. + +```swift +// In main app: enable and signal +let manager = try ContactProviderManager(domainIdentifier: "com.myapp.contacts") +try await manager.enable() // May prompt user authorization +try await manager.signalEnumerator() // Trigger sync when data changes + +// In extension +@main +class Provider: ContactProviderExtension { + func configure(for domain: ContactProviderDomain) { /* setup */ } + + func enumerator(for collection: ContactItem.Identifier) -> ContactItemEnumerator { + return MyEnumerator() + } + + func invalidate() async throws { /* cleanup */ } +} +``` + +**Requires**: App Group for data sharing between app and extension. + +--- + +## Pressure Scenarios + +### Scenario 1: "We need full access to show contact suggestions" + +**Pressure**: PM wants full Contacts access for autocomplete. + +**Why resist**: ContactAccessButton provides exactly this — search results for contacts the app doesn't have, one-tap to grant access. No scary full-access prompt. + +**Response**: "ContactAccessButton gives us search-driven contact discovery without asking for full access. Users grant access to exactly the contacts they want to share, one at a time. Denial rate drops from 40%+ to near zero." + +### Scenario 2: "Just fetch all keys, we might display any field" + +**Pressure**: Developer fetches all contact keys "to be safe." + +**Why resist**: Overfetching contacts data is both a privacy concern (triggers stricter Apple review) and a performance problem (slower fetches, more memory). + +**Response**: "Fetching only the keys we display means faster queries and less privacy exposure. Use `CNContactFormatter.descriptorForRequiredKeys(for:)` for name display — it handles all locale variations." + +### Scenario 3: "CNContactPickerViewController is enough, we don't need the button" + +**Pressure**: Team sticks with picker because it's familiar. + +**Why resist**: Picker gives one-time snapshots — the contacts are not persistently accessible. If you need to store or sync the contact, you need persistent access through ContactAccessButton or full authorization. + +**Response**: "Picker works for one-time actions (share a phone number). But if we need to remember the contact (friend list, favorites), we need ContactAccessButton for persistent limited access." + +--- + +## Migration Checklist + +When updating an app for iOS 18 limited access: + +1. Check `authorizationStatus(for: .contacts)` for `.limited` case +2. Add `ContactAccessButton` to contact search flows +3. Test that app handles seeing only a subset of contacts +4. Verify app-created contacts are always visible +5. Add `contactAccessPicker` for bulk access management if needed +6. Test the two-stage authorization prompt flow +7. Ensure `keysToFetch` is minimal — limited access doesn't change key behavior + +--- + +## Resources + +**WWDC**: 2024-10121 + +**Docs**: /contacts, /contactsui, /contactprovider, /technotes/tn3149 + +**Skills**: contacts-ref, eventkit, privacy-ux diff --git a/.claude/skills/axiom-contacts/agents/openai.yaml b/.claude/skills/axiom-contacts/agents/openai.yaml new file mode 100644 index 0000000..27e87e8 --- /dev/null +++ b/.claude/skills/axiom-contacts/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Contacts" + short_description: "Accessing ANY contact data, requesting Contacts permissions, choosing between picker and store access, implementing C..." diff --git a/.claude/skills/axiom-core-data-diag/.openskills.json b/.claude/skills/axiom-core-data-diag/.openskills.json new file mode 100644 index 0000000..b2a6675 --- /dev/null +++ b/.claude/skills/axiom-core-data-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-core-data-diag", + "installedAt": "2026-04-12T08:06:07.075Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-core-data-diag/SKILL.md b/.claude/skills/axiom-core-data-diag/SKILL.md new file mode 100644 index 0000000..7fdad72 --- /dev/null +++ b/.claude/skills/axiom-core-data-diag/SKILL.md @@ -0,0 +1,920 @@ +--- +name: axiom-core-data-diag +description: Use when debugging schema migration crashes, concurrency thread-confinement errors, N+1 query performance, SwiftData to Core Data bridging, or testing migrations without data loss - systematic Core Data diagnostics with safety-first migration patterns +license: MIT +metadata: + version: "1.0.0" +--- + +# Core Data Diagnostics & Migration + +## Overview + +Core Data issues manifest as production crashes from schema mismatches, mysterious concurrency errors, performance degradation under load, and data corruption from unsafe migrations. **Core principle** 85% of Core Data problems stem from misunderstanding thread-confinement, schema migration requirements, and relationship query patterns—not Core Data defects. + +## Red Flags — Suspect Core Data Issue + +If you see ANY of these, suspect a Core Data misunderstanding, not framework breakage: +- Crash on production launch: "Unresolvable fault" after schema change +- Thread-confinement error: "Accessing NSManagedObject on a different thread" +- App suddenly slow after adding a User→Posts relationship +- SwiftData app needs complex features; considering mixing Core Data alongside +- Schema migration works in simulator but crashes on production +- ❌ **FORBIDDEN** "Core Data is broken, we need a different database" + - Core Data handles trillions of records in production apps + - Schema mismatches and thread errors are always developer code, not framework + - Do not rationalize away the issue—diagnose it + +**Critical distinction** Simulator deletes the database on each rebuild, hiding schema mismatch issues. Real devices keep persistent databases and crash immediately on schema mismatch. **MANDATORY: Test migrations on real device with real data before shipping.** + +## Mandatory First Steps + +**ALWAYS run these FIRST** (before changing code): + +```swift +// 1. Identify the crash/issue type +// Screenshot the crash message and note: +// - "Unresolvable fault" = schema mismatch +// - "different thread" = thread-confinement +// - Slow performance = N+1 queries or fetch size issues +// - Data corruption = unsafe migration +// Record: "Crash type: [exact message]" + +// 2. Check if it's schema mismatch +// Compare these: +let coordinator = persistentStoreCoordinator +let model = coordinator.managedObjectModel +let store = coordinator.persistentStores.first + +// Get actual store schema version: +do { + let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore( + ofType: NSSQLiteStoreType, + at: storeURL, + options: nil + ) + print("Store version identifier: \(metadata[NSStoreModelVersionIdentifiersKey] ?? "unknown")") + + // Get app's current model version: + print("App model version: \(model.versionIdentifiers)") + + // If different = schema mismatch +} catch { + print("Schema check error: \(error)") +} +// Record: "Store version vs. app model: match or mismatch?" + +// 3. Check thread-confinement for concurrency errors +// For any NSManagedObject access: +print("Main thread? \(Thread.isMainThread)") +print("Context concurrency type: \(context.concurrencyType.rawValue)") +print("Accessing from: \(Thread.current)") +// Record: "Thread mismatch? Yes/no" + +// 4. Profile relationship access for N+1 problems +// In Xcode, run with arguments: +// -com.apple.CoreData.SQLDebug 1 +// Check Console for SQL queries: +// SELECT * FROM USERS; (1 query) +// SELECT * FROM POSTS WHERE user_id = 1; (1 query per user = N+1!) +// Record: "N+1 found? Yes/no, how many extra queries" + +// 5. Check SwiftData vs. Core Data confusion +if #available(iOS 17.0, *) { + // If using SwiftData @Model + Core Data simultaneously: + // Error: "Store is locked" or "EXC_BAD_ACCESS" + // = trying to access same database from both layers + print("Using both SwiftData and Core Data on same store?") +} +// Record: "Mixing SwiftData + Core Data? Yes/no" +``` + +#### What this tells you +- **Schema mismatch** → Proceed to Pattern 1 (lightweight migration decision) +- **Thread-confinement error** → Proceed to Pattern 2 (async/await concurrency) +- **N+1 queries** → Proceed to Pattern 3 (relationship prefetching) +- **SwiftData + Core Data conflict** → Proceed to Pattern 4 (bridging) +- **Slow after migration** → Proceed to Pattern 5 (testing safety) + +#### MANDATORY INTERPRETATION + +Before changing ANY code, identify ONE of these: + +1. If crash is "Unresolvable fault" AND store/model versions differ → Schema mismatch (not user error) +2. If crash mentions "different thread" AND you're using DispatchQueue → Thread-confinement (not thread-safe design) +3. If performance degrades with relationship access → N+1 queries (check SQL log) +4. If SwiftData and Core Data code exist together → Conflicting data layers (architectural issue) +5. If migration test passes but production fails → Edge case in real data (testing gap) + +#### If diagnostics are contradictory or unclear +- STOP. Do NOT proceed to patterns yet +- Add print statements to every NSManagedObject access (thread check) +- Add `-com.apple.CoreData.SQLDebug 1` and count SQL queries +- Establish baseline: what's actually happening vs. what you assumed + +## Decision Tree + +``` +Core Data problem suspected? +├─ Crash: "Unresolvable fault"? +│ └─ YES → Schema mismatch (store ≠ app model) +│ ├─ Add new required field? → Pattern 1a (lightweight migration) +│ ├─ Remove field, rename, or change type? → Pattern 1b (heavy migration) +│ └─ Don't know how to fix? → Pattern 1c (testing safety) +│ +├─ Crash: "different thread"? +│ └─ YES → Thread-confinement violated +│ ├─ Using DispatchQueue for background work? → Pattern 2a (async context) +│ ├─ Mixing Core Data with async/await? → Pattern 2b (structured concurrency) +│ └─ SwiftUI @FetchRequest causing issues? → Pattern 2c (@FetchRequest safety) +│ +├─ Performance: App became slow? +│ └─ YES → Likely N+1 queries +│ ├─ Accessing user.posts in loop? → Pattern 3a (prefetching) +│ ├─ Large result set? → Pattern 3b (batch sizing) +│ └─ Just added relationships? → Pattern 3c (relationship tuning) +│ +├─ Using both SwiftData and Core Data? +│ └─ YES → Data layer conflict +│ ├─ Need Core Data features SwiftData lacks? → Pattern 4a (drop to Core Data) +│ ├─ Already committed to SwiftData? → Pattern 4b (stay in SwiftData) +│ └─ Unsure which to use? → Pattern 4c (decision framework) +│ +└─ Migration works locally but crashes in production? + └─ YES → Testing gap + ├─ Didn't test with real data? → Pattern 5a (production testing) + ├─ Schema change affects large dataset? → Pattern 5b (migration safety) + └─ Need verification before shipping? → Pattern 5c (pre-deployment checklist) +``` + +## Common Patterns + +### Pattern Selection Rules (MANDATORY) + +#### Apply ONE pattern at a time, starting with diagnostics + +1. **Always start with Mandatory First Steps** — Identify the actual problem +2. **Run decision tree** — Narrow to specific pattern +3. **Apply ONE pattern** — Don't combine patterns +4. **Test on real device** — Simulator hides issues +5. **Verify with migration test** — Before deploying + +#### FORBIDDEN +- ❌ Changing code without diagnostics +- ❌ Skipping real device testing +- ❌ Using simulator success as proof of migration safety +- ❌ Mixing multiple migration patterns +- ❌ Deploying migrations without pre-deployment verification + +--- + +### Pattern 1a: Lightweight Migration (Simple Schema Changes) + +**PRINCIPLE** Core Data can automatically migrate simple schemas (additive changes) without data loss if done correctly. + +#### ✅ SAFE Lightweight Migrations +- Adding new optional field: `@NSManaged var nickname: String?` +- Adding new required field WITH default: Create attribute with default value +- Renaming entity or attribute: Use mapping model with automatic mapping +- Removing unused field: Just delete from model (data stays on disk, ignored) + +#### ❌ WRONG (Crashes production) +```swift +// BAD: Adding required field without migration +@NSManaged var userID: String // Required, no default + +// BAD: Assuming simulator = production +// Works in simulator (deletes DB), crashes on real device + +// BAD: Modifying field type +@NSManaged var createdAt: Date // Was String, now Date +// Core Data can't automatically convert +``` + +#### ✅ CORRECT (Safe lightweight migration) +```swift +// 1. In Xcode: Editor → Add Model Version +// Creates new .xcdatamodel version file + +// 2. In new version, add required field WITH default: +@NSManaged var userID: String = UUID().uuidString + +// 3. Mark as current model version: +// File Inspector → Versioned Core Data Model +// Check "Current Model Version" + +// 4. Test: +// Simulate old version: delete app, copy old database, run with new code +// Real app loads → migration succeeded + +// 5. Deploy when confident +``` + +#### When this works +- Adding optional fields (always safe) +- Adding required fields WITH default values +- Removing fields +- Renaming entities/attributes with mapping model + +#### When this FAILS (don't try lightweight) +- Changing field type (String → Int) +- Making optional field required (data has nulls, can't convert) +- Complex relationship changes +- Custom data transformations needed + +**Time cost** 5-10 minutes for lightweight migration setup + +--- + +### Pattern 1b: Heavy Migration (Complex Schema Changes) + +**PRINCIPLE** When lightweight migration won't work, use NSEntityMigrationPolicy for custom transformation logic. + +#### Use when +- Changing field types (String → Date) +- Making optional required (need to populate existing nulls) +- Complex relationship restructuring +- Custom data transformations (e.g., split "firstName lastName" into separate fields) + +#### Example: Convert String dates to Date objects + +```swift +// 1. Create mapping model +// File → New → Mapping Model +// Source: old version, Destination: new version + +// 2. Create custom migration policy +class DateMigrationPolicy: NSEntityMigrationPolicy { + override func createDestinationInstances( + forSource sInstance: NSManagedObject, + in mapping: NSEntityMapping, + manager: NSMigrationManager + ) throws { + let destination = NSEntityDescription.insertNewObject( + forEntityName: mapping.destinationEntityName ?? "", + into: manager.destinationContext + ) + + for key in sInstance.entity.attributesByName.keys { + destination.setValue(sInstance.value(forKey: key), forKey: key) + } + + // Custom transformation: String → Date + if let dateString = sInstance.value(forKey: "createdAt") as? String, + let date = ISO8601DateFormatter().date(from: dateString) { + destination.setValue(date, forKey: "createdAt") + } else { + destination.setValue(Date(), forKey: "createdAt") + } + + manager.associate(source: sInstance, withDestinationInstance: destination, for: mapping) + } +} + +// 3. In mapping model Inspector: +// Set Custom Policy Class: DateMigrationPolicy + +// 4. Test extensively with real data before shipping +``` + +#### Critical safety rules +- ALWAYS backup database before testing migration +- Test migration on COPY of production data +- Verify data integrity after migration (spot checks) +- Create rollback plan if migration fails + +**Time cost** 30-60 minutes per migration + testing + +--- + +### Pattern 2a: Async Context for Background Fetching + +**PRINCIPLE** Core Data objects are thread-confined. Fetch on background thread, convert to lightweight representations for main thread. + +#### ❌ WRONG (Thread-confinement crash) +```swift +DispatchQueue.global().async { + let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) + let results = try! context.fetch(request) + + DispatchQueue.main.async { + self.objects = results // ❌ CRASH: objects faulted on background thread + } +} +``` + +#### ✅ CORRECT (Use private queue context for background work) +```swift +// Create background context +let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) +backgroundContext.parent = viewContext + +// Fetch on background thread +backgroundContext.perform { + do { + let results = try backgroundContext.fetch(userRequest) + + // Convert to lightweight representation BEFORE main thread + let userIDs = results.map { $0.id } // Just the IDs, not full objects + + DispatchQueue.main.async { + // On main thread, fetch full objects from main context + let mainResults = try self.viewContext.fetch(request) + self.objects = mainResults + } + } catch { + print("Fetch error: \(error)") + } +} +``` + +#### Why this works +- Background context fetches on background thread (safe) +- Converts heavy objects to lightweight values (safe to pass to main) +- Main context fetches on main thread (safe) +- No thread-confined objects crossing thread boundaries + +**Time cost** 10 minutes to restructure + +--- + +### Pattern 2b: Structured Concurrency (async/await with Core Data) + +**PRINCIPLE** Use NSPersistentContainer or NSManagedObjectContext async methods for Swift Concurrency compatibility. + +#### ✅ CORRECT (iOS 13+ async APIs) +```swift +// iOS 13+: Use async perform +let users = try await viewContext.perform { + try viewContext.fetch(userRequest) +} +// Executes fetch on correct thread, returns to caller + +// iOS 17+: Use Swift Concurrency async/await directly +let users = try await container.mainContext.fetch(userRequest) + +// For background work: +let backgroundUsers = try await backgroundContext.perform { + try backgroundContext.fetch(userRequest) +} +// Fetch happens on background queue, thread-safe +``` + +#### ❌ WRONG (Mixing Swift Concurrency with DispatchQueue) +```swift +async { + DispatchQueue.global().async { + try context.fetch(request) // ❌ Wrong thread! + } +} +``` + +**Time cost** 5 minutes to convert from DispatchQueue to async/await + +--- + +### Pattern 3a: Relationship Prefetching (Prevent N+1) + +**PRINCIPLE** Tell Core Data to fetch relationships eagerly instead of lazy-loading on access. + +#### ❌ WRONG (N+1 query pattern) +```swift +let users = try context.fetch(userRequest) + +for user in users { + let posts = user.posts // ❌ Triggers fetch for EACH user! + // 1 fetch for users + N fetches for relationships = N+1 total +} +``` + +#### ✅ CORRECT (Prefetch relationships) +```swift +var request = NSFetchRequest(entityName: "User") + +// Tell Core Data to fetch relationships eagerly +request.relationshipKeyPathsForPrefetching = ["posts", "comments"] +// Now relationships are fetched in a single query per relationship + +let users = try context.fetch(request) + +for user in users { + let posts = user.posts // ✅ INSTANT: Already fetched + // Total: 1 fetch for users + 1 fetch for all posts = 2 queries +} +``` + +#### Other optimization patterns +```swift +// Batch size: fetch in chunks for large result sets +request.fetchBatchSize = 100 + +// Faulting behavior: convert faults to lightweight snapshots +request.returnsObjectsAsFaults = false // Keep objects in memory +// Use carefully—can cause memory pressure with large results + +// Distinct: remove duplicates from relationship fetches +request.returnsDistinctResults = true +``` + +**Time cost** 2-5 minutes to add prefetching + +--- + +### Pattern 3b: Fetch Batch Sizing + +**PRINCIPLE** For large result sets, fetch in batches to manage memory. + +#### Example: Scrolling through 100,000 users + +```swift +var request = NSFetchRequest(entityName: "User") +request.fetchBatchSize = 100 // Fetch 100 at a time + +// Set sort descriptor for stable pagination +request.sortDescriptors = [NSSortDescriptor(key: "id", ascending: true)] + +let results = try context.fetch(request) +// Memory footprint: ~100 users at a time, not all 100,000 + +for user in results { + // Accessing user 0-99: in memory + // Accessing user 100: batch refetch (user 100-199) + // Auto-pagination, minimal memory usage +} +``` + +**Time cost** 3 minutes to tune batch size + +--- + +### Pattern 4a: Core Data Features SwiftData Doesn't Have + +**Scenario** You chose SwiftData, but need features it lacks. + +#### SwiftData lacks +- Complex migrations (auto-migration only) +- Custom validation (before save) +- Relationship delete rules (cascade, deny, nullify) +- Direct SQL queries +- Advanced prefetching +- Faulting control + +#### When to drop to Core Data from SwiftData +- Need custom migrations +- Need validation logic +- Need complex relationship rules +- Need raw SQL for performance +- Need fault tolerance patterns + +#### ✅ CORRECT (Hybrid approach when necessary) +```swift +// Keep SwiftData for simple entities +@Model final class Note { + var id: String + var title: String +} + +// Drop to Core Data for complex operations +let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) +backgroundContext.parent = container.viewContext + +// Fetch with Core Data, convert to SwiftData models +let results = try backgroundContext.perform { + try backgroundContext.fetch(coreDataRequest) +} +``` + +**CRITICAL** Do NOT access the same entity from both SwiftData and Core Data simultaneously. One or the other, not both. + +**Time cost** 30-60 minutes to create bridging layer + +--- + +### Pattern 4b: Stay in SwiftData (Recommended for New Projects) + +**Scenario** You're in SwiftData and wondering if you need Core Data. + +#### SwiftData provides 80% of Core Data functionality for modern apps +- Type-safe models (@Model) +- Reactive queries (@Query) +- CloudKit sync (built-in) +- Automatic migrations (for simple changes) +- Proper async/await integration + +#### When SwiftData is sufficient +- Simple schemas (users, notes, todos) +- Minimal relationship complexity +- CloudKit sync needed +- iOS 17+ requirement acceptable +- No legacy Core Data code to maintain + +#### Decision: Stay in SwiftData if you can answer YES to 3+ of these +- ✅ iOS 17+ only (no iOS 16 support needed) +- ✅ Simple relationships (1-to-many, not many-to-many) +- ✅ Standard migrations (add fields, remove fields) +- ✅ CloudKit sync beneficial +- ✅ Type safety important + +#### Decision: Drop to Core Data if +- ❌ Need iOS 16 support (SwiftData iOS 17+ only) +- ❌ Complex relationship rules (cascade rules, constraints) +- ❌ Custom migrations required +- ❌ Raw SQL needed for performance +- ❌ Already have Core Data codebase + +**Time cost** 0 minutes (decision only) + +--- + +### Pattern 5a: Safe Production Testing Before Migration + +**PRINCIPLE** Never deploy a migration without testing against real data. + +#### MANDATORY Pre-Deployment Checklist + +```swift +// Step 1: Export production database +// From running app in simulator or real device: +// ~/Library/Developer/CoreData/[AppName]/ +// Copy entire [AppName].sqlite database + +// Step 2: Create migration test +@Test func testProductionDataMigration() throws { + // Copy production database to test location + let testDB = tempDirectory.appendingPathComponent("test.sqlite") + try FileManager.default.copyItem(from: prodDatabase, to: testDB) + + // Attempt migration + var config = ModelConfiguration(url: testDB, isStoredInMemory: false) + let container = try ModelContainer(for: User.self, configurations: [config]) + + // Verify data integrity + let context = container.mainContext + let allUsers = try context.fetch(FetchDescriptor()) + + // Spot checks: verify specific records migrated correctly + guard let user1 = allUsers.first(where: { $0.id == "test-id-1" }) else { + throw MigrationError.missingUser + } + + // Check derived data is correct + XCTAssertEqual(user1.name, "Expected Name") + XCTAssertNotNil(user1.createdAt) + + // Check relationships + XCTAssertEqual(user1.posts.count, expectedPostCount) +} + +// Step 3: Run test against real production data +// Pass ✓ before shipping +``` + +#### Safety rules +- ❌ NEVER test migrations with simulator (simulator deletes DB) +- ✅ ALWAYS test with copy of real production data +- ✅ ALWAYS verify spot checks (specific records) +- ✅ ALWAYS check relationships loaded correctly +- ✅ ALWAYS have rollback plan documented + +**Time cost** 15-30 minutes to create migration test + +--- + +### Pattern 5c: Pre-Deployment Verification Checklist + +#### MANDATORY before shipping ANY Core Data change + +- [ ] Did you create a new .xcdatamodel version? (Not just editing the existing one) +- [ ] Does the new version have a mapping model if needed? +- [ ] Did you test migration with real production data? (Not simulator) +- [ ] Did you verify 5+ specific records migrated correctly? +- [ ] Did you check relationships loaded? +- [ ] Did you test on real device (oldest supported)? +- [ ] Does app launch without crashing? (Fresh install) +- [ ] Does app launch with old data? (Migration path) +- [ ] Is rollback plan documented? (In case production fails) + +#### If you answer NO to any item +- ❌ DO NOT SHIP +- Go back, fix the issue, re-test +- One "NO" = data loss risk + +**Time cost** 5 minutes checklist + +--- + +## Quick Reference Table + +| Issue | Check | Fix | +|-------|-------|-----| +| "Unresolvable fault" crash | Do store/model versions match? | Create .xcdatamodel version + mapping model | +| "Different thread" crash | Is fetch happening on main thread? | Use private queue context for background work | +| App became slow | Are relationships being prefetched? | Add relationshipKeyPathsForPrefetching | +| N+1 query performance | Check `-com.apple.CoreData.SQLDebug 1` logs | Add prefetching or convert to lightweight representation | +| SwiftData needs Core Data features | Do you need custom migrations? | Use Core Data NSEntityMigrationPolicy | +| Not sure about SwiftData vs. Core Data | Do you need iOS 16 support? | Use Core Data for iOS 16, SwiftData for iOS 17+ | +| Migration test works, production fails | Did you test with real data? | Create migration test with production database copy | + +--- + +## When You're Stuck After 30 Minutes + +If you've spent >30 minutes and the Core Data issue persists: + +#### STOP. You either +1. Skipped mandatory diagnostics (most common) +2. Misidentified the actual problem +3. Applied wrong pattern for your symptom +4. Haven't tested on real device/real data +5. Have edge case requiring custom NSEntityMigrationPolicy + +#### MANDATORY checklist before claiming "skill didn't work" + +- [ ] I ran all Mandatory First Steps diagnostics +- [ ] I identified the problem type (schema, concurrency, performance, bridging, testing) +- [ ] I checked Core Data SQL debug logs (`-com.apple.CoreData.SQLDebug 1`) +- [ ] I tested on real device with real data (not simulator) +- [ ] I applied the FIRST matching pattern from Decision Tree +- [ ] I created a migration test if schema changed +- [ ] I verified at least 3 specific records migrated correctly +- [ ] I have a rollback plan documented + +#### If ALL boxes are checked and still broken +- You need custom NSEntityMigrationPolicy (not covered by basic patterns) +- Time cost: 60-90 minutes for complex migration +- Ask: "What data transformation is actually needed?" and implement custom policy + +#### Time cost transparency +- Pattern 1 (lightweight migration): 5-10 minutes +- Pattern 1 (heavy migration with custom policy): 60-90 minutes +- Pattern 2 (concurrency): 5-10 minutes +- Pattern 3 (prefetching): 2-5 minutes +- Pattern 4 (bridging): 30-60 minutes +- Pattern 5 (testing): 15-30 minutes + +--- + +## Common Mistakes + +❌ **Testing migration in simulator only** +- Simulator deletes database on rebuild, hiding schema mismatches +- Fix: ALWAYS test on real device or with production database copy + +❌ **Assuming default values protect against data loss** +- Default values only work for new records, not existing data +- Fix: Use NSEntityMigrationPolicy for existing data + +❌ **Accessing Core Data objects across threads without conversion** +- Objects are thread-confined, can't cross thread boundaries +- Fix: Convert to lightweight representations before passing to other threads + +❌ **Not realizing relationship access = database query** +- `user.posts` triggers a fetch for EACH user (N+1) +- Fix: Use relationshipKeyPathsForPrefetching or extract IDs first + +❌ **Mixing SwiftData and Core Data on same store** +- Both layers can't access the same database simultaneously +- Fix: Choose one layer, or use hybrid approach with separate entities + +❌ **Deploying migrations without pre-deployment testing** +- Edge cases in production data cause crashes +- Fix: MANDATORY migration test with real production data + +❌ **Rationalizing: "I'll just delete the data"** +- ❌ FORBIDDEN: Users won't appreciate losing their data +- Users uninstall and leave bad reviews +- Fix: Invest in safe migration testing + +--- + +## Production Crisis Pressure: Defending Safe Migration Patterns + +### The Problem + +Under production crisis pressure, you'll face requests to: +- "Users are crashing - just delete the database and start fresh" +- "Migration is taking too long - skip the testing and ship it" +- "We can't wait 2 days for proper migration - hack it together" +- "Schema mismatch? Just force-create a new store" + +These sound like pragmatic crisis responses. **But they cause data loss and permanent user trust damage.** Your job: defend using data safety principles and customer impact, not fear of pressure. + +### Red Flags — PM/Manager Requests That Cause Data Loss + +If you hear ANY of these during a production crisis, **STOP and reference this skill**: + +- ❌ **"Delete the persistent store and start fresh"** – Users lose ALL their data permanently +- ❌ **"Force lightweight migration without testing"** – High risk of data corruption in production +- ❌ **"Skip migration and create new store"** – Abandons existing user data +- ❌ **"We'll fix data issues after launch"** – Impossible to recover lost/corrupted data +- ❌ **"Just ship it, we can handle support tickets"** – Data loss creates permanent user churn +- ❌ **"Test on simulator is enough"** – Simulator deletes database on rebuild, hides schema mismatches + +### How to Push Back Professionally + +#### Step 1: Quantify the Customer Impact + +``` +"I want to resolve this crash ASAP, but let me show you what deleting the store means: + +Current situation: +- 10,000 active users with data +- Average 50 items per user (500,000 total records) +- Users have 1 week to 2 years of accumulated data + +If we delete the store: +- 10,000 users lose ALL their data on next app launch +- Uninstall rate: 60-80% (industry standard after data loss) +- App Store reviews: Expect 1-star reviews citing data loss +- Recovery: Impossible - data is gone permanently + +Safe alternative: +- Test migration on real device with production data copy (2-4 hours) +- Deploy migration that preserves user data +- Uninstall rate: <5% (standard update churn)" +``` + +#### Step 2: Demonstrate the Risk + +Show the PM/manager what happens: +1. Copy production database from device backup +2. Run proposed "quick fix" (delete store) +3. Show: All user data gone permanently +4. Show alternative: Safe migration preserving data +5. Time comparison: 30 min hack vs. 2-4 hour safe migration + +#### Reference +- "Users don't forgive data loss" (App Store review patterns) +- Migration testing on real device prevents 95% of production crashes +- Schema mismatch crashes affect 100% of existing users + +#### Step 3: Offer Compromise + +``` +"I can get us through this crisis while protecting user data: + +#### Fast track (4 hours total) +1. Copy production database from TestFlight user (30 min) +2. Write and test migration on real device copy (2 hours) +3. Submit build with tested migration (30 min) +4. Monitor first 100 updates for crashes (1 hour) + +#### Fallback if migration fails +- Have "delete store" build ready as Plan B +- Only deploy if migration shows 100% failure rate +- Communicate data loss to users proactively + +This approach: +- Tries safe path first (protects user data) +- Has emergency fallback (if migration impossible) +- Honest timeline (4 hours vs. "just delete it" 30 min)" +``` + +#### Step 4: Document the Decision + +If overruled (PM insists on deleting store): + +``` +Slack message to PM + team: + +"Production crisis: Schema mismatch causing crashes for existing users. + +PM decision: Delete persistent store to resolve immediately. + +Impact assessment: +- 10,000 users lose ALL data permanently on next app launch +- Expected uninstall rate: 60-80% based on data loss patterns +- App Store review damage: High risk of 1-star reviews +- Customer support: Expect high volume of data loss complaints +- Recovery: Impossible - deleted data cannot be recovered + +Alternative proposed (4-hour safe migration) was declined due to urgency. + +I'm flagging this decision proactively so we can: +1. Prepare support team for data loss complaints +2. Draft App Store response to expected negative reviews +3. Consider user communication about data loss before launch" +``` + +#### Why this works +- You're not questioning their judgment under pressure +- You're quantifying user impact (business consequences) +- You're offering a solution with honest timeline +- You're providing fallback option (not blocking progress) +- You're documenting the decision (protects you post-launch) + +### Real-World Example: Production Crash (500K Active Users) + +#### Scenario +- Production app crashing for 100% of users after update +- Error: "The model used to open the store is incompatible with the one used to create the store" +- CTO says: "Delete the database and ship hotfix in 2 hours" +- 500,000 active users with average 6 months of data each + +#### What to do + +```swift +// ❌ WRONG - Deletes all user data (CTO's request) +let coordinator = NSPersistentStoreCoordinator(managedObjectModel: model) +let storeURL = /* persistent store URL */ +try? FileManager.default.removeItem(at: storeURL) // 500K users lose data +try! coordinator.addPersistentStore(ofType: NSSQLiteStoreType, + configurationName: nil, + at: storeURL, + options: nil) + +// ✅ CORRECT - Safe lightweight migration (4-hour timeline) +let options = [ + NSMigratePersistentStoresAutomaticallyOption: true, + NSInferMappingModelAutomaticallyOption: true +] + +do { + try coordinator.addPersistentStore(ofType: NSSQLiteStoreType, + configurationName: nil, + at: storeURL, + options: options) + // Migration succeeded - user data preserved +} catch { + // Migration failed — NOW consider deleting with user communication + print("Migration error: \(error)") +} +``` + +#### In the meeting, show +1. Schema version mismatch causing crash +2. Lightweight migration can fix automatically +3. Testing on production database copy (2 hours) +4. Time comparison: 2 hours (safe) vs. immediate (data loss) + +**Time estimate** 4 hours total (2 hours migration testing, 2 hours build/deploy) + +#### Result +- Honest timeline manages expectations +- Safe migration preserves 500K users' data +- Uninstall rate: 3% (standard update churn) +- App Store reviews: No data loss complaints + +#### Alternative if migration truly impossible +- Document why migration failed +- Communicate data loss to users proactively +- Provide export feature in next version + +### When to Accept Data Loss (Even If You Disagree) + +Sometimes data loss is the only option. Accept if: + +- [ ] Migration is genuinely impossible (tried on production data copy) +- [ ] PM/CTO understand 60-80% expected uninstall rate +- [ ] Team commits to user communication about data loss +- [ ] You've documented technical reasons migration failed + +#### Document in Slack + +``` +"Production crisis: Migration failed on production data copy after 4-hour testing. + +Technical details: +- Attempted lightweight migration: Failed with [error] +- Attempted heavy migration with mapping model: Failed with [error] +- Root cause: [specific schema incompatibility] + +Data loss decision: +- No safe migration path exists +- PM approved delete persistent store approach +- Expected impact: 60-80% uninstall rate (500K → 100-200K users) + +Mitigation plan: +- Add data export feature before next schema change +- Communicate data loss to users via in-app message +- Prepare support team for complaints +- Monitor uninstall rates post-launch" +``` + +This protects you and shows you exhausted safe options first. + +--- + +## Real-World Impact + +**Before** Core Data debugging 3-8 hours per issue +- Crashes in production (schema mismatch) +- Performance gradually degrades (N+1 queries) +- Thread errors in background operations +- Data corruption from unsafe migrations +- Customer trust damaged + +**After** 30 minutes to 2 hours with systematic diagnosis +- Identify problem type with diagnostics (5 min) +- Apply correct pattern (5-10 min) +- Test on real device (varies) +- Deploy with confidence + +**Key insight** Core Data has well-established patterns for every common issue. The problem is developers don't know which pattern applies to their symptom. + +--- + +**Last Updated**: 2025-11-30 +**Status**: TDD-tested with pressure scenarios +**Framework**: Core Data (Foundation framework) +**Complements**: SwiftData skill (understanding relationship to Core Data) diff --git a/.claude/skills/axiom-core-data-diag/agents/openai.yaml b/.claude/skills/axiom-core-data-diag/agents/openai.yaml new file mode 100644 index 0000000..1029d25 --- /dev/null +++ b/.claude/skills/axiom-core-data-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Core Data Diagnostics" + short_description: "Debugging schema migration crashes, concurrency thread-confinement errors, N+1 query performance, SwiftData to Core D..." diff --git a/.claude/skills/axiom-core-data/.openskills.json b/.claude/skills/axiom-core-data/.openskills.json new file mode 100644 index 0000000..cbd62bb --- /dev/null +++ b/.claude/skills/axiom-core-data/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-core-data", + "installedAt": "2026-04-12T08:06:06.545Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-core-data/SKILL.md b/.claude/skills/axiom-core-data/SKILL.md new file mode 100644 index 0000000..a45ed0a --- /dev/null +++ b/.claude/skills/axiom-core-data/SKILL.md @@ -0,0 +1,417 @@ +--- +name: axiom-core-data +description: Use when choosing Core Data vs SwiftData, setting up the Core Data stack, modeling relationships, or implementing concurrency patterns - prevents thread-confinement errors and migration crashes +license: MIT +compatibility: iOS 3+, macOS 10.4+ +metadata: + version: "1.0.0" + last-updated: "2025-12-25" +--- + +# Core Data + +## Overview + +**Core principle**: Core Data is a mature object graph and persistence framework. Use it when needing features SwiftData doesn't support, or when targeting older iOS versions. + +**When to use Core Data vs SwiftData**: +- **SwiftData** (iOS 17+) — New apps, simpler API, Swift-native +- **Core Data** — iOS 16 and earlier, advanced features, existing codebases + +## Quick Decision Tree + +``` +Which persistence framework? + +├─ Targeting iOS 17+ only? +│ ├─ Simple data model? → SwiftData (recommended) +│ ├─ Need public CloudKit database? → Core Data (SwiftData is private-only) +│ ├─ Need custom migration logic? → Core Data (more control) +│ └─ Existing Core Data app? → Keep Core Data or migrate gradually +│ +├─ Targeting iOS 16 or earlier? +│ └─ Core Data (SwiftData unavailable) +│ +└─ Need both? → Use Core Data with SwiftData wrapper (advanced) +``` + +## Red Flags + +If ANY of these appear, STOP: + +- ❌ "Access managed objects on any thread" — Thread-confinement violation +- ❌ "Skip migration testing on real device" — Simulator hides schema issues +- ❌ "Use a singleton context everywhere" — Leads to concurrency crashes +- ❌ "Force lightweight migration always" — Complex changes need mapping models +- ❌ "Fetch in view body" — Use @FetchRequest or observe in view model + +## Core Data Stack Setup + +### Modern Stack (iOS 10+) + +```swift +import CoreData + +class CoreDataStack { + static let shared = CoreDataStack() + + lazy var persistentContainer: NSPersistentContainer = { + let container = NSPersistentContainer(name: "Model") + + // Configure for CloudKit if needed + // container.persistentStoreDescriptions.first?.cloudKitContainerOptions = + // NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.com.app") + + container.loadPersistentStores { description, error in + if let error = error { + // Handle appropriately for production + fatalError("Failed to load store: \(error)") + } + } + + // Enable automatic merging + container.viewContext.automaticallyMergesChangesFromParent = true + container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + + return container + }() + + var viewContext: NSManagedObjectContext { + persistentContainer.viewContext + } + + func newBackgroundContext() -> NSManagedObjectContext { + persistentContainer.newBackgroundContext() + } +} +``` + +### CloudKit Integration + +```swift +import CoreData + +class CloudKitStack { + lazy var container: NSPersistentCloudKitContainer = { + let container = NSPersistentCloudKitContainer(name: "Model") + + guard let description = container.persistentStoreDescriptions.first else { + fatalError("No store description") + } + + // Enable CloudKit sync + description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions( + containerIdentifier: "iCloud.com.yourapp" + ) + + // Enable history tracking for sync + description.setOption(true as NSNumber, + forKey: NSPersistentHistoryTrackingKey) + description.setOption(true as NSNumber, + forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) + + container.loadPersistentStores { _, error in + if let error = error { + fatalError("CloudKit store failed: \(error)") + } + } + + container.viewContext.automaticallyMergesChangesFromParent = true + + return container + }() +} +``` + +## Concurrency Patterns + +### The Golden Rule + +**NEVER pass NSManagedObject across threads.** Pass objectID instead. + +```swift +// ❌ WRONG: Passing object across threads +let user = viewContext.fetch(...) // Main thread +Task.detached { + print(user.name) // CRASH: Wrong thread +} + +// ✅ CORRECT: Pass objectID, fetch on target context +let userID = user.objectID +Task.detached { + let bgContext = CoreDataStack.shared.newBackgroundContext() + let user = bgContext.object(with: userID) as! User + print(user.name) // Safe +} +``` + +### Background Processing + +```swift +// ✅ CORRECT: Background context for heavy work +func importData(_ items: [ImportItem]) async throws { + let context = CoreDataStack.shared.newBackgroundContext() + + try await context.perform { + for item in items { + let entity = Entity(context: context) + entity.configure(from: item) + } + + try context.save() + } +} + +// Changes automatically merge to viewContext if configured +``` + +### Async/Await (iOS 15+) + +```swift +// Modern async context operations +func fetchUsers() async throws -> [User] { + let context = CoreDataStack.shared.viewContext + + return try await context.perform { + let request = User.fetchRequest() + request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] + return try context.fetch(request) + } +} +``` + +## Relationship Modeling + +### One-to-Many + +```swift +// In User entity +@NSManaged var posts: NSSet? + +// Convenience accessors +extension User { + var postsArray: [Post] { + (posts?.allObjects as? [Post]) ?? [] + } + + func addPost(_ post: Post) { + mutableSetValue(forKey: "posts").add(post) + } +} +``` + +### Many-to-Many + +```swift +// Both sides have NSSet +// User.tags <-> Tag.users + +extension User { + func addTag(_ tag: Tag) { + mutableSetValue(forKey: "tags").add(tag) + // Core Data automatically adds to tag.users + } +} +``` + +### Delete Rules + +| Rule | Behavior | Use Case | +|------|----------|----------| +| **Nullify** | Set relationship to nil | Optional relationships | +| **Cascade** | Delete related objects | Owned children (User → Posts) | +| **Deny** | Prevent deletion if related objects exist | Protect referenced data | +| **No Action** | Do nothing (manual cleanup required) | Rarely appropriate | + +## Fetching Patterns + +### SwiftUI Integration + +```swift +struct UserList: View { + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \User.name, ascending: true)], + predicate: NSPredicate(format: "isActive == YES"), + animation: .default + ) + private var users: FetchedResults + + var body: some View { + List(users) { user in + Text(user.name ?? "Unknown") + } + } +} + +// Dynamic predicates +struct FilteredList: View { + @FetchRequest var items: FetchedResults + + init(category: String) { + _items = FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \Item.date, ascending: false)], + predicate: NSPredicate(format: "category == %@", category) + ) + } +} +``` + +### Batch Fetching (Avoid N+1) + +```swift +// ❌ WRONG: N+1 queries +let users = try context.fetch(User.fetchRequest()) +for user in users { + print(user.posts?.count ?? 0) // Fault fired for each user +} + +// ✅ CORRECT: Prefetch relationships +let request = User.fetchRequest() +request.relationshipKeyPathsForPrefetching = ["posts"] +let users = try context.fetch(request) +for user in users { + print(user.posts?.count ?? 0) // Already loaded +} +``` + +### Batch Size for Large Datasets + +```swift +let request = User.fetchRequest() +request.fetchBatchSize = 20 // Load 20 at a time as needed +request.returnsObjectsAsFaults = true // Default, memory efficient +``` + +## Schema Migration + +### Lightweight Migration (Automatic) + +Handled automatically for: +- Adding optional attributes +- Removing attributes +- Renaming (with renaming identifier) +- Adding relationships with optional or default value + +```swift +let description = NSPersistentStoreDescription() +description.shouldMigrateStoreAutomatically = true +description.shouldInferMappingModelAutomatically = true +``` + +### When Mapping Model Is Needed + +- Changing attribute types +- Splitting/merging entities +- Complex relationship changes +- Data transformation during migration + +```swift +// Create mapping model in Xcode: +// File → New → Mapping Model +// Select source and destination models +``` + +### Migration Testing Checklist + +**MANDATORY before shipping**: + +1. ✓ Test on REAL DEVICE (simulator deletes DB on rebuild) +2. ✓ Install old version, create data +3. ✓ Install new version over it +4. ✓ Verify all data accessible +5. ✓ Check migration performance (large datasets) + +## Anti-Patterns + +### 1. Singleton Context for Everything + +```swift +// ❌ WRONG: One context for all operations +class DataManager { + let context = CoreDataStack.shared.viewContext + + func importInBackground() { + // Using main context on background = crash + for item in largeDataset { + let entity = Entity(context: context) + } + } +} + +// ✅ CORRECT: Context per operation type +func importInBackground() { + let bgContext = CoreDataStack.shared.newBackgroundContext() + bgContext.perform { + // Safe background work + } +} +``` + +### 2. Fetching in View Body + +```swift +// ❌ WRONG: Fetch on every render +var body: some View { + let users = try? context.fetch(User.fetchRequest()) // Called repeatedly! + List(users ?? []) { ... } +} + +// ✅ CORRECT: Use @FetchRequest +@FetchRequest(sortDescriptors: []) +var users: FetchedResults + +var body: some View { + List(users) { ... } // Automatic updates +} +``` + +### 3. Ignoring Merge Policy + +```swift +// ❌ WRONG: No merge policy (conflicts crash) +let context = container.viewContext + +// ✅ CORRECT: Define merge behavior +context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy +context.automaticallyMergesChangesFromParent = true +``` + +## Performance Tips + +1. **Use fetchBatchSize** for large result sets +2. **Prefetch relationships** that will be accessed +3. **Use background contexts** for imports/exports +4. **Batch save** — don't save after each insert +5. **Use fetchLimit** when only first N results are needed +6. **Profile with SQL debug**: `-com.apple.CoreData.SQLDebug 1` + +## Pressure Scenarios + +### Scenario 1: "SwiftData is simpler, let's migrate now" + +**Situation**: New iOS 17 features available, temptation to migrate mid-project. + +**Risk**: Migration is complex. Mixed Core Data + SwiftData has sharp edges. + +**Response**: "Complete current milestone first. Migration needs dedicated time and testing." + +### Scenario 2: "Skip migration testing, simulator works" + +**Situation**: Schema change tested only in simulator. + +**Risk**: Simulator deletes database on rebuild. Real devices keep persistent data and crash. + +**Response**: "MANDATORY: Test on real device with real data. 15 minutes now prevents production crash." + +## tvOS + +**CoreData + CloudKit is dangerous on tvOS.** CloudKit metadata causes significant space inflation in the local store, and tvOS has no persistent local storage — the system deletes Caches (including Application Support) at any time. The inflated store plus random deletion is a worst-case combination. + +**Recommendation**: Use SQLiteData with CloudKit SyncEngine instead for tvOS data persistence. See `axiom-tvos` for full tvOS storage constraints. + +## Related Skills + +- `axiom-core-data-diag` — Debugging migrations, thread errors, N+1 queries +- `axiom-swiftdata` — Modern alternative for iOS 17+ +- `axiom-database-migration` — Safe schema evolution patterns +- `axiom-swift-concurrency` — Async/await patterns for Core Data diff --git a/.claude/skills/axiom-core-data/agents/openai.yaml b/.claude/skills/axiom-core-data/agents/openai.yaml new file mode 100644 index 0000000..38c3441 --- /dev/null +++ b/.claude/skills/axiom-core-data/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Core Data" + short_description: "Choosing Core Data vs SwiftData, setting up the Core Data stack, modeling relationships, or implementing concurrency ..." diff --git a/.claude/skills/axiom-core-location-diag/.openskills.json b/.claude/skills/axiom-core-location-diag/.openskills.json new file mode 100644 index 0000000..bb2cc87 --- /dev/null +++ b/.claude/skills/axiom-core-location-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-core-location-diag", + "installedAt": "2026-04-12T08:06:08.079Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-core-location-diag/SKILL.md b/.claude/skills/axiom-core-location-diag/SKILL.md new file mode 100644 index 0000000..a313283 --- /dev/null +++ b/.claude/skills/axiom-core-location-diag/SKILL.md @@ -0,0 +1,540 @@ +--- +name: axiom-core-location-diag +description: Use for Core Location troubleshooting - no location updates, background location broken, authorization denied, geofence not triggering +license: MIT +compatibility: iOS 17+, iPadOS 17+, macOS 14+, watchOS 10+ +metadata: + version: "1.0.0" + last-updated: "2026-01-03" +--- + +# Core Location Diagnostics + +Symptom-based troubleshooting for Core Location issues. + +## When to Use + +- Location updates never arrive +- Background location stops working +- Authorization always denied +- Location accuracy unexpectedly poor +- Geofence events not triggering +- Location icon won't go away + +## Related Skills + +- `axiom-core-location` — Implementation patterns, decision trees +- `axiom-core-location-ref` — API reference, code examples +- `axiom-energy-diag` — Battery drain from location +- `axiom-mapkit-diag` — For map-specific location display issues (Symptom 7) + +--- + +## Symptom 1: Location Updates Never Arrive + +### Quick Checks + +```swift +// 1. Check authorization +let status = CLLocationManager().authorizationStatus +print("Authorization: \(status.rawValue)") +// 0=notDetermined, 1=restricted, 2=denied, 3=authorizedAlways, 4=authorizedWhenInUse + +// 2. Check if location services enabled system-wide +print("Services enabled: \(CLLocationManager.locationServicesEnabled())") + +// 3. Check accuracy authorization +let accuracy = CLLocationManager().accuracyAuthorization +print("Accuracy: \(accuracy == .fullAccuracy ? "full" : "reduced")") +``` + +### Decision Tree + +``` +Q1: What does authorizationStatus return? +├─ .notDetermined → Authorization never requested +│ Fix: Add CLServiceSession(authorization: .whenInUse) or requestWhenInUseAuthorization() +│ +├─ .denied → User denied access +│ Fix: Show UI explaining why location needed, link to Settings +│ +├─ .restricted → Parental controls block access +│ Fix: Inform user, offer manual location input +│ +└─ .authorizedWhenInUse / .authorizedAlways → Check next + +Q2: Is locationServicesEnabled() returning true? +├─ NO → Location services disabled system-wide +│ Fix: Show UI prompting user to enable in Settings → Privacy → Location Services +│ +└─ YES → Check next + +Q3: Are you iterating the AsyncSequence? +├─ NO → Updates only arrive when you await +│ Fix: Task { for try await update in CLLocationUpdate.liveUpdates() { ... } } +│ +└─ YES → Check next + +Q4: Is the Task cancelled or broken? +├─ YES → Task cancelled before updates arrived +│ Fix: Ensure Task lives long enough (store in property, not local) +│ +└─ NO → Check next + +Q5: Is location available? (iOS 17+) +├─ Check update.locationUnavailable +│ If true: Device cannot determine location (indoors, airplane mode, no GPS) +│ Fix: Wait or inform user to move to better location +│ +└─ Check update.authorizationDenied / update.authorizationDeniedGlobally + If true: Handle denial gracefully +``` + +### Info.plist Checklist + +```xml + +NSLocationWhenInUseUsageDescription +Your clear explanation here + + +NSLocationAlwaysAndWhenInUseUsageDescription +Your clear explanation here +``` + +Missing these keys = silent failure with no prompt. + +--- + +## Symptom 2: Background Location Not Working + +### Quick Checks + +1. **Background mode capability**: Xcode → Signing & Capabilities → Background Modes → Location updates +2. **Info.plist**: Should have `UIBackgroundModes` with `location` value +3. **CLBackgroundActivitySession**: Must be created AND held + +### Decision Tree + +``` +Q1: Is "Location updates" checked in Background Modes? +├─ NO → Background location silently disabled +│ Fix: Xcode → Signing & Capabilities → Background Modes → Location updates +│ +└─ YES → Check next + +Q2: Are you holding CLBackgroundActivitySession? +├─ NO / Using local variable → Session deallocates, background stops +│ Fix: Store in property: var backgroundSession: CLBackgroundActivitySession? +│ +└─ YES → Check next + +Q3: Was session started from foreground? +├─ NO → Cannot start new session from background +│ Fix: Create CLBackgroundActivitySession while app in foreground +│ +└─ YES → Check next + +Q4: Is app being terminated and not recovering? +├─ YES → Not recreating session on relaunch +│ Fix: In didFinishLaunchingWithOptions: +│ if wasTrackingLocation { +│ backgroundSession = CLBackgroundActivitySession() +│ startLocationUpdates() +│ } +│ +└─ NO → Check authorization level + +Q5: What is authorization level? +├─ .authorizedWhenInUse → This is fine with CLBackgroundActivitySession +│ The blue indicator allows background access +│ +├─ .authorizedAlways → Should work, check session lifecycle +│ +└─ .denied → No background access possible +``` + +### Common Mistakes + +```swift +// ❌ WRONG: Local variable deallocates immediately +func startTracking() { + let session = CLBackgroundActivitySession() // Dies at end of function! + startLocationUpdates() +} + +// ✅ RIGHT: Property keeps session alive +var backgroundSession: CLBackgroundActivitySession? + +func startTracking() { + backgroundSession = CLBackgroundActivitySession() + startLocationUpdates() +} +``` + +--- + +## Symptom 3: Authorization Always Denied + +### Decision Tree + +``` +Q1: Is this a fresh install or returning user? +├─ FRESH INSTALL with immediate denial → Check Info.plist strings +│ Missing/empty NSLocationWhenInUseUsageDescription = automatic denial +│ +└─ RETURNING USER → Check previous denial + +Q2: Did user previously deny? +├─ YES → User must manually re-enable in Settings +│ Fix: Show UI explaining value, with button to open Settings: +│ UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) +│ +└─ NO → Check next + +Q3: Are you requesting authorization at wrong time? +├─ Requesting when app not "in use" → insufficientlyInUse +│ Check: update.insufficientlyInUse or diagnostic.insufficientlyInUse +│ Fix: Only request authorization from foreground, during user interaction +│ +└─ NO → Check next + +Q4: Is device in restricted mode? +├─ YES → .restricted status (parental controls, MDM) +│ Fix: Cannot override. Offer manual location input. +│ +└─ NO → Check Info.plist again + +Q5: Are Info.plist strings compelling? +├─ Generic string → Users more likely to deny +│ Bad: "This app needs your location" +│ Good: "Your location helps us show restaurants within walking distance" +│ +└─ Review: Look at string from user's perspective +``` + +### Info.plist String Best Practices + +```xml + +NSLocationWhenInUseUsageDescription +We need your location. + + +NSLocationWhenInUseUsageDescription +Your location helps show restaurants, coffee shops, and attractions within walking distance. + + +NSLocationAlwaysAndWhenInUseUsageDescription +We need your location always. + + +NSLocationAlwaysAndWhenInUseUsageDescription +Enable background location to receive reminders when you arrive at saved places, even when the app is closed. +``` + +--- + +## Symptom 4: Location Accuracy Unexpectedly Poor + +### Quick Checks + +```swift +// 1. Check accuracy authorization +let accuracy = CLLocationManager().accuracyAuthorization +print("Accuracy auth: \(accuracy == .fullAccuracy ? "full" : "reduced")") + +// 2. Check update's accuracy flag (iOS 17+) +for try await update in CLLocationUpdate.liveUpdates() { + if update.accuracyLimited { + print("Accuracy limited - updates every 15-20 min") + } + if let location = update.location { + print("Horizontal accuracy: \(location.horizontalAccuracy)m") + } +} +``` + +### Decision Tree + +``` +Q1: What is accuracyAuthorization? +├─ .reducedAccuracy → User chose approximate location +│ Options: +│ 1. Accept reduced accuracy (weather, city-level features) +│ 2. Request temporary full accuracy: +│ CLServiceSession(authorization: .whenInUse, fullAccuracyPurposeKey: "Navigation") +│ 3. Explain value and link to Settings +│ +└─ .fullAccuracy → Check environment and configuration + +Q2: What is horizontalAccuracy on locations? +├─ < 0 (typically -1) → INVALID location, do not use +│ Meaning: System could not determine accuracy (no valid fix) +│ Fix: Filter out: guard location.horizontalAccuracy >= 0 else { continue } +│ Common when: Indoors with no WiFi, airplane mode, immediately after cold start +│ +├─ > 100m → Likely using WiFi/cell only (no GPS) +│ Causes: Indoors, airplane mode, dense urban canyon +│ Fix: User needs to move to better location, or wait for GPS lock +│ +├─ 10-100m → Normal for most use cases +│ If need better: Use .automotiveNavigation or .otherNavigation config +│ +└─ < 10m → Good GPS accuracy + Note: .automotiveNavigation can achieve ~5m + +Q3: What LiveConfiguration are you using? +├─ .default or none → System manages, may prioritize battery +│ If need more accuracy: Use .fitness, .otherNavigation, or .automotiveNavigation +│ +├─ .fitness → Good for pedestrian activities +│ +└─ .automotiveNavigation → Highest accuracy, axiom-highest battery + Only use for actual navigation + +Q4: Is the location stale? +├─ Check location.timestamp +│ If old: Device hasn't moved, or updates paused (isStationary) +│ +└─ If timestamp recent but accuracy poor: Environmental issue +``` + +### Requesting Temporary Full Accuracy (iOS 18+) + +```swift +// Requires Info.plist entry: +// NSLocationTemporaryUsageDescriptionDictionary +// NavigationPurpose: "Precise location enables turn-by-turn directions" + +let session = CLServiceSession( + authorization: .whenInUse, + fullAccuracyPurposeKey: "NavigationPurpose" +) +``` + +--- + +## Symptom 5: Geofence Events Not Triggering + +### Quick Checks + +```swift +let monitor = await CLMonitor("MyMonitor") + +// 1. Check condition count (max 20) +let count = await monitor.identifiers.count +print("Conditions: \(count)/20") + +// 2. Check specific condition +if let record = await monitor.record(for: "MyGeofence") { + let lastEvent = record.lastEvent + print("State: \(lastEvent.state)") + print("Date: \(lastEvent.date)") + + if let geo = record.condition as? CLMonitor.CircularGeographicCondition { + print("Center: \(geo.center)") + print("Radius: \(geo.radius)m") + } +} +``` + +### Decision Tree + +``` +Q1: How many conditions are monitored? +├─ 20 → At the limit, new conditions ignored +│ Fix: Prioritize important conditions, swap dynamically based on user location +│ Check: lastEvent.conditionLimitExceeded +│ +└─ < 20 → Check next + +Q2: What is the radius? +├─ < 100m → Unreliable, may not trigger +│ Fix: Use minimum 100m radius for reliable detection +│ +└─ >= 100m → Check next + +Q3: Is the app awaiting monitor.events? +├─ NO → Events not processed, lastEvent not updated +│ Fix: Always have a Task awaiting: +│ for try await event in monitor.events { ... } +│ +└─ YES → Check next + +Q4: Was monitor reinitialized on app launch? +├─ NO → Monitor conditions lost after termination +│ Fix: Recreate monitor with same name in didFinishLaunchingWithOptions +│ +└─ YES → Check next + +Q5: What does lastEvent show? +├─ state: .unknown → System hasn't determined state yet +│ Wait for determination, or check if monitoring is working +│ +├─ state: .satisfied → Inside region, waiting for exit +│ +├─ state: .unsatisfied → Outside region, waiting for entry +│ +└─ Check lastEvent.date → When was last update? + If very old: May not be monitoring correctly + +Q6: Is accuracyLimited preventing monitoring? +├─ Check: lastEvent.accuracyLimited +│ If true: Reduced accuracy prevents geofencing +│ Fix: Request full accuracy or accept limitation +│ +└─ NO → Check environment (device must have location access) +``` + +### Common Mistakes + +```swift +// ❌ WRONG: Not awaiting events +let monitor = await CLMonitor("Test") +await monitor.add(condition, identifier: "Place") +// Nothing happens - no Task awaiting events! + +// ✅ RIGHT: Always await events +let monitor = await CLMonitor("Test") +await monitor.add(condition, identifier: "Place") + +Task { + for try await event in monitor.events { + switch event.state { + case .satisfied: handleEntry(event.identifier) + case .unsatisfied: handleExit(event.identifier) + case .unknown: break + @unknown default: break + } + } +} + +// ❌ WRONG: Creating multiple monitors with same name +let monitor1 = await CLMonitor("App") // OK +let monitor2 = await CLMonitor("App") // UNDEFINED BEHAVIOR + +// ✅ RIGHT: One monitor instance per name +class LocationService { + private var monitor: CLMonitor? + + func setup() async { + monitor = await CLMonitor("App") + } +} +``` + +--- + +## Symptom 6: Location Icon Won't Go Away + +### Quick Checks + +The location arrow appears when: +- App actively receiving location updates +- CLMonitor is monitoring conditions +- Background activity session active + +### Decision Tree + +``` +Q1: Is your app still iterating liveUpdates? +├─ YES → Updates continue until you break/cancel +│ Fix: Cancel the Task or break from loop: +│ locationTask?.cancel() +│ +└─ NO → Check next + +Q2: Is CLBackgroundActivitySession still held? +├─ YES → Session keeps location access active +│ Fix: Invalidate when done: +│ backgroundSession?.invalidate() +│ backgroundSession = nil +│ +└─ NO → Check next + +Q3: Is CLMonitor still monitoring conditions? +├─ YES → CLMonitor uses location for geofencing +│ Note: This is expected behavior - icon shows monitoring active +│ Fix: If truly done, remove all conditions: +│ for id in await monitor.identifiers { +│ await monitor.remove(id) +│ } +│ +└─ NO → Check next + +Q4: Is legacy CLLocationManager still running? +├─ Check: manager.stopUpdatingLocation() called? +│ Check: manager.stopMonitoring(for: region) for all regions? +│ Fix: Ensure all legacy APIs stopped +│ +└─ NO → Check other location-using frameworks + +Q5: Other frameworks using location? +├─ MapKit with showsUserLocation = true → Shows location +│ Fix: mapView.showsUserLocation = false when not needed +│ +├─ Core Motion with location → Shows location +│ +└─ Check all location-using code +``` + +### Force Stop All Location + +```swift +// Stop modern APIs +locationTask?.cancel() +backgroundSession?.invalidate() +backgroundSession = nil + +// Remove all CLMonitor conditions +for id in await monitor.identifiers { + await monitor.remove(id) +} + +// Stop legacy APIs +manager.stopUpdatingLocation() +manager.stopMonitoringSignificantLocationChanges() +manager.stopMonitoringVisits() + +for region in manager.monitoredRegions { + manager.stopMonitoring(for: region) +} +``` + +--- + +## Console Debugging + +### Filter Location Logs + +```bash +# View locationd logs +log stream --predicate 'subsystem == "com.apple.locationd"' --level debug + +# View your app's location-related logs +log stream --predicate 'subsystem == "com.apple.CoreLocation"' --level debug + +# Filter for specific process +log stream --predicate 'process == "YourAppName" AND subsystem == "com.apple.CoreLocation"' +``` + +### Common Log Messages + +| Log Message | Meaning | +|-------------|---------| +| `Client is not authorized` | Authorization denied or not requested | +| `Location services disabled` | System-wide toggle off | +| `Accuracy authorization is reduced` | User chose approximate location | +| `Condition limit exceeded` | At 20-condition maximum | +| `Background location access denied` | Missing background capability or session | + +--- + +## Resources + +**WWDC**: 2023-10180, 2023-10147, 2024-10212 + +**Docs**: /corelocation, /corelocation/clmonitor, /corelocation/cllocationupdate + +**Skills**: axiom-core-location, axiom-core-location-ref, axiom-energy-diag diff --git a/.claude/skills/axiom-core-location-diag/agents/openai.yaml b/.claude/skills/axiom-core-location-diag/agents/openai.yaml new file mode 100644 index 0000000..cf131bd --- /dev/null +++ b/.claude/skills/axiom-core-location-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Core Location Diagnostics" + short_description: "Core Location troubleshooting" diff --git a/.claude/skills/axiom-core-location-ref/.openskills.json b/.claude/skills/axiom-core-location-ref/.openskills.json new file mode 100644 index 0000000..7f4f2e1 --- /dev/null +++ b/.claude/skills/axiom-core-location-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-core-location-ref", + "installedAt": "2026-04-12T08:06:08.634Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-core-location-ref/SKILL.md b/.claude/skills/axiom-core-location-ref/SKILL.md new file mode 100644 index 0000000..804732f --- /dev/null +++ b/.claude/skills/axiom-core-location-ref/SKILL.md @@ -0,0 +1,746 @@ +--- +name: axiom-core-location-ref +description: Use for Core Location API reference - CLLocationUpdate, CLMonitor, CLServiceSession, authorization, background location, geofencing +license: MIT +compatibility: iOS 17+, iPadOS 17+, macOS 14+, watchOS 10+ +metadata: + version: "1.0.0" + last-updated: "2026-01-03" +--- + +# Core Location Reference + +Comprehensive API reference for modern Core Location (iOS 17+). + +## When to Use + +- Need API signatures for CLLocationUpdate, CLMonitor, CLServiceSession +- Implementing geofencing or region monitoring +- Configuring background location updates +- Understanding authorization patterns +- Debugging location service issues + +## Related Skills + +- `axiom-core-location` — Anti-patterns, decision trees, pressure scenarios +- `axiom-core-location-diag` — Symptom-based troubleshooting +- `axiom-energy-ref` — Location as battery subsystem (accuracy vs power) + +--- + +## Part 1: Modern API Overview (iOS 17+) + +Four key classes replace legacy CLLocationManager patterns: + +| Class | Purpose | iOS | +|-------|---------|-----| +| `CLLocationUpdate` | AsyncSequence for location updates | 17+ | +| `CLMonitor` | Condition-based geofencing/beacons | 17+ | +| `CLServiceSession` | Declarative authorization goals | 18+ | +| `CLBackgroundActivitySession` | Background location support | 17+ | + +**Migration path**: Legacy CLLocationManager still works, but new APIs provide: +- Swift concurrency (async/await) +- Automatic pause/resume +- Simplified authorization +- Better battery efficiency + +--- + +## Part 2: CLLocationUpdate API + +### Basic Usage + +```swift +import CoreLocation + +Task { + do { + for try await update in CLLocationUpdate.liveUpdates() { + if let location = update.location { + // Process location + } + if update.isStationary { + break // Stop when user stops moving + } + } + } catch { + // Handle location errors + } +} +``` + +### LiveConfiguration Options + +```swift +CLLocationUpdate.liveUpdates(.default) +CLLocationUpdate.liveUpdates(.automotiveNavigation) +CLLocationUpdate.liveUpdates(.otherNavigation) +CLLocationUpdate.liveUpdates(.fitness) +CLLocationUpdate.liveUpdates(.airborne) +``` + +Choose based on use case. If unsure, use `.default` or omit parameter. + +### Key Properties + +| Property | Type | Description | +|----------|------|-------------| +| `location` | `CLLocation?` | Current location (nil if unavailable) | +| `isStationary` | `Bool` | True when device stopped moving | +| `authorizationDenied` | `Bool` | User denied location access | +| `authorizationDeniedGlobally` | `Bool` | Location services disabled system-wide | +| `authorizationRequestInProgress` | `Bool` | Awaiting user authorization decision | +| `accuracyLimited` | `Bool` | Reduced accuracy (updates every 15-20 min) | +| `locationUnavailable` | `Bool` | Cannot determine location | +| `insufficientlyInUse` | `Bool` | Can't request auth (not in foreground) | + +### Automatic Pause/Resume + +When device becomes stationary: +1. Final update delivered with `isStationary = true` and valid `location` +2. Updates pause (saves battery) +3. When device moves, updates resume with `isStationary = false` + +No action required—happens automatically. + +### AsyncSequence Operations + +```swift +// Get first location with speed > 10 m/s +let fastUpdate = try await CLLocationUpdate.liveUpdates() + .first { $0.location?.speed ?? 0 > 10 } + +// WARNING: Avoid filters that may never match (e.g., horizontalAccuracy < 1) +``` + +--- + +## Part 3: CLMonitor API + +Swift actor for monitoring geographic conditions and beacons. + +### Basic Geofencing + +```swift +let monitor = await CLMonitor("MyMonitor") + +// Add circular region +let condition = CLMonitor.CircularGeographicCondition( + center: CLLocationCoordinate2D(latitude: 37.33, longitude: -122.01), + radius: 100 +) +await monitor.add(condition, identifier: "ApplePark") + +// Await events +for try await event in monitor.events { + switch event.state { + case .satisfied: // User entered region + handleEntry(event.identifier) + case .unsatisfied: // User exited region + handleExit(event.identifier) + case .unknown: + break + @unknown default: + break + } +} +``` + +### CircularGeographicCondition + +```swift +CLMonitor.CircularGeographicCondition( + center: CLLocationCoordinate2D, + radius: CLLocationDistance // meters, minimum ~100m effective +) +``` + +### BeaconIdentityCondition + +Three granularity levels: + +```swift +// All beacons with UUID (any site) +CLMonitor.BeaconIdentityCondition(uuid: myUUID) + +// Specific site (UUID + major) +CLMonitor.BeaconIdentityCondition(uuid: myUUID, major: 100) + +// Specific beacon (UUID + major + minor) +CLMonitor.BeaconIdentityCondition(uuid: myUUID, major: 100, minor: 5) +``` + +### Condition Limit + +**Maximum 20 conditions per app.** Prioritize what to monitor. Swap regions dynamically based on user location if needed. + +### Adding with Assumed State + +```swift +// If you know initial state +await monitor.add(condition, identifier: "Work", assuming: .unsatisfied) +``` + +Core Location will correct if assumption wrong. + +### Accessing Records + +```swift +// Get single record +if let record = await monitor.record(for: "ApplePark") { + let condition = record.condition + let lastEvent = record.lastEvent + let state = lastEvent.state + let date = lastEvent.date +} + +// Get all identifiers +let allIds = await monitor.identifiers +``` + +### Event Properties + +| Property | Description | +|----------|-------------| +| `identifier` | String identifier of condition | +| `state` | `.satisfied`, `.unsatisfied`, `.unknown` | +| `date` | When state changed | +| `refinement` | For wildcard beacons, actual UUID/major/minor detected | +| `conditionLimitExceeded` | Too many conditions (max 20) | +| `conditionUnsupported` | Condition type not available | +| `accuracyLimited` | Reduced accuracy prevents monitoring | + +### Critical Requirements + +1. **One monitor per name** — Only one instance with given name at a time +2. **Always await events** — Events only become `lastEvent` after handling +3. **Reinitialize on launch** — Recreate monitor in `didFinishLaunchingWithOptions` + +--- + +## Part 4: CLServiceSession API (iOS 18+) + +Declarative authorization—tell Core Location what you need, not what to do. + +### Basic Usage + +```swift +// Hold session for duration of feature +let session = CLServiceSession(authorization: .whenInUse) + +for try await update in CLLocationUpdate.liveUpdates() { + // Process updates +} +``` + +### Authorization Requirements + +```swift +CLServiceSession(authorization: .none) // No auth request +CLServiceSession(authorization: .whenInUse) // Request When In Use +CLServiceSession(authorization: .always) // Request Always (must start in foreground) +``` + +### Full Accuracy Request + +```swift +// For features requiring precise location (e.g., navigation) +CLServiceSession( + authorization: .whenInUse, + fullAccuracyPurposeKey: "NavigationPurpose" // Key in Info.plist +) +``` + +Requires `NSLocationTemporaryUsageDescriptionDictionary` in Info.plist. + +### Implicit Sessions + +Iterating `CLLocationUpdate.liveUpdates()` or `CLMonitor.events` creates implicit session with `.whenInUse` goal. + +To disable implicit sessions: +```xml + +NSLocationRequireExplicitServiceSession + +``` + +### Session Layering + +Don't replace sessions—layer them: + +```swift +// Base session for app +let baseSession = CLServiceSession(authorization: .whenInUse) + +// Additional session when navigation feature active +let navSession = CLServiceSession( + authorization: .whenInUse, + fullAccuracyPurposeKey: "Nav" +) +// Both sessions active simultaneously +``` + +### Diagnostic Properties + +```swift +for try await diagnostic in session.diagnostics { + if diagnostic.authorizationDenied { + // User denied—offer alternative + } + if diagnostic.authorizationDeniedGlobally { + // Location services off system-wide + } + if diagnostic.insufficientlyInUse { + // Can't request auth (not foreground) + } + if diagnostic.alwaysAuthorizationDenied { + // Always auth specifically denied + } + if !diagnostic.authorizationRequestInProgress { + // Decision made (granted or denied) + break + } +} +``` + +### Session Lifecycle + +Sessions persist through: +- App backgrounding +- App suspension +- App termination (Core Location tracks) + +On relaunch, recreate sessions immediately in `didFinishLaunchingWithOptions`. + +--- + +## Part 5: Authorization State Machine + +### Authorization Levels + +| Status | Description | +|--------|-------------| +| `.notDetermined` | User hasn't decided | +| `.restricted` | Parental controls prevent access | +| `.denied` | User explicitly refused | +| `.authorizedWhenInUse` | Access while app active | +| `.authorizedAlways` | Background access | + +### Accuracy Authorization + +| Value | Description | +|-------|-------------| +| `.fullAccuracy` | Precise location | +| `.reducedAccuracy` | Approximate (~5km), updates every 15-20 min | + +### Required Info.plist Keys + +```xml + +NSLocationWhenInUseUsageDescription +We need your location to show nearby places + + +NSLocationAlwaysAndWhenInUseUsageDescription +We track your location to send arrival reminders + + +NSLocationDefaultAccuracyReduced + +``` + +### Legacy Authorization Pattern + +```swift +@MainActor +class LocationManager: NSObject, CLLocationManagerDelegate { + private let manager = CLLocationManager() + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + switch manager.authorizationStatus { + case .notDetermined: + manager.requestWhenInUseAuthorization() + case .authorizedWhenInUse, .authorizedAlways: + enableLocationFeatures() + case .denied, .restricted: + disableLocationFeatures() + @unknown default: + break + } + } +} +``` + +--- + +## Part 6: Background Location + +### Requirements + +1. **Background mode capability**: Signing & Capabilities → Background Modes → Location updates +2. **Info.plist**: Adds `UIBackgroundModes` with `location` value +3. **CLBackgroundActivitySession** or **LiveActivity** + +### CLBackgroundActivitySession + +```swift +// Create and HOLD reference (deallocation invalidates session) +var backgroundSession: CLBackgroundActivitySession? + +func startBackgroundTracking() { + // Must start from foreground + backgroundSession = CLBackgroundActivitySession() + + Task { + for try await update in CLLocationUpdate.liveUpdates() { + processUpdate(update) + } + } +} + +func stopBackgroundTracking() { + backgroundSession?.invalidate() + backgroundSession = nil +} +``` + +### Background Indicator + +Blue status bar/pill appears when: +- App authorized as "When In Use" +- App receiving location in background +- CLBackgroundActivitySession active + +### App Lifecycle + +1. **Foreground → Background**: Session continues +2. **Background → Suspended**: Session preserved, updates pause +3. **Suspended → Terminated**: Core Location tracks session +4. **Terminated → Background launch**: Recreate session immediately + +```swift +func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Recreate background session if was tracking + if wasTrackingLocation { + backgroundSession = CLBackgroundActivitySession() + startLocationUpdates() + } + return true +} +``` + +--- + +## Part 7: Legacy APIs (iOS 12-16) + +### CLLocationManager Delegate Pattern + +```swift +class LocationManager: NSObject, CLLocationManagerDelegate { + private let manager = CLLocationManager() + + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyBest + manager.distanceFilter = 10 // meters + } + + func startUpdates() { + manager.startUpdatingLocation() + } + + func stopUpdates() { + manager.stopUpdatingLocation() + } + + func locationManager(_ manager: CLLocationManager, + didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + // Process location + } +} +``` + +### Accuracy Constants + +| Constant | Accuracy | Battery Impact | +|----------|----------|----------------| +| `kCLLocationAccuracyBestForNavigation` | ~5m | Highest | +| `kCLLocationAccuracyBest` | ~10m | Very High | +| `kCLLocationAccuracyNearestTenMeters` | ~10m | High | +| `kCLLocationAccuracyHundredMeters` | ~100m | Medium | +| `kCLLocationAccuracyKilometer` | ~1km | Low | +| `kCLLocationAccuracyThreeKilometers` | ~3km | Very Low | +| `kCLLocationAccuracyReduced` | ~5km | Lowest | + +### Legacy Region Monitoring + +```swift +// Deprecated in iOS 17, use CLMonitor instead +let region = CLCircularRegion( + center: coordinate, + radius: 100, + identifier: "MyRegion" +) +region.notifyOnEntry = true +region.notifyOnExit = true +manager.startMonitoring(for: region) +``` + +### Significant Location Changes + +Low-power alternative for coarse tracking: + +```swift +manager.startMonitoringSignificantLocationChanges() +// Updates ~500m movements, works in background +``` + +### Visit Monitoring + +Detect arrivals/departures: + +```swift +manager.startMonitoringVisits() + +func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) { + let arrival = visit.arrivalDate + let departure = visit.departureDate + let coordinate = visit.coordinate +} +``` + +--- + +## Part 8: Geofencing Best Practices + +### Region Size + +- **Minimum effective radius**: ~100 meters +- **Smaller regions**: May not trigger reliably +- **Larger regions**: More reliable but less precise + +### 20-Region Limit Strategy + +```swift +// Dynamic region management +func updateMonitoredRegions(userLocation: CLLocation) async { + let nearbyPOIs = fetchNearbyPOIs(around: userLocation, limit: 20) + + // Remove old regions + for id in await monitor.identifiers { + if !nearbyPOIs.contains(where: { $0.id == id }) { + await monitor.remove(id) + } + } + + // Add new regions + for poi in nearbyPOIs { + let condition = CLMonitor.CircularGeographicCondition( + center: poi.coordinate, + radius: 100 + ) + await monitor.add(condition, identifier: poi.id) + } +} +``` + +### Entry/Exit Timing + +- **Entry**: Usually within seconds to minutes +- **Exit**: May take 3-5 minutes after leaving +- **Accuracy depends on**: Cell towers, WiFi, GPS availability + +### Persistence + +- Conditions persist across app launches +- Must reinitialize monitor with same name on launch +- Core Location wakes app for events + +--- + +## Part 9: Testing and Simulation + +### Xcode Location Simulation + +1. Run on simulator +2. Debug → Simulate Location → Choose location +3. Or use custom GPX file + +### Custom GPX Route + +```xml + + + + + + + + + +``` + +### Testing Authorization States + +Settings → Privacy & Security → Location Services: +- Toggle app authorization +- Toggle system-wide location services +- Test reduced accuracy + +### Console Filtering + +```bash +# Filter location logs +log stream --predicate 'subsystem == "com.apple.locationd"' +``` + +--- + +## Part 10: Swift Concurrency Integration + +### Task Cancellation + +```swift +let locationTask = Task { + for try await update in CLLocationUpdate.liveUpdates() { + if Task.isCancelled { break } + processUpdate(update) + } +} + +// Later +locationTask.cancel() +``` + +### MainActor Considerations + +```swift +@MainActor +class LocationViewModel: ObservableObject { + @Published var currentLocation: CLLocation? + + func startTracking() { + Task { + for try await update in CLLocationUpdate.liveUpdates() { + // Already on MainActor, safe to update @Published + self.currentLocation = update.location + } + } + } +} +``` + +### Error Handling + +```swift +Task { + do { + for try await update in CLLocationUpdate.liveUpdates() { + if update.authorizationDenied { + throw LocationError.authorizationDenied + } + processUpdate(update) + } + } catch { + handleError(error) + } +} +``` + +--- + +## Part 11: Geocoding + +### CLGeocoder — Forward Geocoding (Address → Coordinate) + +```swift +let geocoder = CLGeocoder() + +func geocodeAddress(_ address: String) async throws -> CLLocation? { + let placemarks = try await geocoder.geocodeAddressString(address) + return placemarks.first?.location +} + +// With locale for localized results +let placemarks = try await geocoder.geocodeAddressString( + "1 Apple Park Way", + in: nil, // CLRegion hint (optional) + preferredLocale: Locale(identifier: "en_US") +) +``` + +### CLGeocoder — Reverse Geocoding (Coordinate → Address) + +```swift +func reverseGeocode(_ location: CLLocation) async throws -> CLPlacemark? { + let placemarks = try await geocoder.reverseGeocodeLocation(location) + return placemarks.first +} + +// Usage +if let placemark = try await reverseGeocode(location) { + let street = placemark.thoroughfare // "Apple Park Way" + let city = placemark.locality // "Cupertino" + let state = placemark.administrativeArea // "CA" + let zip = placemark.postalCode // "95014" + let country = placemark.country // "United States" + let isoCountry = placemark.isoCountryCode // "US" +} +``` + +### CLPlacemark Key Properties + +| Property | Example | Notes | +|----------|---------|-------| +| `name` | "Apple Park" | Location name | +| `thoroughfare` | "Apple Park Way" | Street name | +| `subThoroughfare` | "1" | Street number | +| `locality` | "Cupertino" | City | +| `subLocality` | "Silicon Valley" | Neighborhood | +| `administrativeArea` | "CA" | State/province | +| `postalCode` | "95014" | ZIP/postal code | +| `country` | "United States" | Country name | +| `isoCountryCode` | "US" | ISO country code | +| `timeZone` | America/Los_Angeles | Time zone | +| `location` | CLLocation | Coordinate | + +### Geocoding Rate Limits + +- **One request at a time** — CLGeocoder throws if a request is in progress +- **Apple rate-limits** — Throttle to avoid `kCLErrorGeocodeCanceled` +- **Cache results** — Don't re-geocode the same address/coordinate +- **Batch carefully** — Add delays between sequential geocode requests + +```swift +// Check if geocoder is busy +if geocoder.isGeocoding { + geocoder.cancelGeocode() // Cancel previous before starting new +} +``` + +--- + +## Troubleshooting Quick Reference + +| Symptom | Check | +|---------|-------| +| No location updates | Authorization status, Info.plist keys | +| Background not working | Background mode capability, CLBackgroundActivitySession | +| Always auth not effective | CLServiceSession with `.always`, started in foreground | +| Geofence not triggering | Region count (max 20), radius (min ~100m) | +| Reduced accuracy only | Check `accuracyAuthorization`, request temporary full accuracy | +| Location icon stays on | Ensure `stopUpdatingLocation()` or break from async loop | + +--- + +## Resources + +**WWDC**: 2023-10180, 2023-10147, 2024-10212 + +**Docs**: /corelocation, /corelocation/clmonitor, /corelocation/cllocationupdate, /corelocation/clservicesession + +**Skills**: axiom-core-location, axiom-core-location-diag, axiom-energy-ref diff --git a/.claude/skills/axiom-core-location-ref/agents/openai.yaml b/.claude/skills/axiom-core-location-ref/agents/openai.yaml new file mode 100644 index 0000000..813982b --- /dev/null +++ b/.claude/skills/axiom-core-location-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Core Location Reference" + short_description: "Core Location API reference" diff --git a/.claude/skills/axiom-core-location/.openskills.json b/.claude/skills/axiom-core-location/.openskills.json new file mode 100644 index 0000000..53509db --- /dev/null +++ b/.claude/skills/axiom-core-location/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-core-location", + "installedAt": "2026-04-12T08:06:07.581Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-core-location/SKILL.md b/.claude/skills/axiom-core-location/SKILL.md new file mode 100644 index 0000000..b42ec50 --- /dev/null +++ b/.claude/skills/axiom-core-location/SKILL.md @@ -0,0 +1,484 @@ +--- +name: axiom-core-location +description: Use for Core Location implementation patterns - authorization strategy, monitoring strategy, accuracy selection, background location +license: MIT +compatibility: iOS 17+, iPadOS 17+, macOS 14+, watchOS 10+ +metadata: + version: "1.0.0" + last-updated: "2026-01-03" +--- + +# Core Location Patterns + +Discipline skill for Core Location implementation decisions. Prevents common authorization mistakes, battery drain, and background location failures. + +## When to Use + +- Choosing authorization strategy (When In Use vs Always) +- Deciding monitoring approach (continuous vs significant-change vs CLMonitor) +- Implementing geofencing or background location +- Debugging "location not working" issues +- Reviewing location code for anti-patterns + +## Related Skills + +- `axiom-core-location-ref` — API reference, code examples +- `axiom-core-location-diag` — Symptom-based troubleshooting +- `axiom-energy` — Location as battery subsystem + +--- + +## Part 1: Anti-Patterns (with Time Costs) + +### Anti-Pattern 1: Premature Always Authorization + +**Wrong** (30-60% denial rate): +```swift +// First launch: "Can we have Always access?" +manager.requestAlwaysAuthorization() +``` + +**Right** (5-10% denial rate): +```swift +// Start with When In Use +CLServiceSession(authorization: .whenInUse) + +// Later, when user triggers background feature: +CLServiceSession(authorization: .always) +``` + +**Time cost**: 15 min to fix code, but 30-60% of users permanently denied = feature adoption destroyed. + +**Why**: Users deny aggressive requests. Start minimal, upgrade when user understands value. + +--- + +### Anti-Pattern 2: Continuous Updates for Geofencing + +**Wrong** (10x battery drain): +```swift +for try await update in CLLocationUpdate.liveUpdates() { + if isNearTarget(update.location) { + triggerGeofence() + } +} +``` + +**Right** (system-managed, low power): +```swift +let monitor = await CLMonitor("Geofences") +let condition = CLMonitor.CircularGeographicCondition( + center: target, radius: 100 +) +await monitor.add(condition, identifier: "Target") + +for try await event in monitor.events { + if event.state == .satisfied { triggerGeofence() } +} +``` + +**Time cost**: 5 min to refactor, saves 10x battery. + +--- + +### Anti-Pattern 3: Ignoring Stationary Detection + +**Wrong** (wasted battery): +```swift +for try await update in CLLocationUpdate.liveUpdates() { + processLocation(update.location) + // Never stops, even when device stationary +} +``` + +**Right** (automatic pause/resume): +```swift +for try await update in CLLocationUpdate.liveUpdates() { + if let location = update.location { + processLocation(location) + } + if update.isStationary, let location = update.location { + // Device stopped moving - updates pause automatically + // Will resume when device moves again + saveLastKnownLocation(location) + } +} +``` + +**Time cost**: 2 min to add check, saves significant battery. + +--- + +### Anti-Pattern 4: No Graceful Denial Handling + +**Wrong** (broken UX): +```swift +for try await update in CLLocationUpdate.liveUpdates() { + guard let location = update.location else { continue } + // User denied - silent failure, no feedback +} +``` + +**Right** (graceful degradation): +```swift +for try await update in CLLocationUpdate.liveUpdates() { + if update.authorizationDenied { + showManualLocationPicker() + break + } + if update.authorizationDeniedGlobally { + showSystemLocationDisabledMessage() + break + } + if let location = update.location { + processLocation(location) + } +} +``` + +**Time cost**: 10 min to add handling, prevents confused users. + +--- + +### Anti-Pattern 5: Wrong Accuracy for Use Case + +**Wrong** (battery drain for weather app): +```swift +// Weather app using navigation accuracy +CLLocationUpdate.liveUpdates(.automotiveNavigation) +``` + +**Right** (match accuracy to need): +```swift +// Weather: city-level is fine +CLLocationUpdate.liveUpdates(.default) // or .fitness for runners + +// Navigation: needs high accuracy +CLLocationUpdate.liveUpdates(.automotiveNavigation) +``` + +| Use Case | Configuration | Accuracy | Battery | +|----------|---------------|----------|---------| +| Navigation | `.automotiveNavigation` | ~5m | Highest | +| Fitness tracking | `.fitness` | ~10m | High | +| Store finder | `.default` | ~10-100m | Medium | +| Weather | `.default` | ~100m+ | Low | + +**Time cost**: 1 min to change, significant battery savings. + +--- + +### Anti-Pattern 6: Not Stopping Updates + +**Wrong** (battery drain, location icon persists): +```swift +func viewDidLoad() { + Task { + for try await update in CLLocationUpdate.liveUpdates() { + updateMap(update.location) + } + } +} +// User navigates away, updates continue forever +``` + +**Right** (cancel when done): +```swift +private var locationTask: Task? + +func startTracking() { + locationTask = Task { + for try await update in CLLocationUpdate.liveUpdates() { + if Task.isCancelled { break } + updateMap(update.location) + } + } +} + +func stopTracking() { + locationTask?.cancel() + locationTask = nil +} +``` + +**Time cost**: 5 min to add cancellation, stops battery drain. + +--- + +### Anti-Pattern 7: Ignoring CLServiceSession (iOS 18+) + +**Wrong** (procedural authorization juggling): +```swift +func requestAuth() { + switch manager.authorizationStatus { + case .notDetermined: + manager.requestWhenInUseAuthorization() + case .authorizedWhenInUse: + if needsFullAccuracy { + manager.requestTemporaryFullAccuracyAuthorization(...) + } + // Complex state machine... + } +} +``` + +**Right** (declarative goals): +```swift +// Just declare what you need - Core Location handles the rest +let session = CLServiceSession(authorization: .whenInUse) + +// For feature needing full accuracy +let navSession = CLServiceSession( + authorization: .whenInUse, + fullAccuracyPurposeKey: "Navigation" +) + +// Monitor diagnostics if needed +for try await diag in session.diagnostics { + if diag.authorizationDenied { handleDenial() } +} +``` + +**Time cost**: 30 min to migrate, simpler code, fewer bugs. + +--- + +## Part 2: Decision Trees + +### Authorization Strategy + +``` +Q1: Does your feature REQUIRE background location? +├─ NO → Use .whenInUse +│ └─ Q2: Does any feature need precise location? +│ ├─ ALWAYS → Add fullAccuracyPurposeKey to session +│ └─ SOMETIMES → Layer full-accuracy session when feature active +│ +└─ YES → Start with .whenInUse, upgrade to .always when user triggers feature + └─ Q3: When does user first need background location? + ├─ IMMEDIATELY (e.g., fitness tracker) → Request .always on first relevant action + └─ LATER (e.g., geofence reminders) → Add .always session when user creates first geofence +``` + +### Monitoring Strategy + +``` +Q1: What are you monitoring for? +├─ USER POSITION (continuous tracking) +│ └─ Use CLLocationUpdate.liveUpdates() +│ └─ Q2: What activity? +│ ├─ Driving navigation → .automotiveNavigation +│ ├─ Walking/cycling nav → .otherNavigation +│ ├─ Fitness tracking → .fitness +│ ├─ Airplane apps → .airborne +│ └─ General → .default or omit +│ +├─ ENTRY/EXIT REGIONS (geofencing) +│ └─ Use CLMonitor with CircularGeographicCondition +│ └─ Note: Maximum 20 conditions per app +│ +├─ BEACON PROXIMITY +│ └─ Use CLMonitor with BeaconIdentityCondition +│ └─ Choose granularity: UUID only, UUID+major, UUID+major+minor +│ +└─ SIGNIFICANT CHANGES ONLY (lowest power) + └─ Use startMonitoringSignificantLocationChanges() (legacy) + └─ Updates ~500m movements, works in background +``` + +### Accuracy Selection + +``` +Q1: What's the minimum accuracy that makes your feature work? +├─ TURN-BY-TURN NAV needs 5-10m → .automotiveNavigation / .otherNavigation +├─ FITNESS TRACKING needs 10-20m → .fitness +├─ STORE FINDER needs 100m → .default +├─ WEATHER/CITY needs 1km+ → .default (reduced accuracy acceptable) +└─ GEOFENCING uses system determination → CLMonitor handles it + +Q2: Will user be moving fast? +├─ DRIVING (high speed) → .automotiveNavigation (extra processing for speed) +├─ CYCLING/WALKING → .otherNavigation +└─ STATIONARY/SLOW → .default + +Always start with lowest acceptable accuracy. Higher accuracy = higher battery drain. +``` + +--- + +## Part 3: Pressure Scenarios + +### Scenario 1: "Just Use Always Authorization" + +**Context**: PM says "Users want location reminders. Just request Always access on first launch so it works." + +**Pressure**: Ship fast, seems simpler. + +**Reality**: +- 30-60% of users will deny Always authorization when asked upfront +- Users who deny can only re-enable in Settings (most won't) +- Feature adoption destroyed before users understand value + +**Response**: +> "Always authorization has 30-60% denial rates when requested upfront. We should start with When In Use, then request Always upgrade when the user creates their first location reminder. This gives us a 5-10% denial rate because users understand why they need it." + +**Evidence**: Apple's own guidance in WWDC 2024-10212: "CLServiceSessions should be taken proactively... hold one requiring full-accuracy when people engage a feature that would warrant a special ask for it." + +--- + +### Scenario 2: "Location Isn't Working in Background" + +**Context**: QA reports "App stops getting location when backgrounded." + +**Pressure**: Quick fix before release. + +**Wrong fixes**: +- Add all background modes +- Use `allowsBackgroundLocationUpdates = true` without understanding +- Request Always authorization + +**Right diagnosis**: +1. Check background mode capability exists +2. Check CLBackgroundActivitySession is held (not deallocated) +3. Check session started from foreground +4. Check authorization level (.whenInUse works with CLBackgroundActivitySession) + +**Response**: +> "Background location requires specific setup. Let me check: (1) Background mode capability, (2) CLBackgroundActivitySession held during tracking, (3) session started from foreground. Missing any of these causes silent failure." + +**Checklist**: +```swift +// 1. Signing & Capabilities → Background Modes → Location updates +// 2. Hold session reference (property, not local variable) +var backgroundSession: CLBackgroundActivitySession? + +func startBackgroundTracking() { + // 3. Must start from foreground + backgroundSession = CLBackgroundActivitySession() + startLocationUpdates() +} +``` + +--- + +### Scenario 3: "Geofence Events Aren't Firing" + +**Context**: Geofences work in testing but not in production for some users. + +**Pressure**: "It works on my device" dismissal. + +**Common causes**: +1. **Too many conditions**: Maximum 20 per app +2. **Radius too small**: Minimum ~100m for reliable triggering +3. **Overlapping regions**: Can cause confusion +4. **Not awaiting events**: Events only become lastEvent after handled +5. **Not reinitializing on launch**: Monitor must be recreated + +**Response**: +> "Geofencing has several system constraints. Check: (1) Are we within the 20-condition limit? (2) Are all radii at least 100m? (3) Is the app reinitializing CLMonitor on launch? (4) Is the app always awaiting on monitor.events?" + +**Diagnostic code**: +```swift +// Check condition count +let count = await monitor.identifiers.count +if count >= 20 { + print("At 20-condition limit!") +} + +// Check all conditions +for id in await monitor.identifiers { + if let record = await monitor.record(for: id) { + let condition = record.condition + if let geo = condition as? CLMonitor.CircularGeographicCondition { + if geo.radius < 100 { + print("Radius too small: \(id)") + } + } + } +} +``` + +--- + +## Part 4: Checklists + +### Pre-Release Location Checklist + +**Info.plist**: +- [ ] `NSLocationWhenInUseUsageDescription` with clear explanation +- [ ] `NSLocationAlwaysAndWhenInUseUsageDescription` if using Always (clear why background needed) +- [ ] `NSLocationDefaultAccuracyReduced` if reduced accuracy acceptable +- [ ] `NSLocationTemporaryUsageDescriptionDictionary` if requesting temporary full accuracy +- [ ] `UIBackgroundModes` includes `location` if background tracking + +**Authorization**: +- [ ] Start with minimal authorization (.whenInUse) +- [ ] Upgrade to .always only when user triggers background feature +- [ ] Handle authorization denial gracefully (offer alternatives) +- [ ] Handle global location services disabled +- [ ] Test with reduced accuracy authorization + +**Updates**: +- [ ] Using appropriate LiveConfiguration for use case +- [ ] Handling isStationary for pause/resume +- [ ] Cancelling location tasks when feature inactive +- [ ] Not using continuous updates for geofencing + +**Testing**: +- [ ] Tested authorization denial flow +- [ ] Tested reduced accuracy mode +- [ ] Tested background-to-foreground transitions +- [ ] Tested app termination and relaunch recovery + +### Background Location Checklist + +**Setup**: +- [ ] Background mode capability added (Location updates) +- [ ] CLBackgroundActivitySession created and HELD (not local variable) +- [ ] Session started from foreground +- [ ] Updates restarted on background launch in didFinishLaunchingWithOptions + +**Authorization**: +- [ ] Using .whenInUse with CLBackgroundActivitySession, OR +- [ ] Using .always (but only if needed beyond background indicator) + +**Lifecycle**: +- [ ] Persisting "was tracking" state for relaunch recovery +- [ ] Recreating CLBackgroundActivitySession on background launch +- [ ] Restarting CLLocationUpdate iteration on launch +- [ ] CLMonitor reinitialized with same name on launch + +**Testing**: +- [ ] Blue background location indicator appears when backgrounded +- [ ] Updates continue when app backgrounded +- [ ] Updates resume after app suspended and resumed +- [ ] Updates resume after app terminated and relaunched + +--- + +## Part 5: iOS Version Considerations + +| Feature | iOS Version | Notes | +|---------|-------------|-------| +| CLLocationUpdate | iOS 17+ | AsyncSequence API | +| CLMonitor | iOS 17+ | Replaces CLCircularRegion | +| CLBackgroundActivitySession | iOS 17+ | Background with blue indicator | +| CLServiceSession | iOS 18+ | Declarative authorization | +| Implicit service sessions | iOS 18+ | From iterating liveUpdates | +| CLLocationManager | iOS 2+ | Legacy but still works | + +**For iOS 14-16 support**: Use CLLocationManager delegate pattern (see core-location-ref Part 7). + +**For iOS 17+**: Prefer CLLocationUpdate and CLMonitor. + +**For iOS 18+**: Add CLServiceSession for declarative authorization. + +--- + +## Resources + +**WWDC**: 2023-10180, 2023-10147, 2024-10212 + +**Docs**: /corelocation, /corelocation/clmonitor, /corelocation/cllocationupdate, /corelocation/clservicesession + +**Skills**: axiom-core-location-ref, axiom-core-location-diag, axiom-energy diff --git a/.claude/skills/axiom-core-location/agents/openai.yaml b/.claude/skills/axiom-core-location/agents/openai.yaml new file mode 100644 index 0000000..d468964 --- /dev/null +++ b/.claude/skills/axiom-core-location/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Core Location" + short_description: "Core Location implementation patterns" diff --git a/.claude/skills/axiom-core-spotlight-ref/.openskills.json b/.claude/skills/axiom-core-spotlight-ref/.openskills.json new file mode 100644 index 0000000..af929f0 --- /dev/null +++ b/.claude/skills/axiom-core-spotlight-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-core-spotlight-ref", + "installedAt": "2026-04-12T08:06:09.175Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-core-spotlight-ref/SKILL.md b/.claude/skills/axiom-core-spotlight-ref/SKILL.md new file mode 100644 index 0000000..29e2adc --- /dev/null +++ b/.claude/skills/axiom-core-spotlight-ref/SKILL.md @@ -0,0 +1,907 @@ +--- +name: axiom-core-spotlight-ref +description: Use when indexing app content for Spotlight search, using NSUserActivity for prediction/handoff, or choosing between CSSearchableItem and IndexedEntity - covers Core Spotlight framework and NSUserActivity integration for iOS 9+ +license: MIT +metadata: + version: "1.0.0" +--- + +# Core Spotlight & NSUserActivity Reference + +## Overview + +Comprehensive guide to Core Spotlight framework and NSUserActivity for making app content discoverable in Spotlight search, enabling Siri predictions, and supporting Handoff. Core Spotlight directly indexes app content while NSUserActivity captures user engagement for prediction. + +**Key distinction** Core Spotlight = indexing all app content; NSUserActivity = marking current user activity for prediction/handoff. + +--- + +## When to Use This Skill + +Use this skill when: +- Indexing app content (documents, notes, orders, messages) for Spotlight +- Using NSUserActivity for Handoff or Siri predictions +- Choosing between CSSearchableItem, IndexedEntity, and NSUserActivity +- Implementing activity continuation from Spotlight results +- Batch indexing for performance +- Deleting indexed content +- Debugging Spotlight search not finding app content +- Integrating NSUserActivity with App Intents (appEntityIdentifier) + +Do NOT use this skill for: +- App Shortcuts implementation (use app-shortcuts-ref) +- App Intents basics (use app-intents-ref) +- Overall discoverability strategy (use app-discoverability) + +--- + +## Related Skills + +- **app-intents-ref** — App Intents framework including IndexedEntity +- **app-discoverability** — Strategic guide for making apps discoverable +- **app-shortcuts-ref** — App Shortcuts for instant availability + +--- + +## When to Use Each API + +| Use Case | Approach | Example | +|----------|----------|---------| +| User viewing specific screen | `NSUserActivity` | User opened order details | +| Index all app content | `CSSearchableItem` | All 500 orders searchable | +| App Intents entity search | `IndexedEntity` | "Find orders where..." | +| Handoff between devices | `NSUserActivity` | Continue editing note on Mac | +| Background content indexing | `CSSearchableItem` batch | Index documents on launch | + +**Apple guidance** Use NSUserActivity for user-initiated activities (screens currently visible), not as a general indexing mechanism. For comprehensive content indexing, use Core Spotlight's CSSearchableItem. + +--- + +## Core Spotlight (CSSearchableItem) + +### Creating Searchable Items + +```swift +import CoreSpotlight +import UniformTypeIdentifiers + +func indexOrder(_ order: Order) { + // 1. Create attribute set with metadata + let attributes = CSSearchableItemAttributeSet(contentType: .item) + attributes.title = order.coffeeName + attributes.contentDescription = "Ordered on \(order.date.formatted())" + attributes.keywords = ["coffee", "order", order.coffeeName.lowercased()] + attributes.thumbnailData = order.imageData + + // Optional: Add location + attributes.latitude = order.location.coordinate.latitude + attributes.longitude = order.location.coordinate.longitude + + // Optional: Add rating + attributes.rating = NSNumber(value: order.rating) + + // 2. Create searchable item + let item = CSSearchableItem( + uniqueIdentifier: order.id.uuidString, // Stable ID + domainIdentifier: "orders", // Grouping + attributeSet: attributes + ) + + // Optional: Set expiration + item.expirationDate = Date().addingTimeInterval(60 * 60 * 24 * 365) // 1 year + + // 3. Index the item + CSSearchableIndex.default().indexSearchableItems([item]) { error in + if let error = error { + print("Indexing error: \(error.localizedDescription)") + } + } +} +``` + +--- + +### Key Properties + +#### uniqueIdentifier +**Purpose** Stable, persistent ID unique to this item within your app. + +```swift +uniqueIdentifier: order.id.uuidString +``` + +**Requirements:** +- Must be stable (same item = same identifier) +- Used for updates and deletion +- Scoped to your app + +--- + +#### domainIdentifier +**Purpose** Groups related items for bulk operations. + +```swift +domainIdentifier: "orders" +``` + +**Use cases:** +- Delete all items in a domain +- Organize by type (orders, documents, messages) +- Batch operations + +**Pattern:** +```swift +// Index with domains +item1.domainIdentifier = "orders" +item2.domainIdentifier = "documents" + +// Delete entire domain +CSSearchableIndex.default().deleteSearchableItems( + withDomainIdentifiers: ["orders"] +) { error in } +``` + +--- + +### CSSearchableItemAttributeSet + +Metadata describing the searchable content. + +```swift +let attributes = CSSearchableItemAttributeSet(contentType: .item) + +// Required +attributes.title = "Order #1234" +attributes.displayName = "Coffee Order" + +// Highly recommended +attributes.contentDescription = "Medium latte with oat milk" +attributes.keywords = ["coffee", "latte", "order"] +attributes.thumbnailData = imageData + +// Optional but valuable +attributes.contentCreationDate = Date() +attributes.contentModificationDate = Date() +attributes.rating = NSNumber(value: 5) +attributes.comment = "My favorite order" +``` + +#### Common Attributes + +| Attribute | Purpose | Example | +|-----------|---------|---------| +| `title` | Primary title | "Coffee Order #1234" | +| `displayName` | User-visible name | "Morning Latte" | +| `contentDescription` | Description text | "Medium latte with oat milk" | +| `keywords` | Search terms | ["coffee", "latte"] | +| `thumbnailData` | Preview image | JPEG/PNG data | +| `contentCreationDate` | When created | Date() | +| `contentModificationDate` | Last modified | Date() | +| `rating` | Star rating | NSNumber(value: 5) | +| `latitude` / `longitude` | Location | 37.7749, -122.4194 | + +#### Document-Specific Attributes + +```swift +// For document types +attributes.contentType = UTType.pdf +attributes.author = "John Doe" +attributes.pageCount = 10 +attributes.fileSize = 1024000 +attributes.path = "/path/to/document.pdf" +``` + +#### Message-Specific Attributes + +```swift +// For messages +attributes.recipients = ["jane@example.com"] +attributes.recipientNames = ["Jane Doe"] +attributes.authorNames = ["John Doe"] +attributes.subject = "Meeting notes" +``` + +--- + +### Batch Indexing for Performance + +#### ❌ DON'T: Index items one at a time +```swift +// Bad: 100 index operations +for order in orders { + CSSearchableIndex.default().indexSearchableItems([order.asSearchableItem()]) { _ in } +} +``` + +#### ✅ DO: Batch index operations +```swift +// Good: 1 index operation +let items = orders.map { $0.asSearchableItem() } + +CSSearchableIndex.default().indexSearchableItems(items) { error in + if let error = error { + print("Batch indexing error: \(error)") + } else { + print("Indexed \(items.count) items") + } +} +``` + +**Recommended batch size** 100-500 items per call. For larger sets, split into multiple batches. + +--- + +### Deletion Patterns + +#### Delete by Identifier +```swift +let identifiers = ["order-1", "order-2", "order-3"] + +CSSearchableIndex.default().deleteSearchableItems( + withIdentifiers: identifiers +) { error in + if let error = error { + print("Deletion error: \(error)") + } +} +``` + +#### Delete by Domain +```swift +// Delete all items in "orders" domain +CSSearchableIndex.default().deleteSearchableItems( + withDomainIdentifiers: ["orders"] +) { error in } +``` + +#### Delete All +```swift +// Nuclear option: delete everything +CSSearchableIndex.default().deleteAllSearchableItems { error in + if let error = error { + print("Failed to delete all: \(error)") + } +} +``` + +**When to delete:** +- User deletes content +- Content expires +- User logs out +- App reset/reinstall + +--- + +### App Entity Integration (App Intents) + +#### Create from App Entity +```swift +import AppIntents + +struct OrderEntity: AppEntity, IndexedEntity { + var id: UUID + + @Property(title: "Coffee", indexingKey: \.title) + var coffeeName: String + + @Property(title: "Date", indexingKey: \.contentCreationDate) + var orderDate: Date + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Order" + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(coffeeName)", subtitle: "Order from \(orderDate.formatted())") + } +} + +// Create searchable item from entity +let order = OrderEntity(id: UUID(), coffeeName: "Latte", orderDate: Date()) +let item = CSSearchableItem(appEntity: order) +CSSearchableIndex.default().indexSearchableItems([item]) +``` + +#### Associate Entity with Existing Item +```swift +let attributes = CSSearchableItemAttributeSet(contentType: .item) +attributes.title = "Order #1234" + +let item = CSSearchableItem( + uniqueIdentifier: "order-1234", + domainIdentifier: "orders", + attributeSet: attributes +) + +// Associate with App Intent entity +item.associateAppEntity(orderEntity, priority: .default) +``` + +**Benefits:** +- Automatic "Find" actions in Shortcuts +- Spotlight search returns entities directly +- App Intents integration + +--- + +## NSUserActivity + +### Overview + +NSUserActivity captures user engagement for: +- **Handoff** — Continue activity on another device +- **Spotlight search** — Index currently viewed content +- **Siri predictions** — Suggest returning to this screen +- **Quick Note** — Link notes to app content + +**Platform support** iOS 8.0+, iPadOS 8.0+, macOS 10.10+, tvOS 9.0+, watchOS 2.0+, axiom-visionOS 1.0+ + +--- + +### Eligibility Properties + +```swift +let activity = NSUserActivity(activityType: "com.app.viewOrder") + +// Enable Spotlight search +activity.isEligibleForSearch = true + +// Enable Siri predictions +activity.isEligibleForPrediction = true + +// Enable Handoff to other devices +activity.isEligibleForHandoff = true + +// Contribute URL to global search (public content only) +activity.isEligibleForPublicIndexing = false +``` + +**Privacy note** Only set `isEligibleForPublicIndexing = true` for publicly accessible content (e.g., blog posts with public URLs). + +--- + +### Basic Pattern + +```swift +func viewOrder(_ order: Order) { + // 1. Create activity + let activity = NSUserActivity(activityType: "com.coffeeapp.viewOrder") + activity.title = order.coffeeName + + // 2. Set eligibility + activity.isEligibleForSearch = true + activity.isEligibleForPrediction = true + + // 3. Provide identifier for updates/deletion + activity.persistentIdentifier = order.id.uuidString + + // 4. Provide rich metadata + let attributes = CSSearchableItemAttributeSet(contentType: .item) + attributes.title = order.coffeeName + attributes.contentDescription = "Your \(order.coffeeName) order" + attributes.thumbnailData = order.imageData + activity.contentAttributeSet = attributes + + // 5. Mark as current + activity.becomeCurrent() + + // 6. Store reference (important!) + self.userActivity = activity +} +``` + +**Critical** Maintain strong reference to activity. It won't appear in search without one. + +--- + +### becomeCurrent() and resignCurrent() + +```swift +// UIKit pattern +class OrderDetailViewController: UIViewController { + var currentActivity: NSUserActivity? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + let activity = NSUserActivity(activityType: "com.app.viewOrder") + activity.title = order.coffeeName + activity.isEligibleForSearch = true + activity.becomeCurrent() // Mark as active + + self.currentActivity = activity + self.userActivity = activity // UIKit integration + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + currentActivity?.resignCurrent() // Mark as inactive + } +} +``` + +```swift +// SwiftUI pattern +struct OrderDetailView: View { + let order: Order + + var body: some View { + VStack { + Text(order.coffeeName) + } + .onAppear { + let activity = NSUserActivity(activityType: "com.app.viewOrder") + activity.title = order.coffeeName + activity.isEligibleForSearch = true + activity.becomeCurrent() + + // SwiftUI automatically manages userActivity + self.userActivity = activity + } + } +} +``` + +--- + +### App Intents Integration (appEntityIdentifier) + +Connect NSUserActivity to App Intent entities. + +```swift +func viewOrder(_ order: Order) { + let activity = NSUserActivity(activityType: "com.app.viewOrder") + activity.title = order.coffeeName + activity.isEligibleForSearch = true + activity.isEligibleForPrediction = true + + // Connect to App Intent entity + activity.appEntityIdentifier = order.id.uuidString + + // Now Spotlight can surface this as an entity suggestion + activity.becomeCurrent() + self.userActivity = activity +} +``` + +**Benefits:** +- Siri suggests this order in relevant contexts +- App Intents can reference this activity +- Shortcuts integration + +--- + +### On-Screen Content Tagging + +**Pattern from WWDC** Tag currently visible content for Spotlight parameter suggestions. + +```swift +func showEvent(_ event: Event) { + let activity = NSUserActivity(activityType: "com.app.viewEvent") + activity.persistentIdentifier = event.id.uuidString + + // Spotlight suggests this event for intent parameters + activity.appEntityIdentifier = event.id.uuidString + + activity.becomeCurrent() + userActivity = activity +} +``` + +**Result** When users invoke intents requiring an event parameter, Spotlight suggests the currently visible event. + +--- + +### Quick Note Integration (macOS/iPadOS) + +For Quick Note linking, activities must: +1. Be the app's **current activity** (via `becomeCurrent()`) +2. Have a clear, concise `title` (nouns, not verbs) +3. Provide stable, consistent identifiers +4. Support navigation to linked content indefinitely +5. Gracefully handle missing content + +```swift +let activity = NSUserActivity(activityType: "com.app.viewNote") +activity.title = note.title // ✅ "Project Ideas" not ❌ "View Note" +activity.persistentIdentifier = note.id.uuidString +activity.targetContentIdentifier = note.id.uuidString +activity.becomeCurrent() +``` + +--- + +### Activity Continuation (Handling Spotlight Taps) + +When users tap Spotlight results, handle continuation: + +#### UIKit +```swift +// AppDelegate or SceneDelegate +func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void +) -> Bool { + guard userActivity.activityType == "com.app.viewOrder" else { + return false + } + + // Extract identifier + if let identifier = userActivity.persistentIdentifier, + let orderID = UUID(uuidString: identifier) { + // Navigate to order + navigateToOrder(orderID) + return true + } + + return false +} +``` + +#### SwiftUI +```swift +@main +struct CoffeeApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .onContinueUserActivity("com.app.viewOrder") { userActivity in + if let identifier = userActivity.persistentIdentifier, + let orderID = UUID(uuidString: identifier) { + // Navigate to order + navigateToOrder(orderID) + } + } + } + } +} +``` + +#### Searchable Item Continuation +```swift +// When continuing from CSSearchableItem +func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void +) -> Bool { + if userActivity.activityType == CSSearchableItemActionType { + // Get identifier from Core Spotlight item + if let identifier = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String { + // Navigate based on identifier + navigateToItem(identifier) + return true + } + } + + return false +} +``` + +--- + +### Deletion APIs + +#### Delete All Saved Activities +```swift +NSUserActivity.deleteAllSavedUserActivities { } +``` + +#### Delete Specific Activities +```swift +let identifiers = ["order-1", "order-2"] + +NSUserActivity.deleteSavedUserActivities( + withPersistentIdentifiers: identifiers +) { } +``` + +**When to delete:** +- User deletes content +- User logs out +- Content no longer accessible + +--- + +## NSUserActivity vs CSSearchableItem + +| Aspect | NSUserActivity | CSSearchableItem | +|--------|---------------|------------------| +| **Purpose** | Current user activity | Indexing all content | +| **When to use** | User viewing a screen | Background content indexing | +| **Scope** | One item at a time | Batch operations | +| **Handoff** | Supported | Not supported | +| **Prediction** | Supported | Not supported | +| **Search** | Limited | Full Spotlight integration | +| **Example** | User viewing order detail | Index all 500 orders | + +**Recommended** Use both: +- NSUserActivity for screens currently visible +- CSSearchableItem for comprehensive content indexing + +--- + +## Testing & Debugging + +### Verify Indexed Items + +#### Using Spotlight +1. Open Spotlight (swipe down on Home Screen) +2. Search for indexed content keywords +3. Verify your app's results appear +4. Tap result → Verify navigation works + +#### Using Console Logs +```swift +CSSearchableIndex.default().fetchLastClientState { clientState, error in + if let error = error { + print("Error fetching client state: \(error)") + } else { + print("Client state: \(clientState?.base64EncodedString() ?? "none")") + } +} +``` + +--- + +### Common Issues + +#### Items not appearing in Spotlight +- Wait 1-2 minutes for indexing +- Verify `isEligibleForSearch = true` +- Check System Settings → Siri & Search → [App] → Show App in Search +- Restart device +- Check console for indexing errors + +#### Activity not triggering Handoff +- Verify `isEligibleForHandoff = true` +- Ensure both devices signed into same iCloud account +- Check Bluetooth and Wi-Fi enabled on both devices +- Verify activityType is reverse DNS (com.company.app.action) + +#### Continuation not working +- Verify `application(_:continue:restorationHandler:)` implemented +- Check activityType matches exactly +- Ensure persistentIdentifier is set +- Test with debugger to verify method is called + +--- + +## Best Practices + +### 1. Selective Indexing + +#### ❌ DON'T: Index everything +```swift +// Bad: Index all 10,000 items +let allItems = try await ItemService.shared.all() +``` + +#### ✅ DO: Index selectively +```swift +// Good: Index recent/important items +let recentItems = try await ItemService.shared.recent(limit: 100) +let favoriteItems = try await ItemService.shared.favorites() +``` + +**Why** Performance, quota limits, user experience. + +--- + +### 2. Use Domain Identifiers + +#### ❌ DON'T: Rely only on unique identifiers +```swift +// Hard to delete all orders +CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: allOrderIDs) +``` + +#### ✅ DO: Group with domains +```swift +// Easy to delete all orders +CSSearchableIndex.default().deleteSearchableItems(withDomainIdentifiers: ["orders"]) +``` + +--- + +### 3. Set Expiration Dates + +#### ❌ DON'T: Index items forever +```swift +// Bad: Items never expire +let item = CSSearchableItem(/* ... */) +``` + +#### ✅ DO: Set reasonable expiration +```swift +// Good: Expire after 1 year +item.expirationDate = Date().addingTimeInterval(60 * 60 * 24 * 365) +``` + +--- + +### 4. Provide Rich Metadata + +#### ❌ DON'T: Minimal metadata +```swift +attributes.title = "Item" +``` + +#### ✅ DO: Rich, searchable metadata +```swift +attributes.title = "Medium Latte Order" +attributes.contentDescription = "Ordered on December 12, 2025" +attributes.keywords = ["coffee", "latte", "order", "medium"] +attributes.thumbnailData = imageData +``` + +--- + +### 5. Handle Missing Content Gracefully + +```swift +func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void +) -> Bool { + guard let identifier = userActivity.persistentIdentifier else { + return false + } + + // Attempt to load content + if let item = try? await ItemService.shared.fetch(id: identifier) { + navigate(to: item) + return true + } else { + // Content deleted or unavailable + showAlert("This content is no longer available") + + // Delete activity from search + NSUserActivity.deleteSavedUserActivities( + withPersistentIdentifiers: [identifier] + ) + + return true // Still handled + } +} +``` + +--- + +## Complete Example + +### Comprehensive Integration + +```swift +import CoreSpotlight +import UniformTypeIdentifiers + +class OrderManager { + + // MARK: - Core Spotlight Indexing + + func indexOrder(_ order: Order) { + let attributes = CSSearchableItemAttributeSet(contentType: .item) + attributes.title = order.coffeeName + attributes.contentDescription = "Order from \(order.date.formatted())" + attributes.keywords = ["coffee", "order", order.coffeeName.lowercased()] + attributes.thumbnailData = order.thumbnailImageData + attributes.contentCreationDate = order.date + attributes.rating = NSNumber(value: order.rating) + + let item = CSSearchableItem( + uniqueIdentifier: order.id.uuidString, + domainIdentifier: "orders", + attributeSet: attributes + ) + + item.expirationDate = Date().addingTimeInterval(60 * 60 * 24 * 365) + + CSSearchableIndex.default().indexSearchableItems([item]) { error in + if let error = error { + print("Indexing error: \(error)") + } + } + } + + func deleteOrder(_ orderID: UUID) { + // Delete from Core Spotlight + CSSearchableIndex.default().deleteSearchableItems( + withIdentifiers: [orderID.uuidString] + ) + + // Delete NSUserActivity + NSUserActivity.deleteSavedUserActivities( + withPersistentIdentifiers: [orderID.uuidString] + ) + } + + func deleteAllOrders() { + CSSearchableIndex.default().deleteSearchableItems( + withDomainIdentifiers: ["orders"] + ) + } + + // MARK: - NSUserActivity for Current Screen + + func createActivityForOrder(_ order: Order) -> NSUserActivity { + let activity = NSUserActivity(activityType: "com.coffeeapp.viewOrder") + activity.title = order.coffeeName + activity.isEligibleForSearch = true + activity.isEligibleForPrediction = true + activity.persistentIdentifier = order.id.uuidString + + // Connect to App Intents + activity.appEntityIdentifier = order.id.uuidString + + // Rich metadata + let attributes = CSSearchableItemAttributeSet(contentType: .item) + attributes.title = order.coffeeName + attributes.contentDescription = "Your \(order.coffeeName) order" + attributes.thumbnailData = order.thumbnailImageData + activity.contentAttributeSet = attributes + + return activity + } +} + +// UIKit view controller +class OrderDetailViewController: UIViewController { + var order: Order! + var currentActivity: NSUserActivity? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + currentActivity = OrderManager.shared.createActivityForOrder(order) + currentActivity?.becomeCurrent() + self.userActivity = currentActivity + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + currentActivity?.resignCurrent() + } +} + +// SwiftUI view +struct OrderDetailView: View { + let order: Order + + var body: some View { + VStack { + Text(order.coffeeName) + .font(.largeTitle) + + Text("Ordered on \(order.date.formatted())") + .foregroundColor(.secondary) + } + .userActivity("com.coffeeapp.viewOrder") { activity in + activity.title = order.coffeeName + activity.isEligibleForSearch = true + activity.isEligibleForPrediction = true + activity.persistentIdentifier = order.id.uuidString + activity.appEntityIdentifier = order.id.uuidString + + let attributes = CSSearchableItemAttributeSet(contentType: .item) + attributes.title = order.coffeeName + attributes.contentDescription = "Your \(order.coffeeName) order" + activity.contentAttributeSet = attributes + } + } +} +``` + +--- + +## Resources + +**WWDC**: 260, 2015-709 + +**Docs**: /corespotlight, /corespotlight/cssearchableitem, /foundation/nsuseractivity + +**Skills**: axiom-app-intents-ref, axiom-app-discoverability, axiom-app-shortcuts-ref + +--- + +**Remember** Core Spotlight indexes all your app's content; NSUserActivity marks what the user is currently doing. Use CSSearchableItem for batch indexing, NSUserActivity for active screens, and connect them to App Intents with appEntityIdentifier for comprehensive discoverability. diff --git a/.claude/skills/axiom-core-spotlight-ref/agents/openai.yaml b/.claude/skills/axiom-core-spotlight-ref/agents/openai.yaml new file mode 100644 index 0000000..e18a208 --- /dev/null +++ b/.claude/skills/axiom-core-spotlight-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Core Spotlight Reference" + short_description: "Indexing app content for Spotlight search, using NSUserActivity for prediction/handoff, or choosing between CSSearcha..." diff --git a/.claude/skills/axiom-cryptokit-ref/.openskills.json b/.claude/skills/axiom-cryptokit-ref/.openskills.json new file mode 100644 index 0000000..ecb0bce --- /dev/null +++ b/.claude/skills/axiom-cryptokit-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-cryptokit-ref", + "installedAt": "2026-04-12T08:06:10.560Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-cryptokit-ref/SKILL.md b/.claude/skills/axiom-cryptokit-ref/SKILL.md new file mode 100644 index 0000000..bb3475f --- /dev/null +++ b/.claude/skills/axiom-cryptokit-ref/SKILL.md @@ -0,0 +1,705 @@ +--- +name: axiom-cryptokit-ref +description: Use when needing CryptoKit API details — hash functions (SHA2/SHA3), HMAC, AES-GCM/ChaChaPoly encryption, ECDSA/EdDSA signatures, ECDH key agreement, ML-KEM/ML-DSA post-quantum algorithms, HPKE encryption, Secure Enclave key types, key representations (raw/DER/PEM/x963), or Swift Crypto cross-platform parity. Covers complete CryptoKit API surface. +license: MIT +--- + +# CryptoKit API Reference + +Complete API reference for Apple CryptoKit: hashing, HMAC, symmetric encryption, key agreement, digital signatures, post-quantum cryptography, HPKE, Secure Enclave, key derivation, and Swift Crypto cross-platform parity. + +## Quick Reference + +```swift +import CryptoKit + +// Generate a symmetric key +let key = SymmetricKey(size: .bits256) + +// AES-GCM encrypt +let sealed = try AES.GCM.seal(plaintext, using: key) +let combined = sealed.combined! // nonce + ciphertext + tag + +// AES-GCM decrypt +let sealedBox = try AES.GCM.SealedBox(combined: combined) +let decrypted = try AES.GCM.open(sealedBox, using: key) + +// ECDSA sign (P256) +let signingKey = P256.Signing.PrivateKey() +let signature = try signingKey.signature(for: data) +let valid = signingKey.publicKey.isValidSignature(signature, for: data) + +// Secure Enclave key +let seKey = try SecureEnclave.P256.Signing.PrivateKey() +let seSignature = try seKey.signature(for: data) +``` + +--- + +## Hashing + +### Hash Functions + +| Algorithm | Type | Output Size | Use | +|-----------|------|-------------|-----| +| SHA256 | SHA256 | 32 bytes | General purpose, most common | +| SHA384 | SHA384 | 48 bytes | TLS, certificate chains | +| SHA512 | SHA512 | 64 bytes | High-security contexts | +| SHA3_256 | SHA3_256 | 32 bytes | NIST post-quantum companion | +| SHA3_384 | SHA3_384 | 48 bytes | Post-quantum companion | +| SHA3_512 | SHA3_512 | 64 bytes | Post-quantum companion | +| Insecure.MD5 | Insecure.MD5 | 16 bytes | Legacy interop only | +| Insecure.SHA1 | Insecure.SHA1 | 20 bytes | Legacy interop only | + +### Single-Call Hashing + +```swift +let digest = SHA256.hash(data: data) +// digest conforms to Sequence of UInt8 +let hex = digest.map { String(format: "%02x", $0) }.joined() +``` + +### Streaming (Incremental) Hashing + +```swift +var hasher = SHA256() +hasher.update(data: chunk1) +hasher.update(data: chunk2) +hasher.update(bufferPointer: unsafePointer) +let digest = hasher.finalize() // SHA256Digest +``` + +### HashFunction Protocol + +All hash types conform to `HashFunction` with: `byteCount`, `blockByteCount`, `init()`, `update(data:)`, `update(bufferPointer:)`, `finalize()`, and `hash(data:)`. + +Digest conforms to `Sequence` (of `UInt8`), supports constant-time `==`, and converts to `Data(digest)` or `Array(digest)`. `description` returns hex string. + +--- + +## Message Authentication (HMAC) + +### SymmetricKey + +```swift +let key = SymmetricKey(size: .bits128) // .bits128, .bits192, .bits256 +let key = SymmetricKey(size: SymmetricKeySize(bitCount: 512)) // Custom size +let key = SymmetricKey(data: existingKeyData) // From existing material + +key.bitCount // Key size in bits +key.withUnsafeBytes { bytes in /* ... */ } // Only way to access raw bytes +``` + +### HMAC Generation and Verification + +```swift +// HMAC is generic over HashFunction +let authCode = HMAC.authenticationCode(for: data, using: key) +// authCode: HMAC.MAC + +let valid = HMAC.isValidAuthenticationCode(authCode, authenticating: data, using: key) + +// Data representation +let macData = Data(authCode) +``` + +### Iterative HMAC + +```swift +var hmac = HMAC(key: key) +hmac.update(data: chunk1) +hmac.update(data: chunk2) +let authCode = hmac.finalize() +``` + +--- + +## Symmetric Encryption + +### AES-GCM + +```swift +// Seal (encrypt + authenticate) +let sealed = try AES.GCM.seal(plaintext, using: key) +let sealed = try AES.GCM.seal(plaintext, using: key, nonce: customNonce) +let sealed = try AES.GCM.seal( + plaintext, + using: key, + nonce: customNonce, + authenticating: associatedData // AAD — authenticated but not encrypted +) + +// SealedBox properties +sealed.nonce // AES.GCM.Nonce (12 bytes) +sealed.ciphertext // Data +sealed.tag // Data (16 bytes) +sealed.combined // Data? (nonce + ciphertext + tag) + +// Open (decrypt + verify) +let plaintext = try AES.GCM.open(sealedBox, using: key) +let plaintext = try AES.GCM.open(sealedBox, using: key, authenticating: associatedData) +``` + +### AES-GCM SealedBox Construction + +```swift +// From combined representation (nonce + ciphertext + tag) +let box = try AES.GCM.SealedBox(combined: combinedData) + +// From components +let box = try AES.GCM.SealedBox( + nonce: AES.GCM.Nonce(data: nonceData), + ciphertext: ciphertextData, + tag: tagData +) +``` + +### AES-GCM Nonce + +```swift +let nonce = AES.GCM.Nonce() // Random 12 bytes (recommended) +let nonce = try AES.GCM.Nonce(data: nonceData) // Custom (MUST be unique per key) +``` + +### ChaChaPoly + +Identical interface to AES-GCM. Preferred for software-only environments without AES-NI. + +```swift +let sealed = try ChaChaPoly.seal(plaintext, using: key) +let sealed = try ChaChaPoly.seal(plaintext, using: key, authenticating: aad) + +let plaintext = try ChaChaPoly.open(sealed, using: key) +let plaintext = try ChaChaPoly.open(sealed, using: key, authenticating: aad) + +// SealedBox, Nonce — same pattern as AES.GCM +let box = try ChaChaPoly.SealedBox(combined: combined) +let nonce = ChaChaPoly.Nonce() +``` + +### AES Key Wrapping + +```swift +// Wrap a key with another key (RFC 3394) +let wrapped = try AES.KeyWrap.wrap(keyToWrap, using: wrappingKey) +// wrapped: Data + +// Unwrap +let unwrapped = try AES.KeyWrap.unwrap(wrapped, using: wrappingKey) +// unwrapped: SymmetricKey +``` + +--- + +## Key Agreement (ECDH) + +### Supported Curves + +| Curve | Type Prefix | Key Size | Use | +|-------|-------------|----------|-----| +| Curve25519 | Curve25519.KeyAgreement | 32 bytes | Modern, fast, safe defaults | +| P-256 | P256.KeyAgreement | 32 bytes | NIST standard, Secure Enclave | +| P-384 | P384.KeyAgreement | 48 bytes | Higher security NIST | +| P-521 | P521.KeyAgreement | 66 bytes | Maximum NIST security | + +### Private Key Creation + +```swift +let privateKey = Curve25519.KeyAgreement.PrivateKey() // Random +let privateKey = P256.KeyAgreement.PrivateKey() // Random +let privateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: true) + +// From serialized representations +let privateKey = try P256.KeyAgreement.PrivateKey(rawRepresentation: rawData) +let privateKey = try P256.KeyAgreement.PrivateKey(derRepresentation: derData) +let privateKey = try P256.KeyAgreement.PrivateKey(pemRepresentation: pemString) +let privateKey = try P256.KeyAgreement.PrivateKey(x963Representation: x963Data) // NIST only +``` + +### Public Key Representations + +```swift +let publicKey = privateKey.publicKey +publicKey.rawRepresentation // Data (all curves) +publicKey.derRepresentation // Data — SubjectPublicKeyInfo (all curves) +publicKey.pemRepresentation // String (all curves) +publicKey.x963Representation // Data — uncompressed point (NIST only) +publicKey.compactRepresentation // Data? (NIST only) +publicKey.compressedRepresentation // Data (NIST only) +``` + +### Shared Secret Derivation + +```swift +let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: peerPublicKey) +// sharedSecret: SharedSecret — NOT directly usable as a key + +// Derive symmetric key with HKDF +let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: saltData, // Can be empty Data() + sharedInfo: infoData, // Context/label data + outputByteCount: 32 // Key size +) + +// Derive with X9.63 KDF +let symmetricKey = sharedSecret.x963DerivedSymmetricKey( + using: SHA256.self, + sharedInfo: infoData, + outputByteCount: 32 +) +``` + +--- + +## Signatures (ECDSA/EdDSA) + +### Supported Algorithms + +| Curve | Algorithm | Type Prefix | +|-------|-----------|-------------| +| Curve25519 | Ed25519 (EdDSA) | Curve25519.Signing | +| P-256 | ECDSA | P256.Signing | +| P-384 | ECDSA | P384.Signing | +| P-521 | ECDSA | P521.Signing | + +### Key Creation + +```swift +let privateKey = P256.Signing.PrivateKey() +let privateKey = Curve25519.Signing.PrivateKey() + +// Same representation constructors as KeyAgreement keys: +// init(rawRepresentation:), init(derRepresentation:), +// init(pemRepresentation:), init(x963Representation:) for NIST curves +``` + +### Sign and Verify + +```swift +// Sign raw data +let signature = try privateKey.signature(for: data) + +// Sign a digest (skip re-hashing already-hashed data) +let digest = SHA256.hash(data: data) +let signature = try privateKey.signature(for: digest) // NIST curves only + +// Verify +let valid = privateKey.publicKey.isValidSignature(signature, for: data) +let valid = privateKey.publicKey.isValidSignature(signature, for: digest) +``` + +### Signature Representations + +```swift +// NIST curves (P256/P384/P521) +signature.derRepresentation // Data — use for cross-platform interop +signature.rawRepresentation // Data — r || s concatenated + +// Reconstruct from DER +let sig = try P256.Signing.ECDSASignature(derRepresentation: derData) +let sig = try P256.Signing.ECDSASignature(rawRepresentation: rawData) + +// Curve25519 — raw bytes only (64 bytes, no DER) +signature.rawRepresentation +``` + +### Cross-Platform Encoding + +Use `derRepresentation` when exchanging signatures with non-CryptoKit systems (OpenSSL, Java, Go). Use `rawRepresentation` for CryptoKit-to-CryptoKit or when wire size matters (DER adds 6-8 bytes overhead). + +--- + +## Post-Quantum Cryptography: ML-KEM + +Key Encapsulation Mechanism based on Module-Lattice (FIPS 203). iOS 26+. + +### Parameter Sets + +| Type | Security Level | Public Key | Ciphertext | Shared Secret | +|------|---------------|------------|------------|---------------| +| MLKEM768 | 128-bit (AES-128 equivalent) | 1,184 bytes | 1,088 bytes | 32 bytes | +| MLKEM1024 | 256-bit (AES-256 equivalent) | 1,568 bytes | 1,568 bytes | 32 bytes | + +### Key Generation + +```swift +let privateKey = MLKEM768.PrivateKey() +let publicKey = privateKey.publicKey + +let privateKey = MLKEM1024.PrivateKey() +``` + +### Encapsulation and Decapsulation + +```swift +// Sender: encapsulate with recipient's public key +let result = try recipientPublicKey.encapsulate() +// result.sharedSecret: SymmetricKey (32 bytes) +// result.encapsulated: Data (ciphertext to send) + +// Recipient: decapsulate with private key +let sharedSecret = try privateKey.decapsulate(result.encapsulated) +// sharedSecret: SymmetricKey — matches sender's sharedSecret +``` + +### Key Representations + +```swift +// Public key +publicKey.rawRepresentation // Data + +// Private key +privateKey.seedRepresentation // Data (compact seed) +privateKey.integrityCheckedRepresentation // Data (seed + SHA3-256 hash) + +// Reconstruct +let pk = try MLKEM768.PrivateKey(seedRepresentation: seedData, publicKey: publicKey) +let pk = try MLKEM768.PrivateKey(integrityCheckedRepresentation: data) +``` + +--- + +## Post-Quantum Cryptography: ML-DSA + +Digital Signature Algorithm based on Module-Lattice (FIPS 204). iOS 26+. + +### Parameter Sets + +| Type | Security Level | Public Key | Signature | +|------|---------------|------------|-----------| +| MLDSA65 | 128-bit | 1,952 bytes | 3,309 bytes | +| MLDSA87 | 256-bit | 2,592 bytes | 4,627 bytes | + +### Key Generation + +```swift +let privateKey = MLDSA65.PrivateKey() +let publicKey = privateKey.publicKey + +let privateKey = MLDSA87.PrivateKey() +``` + +### Sign and Verify + +```swift +// Sign — returns Data (not a typed Signature struct) +let signatureData = try privateKey.signature(for: data) + +// Sign with context (domain separation) +let signatureData = try privateKey.signature(for: data, context: contextData) + +// Verify — takes DataProtocol for signature parameter +let valid = publicKey.isValidSignature(signatureData, for: data) +let valid = publicKey.isValidSignature(signatureData, for: data, context: contextData) +``` + +### Key and Signature Representations + +```swift +// Public key +publicKey.rawRepresentation + +// Private key +privateKey.seedRepresentation +privateKey.integrityCheckedRepresentation + +// Reconstruct +let pk = try MLDSA65.PrivateKey(seedRepresentation: seedData, publicKey: publicKey) +let pk = try MLDSA65.PrivateKey(integrityCheckedRepresentation: data) + +// Signature is raw Data — no typed Signature struct +// Store/transmit signatureData directly +``` + +--- + +## Hybrid Post-Quantum: X-Wing KEM + +Combines ML-KEM768 + Curve25519 ECDH for hybrid post-quantum key exchange. If either algorithm holds, the combined scheme holds. iOS 26+. + +```swift +let privateKey = XWingMLKEM768X25519.PrivateKey() +let publicKey = privateKey.publicKey + +// Encapsulate +let result = try publicKey.encapsulate() +// result.sharedSecret, result.encapsulated + +// Decapsulate +let sharedSecret = try privateKey.decapsulate(result.encapsulated) + +// Representations +publicKey.rawRepresentation +privateKey.seedRepresentation +privateKey.integrityCheckedRepresentation +``` + +--- + +## HPKE (Hybrid Public Key Encryption) + +Hybrid Public Key Encryption (RFC 9180). Combines KEM + KDF + AEAD into a single encryption scheme. iOS 17+ (classical ciphersuites). Post-quantum ciphersuites (XWing) require iOS 26+. + +### Predefined Ciphersuites + +| Ciphersuite | KEM | KDF | AEAD | +|-------------|-----|-----|------| +| `.XWingMLKEM768X25519_SHA256_AES_GCM_256` | X-Wing | HKDF-SHA256 | AES-256-GCM | +| `.Curve25519_SHA256_ChachaPoly` | Curve25519 | HKDF-SHA256 | ChaCha20Poly1305 | +| `.P256_SHA256_AES_GCM_256` | P-256 | HKDF-SHA256 | AES-256-GCM | +| `.P384_SHA384_AES_GCM_256` | P-384 | HKDF-SHA384 | AES-256-GCM | +| `.P521_SHA512_AES_GCM_256` | P-521 | HKDF-SHA512 | AES-256-GCM | + +### Custom Ciphersuite Composition + +```swift +let ciphersuite = HPKE.Ciphersuite( + kem: .Curve25519_HKDF_SHA256, + kdf: .HKDF_SHA256, + aead: .AES_GCM_128 +) +``` + +#### KEM Options + +`.Curve25519_HKDF_SHA256`, `.P256_HKDF_SHA256`, `.P384_HKDF_SHA384`, `.P521_HKDF_SHA512`, `.XWingMLKEM768X25519` (iOS 26+) + +#### KDF Options + +`.HKDF_SHA256`, `.HKDF_SHA384`, `.HKDF_SHA512` + +#### AEAD Options + +`.AES_GCM_128`, `.AES_GCM_256`, `.chaChaPoly`, `.exportOnly` + +### Sender (Encrypt) + +```swift +var sender = try HPKE.Sender( + recipientKey: recipientPublicKey, + ciphersuite: .Curve25519_SHA256_ChachaPoly, + info: infoData // Binding context (can be empty) +) + +let ciphertext = try sender.seal(plaintext) +let ciphertext = try sender.seal(plaintext, authenticating: aad) + +let encapsulatedKey = sender.encapsulatedKey // Send alongside ciphertext + +// Export secret (for key derivation without encryption) +let exported = try sender.exportSecret(context: ctx, outputByteCount: 32) +``` + +### Recipient (Decrypt) + +```swift +var recipient = try HPKE.Recipient( + privateKey: recipientPrivateKey, + ciphersuite: .Curve25519_SHA256_ChachaPoly, + info: infoData, + encapsulatedKey: encapsulatedKey // From sender +) + +let plaintext = try recipient.open(ciphertext) +let plaintext = try recipient.open(ciphertext, authenticating: aad) + +let exported = try recipient.exportSecret(context: ctx, outputByteCount: 32) +``` + +### Additional Modes + +Both Sender and Recipient accept optional authentication and PSK parameters: + +```swift +// Authenticated mode — proves sender identity +var sender = try HPKE.Sender( + recipientKey: recipientPublicKey, ciphersuite: ciphersuite, info: infoData, + authenticatedBy: senderPrivateKey +) +var recipient = try HPKE.Recipient( + privateKey: recipientPrivateKey, ciphersuite: ciphersuite, info: infoData, + encapsulatedKey: encapsulatedKey, authenticatedBy: senderPublicKey +) + +// PSK mode — adds pre-shared key binding +// Add to either Sender or Recipient init: +// presharedKey: psk, // SymmetricKey +// presharedKeyIdentifier: pskID // Data +``` + +### HPKE Error Types + +```swift +HPKE.Errors.inconsistentParameters // Ciphersuite/key mismatch +HPKE.Errors.inconsistentCiphersuiteAndKey // Key type doesn't match KEM +HPKE.Errors.exportOnlyMode // Seal/open called in export-only mode +HPKE.Errors.inconsistentPSKInputs // PSK and PSK ID must both be provided or neither +HPKE.Errors.expectedPSK // PSK mode requires PSK +HPKE.Errors.unexpectedPSK // Non-PSK mode given PSK +HPKE.Errors.outOfRangeSequenceNumber // Sequence number overflow +HPKE.Errors.ciphertextTooShort // Ciphertext shorter than tag size +``` + +--- + +## Secure Enclave + +Hardware-backed key storage. Keys never leave the Secure Enclave chip. Device-bound and non-exportable. + +### Availability Check + +```swift +SecureEnclave.isAvailable // false on Simulator, true on devices with SE +``` + +### Supported Key Types + +| Type | Use | +|------|-----| +| `SecureEnclave.P256.Signing.PrivateKey` | ECDSA signatures | +| `SecureEnclave.P256.KeyAgreement.PrivateKey` | ECDH key agreement | +| `SecureEnclave.MLKEM768.PrivateKey` | Post-quantum KEM (iOS 26+) | +| `SecureEnclave.MLKEM1024.PrivateKey` | Post-quantum KEM (iOS 26+) | +| `SecureEnclave.MLDSA65.PrivateKey` | Post-quantum signatures (iOS 26+) | +| `SecureEnclave.MLDSA87.PrivateKey` | Post-quantum signatures (iOS 26+) | + +### Key Creation + +```swift +let key = try SecureEnclave.P256.Signing.PrivateKey() // Default access control + +// With biometric access control +let accessControl = SecAccessControlCreateWithFlags( + nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + [.privateKeyUsage, .biometryCurrentSet], nil +)! +let key = try SecureEnclave.P256.Signing.PrivateKey(accessControl: accessControl) + +// With pre-prompted biometric context +let context = LAContext() +context.localizedReason = "Sign transaction" +let key = try SecureEnclave.P256.Signing.PrivateKey( + accessControl: accessControl, authenticationContext: context +) +``` + +### Persistence and Usage + +```swift +// dataRepresentation is an opaque device-bound blob — store in Keychain +let wrapped = key.dataRepresentation +let restored = try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: wrapped) +let restored = try SecureEnclave.P256.Signing.PrivateKey( + dataRepresentation: wrapped, authenticationContext: context +) + +// SE keys use the same sign/verify/agree API as software keys +let signature = try seKey.signature(for: data) +let valid = seKey.publicKey.isValidSignature(signature, for: data) +let publicKeyData = seKey.publicKey.derRepresentation // Public key IS exportable +``` + +--- + +## Key Derivation (HKDF) + +HMAC-based Key Derivation Function (RFC 5869). + +### One-Step Derivation + +```swift +let derivedKey = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: ikm), + salt: saltData, // Optional, can be empty + info: infoData, // Context/label + outputByteCount: 32 +) +// derivedKey: SymmetricKey +``` + +### Two-Step (Extract + Expand) + +Use two-step when deriving multiple keys from the same input: extract once, expand with different `info` values. + +```swift +let prk = HKDF.extract(inputKeyMaterial: SymmetricKey(data: ikm), salt: saltData) +let encKey = HKDF.expand(pseudoRandomKey: prk, info: Data("enc".utf8), outputByteCount: 32) +let macKey = HKDF.expand(pseudoRandomKey: prk, info: Data("mac".utf8), outputByteCount: 32) +``` + +--- + +## Error Types + +### CryptoKitError + +```swift +CryptoKitError.incorrectKeySize // Key size doesn't match algorithm +CryptoKitError.incorrectParameterSize // Parameter size invalid +CryptoKitError.authenticationFailure // GCM/ChaCha tag verification failed, HMAC mismatch +CryptoKitError.underlyingCoreCryptoError(error:) // Low-level failure +CryptoKitError.wrapFailure // AES key wrap failed +CryptoKitError.unwrapFailure // AES key unwrap failed +``` + +### CryptoKitASN1Error + +```swift +CryptoKitASN1Error.invalidASN1Object // Malformed ASN.1 structure +CryptoKitASN1Error.invalidASN1IntegerEncoding // Bad integer encoding +CryptoKitASN1Error.truncatedASN1Field // Data ends prematurely +CryptoKitASN1Error.invalidFieldIdentifier // Unknown ASN.1 tag +CryptoKitASN1Error.unexpectedFieldType // Wrong ASN.1 type +CryptoKitASN1Error.invalidObjectIdentifier // Bad OID +CryptoKitASN1Error.invalidPEMDocument // PEM header/footer or Base64 invalid +``` + +### HPKE and KEM Errors + +```swift +// HPKE.Errors — see HPKE section for full list of 8 cases +HPKE.Errors.inconsistentParameters +HPKE.Errors.ciphertextTooShort +// ... (6 more) + +// KEM.Errors (iOS 26+) +KEM.Errors.publicKeyMismatchDuringInitialization +KEM.Errors.invalidSeed +``` + +--- + +## Swift Crypto Cross-Platform Parity + +Apple's open-source [swift-crypto](https://github.com/apple/swift-crypto) provides CryptoKit APIs on Linux, Windows, and other platforms. + +### Import Difference + +```swift +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto // swift-crypto package +#endif +``` + +### API Parity + +Everything maps 1:1 except `SecureEnclave.*` (requires Apple hardware). Hashing, HMAC, AES-GCM, ChaChaPoly, ECDH, ECDSA/EdDSA, ML-KEM, ML-DSA, X-Wing, HPKE, HKDF, and AES Key Wrap are all available cross-platform. + +```swift +// Package.swift +.package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0") +// Target: .product(name: "Crypto", package: "swift-crypto") +``` + +--- + +## Resources + +**WWDC**: 2019-709, 2024-10120 + +**Docs**: /cryptokit, /cryptokit/performing-common-cryptographic-operations, /security/certificate-key-and-trust-services/keys/storing-keys-in-the-secure-enclave + +**Skills**: axiom-cryptokit diff --git a/.claude/skills/axiom-cryptokit-ref/agents/openai.yaml b/.claude/skills/axiom-cryptokit-ref/agents/openai.yaml new file mode 100644 index 0000000..ac14252 --- /dev/null +++ b/.claude/skills/axiom-cryptokit-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "CryptoKit Reference" + short_description: "Needing CryptoKit API details" diff --git a/.claude/skills/axiom-cryptokit/.openskills.json b/.claude/skills/axiom-cryptokit/.openskills.json new file mode 100644 index 0000000..cc1a709 --- /dev/null +++ b/.claude/skills/axiom-cryptokit/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-cryptokit", + "installedAt": "2026-04-12T08:06:09.923Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-cryptokit/SKILL.md b/.claude/skills/axiom-cryptokit/SKILL.md new file mode 100644 index 0000000..fe22e23 --- /dev/null +++ b/.claude/skills/axiom-cryptokit/SKILL.md @@ -0,0 +1,431 @@ +--- +name: axiom-cryptokit +description: Use when encrypting data, signing payloads, verifying signatures, generating keys, using Secure Enclave, migrating from CommonCrypto, or adopting quantum-secure cryptography. Covers CryptoKit design philosophy, AES-GCM, ECDSA, ECDH, Secure Enclave keys, HPKE, ML-KEM, ML-DSA, and cross-platform interop with Swift Crypto. +license: MIT +--- + +# CryptoKit + +Authenticated encryption, digital signatures, key agreement, Secure Enclave key management, and quantum-secure cryptography for iOS apps. + +## When to Use This Skill + +Use when you need to: +- ☑ Encrypt data at rest or in transit beyond what TLS/Data Protection provides +- ☑ Sign payloads for integrity verification (receipts, tokens, API requests) +- ☑ Generate or manage cryptographic keys (including Secure Enclave hardware keys) +- ☑ Migrate from CommonCrypto C API to CryptoKit +- ☑ Implement key agreement (ECDH) for end-to-end encryption +- ☑ Use HPKE for modern asymmetric encryption +- ☑ Adopt quantum-secure algorithms (ML-KEM, ML-DSA) for post-quantum readiness +- ☑ Interoperate with server-side Swift Crypto or non-Apple platforms + +## Example Prompts + +"How do I encrypt user data with AES-GCM?" +"How do I sign a payload and verify it on my server?" +"How do I use the Secure Enclave to protect a signing key?" +"I'm using CommonCrypto — should I migrate to CryptoKit?" +"How do I do ECDH key agreement for end-to-end encryption?" +"How do I make my app quantum-secure?" +"My server can't verify signatures from my iOS app" +"What's the difference between P256 and Curve25519?" + +## Red Flags + +Signs you're making this harder or less secure than it needs to be: + +- ❌ Using CommonCrypto C API when CryptoKit exists — Buffer management nightmares, no authenticated encryption, manual IV handling. CryptoKit provides memory-safe, authenticated encryption in one call. Migration takes minutes per call site. +- ❌ AES-CBC without authentication — Ciphertext is malleable. Padding oracle attacks let an attacker decrypt data one byte at a time without the key. AES-GCM (CryptoKit's default) provides authenticated encryption — tampering is detected automatically. +- ❌ Rolling your own crypto protocol — Timing attacks, padding oracles, nonce reuse. Unless you are a professional cryptographer, use CryptoKit's high-level APIs. Apple's security team designed them to prevent exactly these mistakes. +- ❌ Ignoring Secure Enclave for signing keys — "Too complex" is wrong. The SE API is nearly identical to software P256. SE keys survive jailbreaks. If the key protects money, health data, or identity, SE is the correct choice at zero extra effort. +- ❌ Using AES-128 when AES-256 costs nothing extra — Grover's algorithm halves symmetric key strength on quantum computers. AES-128 drops to 64-bit security. AES-256 drops to 128-bit — still safe. The performance difference is negligible. +- ❌ Force-unwrapping crypto operations — `AES.GCM.open` and signature verification throw on failure. Force-unwrapping masks authentication failures and turns security errors into crashes. Always use `try`/`catch`. +- ❌ Storing raw private keys in UserDefaults — Readable from device backups, accessible on jailbroken devices, visible in plaintext in the app's plist. Use Keychain for software keys, Secure Enclave for hardware-bound keys. + +## Do You Actually Need CryptoKit? + +```dot +digraph need_cryptokit { + "What are you\nprotecting?" [shape=diamond]; + "Data in transit\nvia HTTPS?" [shape=diamond]; + "Data at rest\non device?" [shape=diamond]; + "Credentials or\ntokens?" [shape=diamond]; + "CloudKit or\niCloud?" [shape=diamond]; + "Custom crypto\nneeded?" [shape=diamond]; + + "Use URLSession + TLS\n(system handles crypto)" [shape=box]; + "Use Data Protection\n(.completeFileProtection)" [shape=box]; + "Use Keychain\n(axiom-keychain skill)" [shape=box]; + "Use CloudKit encryption\n(encryptedValues)" [shape=box]; + "YES — Use CryptoKit" [shape=box, style=bold]; + + "What are you\nprotecting?" -> "Data in transit\nvia HTTPS?" [label="network"]; + "What are you\nprotecting?" -> "Data at rest\non device?" [label="storage"]; + "What are you\nprotecting?" -> "Credentials or\ntokens?" [label="secrets"]; + "What are you\nprotecting?" -> "CloudKit or\niCloud?" [label="sync"]; + "What are you\nprotecting?" -> "Custom crypto\nneeded?" [label="signatures, key exchange,\nE2E encryption"]; + + "Data in transit\nvia HTTPS?" -> "Use URLSession + TLS\n(system handles crypto)" [label="standard HTTPS"]; + "Data in transit\nvia HTTPS?" -> "Custom crypto\nneeded?" [label="need E2E beyond TLS"]; + "Data at rest\non device?" -> "Use Data Protection\n(.completeFileProtection)" [label="file-level"]; + "Data at rest\non device?" -> "Custom crypto\nneeded?" [label="field-level encryption"]; + "Credentials or\ntokens?" -> "Use Keychain\n(axiom-keychain skill)" [label="yes"]; + "CloudKit or\niCloud?" -> "Use CloudKit encryption\n(encryptedValues)" [label="yes"]; + "Custom crypto\nneeded?" -> "YES — Use CryptoKit" [label="yes"]; +} +``` + +From WWDC 2019-709: "We strongly recommend you rely on higher level system frameworks when you can." CryptoKit is for when system frameworks don't cover your use case — custom signatures, field-level encryption, key agreement, interop with non-Apple systems. + +## Secure Enclave Key Management + +The Secure Enclave is a hardware security module built into Apple devices. Keys generated in the SE never leave the hardware — not even Apple can extract them. + +### When to Use Secure Enclave + +| Scenario | Use SE? | Why | +|----------|---------|-----| +| Signing API requests | Yes | Key can't be extracted from device | +| Biometric-gated decryption | Yes | SE ties key to Face ID/Touch ID | +| End-to-end encryption key | Yes (key agreement) | Private key hardware-bound | +| Encrypting data for another device | No | Key must be exportable | +| Server-shared symmetric key | No | SE only does asymmetric (P256, ML-KEM, ML-DSA) | +| Cross-device sync | No | SE keys are device-bound | + +### SE Key Generation with Biometric Gating + +```swift +import CryptoKit +import LocalAuthentication + +guard SecureEnclave.isAvailable else { + let softwareKey = P256.Signing.PrivateKey() + return +} + +let context = LAContext() +context.localizedReason = "Authenticate to sign transaction" + +guard let accessControl = SecAccessControlCreateWithFlags( + nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + [.privateKeyUsage, .biometryCurrentSet], nil +) else { + throw CryptoKitError.underlyingCoreCryptoError(error: 0) +} + +let seKey = try SecureEnclave.P256.Signing.PrivateKey( + compactRepresentable: false, + accessControl: accessControl, + authenticationContext: .init(context) +) + +let signature = try seKey.signature(for: data) +let publicKeyDER = seKey.publicKey.derRepresentation +``` + +### SE Key Lifecycle + +`seKey.dataRepresentation` is a **wrapped blob**, not raw key material. It contains an encrypted reference that only this specific Secure Enclave hardware can unwrap. Exporting it to another device produces an unusable blob. + +- **Survives**: App updates, device reboots +- **Does NOT survive**: App reinstall, device restore, device erase, migration to new device +- **Persistence**: Store `dataRepresentation` in Keychain. Restore with: + +```swift +let restoredKey = try SecureEnclave.P256.Signing.PrivateKey( + dataRepresentation: savedKeyData, authenticationContext: .init(context) +) +``` + +### SE Constraints + +- **Classical**: P256 only — no Curve25519, no P384/P521. One curve, two purposes: `SecureEnclave.P256.Signing` and `SecureEnclave.P256.KeyAgreement` +- **Post-quantum (iOS 26+)**: `SecureEnclave.MLKEM768`, `SecureEnclave.MLKEM1024`, `SecureEnclave.MLDSA65`, `SecureEnclave.MLDSA87` +- Signing and key agreement only — no direct encryption +- Simulator has no SE — `SecureEnclave.isAvailable` returns false. Always provide a software fallback for testing. + +## Common Workflows + +### Hash Data Integrity + +```swift +import CryptoKit + +let data = "Hello, world".data(using: .utf8)! +let digest = SHA256.hash(data: data) +guard digest == SHA256.hash(data: data) else { /* tampering detected */ } +``` + +Available: `SHA256`, `SHA384`, `SHA512`. Use `Insecure.MD5`/`Insecure.SHA1` only for legacy protocol compatibility, never for security. + +### HMAC Message Authentication + +```swift +let key = SymmetricKey(size: .bits256) +let mac = HMAC.authenticationCode(for: data, using: key) +let isValid = HMAC.isValidAuthenticationCode(mac, authenticating: data, using: key) +``` + +Constant-time comparison built in — safe against timing attacks. Use HMAC when both parties share a symmetric key. + +### AES-GCM Encrypt/Decrypt + +```swift +let key = SymmetricKey(size: .bits256) +let plaintext = "Secret message".data(using: .utf8)! + +let sealedBox = try AES.GCM.seal(plaintext, using: key) +let combined = sealedBox.combined! + +let restoredBox = try AES.GCM.SealedBox(combined: combined) +let decrypted = try AES.GCM.open(restoredBox, using: key) +``` + +Authenticated encryption — tampering triggers `CryptoKitError.authenticationFailure`. No separate HMAC step needed. Nonce is auto-generated and prepended to `combined`. + +### ECDSA Sign/Verify + +```swift +let privateKey = P256.Signing.PrivateKey() +let signature = try privateKey.signature(for: data) +let isValid = privateKey.publicKey.isValidSignature(signature, for: data) +``` + +Curves: `P256` (secp256r1, most common), `P384`, `P521`, `Curve25519` (Ed25519, fastest). Use P256 for server interop. Use Curve25519 when you control both sides. + +### ECDH Key Agreement + +```swift +let alicePrivate = P256.KeyAgreement.PrivateKey() +let bobPrivate = P256.KeyAgreement.PrivateKey() + +let sharedSecret = try alicePrivate.sharedSecretFromKeyAgreement( + with: bobPrivate.publicKey +) + +let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: "my-app-v1".data(using: .utf8)!, + sharedInfo: Data(), + outputByteCount: 32 +) +``` + +Never use the raw shared secret directly — always derive a symmetric key via HKDF. The raw secret has biased bits that weaken encryption. + +### HPKE End-to-End Encryption + +HPKE (Hybrid Public Key Encryption) combines key agreement and symmetric encryption in one step. Preferred over manual ECDH + AES-GCM for new protocols. Classical ciphersuites (P256, Curve25519) available iOS 17+. The quantum-secure XWing ciphersuite and ML-KEM/ML-DSA require iOS 26+. + +```swift +let recipientPrivate = P256.KeyAgreement.PrivateKey() + +var sender = try HPKE.Sender( + recipientKey: recipientPrivate.publicKey, + ciphersuite: .P256_SHA256_AES_GCM_256, + info: "my-app-message-v1".data(using: .utf8)! +) +let ciphertext = try sender.seal(plaintext) + +var recipient = try HPKE.Recipient( + privateKey: recipientPrivate, + ciphersuite: .P256_SHA256_AES_GCM_256, + info: "my-app-message-v1".data(using: .utf8)!, + encapsulatedKey: sender.encapsulatedKey +) +let decrypted = try recipient.open(ciphertext) +``` + +## Quantum-Secure Migration + +From WWDC 2025-314: harvest-now-decrypt-later is not theoretical. Nation-state actors are recording encrypted traffic today to decrypt with future quantum computers. Data with sensitivity beyond 10 years needs post-quantum protection now. + +### Migration Priority + +1. **Quantum-secure TLS (automatic)** — iOS 26 URLSession and Network.framework use quantum-secure TLS by default. No code changes needed. iMessage has used PQ3 since iOS 17.4. +2. **Custom protocol encryption** — If your app uses manual ECDH + AES or custom key exchange, those channels are NOT automatically upgraded. Replace with post-quantum HPKE. +3. **Symmetric key upgrade** — Upgrade AES-128 to AES-256. Grover's algorithm halves symmetric key strength, so 256-bit keys provide 128-bit post-quantum security. + +### Post-Quantum HPKE (iOS 26+) + +Same HPKE API, different ciphersuite. Combines ML-KEM (quantum-safe) with X25519 (classical) for hybrid security — Apple's recommendation: + +```swift +let recipientPrivate = XWingMLKEM768X25519.PrivateKey() + +var sender = try HPKE.Sender( + recipientKey: recipientPrivate.publicKey, + ciphersuite: .XWingMLKEM768X25519_SHA256_AES_GCM_256, + info: "my-app-pq-v1".data(using: .utf8)! +) +let ciphertext = try sender.seal(plaintext) + +var recipient = try HPKE.Recipient( + privateKey: recipientPrivate, + ciphersuite: .XWingMLKEM768X25519_SHA256_AES_GCM_256, + info: "my-app-pq-v1".data(using: .utf8)!, + encapsulatedKey: sender.encapsulatedKey +) +let decrypted = try recipient.open(ciphertext) +``` + +Hybrid constructions (classical + post-quantum) ensure security even if one algorithm is broken. The XWing construction pairs ML-KEM768 with X25519 so a classical break alone or a quantum break alone cannot compromise the exchange. + +### ML-KEM and ML-DSA Direct Usage (iOS 26+) + +```swift +let kemPrivate = try MLKEM768.PrivateKey() +let (sharedSecret, encapsulation) = try kemPrivate.publicKey.encapsulate() +let derivedSecret = try kemPrivate.decapsulate(encapsulation) + +let dsaPrivate = try MLDSA65.PrivateKey() +let signature = try dsaPrivate.signature(for: data) +let isValid = dsaPrivate.publicKey.isValidSignature(signature, for: data) +``` + +Use ML-KEM for key encapsulation when building custom protocols. Use ML-DSA for signatures when P256/Ed25519 won't survive quantum analysis. Prefer HPKE with hybrid ciphersuites over raw ML-KEM for most applications. + +## Cross-Platform Considerations + +### Swift Crypto Parity + +On Linux/server: `import Crypto` (apple/swift-crypto) provides the same API minus Secure Enclave and Keychain. For shared code: + +```swift +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +``` + +### Signature Encoding Interop + +CryptoKit's ECDSA signatures use **raw format** (r || s concatenation, 64 bytes for P256). Most non-Apple platforms (OpenSSL, Java, .NET) expect **DER format** (ASN.1 encoded, variable length 70-72 bytes for P256). + +```swift +let signature = try privateKey.signature(for: data) + +let raw = signature.rawRepresentation +let der = signature.derRepresentation +``` + +When receiving signatures from a server: + +```swift +let fromDER = try P256.Signing.ECDSASignature(derRepresentation: serverDER) +let fromRaw = try P256.Signing.ECDSASignature(rawRepresentation: serverRaw) +``` + +### Key Export for Server Verification + +```swift +let publicKey = privateKey.publicKey +publicKey.derRepresentation +publicKey.pemRepresentation +publicKey.x963Representation +``` + +DER for most platforms, PEM for config files and REST APIs, X9.63 for compact JavaScript interop. + +### secp256r1 vs secp256k1 + +CryptoKit uses **secp256r1** (P-256, prime256v1) — the NIST standard curve used by TLS, government systems, and enterprise software. + +Bitcoin and Ethereum use **secp256k1** (Koblitz curve) — a different curve entirely. These are **not interoperable**. A P-256 signature cannot be verified with a secp256k1 verifier. If you need secp256k1 for blockchain interop, use a dedicated library (libsecp256k1 wrapper), not CryptoKit. + +### SE Keys and Cross-Platform + +Secure Enclave keys are device-bound. `dataRepresentation` is a wrapped blob that only the originating hardware can unwrap. This means SE keys enable crypto-shredding by design — destroying the device or reinstalling the app makes all SE-encrypted data permanently irrecoverable. Plan key lifecycle accordingly. Store recovery paths (server-escrowed backup keys, multi-device key distribution) if data must survive device loss. + +## Anti-Rationalization + +| Rationalization | Why It Fails | Time Cost | +|-----------------|--------------|-----------| +| "CommonCrypto works fine, no need to migrate" | Buffer overflows, no authenticated encryption, manual IV management. One wrong buffer size = silent data corruption or security vulnerability. | 2-4 hours debugging subtle encryption failures that CryptoKit prevents by design | +| "I'll add authentication to AES-CBC later" | Without authentication, ciphertext is malleable. Attackers modify data without detection. "Later" means after the vulnerability ships. | 4-8 hours incident response when tampered data is discovered in production | +| "Nonces don't need to be random for my use case" | Nonce reuse with AES-GCM leaks plaintext via XOR of ciphertexts. There is no use case where fixed nonces are safe with GCM. | Catastrophic — full plaintext recovery of all messages encrypted with the reused nonce | +| "Secure Enclave is overkill for this" | Software keys can be extracted from jailbroken devices and backups. SE keys cannot. If the key protects money, health data, or identity, SE is not overkill. | 0 extra development time (API is nearly identical to software P256) | +| "Quantum computing is decades away" | Harvest-now-decrypt-later means data recorded today will be decrypted when quantum computers arrive. Apple already ships quantum-secure TLS in iOS 26. | 0 if you use iOS 26 defaults. 1-2 hours for custom protocol migration. | +| "I'll just use my own encryption scheme" | Professional cryptographers spend years designing protocols. Timing side channels, padding oracles, nonce misuse are invisible without expert review. | Weeks to months of security audit + remediation | +| "DER vs raw doesn't matter, it's the same signature" | Wrong encoding = server verification failure. The math is correct but the encoding is wrong. | 2-4 hours debugging interop that one `.derRepresentation` call prevents | + +## Pressure Scenarios + +### Scenario 1: "Just encrypt it with whatever, we ship today" + +**Context**: Feature deadline approaching. Developer needs to encrypt user data before persisting it. + +**Pressure**: "Don't overthink the crypto. AES-CBC, CommonCrypto, whatever gets it done by end of day." + +**Reality**: AES-CBC without authentication is vulnerable to padding oracle attacks. CommonCrypto requires manual buffer management where one wrong size creates silent data corruption. The migration from `CCCrypt` to `AES.GCM.seal` is 5-10 lines of code — less code than the CommonCrypto version — and eliminates an entire vulnerability class. + +**Correct action**: Replace `CCCrypt(kCCEncrypt, kCCAlgorithmAES, ...)` with `AES.GCM.seal(plaintext, using: key)`. Map existing key material to `SymmetricKey(data:)`. If migrating existing CBC-encrypted data, add a read path that decrypts old format and re-encrypts on first access. + +**Push-back template**: "AES-GCM via CryptoKit is actually fewer lines than CommonCrypto and provides authenticated encryption for free. The CBC code has a vulnerability class that GCM eliminates. Switching takes 10 minutes, not hours." + +### Scenario 2: "We need cross-platform, skip Secure Enclave" + +**Context**: Building an E2E encrypted feature that must work on iOS and Android. Developer proposes storing signing keys in Keychain (software) to keep parity with Android's software keystore. + +**Pressure**: "Android doesn't have a Secure Enclave equivalent with the same guarantees. Let's keep it simple and consistent across platforms." + +**Reality**: Android has hardware-backed Keystore (StrongBox) with similar guarantees. Even if the Android side uses software keys, that doesn't mean the iOS side should. SE protection is free on iOS — the API is nearly identical to software P256. Use SE on iOS, hardware Keystore on Android, software fallback where hardware is unavailable. + +**Correct action**: Use `SecureEnclave.P256.Signing.PrivateKey` with a fallback to `P256.Signing.PrivateKey`. The public key and signature formats are identical — the server doesn't know or care which generated them. + +**Push-back template**: "The SE API is the same as software P256 — no extra complexity. The server verifies the same public key format either way. We get hardware protection on devices that support it and graceful fallback on devices that don't. Both platforms can use their best available hardware." + +### Scenario 3: "Quantum-safe is premature optimization" + +**Context**: Designing a new E2E encrypted messaging protocol. Developer proposes classical ECDH + AES-GCM. + +**Pressure**: "Quantum computers are at least a decade away. We're over-engineering this." + +**Reality**: Harvest-now-decrypt-later means adversaries record encrypted traffic today and decrypt it when quantum computers arrive. For ephemeral data (session tokens), classical crypto is fine. For messages with long-term sensitivity (health records, financial data, private communications), post-quantum protection is warranted now. Apple already shipped PQ3 for iMessage and quantum-secure TLS in iOS 26. + +**Correct action**: Use `HPKE.Ciphersuite.XWingMLKEM768X25519_SHA256_AES_GCM_256` instead of `.P256_SHA256_AES_GCM_256`. The code change is one ciphersuite constant — same API, same complexity, hybrid classical + quantum-safe protection. + +**Push-back template**: "The code change is literally one ciphersuite constant. Apple already did this for iMessage (PQ3) and iOS 26 TLS. One line of code removes the harvest-now-decrypt-later risk for data that's still sensitive in 10 years." + +## Checklist + +Before shipping any custom cryptography: + +**Algorithm Selection**: +- [ ] Using authenticated encryption (AES-GCM, not AES-CBC/ECB) +- [ ] Hash function is SHA256+ (not MD5/SHA1) for security purposes +- [ ] Curve appropriate for use case (P256 for interop, Curve25519 for performance) +- [ ] Post-quantum ciphersuite considered for long-term sensitive data + +**Key Management**: +- [ ] Private keys stored in Secure Enclave (if hardware available and key doesn't need export) +- [ ] Fallback to Keychain with appropriate access control if SE unavailable +- [ ] No private keys in UserDefaults, files, or hardcoded in source +- [ ] Key rotation strategy defined for long-lived keys + +**Nonce/IV Handling**: +- [ ] Using CryptoKit's automatic nonce generation (not custom) +- [ ] Not reusing nonces across encryptions +- [ ] Not storing or hardcoding nonces + +**Interop** (if communicating with non-Apple platforms): +- [ ] Signature encoding matches server expectation (DER vs raw) +- [ ] Public key format agreed upon (DER, PEM, or X9.63) +- [ ] Curve matches server (secp256r1 not secp256k1) +- [ ] Testing with actual server verification, not just local round-trip + +**Secure Enclave** (if used): +- [ ] `SecureEnclave.isAvailable` checked with software fallback +- [ ] `dataRepresentation` stored in Keychain for persistence +- [ ] Access control flags match UX (biometric per-use vs session-based) +- [ ] Key lifecycle documented (does not survive reinstall/restore) + +## Resources + +**WWDC**: 2019-709, 2025-314 + +**Docs**: /cryptokit, /cryptokit/secureenclave, /security/certificate_key_and_trust_services + +**Skills**: axiom-cryptokit-ref, axiom-keychain diff --git a/.claude/skills/axiom-cryptokit/agents/openai.yaml b/.claude/skills/axiom-cryptokit/agents/openai.yaml new file mode 100644 index 0000000..cfd3ff9 --- /dev/null +++ b/.claude/skills/axiom-cryptokit/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "CryptoKit" + short_description: "Encrypting data, signing payloads, verifying signatures, generating keys, using Secure Enclave, migrating from Common..." diff --git a/.claude/skills/axiom-database-migration/.openskills.json b/.claude/skills/axiom-database-migration/.openskills.json new file mode 100644 index 0000000..21d6497 --- /dev/null +++ b/.claude/skills/axiom-database-migration/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-database-migration", + "installedAt": "2026-04-12T08:06:11.159Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-database-migration/SKILL.md b/.claude/skills/axiom-database-migration/SKILL.md new file mode 100644 index 0000000..321e7e7 --- /dev/null +++ b/.claude/skills/axiom-database-migration/SKILL.md @@ -0,0 +1,442 @@ +--- +name: axiom-database-migration +description: Use when adding/modifying database columns, encountering "FOREIGN KEY constraint failed", "no such column", "cannot add NOT NULL column" errors, or creating schema migrations for SQLite/GRDB/SQLiteData - prevents data loss with safe migration patterns and testing workflows for iOS/macOS apps +license: MIT +metadata: + version: "1.0.0" +--- + +# Database Migration + +## Overview + +Safe database schema evolution for production apps with user data. **Core principle** Migrations are immutable after shipping. Make them additive, idempotent, and thoroughly tested. + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "I need to add a new column to store user preferences, but the app is already live with user data. How do I do this safely?" +→ The skill covers safe additive patterns for adding columns without losing existing data, including idempotency checks + +#### 2. "I'm getting 'cannot add NOT NULL column' errors when I try to migrate. What does this mean and how do I fix it?" +→ The skill explains why NOT NULL columns fail with existing rows, and shows the safe pattern (nullable first, backfill later) + +#### 3. "I need to change a column from text to integer. Can I just ALTER the column type?" +→ The skill demonstrates the safe pattern: add new column → migrate data → deprecate old (NEVER delete) + +#### 4. "I'm adding a foreign key relationship between tables. How do I add the relationship without breaking existing data?" +→ The skill covers safe foreign key patterns: add column → populate data → add index (SQLite limitations explained) + +#### 5. "Users are reporting crashes after the last update. I changed a migration but the app is already in production. What do I do?" +→ The skill explains migrations are immutable after shipping; shows how to create a new migration to fix the issue rather than modifying the old one + +--- + +## ⛔ NEVER Do These (Data Loss Risk) + +#### These actions DESTROY user data in production + +❌ **NEVER use DROP TABLE** with user data +❌ **NEVER modify shipped migrations** (create new one instead) +❌ **NEVER recreate tables** to change schema (loses data) +❌ **NEVER add NOT NULL column** without DEFAULT value +❌ **NEVER delete columns** (SQLite doesn't support DROP COLUMN safely) + +#### If you're tempted to do any of these, STOP and use the safe patterns below. + +## Mandatory Rules + +#### ALWAYS follow these + +1. **Additive only** Add new columns/tables, never delete +2. **Idempotent** Check existence before creating (safe to run twice) +3. **Transactional** Wrap entire migration in single transaction +4. **Test both paths** Fresh install AND migration from previous version +5. **Nullable first** Add columns as NULL, backfill later if needed +6. **Immutable** Once shipped to users, migrations cannot be changed + +## Safe Patterns + +### Adding Column (Most Common) + +```swift +// ✅ Safe pattern +func migration00X_AddNewColumn() throws { + try database.write { db in + // 1. Check if column exists (idempotency) + let hasColumn = try db.columns(in: "tableName") + .contains { $0.name == "newColumn" } + + if !hasColumn { + // 2. Add as nullable (works with existing rows) + try db.execute(sql: """ + ALTER TABLE tableName + ADD COLUMN newColumn TEXT + """) + } + } +} +``` + +#### Why this works +- Nullable columns don't require DEFAULT +- Existing rows get NULL automatically +- No data transformation needed +- Safe for users upgrading from old versions + +### Adding Column with Default Value + +```swift +// ✅ Safe pattern with default +func migration00X_AddColumnWithDefault() throws { + try database.write { db in + let hasColumn = try db.columns(in: "tracks") + .contains { $0.name == "playCount" } + + if !hasColumn { + try db.execute(sql: """ + ALTER TABLE tracks + ADD COLUMN playCount INTEGER DEFAULT 0 + """) + } + } +} +``` + +### Changing Column Type (Advanced) + +**Pattern**: Add new column → migrate data → deprecate old (NEVER delete) + +```swift +// ✅ Safe pattern for type change +func migration00X_ChangeColumnType() throws { + try database.write { db in + // Step 1: Add new column with new type + try db.execute(sql: """ + ALTER TABLE users + ADD COLUMN age_new INTEGER + """) + + // Step 2: Migrate existing data + try db.execute(sql: """ + UPDATE users + SET age_new = CAST(age_old AS INTEGER) + WHERE age_old IS NOT NULL + """) + + // Step 3: Application code uses age_new going forward + // (Never delete age_old column - just stop using it) + } +} +``` + +### Adding Foreign Key Constraint + +```swift +// ✅ Safe pattern for foreign keys +func migration00X_AddForeignKey() throws { + try database.write { db in + // Step 1: Add new column (nullable initially) + try db.execute(sql: """ + ALTER TABLE tracks + ADD COLUMN album_id TEXT + """) + + // Step 2: Populate the data + try db.execute(sql: """ + UPDATE tracks + SET album_id = ( + SELECT id FROM albums + WHERE albums.title = tracks.album_name + ) + """) + + // Step 3: Add index (helps query performance) + try db.execute(sql: """ + CREATE INDEX IF NOT EXISTS idx_tracks_album_id + ON tracks(album_id) + """) + + // Note: SQLite doesn't allow adding FK constraints to existing tables + // The foreign key relationship is enforced at the application level + } +} +``` + +### Complex Schema Refactoring + +**Pattern**: Break into multiple migrations + +```swift +// Migration 1: Add new structure +func migration010_AddNewTable() throws { + try database.write { db in + try db.execute(sql: """ + CREATE TABLE IF NOT EXISTS new_structure ( + id TEXT PRIMARY KEY, + data TEXT + ) + """) + } +} + +// Migration 2: Copy data +func migration011_MigrateData() throws { + try database.write { db in + try db.execute(sql: """ + INSERT INTO new_structure (id, data) + SELECT id, data FROM old_structure + """) + } +} + +// Migration 3: Add indexes +func migration012_AddIndexes() throws { + try database.write { db in + try db.execute(sql: """ + CREATE INDEX IF NOT EXISTS idx_new_structure_data + ON new_structure(data) + """) + } +} + +// Old structure stays around (deprecated in code) +``` + +## Testing Checklist + +#### BEFORE deploying any migration + +```swift +// Test 1: Migration path (CRITICAL - tests data preservation) +@Test func migrationFromV1ToV2Succeeds() async throws { + let db = try Database(inMemory: true) + + // Simulate v1 schema + try db.write { db in + try db.execute(sql: "CREATE TABLE tableName (id TEXT PRIMARY KEY)") + try db.execute(sql: "INSERT INTO tableName (id) VALUES ('test1')") + } + + // Run v2 migration + try db.runMigrations() + + // Verify data survived + new column exists + try db.read { db in + let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tableName") + #expect(count == 1) // Data preserved + + let columns = try db.columns(in: "tableName").map { $0.name } + #expect(columns.contains("newColumn")) // New column exists + } +} +``` + +**Test 2** Fresh install (run all migrations, verify final schema) +```swift +@Test func freshInstallCreatesCorrectSchema() async throws { + let db = try Database(inMemory: true) + + // Run all migrations + try db.runMigrations() + + // Verify final schema + try db.read { db in + let tables = try db.tables() + #expect(tables.contains("tableName")) + + let columns = try db.columns(in: "tableName").map { $0.name } + #expect(columns.contains("id")) + #expect(columns.contains("newColumn")) + } +} +``` + +**Test 3** Idempotency (run migrations twice, should not throw) +```swift +@Test func migrationsAreIdempotent() async throws { + let db = try Database(inMemory: true) + + // Run migrations twice + try db.runMigrations() + try db.runMigrations() // Should not throw + + // Verify still correct + try db.read { db in + let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tableName") + #expect(count == 0) // No duplicate data + } +} +``` + +#### Manual testing (before TestFlight) +1. Install v(n-1) build on device → add real user data +2. Install v(n) build (with new migration) +3. Verify: App launches, data visible, no crashes + +## Decision Tree + +``` +What are you trying to do? +├─ Add new column? +│ └─ ALTER TABLE ADD COLUMN (nullable) → Done +├─ Add column with default? +│ └─ ALTER TABLE ADD COLUMN ... DEFAULT value → Done +├─ Change column type? +│ └─ Add new column → Migrate data → Deprecate old → Done +├─ Delete column? +│ └─ Mark as deprecated in code → Never delete from schema → Done +├─ Rename column? +│ └─ Add new column → Migrate data → Deprecate old → Done +├─ Add foreign key? +│ └─ Add column → Populate data → Add index → Done +└─ Complex refactor? + └─ Break into multiple migrations → Test each step → Done +``` + +## Common Errors + +| Error | Fix | +|-------|-----| +| `FOREIGN KEY constraint failed` | Check parent row exists, or disable FK temporarily | +| `no such column: columnName` | Add migration to create column | +| `cannot add NOT NULL column` | Use nullable column first, backfill in separate migration | +| `table tableName already exists` | Add `IF NOT EXISTS` clause | +| `duplicate column name` | Check if column exists before adding (idempotency) | + +## Common Mistakes + +❌ **Adding NOT NULL without DEFAULT** +```swift +// ❌ Fails on existing data +ALTER TABLE albums ADD COLUMN rating INTEGER NOT NULL +``` + +✅ **Correct: Add as nullable first** +```swift +ALTER TABLE albums ADD COLUMN rating INTEGER // NULL allowed +// Backfill in separate migration if needed +UPDATE albums SET rating = 0 WHERE rating IS NULL +``` + +❌ **Forgetting to check for existence** — Always add `IF NOT EXISTS` or manual check + +❌ **Modifying shipped migrations** — Create new migration instead + +❌ **Not testing migration path** — Always test upgrade from previous version + +## GRDB-Specific Patterns + +### DatabaseMigrator Setup + +```swift +var migrator = DatabaseMigrator() + +// Migration 1 +migrator.registerMigration("v1") { db in + try db.execute(sql: """ + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL + ) + """) +} + +// Migration 2 +migrator.registerMigration("v2") { db in + let hasColumn = try db.columns(in: "users") + .contains { $0.name == "email" } + + if !hasColumn { + try db.execute(sql: """ + ALTER TABLE users + ADD COLUMN email TEXT + """) + } +} + +// Apply migrations +try migrator.migrate(dbQueue) +``` + +### Checking Migration Status + +```swift +// Check which migrations have been applied +let appliedMigrations = try dbQueue.read { db in + try migrator.appliedMigrations(db) +} +print("Applied migrations: \(appliedMigrations)") + +// Check if migrations are needed +let hasBeenMigrated = try dbQueue.read { db in + try migrator.hasBeenMigrated(db) +} +``` + +## SwiftData Migrations + +For SwiftData (iOS 17+), use `VersionedSchema` and `SchemaMigrationPlan`: + +```swift +// Define schema versions +enum MyAppSchemaV1: VersionedSchema { + static var versionIdentifier = Schema.Version(1, 0, 0) + static var models: [any PersistentModel.Type] { + [Track.self, Album.self] + } +} + +enum MyAppSchemaV2: VersionedSchema { + static var versionIdentifier = Schema.Version(2, 0, 0) + static var models: [any PersistentModel.Type] { + [Track.self, Album.self, Playlist.self] // Added Playlist + } +} + +// Define migration plan +enum MyAppMigrationPlan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { + [MyAppSchemaV1.self, MyAppSchemaV2.self] + } + + static var stages: [MigrationStage] { + [migrateV1toV2] + } + + static let migrateV1toV2 = MigrationStage.custom( + fromVersion: MyAppSchemaV1.self, + toVersion: MyAppSchemaV2.self, + willMigrate: nil, + didMigrate: { context in + // Custom migration logic here + } + ) +} +``` + +## Real-World Impact + +**Before** Developer adds NOT NULL column → migration fails for 50% of users → emergency rollback → data inconsistency + +**After** Developer adds nullable column → tests both paths → smooth deployment → backfills data in v2 + +**Key insight** Migrations can't be rolled back in production. Get them right the first time through thorough testing. + +## tvOS + +**tvOS migrations may run against a fresh database.** The system deletes local storage under pressure, so your app may launch with no database at all. Migrations must handle this gracefully — they effectively become both "create" and "upgrade" operations. + +**Key implications**: +- Migrations must be idempotent (already a best practice, but critical here) +- Don't assume previous data exists for backfill operations +- Test the "fresh install" path as often as the "upgrade" path + +See `axiom-tvos` for full tvOS storage constraints. + +--- + +**Last Updated**: 2025-11-28 +**Frameworks**: SQLite, GRDB, SwiftData +**Status**: Production-ready patterns for safe schema evolution diff --git a/.claude/skills/axiom-database-migration/agents/openai.yaml b/.claude/skills/axiom-database-migration/agents/openai.yaml new file mode 100644 index 0000000..b6422ae --- /dev/null +++ b/.claude/skills/axiom-database-migration/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Database Migration" + short_description: "Adding/modifying database columns, encountering \"FOREIGN KEY constraint failed\", \"no such column\", \"cannot add NOT NU..." diff --git a/.claude/skills/axiom-debug-tests/.openskills.json b/.claude/skills/axiom-debug-tests/.openskills.json new file mode 100644 index 0000000..00754ca --- /dev/null +++ b/.claude/skills/axiom-debug-tests/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-debug-tests", + "installedAt": "2026-04-12T08:06:11.160Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-debug-tests/SKILL.md b/.claude/skills/axiom-debug-tests/SKILL.md new file mode 100644 index 0000000..d7810f3 --- /dev/null +++ b/.claude/skills/axiom-debug-tests/SKILL.md @@ -0,0 +1,329 @@ +--- +name: axiom-debug-tests +description: Use this agent for closed-loop test debugging - automatically analyzes test failures, suggests fixes, and re-runs tests until passing. +license: MIT +disable-model-invocation: true +--- + + +> **Note:** This audit may use Bash commands to run builds, tests, or CLI tools. +# Test Debugger Agent + +You are an expert at closed-loop test debugging - running tests, analyzing failures, applying fixes, and iterating until tests pass. + +## Core Principle + +**Closed-loop debugging flow:** +``` +RUN → CAPTURE → ANALYZE → SUGGEST → FIX → VERIFY → REPORT + ↑ | + └──────────────── (if still failing) ─────────┘ +``` + +## Your Mission + +1. Run the failing test(s) +2. Capture failure evidence (screenshots, logs) +3. Analyze failures using pattern recognition +4. Suggest specific fixes +5. Apply fixes (with user confirmation) +6. Re-run to verify +7. Report final status + +## Phase 1: Run Tests + +```bash +# Get booted simulator +BOOTED_UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid' | head -1) + +# Create result bundle +RESULT_PATH="/tmp/debug-test-$(date +%s).xcresult" + +# Run specific failing tests +xcodebuild test \ + -scheme "UITests" \ + -destination "platform=iOS Simulator,id=$BOOTED_UDID" \ + -resultBundlePath "$RESULT_PATH" \ + -only-testing:"//" \ + 2>&1 | tee /tmp/xcodebuild-debug.log + +echo "Results: $RESULT_PATH" +``` + +## Phase 2: Capture Evidence + +```bash +# Export failure attachments +ATTACHMENTS_DIR="/tmp/debug-failures-$(date +%s)" +mkdir -p "$ATTACHMENTS_DIR" + +xcrun xcresulttool export attachments \ + --path "$RESULT_PATH" \ + --output-path "$ATTACHMENTS_DIR" \ + --only-failures + +# Read manifest +cat "$ATTACHMENTS_DIR/manifest.json" | jq '.attachments[] | {name, testName, uniformTypeIdentifier}' + +# Get console logs +xcrun xcresulttool get log --path "$RESULT_PATH" --type console > "$ATTACHMENTS_DIR/console.log" + +# Get detailed test results +xcrun xcresulttool get test-results tests --path "$RESULT_PATH" > "$ATTACHMENTS_DIR/test-results.txt" +``` + +## Phase 3: Analyze Failures + +### Failure Pattern Recognition + +| Pattern | Error Message | Root Cause | Fix | +|---------|---------------|------------|-----| +| **Element Not Found (test bug)** | `Failed to find element` | Wrong query or missing accessibilityIdentifier | Fix query or add identifier | +| **Element Not Found (app bug)** | `Failed to find element` | Element never implemented or in wrong view | Report: app code needs this element — do NOT rewrite test | +| **Timeout** | `Timed out waiting for element` | Slow app, short timeout | Increase timeout, optimize app | +| **State Mismatch** | `Expected X, got Y` | Race condition | Add explicit wait | +| **Not Hittable** | `Element exists but not hittable` | Element obscured | Dismiss keyboard/sheet, scroll | +| **Stale Element** | `Element no longer attached` | View refreshed | Re-query element | +| **Wrong Query** | `Multiple matches found` | Ambiguous query | Use more specific identifier | + +### Analysis Workflow + +```bash +# 1. Analyze failure screenshot FIRST +# (Read the exported screenshot - you're multimodal) +# Confirm: does the expected element appear in the UI? + +# 2. Check error message +grep -A5 "Failure:" /tmp/xcodebuild-debug.log + +# 3. Find file and line +grep -E "\.swift:[0-9]+" /tmp/xcodebuild-debug.log + +# 4. Read the test code +# (Use Read tool on the file:line from above) +``` + +### Element Not Found Triage + +When a test can't find a UI element, determine whether the problem is in the test or the app BEFORE suggesting fixes: + +1. **Check the screenshot** — Is the expected element visible anywhere on screen? +2. **If element is NOT visible**: Search the app source code for the element (grep for the expected text, identifier, or view name) + - Element not in source → **App bug**: element was never implemented. Report this — do NOT rewrite test queries. Do not search for partial matches or alternative element names. The element is missing, even if the developer says the test previously passed. + - Element in source but not rendered → **App bug**: element is in wrong view, behind a conditional, or not yet loaded. Report the specific issue. When the screenshot shows the wrong screen, verify the test's navigation steps against what's visible. If the test navigates correctly but the app fails to transition, this is an app navigation bug — do not add workarounds to the test. +3. **If element IS visible**: The test query is wrong. Check accessibilityIdentifier, label text, element type. + +**Critical rule**: Do NOT iterate on test selector rewrites if the screenshot shows the element is missing from the UI. The test is correct — the app is incomplete. + +## Phase 4: Suggest Fixes + +Based on pattern analysis, suggest specific code changes: + +### Element Not Found Fix + +**If triage identified a test bug** (element visible but query wrong): + +```swift +// BEFORE (missing identifier) +Button("Login") { ... } + +// AFTER (with identifier) +Button("Login") { ... } + .accessibilityIdentifier("loginButton") +``` + +**If triage identified an app bug** (element not in UI): Skip to Phase 7 — report the missing element as an app issue. Do not modify test code. + +### Timeout Fix + +```swift +// BEFORE (might timeout) +XCTAssertTrue(element.exists) + +// AFTER (explicit wait) +XCTAssertTrue(element.waitForExistence(timeout: 10)) +``` + +### Not Hittable Fix + +```swift +// BEFORE (might be obscured) +button.tap() + +// AFTER (wait for hittable) +let predicate = NSPredicate(format: "isHittable == true") +let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button) +_ = XCTWaiter.wait(for: [expectation], timeout: 5) +button.tap() + +// Or dismiss keyboard first +if app.keyboards.count > 0 { + app.toolbars.buttons["Done"].tap() +} +``` + +### Race Condition Fix + +```swift +// BEFORE (race condition) +button.tap() +XCTAssertTrue(resultLabel.exists) + +// AFTER (wait for result) +button.tap() +XCTAssertTrue(resultLabel.waitForExistence(timeout: 5)) +``` + +## Phase 5: Apply Fixes + +1. **Show proposed change** to user +2. **Get confirmation** before editing +3. **Apply edit** using Edit tool +4. **Log the change** for verification + +```markdown +## Proposed Fix + +**File**: `LoginTests.swift:47` +**Issue**: Missing waitForExistence before tap +**Change**: +```diff +- loginButton.tap() ++ XCTAssertTrue(loginButton.waitForExistence(timeout: 5)) ++ loginButton.tap() +``` + +Shall I apply this fix? +``` + +## Phase 6: Verify Fix + +```bash +# Re-run ONLY the failing test +xcodebuild test \ + -scheme "UITests" \ + -destination "platform=iOS Simulator,id=$BOOTED_UDID" \ + -resultBundlePath "/tmp/verify-$(date +%s).xcresult" \ + -only-testing:"//" + +# Check result +xcrun xcresulttool get test-results summary --path /tmp/verify-*.xcresult +``` + +## Phase 7: Report + +```markdown +## Test Debugging Complete + +### Original Failures +- [TestClass/testMethod]: [original error] + +### Fixes Applied +1. **LoginTests.swift:47** — Added waitForExistence before tap +2. **ProfileTests.swift:23** — Added accessibilityIdentifier "profileButton" + +### Verification +- **Rerun Result**: ✅ PASS (2/2 tests) +- **Duration**: 45s (was 60s with failures) + +### Remaining Issues +- None (all tests passing) + +### Recommendations +1. Add accessibilityIdentifier to all interactive elements +2. Always use waitForExistence before interactions +3. Consider adding test helpers for common patterns +``` + +## Decision Tree + +``` +User reports test failure +↓ +Run test with result bundle +↓ +Check result: +├─ Build failed → Delegate to build-fixer agent +├─ Tests passed → Report success +└─ Tests failed: + ├─ Export failure attachments + ├─ Read failure screenshot FIRST (multimodal analysis) + ├─ Analyze error pattern: + │ ├─ Element not found: + │ │ ├─ Screenshot shows element → Fix test query/identifier + │ │ └─ Screenshot missing element → Search app source + │ │ ├─ Not implemented → Report: app needs this element + │ │ └─ Wrong view/conditional → Report: app code bug + │ ├─ Timeout → Check wait/timeout values + │ ├─ Not hittable → Check for obscuring elements + │ └─ State mismatch → Check for race conditions + ├─ Read test source code + ├─ Suggest specific fix + ├─ Get user approval + ├─ Apply fix + └─ Re-run test (loop back if still failing) +``` + +## Integration with Other Skills + +When analyzing failures, consider: + +- **axiom-xctest-automation**: Best practices for element queries, waiting +- **axiom-ui-testing**: Condition-based waiting patterns +- **axiom-swift-concurrency**: Async test patterns, race conditions +- **axiom-swiftui-debugging**: View update issues in UI tests + +## Guidelines + +1. **Always export attachments** - Screenshots are invaluable +2. **Read screenshots** - You're multimodal, analyze them +3. **One fix at a time** - Don't batch multiple changes +4. **Verify each fix** - Re-run after each change +5. **Get user confirmation** - Before editing code +6. **Max 3 iterations** - If still failing, escalate to user +7. **Log all changes** - For audit trail + +**Never**: +- Apply fixes without analyzing the failure first +- Edit code without user confirmation +- Skip the verification re-run after a fix +- Batch multiple fixes before verifying each one works +- Continue beyond 3 failed iterations without escalating + +## Error Quick Reference + +| Symptom | Quick Check | Likely Fix | +|---------|-------------|------------| +| "Failed to find element" | Screenshot shows element? | YES: Add identifier. NO: Check app source — element may not exist | +| "Timed out" | Check app loading | Increase timeout or optimize | +| "Not hittable" | Keyboard visible? | Dismiss keyboard | +| "Multiple matches" | Generic query? | Use specific identifier | +| "Test hangs" | Infinite wait? | Add timeout, check deadlock | + +## Example Interaction + +**User**: "My testLoginWithValidCredentials keeps timing out" + +**Your response**: +1. Run the specific test with result bundle +2. Export failure screenshot +3. Read screenshot - see if login form loaded +4. Read test code - find the timeout line +5. Analyze: timeout is 5s but app loads slowly +6. Suggest: Increase timeout to 15s or add loading indicator check +7. Get user confirmation +8. Apply fix +9. Re-run test +10. Report pass/fail + +## Resources + +**WWDC**: 2019-413 (Testing in Xcode), 2025-344 (Record, replay, and review) + +**Skills**: axiom-ios-testing, axiom-xctest-automation + +## Related + +For test execution: `test-runner` agent +For simulator issues: `simulator-tester` agent +For build issues: `build-fixer` agent diff --git a/.claude/skills/axiom-debug-tests/agents/openai.yaml b/.claude/skills/axiom-debug-tests/agents/openai.yaml new file mode 100644 index 0000000..b0f55fd --- /dev/null +++ b/.claude/skills/axiom-debug-tests/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Debug Tests" + short_description: "Use this agent for closed-loop test debugging" diff --git a/.claude/skills/axiom-deep-link-debugging/.openskills.json b/.claude/skills/axiom-deep-link-debugging/.openskills.json new file mode 100644 index 0000000..3c6fb27 --- /dev/null +++ b/.claude/skills/axiom-deep-link-debugging/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-deep-link-debugging", + "installedAt": "2026-04-12T08:06:11.752Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-deep-link-debugging/SKILL.md b/.claude/skills/axiom-deep-link-debugging/SKILL.md new file mode 100644 index 0000000..a43d58f --- /dev/null +++ b/.claude/skills/axiom-deep-link-debugging/SKILL.md @@ -0,0 +1,644 @@ +--- +name: axiom-deep-link-debugging +description: Use when adding debug-only deep links for testing, enabling simulator navigation to specific screens, or integrating with automated testing workflows - enables closed-loop debugging without production deep link implementation +license: MIT +compatibility: iOS 13+ +metadata: + version: "1.0.0" + last-updated: "2025-12-08" +--- + +# Deep Link Debugging + +## When to Use This Skill + +Use when: +- Adding debug-only deep links for simulator testing +- Enabling automated navigation to specific screens for screenshot/testing +- Integrating with `simulator-tester` agent or `/axiom:screenshot` +- Need to navigate programmatically without production deep link implementation +- Testing navigation flows without manual tapping + +**Do NOT use for**: +- Production deep linking (use `axiom-swiftui-nav` skill instead) +- Universal links or App Clips +- Complex routing architectures + +## Example Prompts + +#### 1. "Claude Code can't navigate to specific screens for testing" +→ Add debug-only URL scheme to enable `xcrun simctl openurl` navigation + +#### 2. "I want to take screenshots of different screens automatically" +→ Create debug deep links for each screen, callable from simulator + +#### 3. "Automated testing needs to set up specific app states" +→ Add debug links that navigate AND configure state + +--- + +## Red Flags — When You Need Debug Deep Links + +If you're experiencing ANY of these, add debug deep links: + +**Testing friction**: +- ❌ "I have to manually tap through 5 screens to test this feature" +- ❌ "Screenshot capture can't show the screen I need to debug" +- ❌ "Automated tests can't reach the error state without complex setup" + +**Debugging inefficiency**: +- ❌ "I make a fix, rebuild, manually navigate, check — takes 3 minutes per iteration" +- ❌ "Can't visually verify fixes because Claude Code can't navigate there" + +**Solution**: Add debug deep links that let you (and Claude Code) jump directly to any screen with any state configuration. + +--- + +## Implementation + +### Pattern 1: Basic Debug URL Scheme (SwiftUI) + +Add a debug-only URL scheme that routes to screens. + +```swift +import SwiftUI + +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + #if DEBUG + .onOpenURL { url in + handleDebugURL(url) + } + #endif + } + } + + #if DEBUG + private func handleDebugURL(_ url: URL) { + guard url.scheme == "debug" else { return } + + // Route based on host + switch url.host { + case "settings": + // Navigate to settings + NotificationCenter.default.post( + name: .navigateToSettings, + object: nil + ) + + case "profile": + // Navigate to profile + let userID = url.queryItems?["id"] ?? "current" + NotificationCenter.default.post( + name: .navigateToProfile, + object: userID + ) + + case "reset": + // Reset app to initial state + resetApp() + + default: + print("⚠️ Unknown debug URL: \(url)") + } + } + #endif +} + +#if DEBUG +extension Notification.Name { + static let navigateToSettings = Notification.Name("navigateToSettings") + static let navigateToProfile = Notification.Name("navigateToProfile") +} + +extension URL { + var queryItems: [String: String]? { + guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false), + let items = components.queryItems else { + return nil + } + return Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value ?? "") }) + } +} +#endif +``` + +**Usage**: +```bash +# From simulator +xcrun simctl openurl booted "debug://settings" +xcrun simctl openurl booted "debug://profile?id=123" +xcrun simctl openurl booted "debug://reset" +``` + +--- + +### Pattern 2: NavigationPath Integration (iOS 16+) + +Integrate debug deep links with NavigationStack for robust navigation. + +```swift +import SwiftUI + +@MainActor +class DebugRouter: ObservableObject { + @Published var path = NavigationPath() + + #if DEBUG + func handleDebugURL(_ url: URL) { + guard url.scheme == "debug" else { return } + + switch url.host { + case "settings": + path.append(Destination.settings) + + case "recipe": + if let id = url.queryItems?["id"], let recipeID = Int(id) { + path.append(Destination.recipe(id: recipeID)) + } + + case "recipe-edit": + if let id = url.queryItems?["id"], let recipeID = Int(id) { + // Navigate to recipe, then to edit + path.append(Destination.recipe(id: recipeID)) + path.append(Destination.recipeEdit(id: recipeID)) + } + + case "reset": + path = NavigationPath() // Pop to root + + default: + print("⚠️ Unknown debug URL: \(url)") + } + } + #endif +} + +struct ContentView: View { + @StateObject private var router = DebugRouter() + + var body: some View { + NavigationStack(path: $router.path) { + HomeView() + .navigationDestination(for: Destination.self) { destination in + destinationView(for: destination) + } + } + #if DEBUG + .onOpenURL { url in + router.handleDebugURL(url) + } + #endif + } + + @ViewBuilder + private func destinationView(for destination: Destination) -> some View { + switch destination { + case .settings: + SettingsView() + case .recipe(let id): + RecipeDetailView(recipeID: id) + case .recipeEdit(let id): + RecipeEditView(recipeID: id) + } + } +} + +enum Destination: Hashable { + case settings + case recipe(id: Int) + case recipeEdit(id: Int) +} +``` + +**Usage**: +```bash +# Navigate to settings +xcrun simctl openurl booted "debug://settings" + +# Navigate to recipe #42 +xcrun simctl openurl booted "debug://recipe?id=42" + +# Navigate to recipe #42 edit screen +xcrun simctl openurl booted "debug://recipe-edit?id=42" + +# Pop to root +xcrun simctl openurl booted "debug://reset" +``` + +--- + +### Pattern 3: State Configuration Links + +Debug links that both navigate AND configure state. + +```swift +#if DEBUG +extension DebugRouter { + func handleDebugURL(_ url: URL) { + guard url.scheme == "debug" else { return } + + switch url.host { + case "login": + // Show login screen + path.append(Destination.login) + + case "login-error": + // Show login screen WITH error state + path.append(Destination.login) + // Trigger error state + NotificationCenter.default.post( + name: .showLoginError, + object: "Invalid credentials" + ) + + case "recipe-empty": + // Show recipe list in empty state + UserDefaults.standard.set(true, forKey: "debug_emptyRecipeList") + path.append(Destination.recipes) + + case "recipe-error": + // Show recipe list with network error + UserDefaults.standard.set(true, forKey: "debug_networkError") + path.append(Destination.recipes) + + default: + print("⚠️ Unknown debug URL: \(url)") + } + } +} +#endif +``` + +**Usage**: +```bash +# Test login error state +xcrun simctl openurl booted "debug://login-error" + +# Test empty recipe list +xcrun simctl openurl booted "debug://recipe-empty" + +# Test network error handling +xcrun simctl openurl booted "debug://recipe-error" +``` + +--- + +### Pattern 4: Info.plist Configuration (DEBUG only) + +Register the debug URL scheme ONLY in debug builds. + +**Step 1**: Add scheme to Info.plist + +```xml +CFBundleURLTypes + + + CFBundleURLSchemes + + debug + + CFBundleURLName + com.example.debug + + +``` + +**Step 2**: Strip from release builds + +Add a Run Script phase to your target's Build Phases (runs BEFORE "Copy Bundle Resources"): + +```bash +# Strip debug URL scheme from Release builds +if [ "${CONFIGURATION}" = "Release" ]; then + echo "Removing debug URL scheme from Info.plist" + + /usr/libexec/PlistBuddy -c "Delete :CFBundleURLTypes:0" "${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}" 2>/dev/null || true +fi +``` + +**Alternative**: Use separate Info.plist files for Debug vs Release configurations in Build Settings. + +--- + +## Integration with Simulator Testing + +### With `/axiom:screenshot` Command + +```bash +# 1. Navigate to screen +xcrun simctl openurl booted "debug://settings" + +# 2. Wait for navigation +sleep 1 + +# 3. Capture screenshot +/axiom:screenshot +``` + +### With `simulator-tester` Agent + +Simply tell the agent: +- "Navigate to Settings and take a screenshot" +- "Open the recipe editor and verify the layout" +- "Go to the error state and show me what it looks like" + +The agent will use your debug deep links to navigate. + +--- + +## Mandatory First Steps + +**ALWAYS complete these steps** before adding debug deep links: + +### Step 1: Define Navigation Needs + +List all screens you need to reach for testing: +``` +- Settings screen +- Profile screen (with specific user ID) +- Recipe detail (with specific recipe ID) +- Error states (login error, network error, etc.) +- Empty states (no recipes, no favorites) +``` + +### Step 2: Choose URL Scheme Pattern + +``` +debug://screen-name # Simple screen navigation +debug://screen-name?param=value # Navigation with parameters +debug://state-name # State configuration +``` + +### Step 3: Add URL Handler + +Use `#if DEBUG` to ensure code is stripped from release builds. + +### Step 4: Test Deep Links + +```bash +# Boot simulator +xcrun simctl boot "iPhone 16 Pro" + +# Launch app +xcrun simctl launch booted com.example.YourApp + +# Test each deep link +xcrun simctl openurl booted "debug://settings" +xcrun simctl openurl booted "debug://profile?id=123" +``` + +--- + +## Common Mistakes + +### ❌ WRONG — Hardcoding navigation in URL handler + +```swift +#if DEBUG +func handleDebugURL(_ url: URL) { + if url.host == "settings" { + // ❌ WRONG — Creates tight coupling + self.showingSettings = true + } +} +#endif +``` + +**Problem**: URL handler now owns navigation logic, duplicating coordinator/router patterns. + +**✅ RIGHT — Use existing navigation system**: +```swift +#if DEBUG +func handleDebugURL(_ url: URL) { + if url.host == "settings" { + // Use existing NavigationPath + path.append(Destination.settings) + } +} +#endif +``` + +--- + +### ❌ WRONG — Leaving debug code in production + +```swift +// ❌ WRONG — No #if DEBUG +func handleDebugURL(_ url: URL) { + // This ships to users! +} +``` + +**Problem**: Debug endpoints exposed in production. Security risk. + +**✅ RIGHT — Wrap in #if DEBUG**: +```swift +#if DEBUG +func handleDebugURL(_ url: URL) { + // Stripped from release builds +} +#endif +``` + +--- + +### ❌ WRONG — Using query parameters without validation + +```swift +#if DEBUG +case "profile": + let userID = Int(url.queryItems?["id"] ?? "0")! // ❌ Force unwrap + path.append(Destination.profile(id: userID)) +#endif +``` + +**Problem**: Crashes if `id` is missing or invalid. + +**✅ RIGHT — Validate parameters**: +```swift +#if DEBUG +case "profile": + guard let idString = url.queryItems?["id"], + let userID = Int(idString) else { + print("⚠️ Invalid profile ID") + return + } + path.append(Destination.profile(id: userID)) +#endif +``` + +--- + +## Testing Checklist + +Before using debug deep links in automated workflows: + +- [ ] URL handler wrapped in `#if DEBUG` +- [ ] All deep links tested manually in simulator +- [ ] Parameters validated (don't force unwrap) +- [ ] Deep links integrate with existing navigation (don't duplicate logic) +- [ ] URL scheme stripped from Release builds (script or separate Info.plist) +- [ ] Documented in README or comments for other developers +- [ ] Works with `/axiom:screenshot` command +- [ ] Works with `simulator-tester` agent + +--- + +## Real-World Example + +**Scenario**: You're debugging a recipe app layout issue in the editor screen. + +**Before** (manual testing): +1. Build app → 30 seconds +2. Launch simulator +3. Tap "Recipes" → wait for load +4. Scroll to recipe #42 +5. Tap to open detail +6. Tap "Edit" +7. Check if layout is fixed +8. Make change, rebuild → repeat from step 1 +**Total**: 2-3 minutes per iteration + +**After** (with debug deep links): +1. Build app → 30 seconds +2. Run: `xcrun simctl openurl booted "debug://recipe-edit?id=42"` +3. Run: `/axiom:screenshot` +4. Claude analyzes screenshot and confirms layout fix +5. Make change if needed, rebuild → repeat from step 2 +**Total**: 45 seconds per iteration + +**Time savings**: 60-75% faster iteration with visual verification + +--- + +## Integration with Existing Navigation + +### For Apps Using NavigationStack + +Add debug URL handler that appends to existing NavigationPath: + +```swift +router.path.append(Destination.fromDebugURL(url)) +``` + +### For Apps Using Coordinator Pattern + +Trigger coordinator methods from debug URL handler: + +```swift +coordinator.navigate(to: .fromDebugURL(url)) +``` + +### For Apps Using Custom Routing + +Integrate with your router's navigation API: + +```swift +AppRouter.shared.push(Screen.fromDebugURL(url)) +``` + +**Key principle**: Debug deep links should USE existing navigation, not replace it. + +--- + +## Advanced Patterns + +### Pattern 5: Parameterized State Setup + +```swift +#if DEBUG +case "test-scenario": + // Parse complex test scenario from URL + // Example: debug://test-scenario?user=premium&recipes=empty&network=slow + + if let userType = url.queryItems?["user"] { + configureUser(type: userType) // "premium", "free", "trial" + } + + if let recipesState = url.queryItems?["recipes"] { + configureRecipes(state: recipesState) // "empty", "full", "error" + } + + if let networkState = url.queryItems?["network"] { + configureNetwork(state: networkState) // "fast", "slow", "offline" + } + + // Now navigate + path.append(Destination.recipes) +#endif +``` + +**Usage**: +```bash +# Test premium user with empty recipe list +xcrun simctl openurl booted "debug://test-scenario?user=premium&recipes=empty" + +# Test slow network with error handling +xcrun simctl openurl booted "debug://test-scenario?network=slow&recipes=error" +``` + +--- + +### Pattern 6: Screenshot Automation Helper + +Create a single URL that sets up AND captures state: + +```swift +#if DEBUG +case "screenshot": + // Parse screen and configuration + guard let screen = url.queryItems?["screen"] else { return } + + // Configure state + if let state = url.queryItems?["state"] { + applyState(state) + } + + // Navigate + navigate(to: screen) + + // Post notification for external capture + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + NotificationCenter.default.post( + name: .readyForScreenshot, + object: screen + ) + } +#endif +``` + +**Usage**: +```bash +# Navigate to login screen with error state, wait, then screenshot +xcrun simctl openurl booted "debug://screenshot?screen=login&state=error" +sleep 2 +xcrun simctl io booted screenshot login-error.png +``` + +--- + +## Related Skills + +- `axiom-swiftui-nav` — Production deep linking and NavigationStack patterns +- `simulator-tester` — Automated simulator testing using debug deep links +- `axiom-xcode-debugging` — Environment-first debugging workflows + +--- + +## Summary + +Debug deep links enable: +- **Closed-loop debugging** with visual verification +- **60-75% faster iteration** on visual fixes +- **Automated testing** without manual navigation +- **Screenshot automation** for any app state + +**Remember**: +1. Wrap ALL debug code in `#if DEBUG` +2. Strip URL scheme from release builds +3. Integrate with existing navigation, don't duplicate +4. Validate all parameters (no force unwraps) +5. Document for team members diff --git a/.claude/skills/axiom-deep-link-debugging/agents/openai.yaml b/.claude/skills/axiom-deep-link-debugging/agents/openai.yaml new file mode 100644 index 0000000..8c70bf3 --- /dev/null +++ b/.claude/skills/axiom-deep-link-debugging/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Deep Link Debugging" + short_description: "Adding debug-only deep links for testing, enabling simulator navigation to specific screens, or integrating with auto..." diff --git a/.claude/skills/axiom-display-performance/.openskills.json b/.claude/skills/axiom-display-performance/.openskills.json new file mode 100644 index 0000000..16afe6d --- /dev/null +++ b/.claude/skills/axiom-display-performance/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-display-performance", + "installedAt": "2026-04-12T08:06:12.352Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-display-performance/SKILL.md b/.claude/skills/axiom-display-performance/SKILL.md new file mode 100644 index 0000000..4a3029a --- /dev/null +++ b/.claude/skills/axiom-display-performance/SKILL.md @@ -0,0 +1,633 @@ +--- +name: axiom-display-performance +description: Use when app runs at unexpected frame rate, stuck at 60fps on ProMotion, frame pacing issues, or configuring render loops. Covers MTKView, CADisplayLink, CAMetalDisplayLink, frame pacing, hitches, system caps. +license: MIT +metadata: + version: "1.0.0" +--- + +# Display Performance + +Systematic diagnosis for frame rate issues on variable refresh rate displays (ProMotion, iPad Pro, future devices). Covers render loop configuration, frame pacing, hitch mechanics, and production telemetry. + +**Key insight**: "ProMotion available" does NOT mean your app automatically runs at 120Hz. You must configure it correctly, account for system caps, and ensure proper frame pacing. + +--- + +## Part 1: Why You're Stuck at 60fps + +### Diagnostic Order + +Check these in order when stuck at 60fps on ProMotion: + +1. **Info.plist key missing?** (iPhone only) → Part 2 +2. **Render loop configured for 60?** (MTKView defaults, CADisplayLink) → Part 3 +3. **System caps enabled?** (Low Power Mode, Limit Frame Rate, Thermal) → Part 5 +4. **Frame time > 8.33ms?** (Can't sustain 120fps) → Part 6 +5. **Frame pacing issues?** (Micro-stuttering despite good FPS) → Part 7 +6. **Measuring wrong thing?** (UIScreen vs actual presentation) → Part 9 + +--- + +## Part 2: Enabling ProMotion on iPhone + +**Critical**: Core Animation won't access frame rates above 60Hz on iPhone unless you add this key. + +```xml + +CADisableMinimumFrameDurationOnPhone + +``` + +Without this key: +- Your `preferredFrameRateRange` hints are ignored above 60Hz +- Other animations may affect your CADisplayLink callback rate +- iPad Pro does NOT require this key + +**When to add**: Any iPhone app that needs >60Hz for games, animations, or smooth scrolling. + +--- + +## Part 3: Render Loop Configuration + +### MTKView Defaults to 60fps + +**This is the most common cause.** MTKView's `preferredFramesPerSecond` defaults to 60. + +```swift +// ❌ WRONG: Implicit 60fps (default) +let mtkView = MTKView(frame: frame, device: device) +mtkView.delegate = self +// Running at 60fps even on ProMotion! + +// ✅ CORRECT: Explicit 120fps request +let mtkView = MTKView(frame: frame, device: device) +mtkView.preferredFramesPerSecond = 120 +mtkView.isPaused = false +mtkView.enableSetNeedsDisplay = false // Continuous, not on-demand +mtkView.delegate = self +``` + +**Critical settings for continuous high-rate rendering:** + +| Property | Value | Why | +|----------|-------|-----| +| `preferredFramesPerSecond` | `120` | Request max rate | +| `isPaused` | `false` | Don't pause the render loop | +| `enableSetNeedsDisplay` | `false` | Continuous mode, not on-demand | + +### CADisplayLink Configuration (iOS 15+) + +Apple explicitly recommends CADisplayLink (not timers) for custom render loops. + +```swift +// ❌ WRONG: Timer-based render loop (drifts, wastes frame time) +Timer.scheduledTimer(withTimeInterval: 1.0/120.0, repeats: true) { _ in + self.render() +} + +// ❌ WRONG: Default CADisplayLink (may hint 60) +let displayLink = CADisplayLink(target: self, selector: #selector(render)) +displayLink.add(to: .main, forMode: .common) + +// ✅ CORRECT: Explicit frame rate range +let displayLink = CADisplayLink(target: self, selector: #selector(render)) +displayLink.preferredFrameRateRange = CAFrameRateRange( + minimum: 80, // Minimum acceptable + maximum: 120, // Preferred maximum + preferred: 120 // What you want +) +displayLink.add(to: .main, forMode: .common) +``` + +**Special priority for games**: iOS 15+ gives 30Hz and 60Hz special priority. If targeting these rates: + +```swift +// 30Hz and 60Hz get priority scheduling +let prioritizedRange = CAFrameRateRange( + minimum: 30, + maximum: 60, + preferred: 60 +) +displayLink.preferredFrameRateRange = prioritizedRange +``` + +### Suggested Frame Rates by Content Type + +| Content Type | Suggested Rate | Notes | +|--------------|----------------|-------| +| Video playback | 24-30 Hz | Match content frame rate | +| Scrolling UI | 60-120 Hz | Higher = smoother | +| Fast games | 60-120 Hz | Match rendering capability | +| Slow animations | 30-60 Hz | Save power | +| Static content | 10-24 Hz | Minimal updates needed | + +--- + +## Part 4: CAMetalDisplayLink (iOS 17+) + +For Metal apps needing precise timing control, `CAMetalDisplayLink` provides more control than CADisplayLink. + +```swift +class MetalRenderer: NSObject, CAMetalDisplayLinkDelegate { + var displayLink: CAMetalDisplayLink? + var metalLayer: CAMetalLayer! + + func setupDisplayLink() { + displayLink = CAMetalDisplayLink(metalLayer: metalLayer) + displayLink?.delegate = self + displayLink?.preferredFrameRateRange = CAFrameRateRange( + minimum: 60, + maximum: 120, + preferred: 120 + ) + // Control render latency (in frames) + displayLink?.preferredFrameLatency = 2 + displayLink?.add(to: .main, forMode: .common) + } + + func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) { + // update.drawable - The drawable to render to + // update.targetTimestamp - Deadline to finish rendering + // update.targetPresentationTimestamp - When frame will display + + guard let drawable = update.drawable else { return } + + let workingTime = update.targetTimestamp - CACurrentMediaTime() + // workingTime = seconds available before deadline + + // Render to drawable... + renderFrame(to: drawable) + } +} +``` + +**Key differences from CADisplayLink:** + +| Feature | CADisplayLink | CAMetalDisplayLink | +|---------|---------------|-------------------| +| Drawable access | Manual via layer | Provided in callback | +| Latency control | None | `preferredFrameLatency` | +| Target timing | timestamp/targetTimestamp | + targetPresentationTimestamp | +| Use case | General animation | Metal-specific rendering | + +**When to use CAMetalDisplayLink:** +- Need precise control over render timing window +- Want to minimize input latency +- Building games or intensive Metal apps +- iOS 17+ only deployment + +--- + +## Part 5: System Caps + +System states can force 60fps even when your code requests 120: + +### Low Power Mode + +**Caps ProMotion devices to 60fps.** + +```swift +// Check programmatically +if ProcessInfo.processInfo.isLowPowerModeEnabled { + // System caps display to 60Hz +} + +// Observe changes +NotificationCenter.default.addObserver( + forName: .NSProcessInfoPowerStateDidChange, + object: nil, + queue: .main +) { _ in + let isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled + self.adjustRenderingForPowerState(isLowPower) +} +``` + +### Limit Frame Rate (Accessibility) + +**Settings → Accessibility → Motion → Limit Frame Rate** caps to 60fps. + +No API to detect. If user reports 60fps despite configuration, have them check this setting. + +### Thermal Throttling + +System restricts 120Hz when device overheats. + +```swift +// Check thermal state +switch ProcessInfo.processInfo.thermalState { +case .nominal, .fair: + preferredFramesPerSecond = 120 +case .serious, .critical: + preferredFramesPerSecond = 60 // Reduce proactively +@unknown default: + break +} + +// Observe thermal changes +NotificationCenter.default.addObserver( + forName: ProcessInfo.thermalStateDidChangeNotification, + object: nil, + queue: .main +) { _ in + self.adjustForThermalState() +} +``` + +### Adaptive Power (iOS 26+, iPhone 17) + +**New in iOS 26**: Adaptive Power is ON by default on iPhone 17/17 Pro. Can throttle even at 60% battery. + +**User action for testing**: Settings → Battery → Power Mode → disable **Adaptive Power**. + +No public API to detect Adaptive Power state. + +--- + +## Part 6: Performance Budget + +### Frame Time Budgets + +| Target FPS | Frame Budget | Vsync Interval | +|------------|--------------|----------------| +| 120 | 8.33ms | Every vsync | +| 90 | 11.11ms | — | +| 60 | 16.67ms | Every 2nd vsync | +| 30 | 33.33ms | Every 4th vsync | + +**If you consistently exceed budget, system drops to next sustainable rate.** + +### Measuring GPU Frame Time + +```swift +func draw(in view: MTKView) { + guard let commandBuffer = commandQueue.makeCommandBuffer() else { return } + + // Your rendering code... + + commandBuffer.addCompletedHandler { buffer in + let gpuTime = buffer.gpuEndTime - buffer.gpuStartTime + let gpuMs = gpuTime * 1000 + + if gpuMs > 8.33 { + print("⚠️ GPU: \(String(format: "%.2f", gpuMs))ms exceeds 120Hz budget") + } + } + + commandBuffer.commit() +} +``` + +### Can't Sustain 120? Target Lower Rate Evenly + +**Critical**: Uneven frame pacing looks worse than consistent lower rate. + +```swift +// If you can't sustain 8.33ms, explicitly target 60 for smooth cadence +if averageGpuTime > 8.33 && averageGpuTime <= 16.67 { + mtkView.preferredFramesPerSecond = 60 +} +``` + +--- + +## Part 7: Frame Pacing + +### The Micro-Stuttering Problem + +Even with good average FPS, inconsistent frame timing causes visible jitter. + +``` +// BAD: Inconsistent intervals despite ~40 FPS average +Frame 1: 25ms +Frame 2: 40ms ← stutter +Frame 3: 25ms +Frame 4: 40ms ← stutter + +// GOOD: Consistent intervals at 30 FPS +Frame 1: 33ms +Frame 2: 33ms +Frame 3: 33ms +Frame 4: 33ms +``` + +**Presenting immediately after rendering causes this.** Use explicit timing control. + +### Frame Pacing APIs + +#### present(afterMinimumDuration:) — Recommended + +Ensures consistent spacing between frames: + +```swift +func draw(in view: MTKView) { + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let drawable = view.currentDrawable else { return } + + // Render to drawable... + + // Present with minimum 33ms between frames (30 FPS target) + commandBuffer.present(drawable, afterMinimumDuration: 0.033) + commandBuffer.commit() +} +``` + +#### present(at:) — Precise Timing + +Schedule presentation at specific time: + +```swift +// Present at specific Mach absolute time +let presentTime = CACurrentMediaTime() + 0.033 +commandBuffer.present(drawable, atTime: presentTime) +``` + +#### presentedTime — Verify Actual Presentation + +Check when frames actually appeared: + +```swift +drawable.addPresentedHandler { drawable in + let actualTime = drawable.presentedTime + if actualTime == 0.0 { + // Frame was dropped! + print("⚠️ Frame dropped") + } else { + print("Frame presented at: \(actualTime)") + } +} +``` + +### Frame Pacing Pattern + +```swift +class SmoothRenderer: NSObject, MTKViewDelegate { + private var targetFrameDuration: CFTimeInterval = 1.0 / 60.0 // 60 FPS target + + func draw(in view: MTKView) { + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let drawable = view.currentDrawable else { return } + + renderScene(to: drawable) + + // Use frame pacing to ensure consistent intervals + commandBuffer.present(drawable, afterMinimumDuration: targetFrameDuration) + commandBuffer.commit() + } + + func adjustTargetFrameRate(canSustain fps: Int) { + switch fps { + case 90...: + targetFrameDuration = 1.0 / 120.0 + case 50...: + targetFrameDuration = 1.0 / 60.0 + default: + targetFrameDuration = 1.0 / 30.0 + } + } +} +``` + +--- + +## Part 8: Understanding Hitches + +### Render Loop Phases + +Frame lifecycle: **Begin Time → Commit Deadline → Presentation Time** + +1. **App Process (CPU)**: Handle events, compute UI updates, Core Animation commit +2. **Render Server (CPU+GPU)**: Transform UI to bitmap, render to buffer +3. **Display Driver**: Swap buffer to screen at vsync + +At 120Hz, each phase has ~8.33ms. Miss any deadline = hitch. + +### Commit Hitch vs Render Hitch + +**Commit Hitch**: App process misses commit deadline +- Cause: Main thread work takes too long +- Fix: Move work off main thread, reduce view complexity + +**Render Hitch**: Render server misses presentation deadline +- Cause: GPU work too complex (blur, shadows, layers) +- Fix: Simplify visual effects, reduce overdraw + +### Double vs Triple Buffering + +**Double Buffer (default)**: +- Frame lifetime: 2 vsync intervals +- Tighter deadlines +- Lower latency + +**Triple Buffer (system may enable)**: +- Frame lifetime: 3 vsync intervals +- Render server gets 2 vsync intervals +- Higher latency but more headroom + +The system automatically switches to triple buffering to recover from render hitches. + +### Hitch Duration + +``` +Expected Frame Lifetime = Begin Time → Presentation Time +Actual Frame Lifetime = Begin Time → Actual Vsync + +Hitch Duration = Actual - Expected +``` + +If hitch duration > 0, the frame was late and previous frame stayed onscreen longer. + +--- + +## Part 9: Measurement + +### UIScreen Lies, Actual Presentation Tells Truth + +```swift +// ❌ This says 120 even when system caps you to 60 +let maxFPS = UIScreen.main.maximumFramesPerSecond +// Reports capability, not actual rate! + +// ✅ Measure from CADisplayLink timing +@objc func displayLinkCallback(_ link: CADisplayLink) { + // Time available to prepare next frame + let workingTime = link.targetTimestamp - CACurrentMediaTime() + + // Actual interval since last callback + if lastTimestamp > 0 { + let interval = link.timestamp - lastTimestamp + let actualFPS = 1.0 / interval + } + lastTimestamp = link.timestamp +} +``` + +### Metal Performance HUD + +Enable on-device real-time performance overlay: + +**Via Xcode scheme:** +1. Edit Scheme → Run → Diagnostics +2. Enable "Show Graphics Overview" +3. Optionally enable "Log Graphics Overview" + +**Via environment variable:** +```bash +MTL_HUD_ENABLED=1 +``` + +**Via device settings:** +Settings → Developer → Graphics HUD → Show Graphics HUD + +**HUD shows:** +- FPS (average) +- GPU time per frame +- Frame interval chart (last 120 frames) +- Memory usage + +### Production Telemetry with MetricKit + +Monitor hitches in production: + +```swift +import MetricKit + +class MetricsManager: NSObject, MXMetricManagerSubscriber { + func didReceive(_ payloads: [MXMetricPayload]) { + for payload in payloads { + if let animationMetrics = payload.animationMetrics { + // Ratio of time spent hitching during scroll + let scrollHitchRatio = animationMetrics.scrollHitchTimeRatio + + // Ratio of time spent hitching in all animations + if #available(iOS 17.0, *) { + let hitchRatio = animationMetrics.hitchTimeRatio + } + + analyzeHitchMetrics(scrollHitchRatio: scrollHitchRatio) + } + } + } +} + +// Register for metrics +MXMetricManager.shared.add(metricsManager) +``` + +**What to track:** +- `scrollHitchTimeRatio`: Time spent hitching while scrolling (UIScrollView only) +- `hitchTimeRatio` (iOS 17+): Time spent hitching in all tracked animations + +--- + +## Part 10: Quick Diagnostic Checklist + +When debugging frame rate issues: + +| Step | Check | Fix | +|------|-------|-----| +| 1 | Info.plist key present? (iPhone) | Add `CADisableMinimumFrameDurationOnPhone` | +| 2 | Limit Frame Rate off? | Settings → Accessibility → Motion | +| 3 | Low Power Mode off? | Settings → Battery | +| 4 | Adaptive Power off? (iPhone 17+) | Settings → Battery → Power Mode | +| 5 | preferredFramesPerSecond = 120? | Set explicitly on MTKView | +| 6 | preferredFrameRateRange set? | Configure on CADisplayLink | +| 7 | GPU frame time < 8.33ms? | Profile with Metal HUD or Instruments | +| 8 | Frame pacing consistent? | Use present(afterMinimumDuration:) | +| 9 | Hitches in production? | Monitor with MetricKit | + +--- + +## Part 11: Common Patterns + +### Pattern: Adaptive Frame Rate with Thermal Awareness + +```swift +class AdaptiveRenderer: NSObject, MTKViewDelegate { + private var recentFrameTimes: [Double] = [] + private let sampleCount = 30 + private var targetFrameDuration: CFTimeInterval = 1.0 / 60.0 + + func draw(in view: MTKView) { + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let drawable = view.currentDrawable else { return } + + let startTime = CACurrentMediaTime() + renderScene(to: drawable) + let frameTime = (CACurrentMediaTime() - startTime) * 1000 + + updateTargetRate(frameTime: frameTime, view: view) + + commandBuffer.present(drawable, afterMinimumDuration: targetFrameDuration) + commandBuffer.commit() + } + + private func updateTargetRate(frameTime: Double, view: MTKView) { + recentFrameTimes.append(frameTime) + if recentFrameTimes.count > sampleCount { + recentFrameTimes.removeFirst() + } + + let avgFrameTime = recentFrameTimes.reduce(0, +) / Double(recentFrameTimes.count) + let thermal = ProcessInfo.processInfo.thermalState + let lowPower = ProcessInfo.processInfo.isLowPowerModeEnabled + + // Constrain based on what we can sustain AND system state + if lowPower || thermal >= .serious { + view.preferredFramesPerSecond = 30 + targetFrameDuration = 1.0 / 30.0 + } else if avgFrameTime < 7.0 && thermal == .nominal { + view.preferredFramesPerSecond = 120 + targetFrameDuration = 1.0 / 120.0 + } else if avgFrameTime < 14.0 { + view.preferredFramesPerSecond = 60 + targetFrameDuration = 1.0 / 60.0 + } else { + view.preferredFramesPerSecond = 30 + targetFrameDuration = 1.0 / 30.0 + } + } +} +``` + +### Pattern: Frame Drop Detection + +```swift +class FrameDropMonitor { + private var expectedPresentTime: CFTimeInterval = 0 + private var dropCount = 0 + + func trackFrame(drawable: MTLDrawable, expectedInterval: CFTimeInterval) { + drawable.addPresentedHandler { [weak self] drawable in + guard let self = self else { return } + + if drawable.presentedTime == 0.0 { + self.dropCount += 1 + print("⚠️ Frame dropped (total: \(self.dropCount))") + } else if self.expectedPresentTime > 0 { + let actualInterval = drawable.presentedTime - self.expectedPresentTime + let variance = abs(actualInterval - expectedInterval) + + if variance > expectedInterval * 0.5 { + print("⚠️ Frame timing variance: \(variance * 1000)ms") + } + } + + self.expectedPresentTime = drawable.presentedTime + } + } +} +``` + +--- + +## Resources + +**WWDC**: 2021-10147, 2018-612, 2022-10083, 2023-10123 + +**Tech Talks**: 10855, 10856, 10857 (Hitch deep dives) + +**Docs**: /quartzcore/cadisplaylink, /quartzcore/cametaldisplaylink, /quartzcore/optimizing-iphone-and-ipad-apps-to-support-promotion-displays, /xcode/understanding-hitches-in-your-app, /metal/mtldrawable/present(afterminimumduration:), /metrickit/mxanimationmetric + +**Skills**: axiom-energy, axiom-ios-graphics, axiom-metal-migration-ref, axiom-performance-profiling diff --git a/.claude/skills/axiom-display-performance/agents/openai.yaml b/.claude/skills/axiom-display-performance/agents/openai.yaml new file mode 100644 index 0000000..3fa7fe4 --- /dev/null +++ b/.claude/skills/axiom-display-performance/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Display Performance" + short_description: "App runs at unexpected frame rate, stuck at 60fps on ProMotion, frame pacing issues, or configuring render loops" diff --git a/.claude/skills/axiom-energy-diag/.openskills.json b/.claude/skills/axiom-energy-diag/.openskills.json new file mode 100644 index 0000000..0142492 --- /dev/null +++ b/.claude/skills/axiom-energy-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-energy-diag", + "installedAt": "2026-04-12T08:06:13.493Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-energy-diag/SKILL.md b/.claude/skills/axiom-energy-diag/SKILL.md new file mode 100644 index 0000000..8da5e7f --- /dev/null +++ b/.claude/skills/axiom-energy-diag/SKILL.md @@ -0,0 +1,378 @@ +--- +name: axiom-energy-diag +description: Symptom-based energy troubleshooting - decision trees for 'app at top of battery settings', 'phone gets hot', 'background drain', 'high cellular usage', with time-cost analysis for each diagnosis path +license: MIT +metadata: + version: "1.0.0" +--- + +# Energy Diagnostics + +Symptom-based troubleshooting for energy issues. Start with your symptom, follow the decision tree, get the fix. + +**Related skills**: `axiom-energy` (patterns, checklists), `axiom-energy-ref` (API reference) + +--- + +## Symptom 1: App at Top of Battery Settings + +Users or you notice your app consuming significant battery. + +### Diagnosis Decision Tree + +``` +App at top of Battery Settings? +│ +├─ Step 1: Run Power Profiler (15 min) +│ ├─ CPU Power Impact high? +│ │ ├─ Continuous? → Timer leak or polling loop +│ │ │ └─ Fix: Check timers, add tolerance, convert to push +│ │ └─ Spikes during actions? → Eager loading or repeated parsing +│ │ └─ Fix: Use LazyVStack, cache parsed data +│ │ +│ ├─ Network Power Impact high? +│ │ ├─ Many small requests? → Batching issue +│ │ │ └─ Fix: Batch requests, use discretionary URLSession +│ │ └─ Regular intervals? → Polling pattern +│ │ └─ Fix: Convert to push notifications +│ │ +│ ├─ GPU Power Impact high? +│ │ ├─ Animations? → Running when not visible +│ │ │ └─ Fix: Stop in viewWillDisappear +│ │ └─ Blur effects? → Over dynamic content +│ │ └─ Fix: Remove or use static backgrounds +│ │ +│ └─ Display Power Impact high? +│ └─ Light backgrounds on OLED? +│ └─ Fix: Implement Dark Mode (up to 70% savings) +│ +└─ Step 2: Check background section in Battery Settings + ├─ High background time? + │ ├─ Location icon visible? → Continuous location + │ │ └─ Fix: Switch to significant-change monitoring + │ ├─ Audio active? → Session not deactivated + │ │ └─ Fix: Deactivate audio session when not playing + │ └─ BGTasks running long? → Not completing promptly + │ └─ Fix: Call setTaskCompleted sooner + │ + └─ Background time appropriate? + └─ Issue is in foreground usage → Focus on CPU/GPU fixes above +``` + +### Time-Cost Analysis + +| Approach | Time | Accuracy | +|----------|------|----------| +| Run Power Profiler, identify subsystem | 15-20 min | High | +| Guess and optimize random areas | 4+ hours | Low | +| Read all code looking for issues | 2+ hours | Medium | + +**Recommendation**: Always use Power Profiler first. It costs 15 minutes but guarantees you optimize the right subsystem. + +--- + +## Symptom 2: Device Gets Hot + +Device temperature increases noticeably during app use. + +### Diagnosis Decision Tree + +``` +Device gets hot during app use? +│ +├─ Hot during specific action? +│ │ +│ ├─ During video/camera use? +│ │ ├─ Video encoding? → Expected, but check efficiency +│ │ │ └─ Fix: Use hardware encoding, reduce resolution if possible +│ │ └─ Camera active unnecessarily? → Not releasing session +│ │ └─ Fix: Call stopRunning() when done +│ │ +│ ├─ During scroll/animation? +│ │ ├─ GPU-intensive effects? → Blur, shadows, many layers +│ │ │ └─ Fix: Reduce effects, cache rendered content +│ │ └─ High frame rate? → Unnecessary 120fps +│ │ └─ Fix: Use CADisplayLink preferredFrameRateRange +│ │ +│ └─ During data processing? +│ ├─ JSON parsing? → Repeated or large payloads +│ │ └─ Fix: Cache parsed results, paginate +│ └─ Image processing? → Synchronous on main thread +│ └─ Fix: Move to background, cache results +│ +├─ Hot during normal use (no specific action)? +│ │ +│ ├─ Run Power Profiler to identify: +│ │ ├─ CPU high continuously → Timer, polling, tight loop +│ │ ├─ GPU high continuously → Animation leak +│ │ └─ Network high continuously → Polling pattern +│ │ +│ └─ Check for infinite loops or runaway recursion +│ └─ Use Time Profiler in Instruments +│ +└─ Hot only in background? + ├─ Location updates continuous? → High accuracy or no stop + │ └─ Fix: Reduce accuracy, stop when done + ├─ Audio session active? → Hardware kept powered + │ └─ Fix: Deactivate when not playing + └─ BGTask running too long? → System may throttle + └─ Fix: Complete tasks faster, use requiresExternalPower +``` + +### Time-Cost Analysis + +| Approach | Time | Outcome | +|----------|------|---------| +| Power Profiler + Time Profiler | 20-30 min | Identifies exact cause | +| Check code for obvious issues | 1-2 hours | May miss non-obvious causes | +| Wait for user complaints | N/A | Reputation damage | + +--- + +## Symptom 3: Background Battery Drain + +App drains battery even when user isn't actively using it. + +### Diagnosis Decision Tree + +``` +High background battery usage? +│ +├─ Step 1: Check Info.plist background modes +│ │ +│ ├─ "location" enabled? +│ │ ├─ Actually need background location? +│ │ │ ├─ YES → Use significant-change, lowest accuracy +│ │ │ └─ NO → Remove background mode, use when-in-use only +│ │ └─ Check: Is stopUpdatingLocation called? +│ │ +│ ├─ "audio" enabled? +│ │ ├─ Audio playing? → Expected +│ │ ├─ Audio NOT playing? → Session still active +│ │ │ └─ Fix: Deactivate session, use autoShutdownEnabled +│ │ └─ Playing silent audio? → Anti-pattern for keeping app alive +│ │ └─ Fix: Use proper background API (BGTask) +│ │ +│ ├─ "fetch" enabled? +│ │ └─ Check: Is earliestBeginDate reasonable? (not too frequent) +│ │ +│ └─ "remote-notification" enabled? +│ └─ Expected for push updates, check didReceiveRemoteNotification efficiency +│ +├─ Step 2: Check BGTaskScheduler usage +│ │ +│ ├─ BGAppRefreshTask scheduled too frequently? +│ │ └─ Fix: Increase earliestBeginDate interval +│ │ +│ ├─ BGProcessingTask not using requiresExternalPower? +│ │ └─ Fix: Add requiresExternalPower = true for non-urgent work +│ │ +│ └─ Tasks not completing? (setTaskCompleted not called) +│ └─ Fix: Always call setTaskCompleted, implement expirationHandler +│ +└─ Step 3: Check beginBackgroundTask usage + │ + ├─ endBackgroundTask called promptly? + │ └─ Fix: Call immediately after work completes, not at expiration + │ + └─ Multiple overlapping background tasks? + └─ Fix: Track task IDs, ensure each is ended +``` + +### Common Background Drain Patterns + +| Pattern | Power Profiler Signature | Fix | +|---------|-------------------------|-----| +| Continuous location | CPU lane + location icon | significant-change | +| Audio session leak | CPU lane steady | setActive(false) | +| Timer not invalidated | CPU spikes at intervals | invalidate in background | +| Polling from background | Network lane at intervals | Push notifications | +| BGTask too long | CPU sustained | Faster completion | + +### Time-Cost Analysis + +| Approach | Time | Outcome | +|----------|------|---------| +| Check Info.plist + BGTask code | 30 min | Finds common issues | +| On-device Power Profiler trace | 1-2 hours (real usage) | Captures real behavior | +| User-collected trace | Variable | Best for unreproducible issues | + +--- + +## Symptom 4: High Energy Only on Cellular + +Battery drains faster on cellular than WiFi. + +### Diagnosis Decision Tree + +``` +High battery drain on cellular only? +│ +├─ Expected: Cellular radio uses more power than WiFi +│ └─ But: Excessive drain indicates optimization opportunity +│ +├─ Check URLSession configuration +│ │ +│ ├─ allowsExpensiveNetworkAccess = true (default)? +│ │ └─ Fix: Set to false for non-urgent requests +│ │ +│ ├─ isDiscretionary = false (default)? +│ │ └─ Fix: Set to true for background downloads +│ │ +│ └─ waitsForConnectivity = false (default)? +│ └─ Fix: Set to true to avoid failed connection retries +│ +├─ Check request patterns +│ │ +│ ├─ Many small requests? → High connection overhead +│ │ └─ Fix: Batch into fewer larger requests +│ │ +│ ├─ Polling? → Radio stays active +│ │ └─ Fix: Push notifications +│ │ +│ └─ Large downloads in foreground? → Could wait for WiFi +│ └─ Fix: Use background URLSession with discretionary +│ +└─ Check Low Data Mode handling + ├─ Respecting allowsConstrainedNetworkAccess? + │ └─ Fix: Set to false for non-essential requests + │ + └─ Checking ProcessInfo.processInfo.isLowDataModeEnabled? + └─ Fix: Reduce payload sizes, defer non-essential transfers +``` + +### Time-Cost Analysis + +| Approach | Time | Outcome | +|----------|------|---------| +| Review URLSession configs | 15 min | Quick wins | +| Add discretionary flags | 30 min | Significant savings | +| Convert poll to push | 2-4 hours | Largest impact | + +--- + +## Symptom 5: Energy Spike During Specific Action + +Noticeable battery drain or heat when performing particular operation. + +### Diagnosis Decision Tree + +``` +Energy spike during specific action? +│ +├─ Step 1: Record Power Profiler during action +│ └─ Note which subsystem spikes (CPU/GPU/Network/Display) +│ +├─ CPU spike? +│ │ +│ ├─ Is it parsing data? +│ │ ├─ Same data parsed repeatedly? +│ │ │ └─ Fix: Cache parsed results (lazy var) +│ │ └─ Large JSON/XML payload? +│ │ └─ Fix: Paginate, stream parse, or use binary format +│ │ +│ ├─ Is it creating views? +│ │ ├─ Many views at once? +│ │ │ └─ Fix: Use LazyVStack/LazyHStack +│ │ └─ Complex view hierarchies? +│ │ └─ Fix: Simplify, use drawingGroup() +│ │ +│ └─ Is it image processing? +│ ├─ On main thread? +│ │ └─ Fix: Move to background queue +│ └─ No caching? +│ └─ Fix: Cache processed images +│ +├─ GPU spike? +│ │ +│ ├─ Starting animation? +│ │ └─ Fix: Ensure frame rate appropriate +│ │ +│ ├─ Showing blur effect? +│ │ └─ Fix: Use solid color or pre-rendered blur +│ │ +│ └─ Complex render? (shadows, masks, many layers) +│ └─ Fix: Simplify, use shouldRasterize, cache +│ +├─ Network spike? +│ │ +│ ├─ Large download started? +│ │ └─ Fix: Use background URLSession, show progress +│ │ +│ ├─ Many parallel requests? +│ │ └─ Fix: Limit concurrency, batch +│ │ +│ └─ Retrying failed requests? +│ └─ Fix: Exponential backoff, waitsForConnectivity +│ +└─ Display spike? + └─ Unusual unless changing brightness programmatically + └─ Fix: Don't modify brightness, let system control +``` + +### Time-Cost Analysis + +| Approach | Time | Outcome | +|----------|------|---------| +| Power Profiler during action | 5-10 min | Identifies subsystem | +| Time Profiler for CPU details | 10-15 min | Identifies function | +| Code review without profiling | 1+ hours | May miss actual cause | + +--- + +## Quick Diagnostic Checklist + +Use this when you need fast answers: + +### 30-Second Check +- [ ] Device plugged in? (Power metrics show 0) +- [ ] Debug build? (Less optimized than release) +- [ ] Low Power Mode on? (May affect measurements) + +### 5-Minute Check (Power Profiler) +- [ ] Which subsystem is dominant? (CPU/GPU/Network/Display) +- [ ] Sustained or spiky? +- [ ] Foreground or background? + +### 15-Minute Investigation +- [ ] If CPU: Run Time Profiler to identify function +- [ ] If Network: Check request frequency and size +- [ ] If GPU: Check animation frame rates +- [ ] If Background: Check Info.plist modes + +### Common Quick Fixes + +| Finding | Quick Fix | Time | +|---------|-----------|------| +| Timer without tolerance | Add `.tolerance = 0.1` | 1 min | +| VStack with large ForEach | Change to LazyVStack | 1 min | +| allowsExpensiveNetworkAccess = true | Set to false | 1 min | +| Missing stopUpdatingLocation | Add stop call | 2 min | +| No Dark Mode | Add asset variants | 30 min | +| Audio session always active | Add setActive(false) | 5 min | + +--- + +## When to Escalate + +### Use `axiom-energy` skill when +- Need full audit checklist +- Want comprehensive patterns with code +- Planning proactive optimization + +### Use `axiom-energy-ref` skill when +- Need specific API details +- Want complete code examples +- Implementing from scratch + +### Use `energy-auditor` agent when +- Want automated codebase scan +- Looking for anti-patterns at scale +- Pre-release energy audit + +Run: `/axiom:audit energy` + +--- + +**Last Updated**: 2025-12-26 +**Platforms**: iOS 26+, iPadOS 26+ diff --git a/.claude/skills/axiom-energy-diag/agents/openai.yaml b/.claude/skills/axiom-energy-diag/agents/openai.yaml new file mode 100644 index 0000000..02b068d --- /dev/null +++ b/.claude/skills/axiom-energy-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Energy Diagnostics" + short_description: "Symptom-based energy troubleshooting" diff --git a/.claude/skills/axiom-energy-ref/.openskills.json b/.claude/skills/axiom-energy-ref/.openskills.json new file mode 100644 index 0000000..1794f52 --- /dev/null +++ b/.claude/skills/axiom-energy-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-energy-ref", + "installedAt": "2026-04-12T08:06:14.104Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-energy-ref/SKILL.md b/.claude/skills/axiom-energy-ref/SKILL.md new file mode 100644 index 0000000..af7feee --- /dev/null +++ b/.claude/skills/axiom-energy-ref/SKILL.md @@ -0,0 +1,1103 @@ +--- +name: axiom-energy-ref +description: Complete energy optimization API reference - Power Profiler workflows, timer/network/location/background APIs, iOS 26 BGContinuedProcessingTask, MetricKit monitoring, with all WWDC code examples +license: MIT +metadata: + version: "1.0.0" +--- + +# Energy Optimization Reference + +Complete API reference for iOS energy optimization, with code examples from WWDC sessions and Apple documentation. + +**Related skills**: `axiom-energy` (decision trees, patterns), `axiom-energy-diag` (troubleshooting) + +--- + +## Part 1: Power Profiler Workflow + +### Recording a Trace with Instruments + +#### Tethered Recording (Connected to Mac) + +``` +1. Connect iPhone wirelessly to Xcode + - Xcode → Window → Devices and Simulators + - Enable "Connect via network" for your device + +2. Profile your app + - Xcode → Product → Profile (Cmd+I) + - Select Blank template + - Click "+" → Add "Power Profiler" + - Optionally add "CPU Profiler" for correlation + +3. Record + - Select your app from target dropdown + - Click Record (red button) + - Use app normally for 2-3 minutes + - Click Stop + +4. Analyze + - Expand Power Profiler track + - Examine per-app lanes: CPU, GPU, Display, Network +``` + +**Important**: Use wireless debugging. When device is charging via cable, system power usage shows 0. + +#### On-Device Recording (Without Mac) + +From WWDC25-226: Capture traces in real-world conditions. + +``` +1. Enable Developer Mode + Settings → Privacy & Security → Developer Mode → Enable + +2. Enable Performance Trace + Settings → Developer → Performance Trace → Enable + Set tracing mode to "Power Profiler" + Toggle ON your app in the app list + +3. Add Control Center shortcut + Control Center → Tap "+" → Add a Control → Performance Trace + +4. Record + Swipe down → Tap Performance Trace icon → Start + Use app (can record up to 10 hours) + Tap Performance Trace icon → Stop + +5. Share trace + Settings → Developer → Performance Trace + Tap Share button next to trace file + AirDrop to Mac or email to developer +``` + +### Interpreting Power Profiler Metrics + +| Lane | Meaning | What High Values Indicate | +|------|---------|--------------------------| +| System Power | Overall battery drain rate | General energy consumption | +| CPU Power Impact | Processor activity score | Computation, timers, parsing | +| GPU Power Impact | Graphics rendering score | Animations, blur, Metal | +| Display Power Impact | Screen power usage | Brightness, content type | +| Network Power Impact | Radio activity score | Requests, downloads, polling | + +**Key insight**: Values are scores for comparison, not absolute measurements. Compare before/after traces on the same device. + +### Comparing Before/After (Example from WWDC25-226) + +```swift +// Before optimization: CPU Power Impact = 21 +VStack { + ForEach(videos) { video in + VideoCardView(video: video) + } +} + +// After optimization: CPU Power Impact = 4.3 +LazyVStack { + ForEach(videos) { video in + VideoCardView(video: video) + } +} +``` + +--- + +## Part 2: Timer Efficiency APIs + +### NSTimer with Tolerance + +```swift +// Basic timer with tolerance +let timer = Timer.scheduledTimer( + withTimeInterval: 1.0, + repeats: true +) { [weak self] _ in + self?.updateUI() +} +timer.tolerance = 0.1 // 10% minimum recommended + +// Add to run loop (if not using scheduledTimer) +RunLoop.current.add(timer, forMode: .common) + +// Always invalidate when done +deinit { + timer.invalidate() +} +``` + +### Combine Timer Publisher + +```swift +import Combine + +class ViewModel: ObservableObject { + private var cancellables = Set() + + func startPolling() { + Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .default) + .autoconnect() + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + } + + func stopPolling() { + cancellables.removeAll() + } +} +``` + +### Dispatch Timer Source (Low-Level) + +From Energy Efficiency Guide: + +```swift +let queue = DispatchQueue(label: "com.app.timer") +let timer = DispatchSource.makeTimerSource(queue: queue) + +// Set interval with leeway (tolerance) +timer.schedule( + deadline: .now(), + repeating: .seconds(1), + leeway: .milliseconds(100) // 10% tolerance +) + +timer.setEventHandler { [weak self] in + self?.performWork() +} + +timer.resume() + +// Cancel when done +timer.cancel() +``` + +> For DispatchSourceTimer lifecycle safety and crash prevention, see `axiom-timer-patterns`. + +### Event-Driven Alternative to Timers + +From Energy Efficiency Guide: Prefer dispatch sources over polling. + +```swift +// Monitor file changes instead of polling +let fileDescriptor = open(filePath.path, O_EVTONLY) +let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: [.write, .delete], + queue: .main +) + +source.setEventHandler { [weak self] in + self?.handleFileChange() +} + +source.setCancelHandler { + close(fileDescriptor) +} + +source.resume() +``` + +--- + +## Part 3: Network Efficiency APIs + +### URLSession Configuration + +```swift +// Standard configuration with energy-conscious settings +let config = URLSessionConfiguration.default +config.waitsForConnectivity = true // Don't fail immediately +config.allowsExpensiveNetworkAccess = false // Prefer WiFi +config.allowsConstrainedNetworkAccess = false // Respect Low Data Mode + +let session = URLSession(configuration: config) +``` + +### Discretionary Background Downloads + +From WWDC22-10083: + +```swift +// Background session for non-urgent downloads +let config = URLSessionConfiguration.background( + withIdentifier: "com.app.downloads" +) +config.isDiscretionary = true // System chooses optimal time +config.sessionSendsLaunchEvents = true + +// Set timeouts +config.timeoutIntervalForResource = 24 * 60 * 60 // 24 hours +config.timeoutIntervalForRequest = 60 + +let session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + +// Create download task with scheduling hints +let task = session.downloadTask(with: url) +task.earliestBeginDate = Date(timeIntervalSinceNow: 2 * 60 * 60) // 2 hours from now +task.countOfBytesClientExpectsToSend = 200 // Small request +task.countOfBytesClientExpectsToReceive = 500_000 // 500KB response + +task.resume() +``` + +### Background Session Delegate + +```swift +class DownloadDelegate: NSObject, URLSessionDownloadDelegate { + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + // Move file from temp location + let destination = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + )[0].appendingPathComponent("downloaded.data") + + try? FileManager.default.moveItem(at: location, to: destination) + } + + func urlSessionDidFinishEvents( + forBackgroundURLSession session: URLSession + ) { + // Notify app delegate to call completion handler + DispatchQueue.main.async { + if let handler = AppDelegate.shared.backgroundCompletionHandler { + handler() + AppDelegate.shared.backgroundCompletionHandler = nil + } + } + } +} +``` + +--- + +## Part 4: Location Efficiency APIs + +### CLLocationManager Configuration + +```swift +import CoreLocation + +class LocationService: NSObject, CLLocationManagerDelegate { + private let manager = CLLocationManager() + + func configure() { + manager.delegate = self + + // Use appropriate accuracy + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + + // Reduce update frequency + manager.distanceFilter = 100 // Update every 100 meters + + // Allow indicator pause when stationary + manager.pausesLocationUpdatesAutomatically = true + + // For background updates (if needed) + manager.allowsBackgroundLocationUpdates = true + manager.showsBackgroundLocationIndicator = true + } + + func startTracking() { + manager.requestWhenInUseAuthorization() + manager.startUpdatingLocation() + } + + func startSignificantChangeTracking() { + // Much more energy efficient for background + manager.startMonitoringSignificantLocationChanges() + } + + func stopTracking() { + manager.stopUpdatingLocation() + manager.stopMonitoringSignificantLocationChanges() + } +} +``` + +### iOS 26+ CLLocationUpdate (Modern Async API) + +```swift +import CoreLocation + +func trackLocation() async throws { + for try await update in CLLocationUpdate.liveUpdates() { + // Check if device became stationary + if update.stationary { + // System pauses updates automatically + // Consider switching to region monitoring + break + } + + if let location = update.location { + handleLocation(location) + } + } +} +``` + +### CLMonitor for Significant Changes + +```swift +import CoreLocation + +func setupRegionMonitoring() async { + let monitor = CLMonitor("significant-changes") + + // Add condition to monitor + let condition = CLMonitor.CircularGeographicCondition( + center: currentLocation.coordinate, + radius: 500 // 500 meter radius + ) + await monitor.add(condition, identifier: "home-region") + + // React to events + for try await event in monitor.events { + switch event.state { + case .satisfied: + // Entered region + handleRegionEntry() + case .unsatisfied: + // Exited region + handleRegionExit() + default: + break + } + } +} +``` + +### Location Accuracy Options + +| Constant | Accuracy | Battery Impact | Use Case | +|----------|----------|----------------|----------| +| `kCLLocationAccuracyBestForNavigation` | ~1m | Extreme | Turn-by-turn only | +| `kCLLocationAccuracyBest` | ~10m | Very High | Fitness tracking | +| `kCLLocationAccuracyNearestTenMeters` | ~10m | High | Precise positioning | +| `kCLLocationAccuracyHundredMeters` | ~100m | Medium | Store locators | +| `kCLLocationAccuracyKilometer` | ~1km | Low | Weather, general | +| `kCLLocationAccuracyThreeKilometers` | ~3km | Very Low | Regional content | + +--- + +## Part 5: Background Execution APIs + +### beginBackgroundTask (Short Tasks) + +```swift +class AppDelegate: UIResponder, UIApplicationDelegate { + var backgroundTask: UIBackgroundTaskIdentifier = .invalid + + func applicationDidEnterBackground(_ application: UIApplication) { + backgroundTask = application.beginBackgroundTask(withName: "Save State") { + // Expiration handler - clean up + self.endBackgroundTask() + } + + // Perform quick work + saveState() + + // End immediately when done + endBackgroundTask() + } + + private func endBackgroundTask() { + guard backgroundTask != .invalid else { return } + UIApplication.shared.endBackgroundTask(backgroundTask) + backgroundTask = .invalid + } +} +``` + +### BGAppRefreshTask + +```swift +import BackgroundTasks + +// Register at app launch +func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.app.refresh", + using: nil + ) { task in + self.handleAppRefresh(task: task as! BGAppRefreshTask) + } + + return true +} + +// Schedule refresh +func scheduleAppRefresh() { + let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh") + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min + + try? BGTaskScheduler.shared.submit(request) +} + +// Handle refresh +func handleAppRefresh(task: BGAppRefreshTask) { + scheduleAppRefresh() // Schedule next refresh + + let fetchTask = Task { + do { + let hasNewData = try await fetchLatestData() + task.setTaskCompleted(success: hasNewData) + } catch { + task.setTaskCompleted(success: false) + } + } + + task.expirationHandler = { + fetchTask.cancel() + } +} +``` + +### BGProcessingTask + +```swift +import BackgroundTasks + +// Register +BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.app.maintenance", + using: nil +) { task in + self.handleMaintenance(task: task as! BGProcessingTask) +} + +// Schedule with requirements +func scheduleMaintenance() { + let request = BGProcessingTaskRequest(identifier: "com.app.maintenance") + request.requiresNetworkConnectivity = true + request.requiresExternalPower = true // Only when charging + + try? BGTaskScheduler.shared.submit(request) +} + +// Handle +func handleMaintenance(task: BGProcessingTask) { + let operation = MaintenanceOperation() + + task.expirationHandler = { + operation.cancel() + } + + operation.completionBlock = { + task.setTaskCompleted(success: !operation.isCancelled) + } + + OperationQueue.main.addOperation(operation) +} +``` + +### iOS 26+ BGContinuedProcessingTask + +From WWDC25-227: Continue user-initiated tasks with system UI. + +```swift +import BackgroundTasks + +// Info.plist: Add identifier to BGTaskSchedulerPermittedIdentifiers +// "com.app.export" or "com.app.exports.*" for wildcards + +// Register handler (can be dynamic, not just at launch) +func setupExportHandler() { + BGTaskScheduler.shared.register("com.app.export") { task in + let continuedTask = task as! BGContinuedProcessingTask + + var shouldContinue = true + continuedTask.expirationHandler = { + shouldContinue = false + } + + // Report progress + continuedTask.progress.totalUnitCount = 100 + continuedTask.progress.completedUnitCount = 0 + + // Perform work + for i in 0..<100 { + guard shouldContinue else { break } + + performExportStep(i) + continuedTask.progress.completedUnitCount = Int64(i + 1) + } + + continuedTask.setTaskCompleted(success: shouldContinue) + } +} + +// Submit request +func startExport() { + let request = BGContinuedProcessingTaskRequest( + identifier: "com.app.export", + title: "Exporting Photos", + subtitle: "0 of 100 photos" + ) + + // Submission strategy + request.strategy = .fail // Fail if can't start immediately + // or default: queue if can't start + + do { + try BGTaskScheduler.shared.submit(request) + } catch { + // Handle submission failure + showExportNotAvailable() + } +} +``` + +### EMRCA Principles (from WWDC25-227) + +Background tasks must be: + +| Principle | Meaning | Implementation | +|-----------|---------|----------------| +| **E**fficient | Lightweight, purpose-driven | Do one thing well | +| **M**inimal | Keep work to minimum | Don't expand scope | +| **R**esilient | Save progress, handle expiration | Checkpoint frequently | +| **C**ourteous | Honor preferences | Check Low Power Mode | +| **A**daptive | Work with system | Don't fight constraints | + +--- + +## Part 6: Display & GPU Efficiency APIs + +### Dark Mode Support + +```swift +// Check current appearance +let isDarkMode = traitCollection.userInterfaceStyle == .dark + +// React to appearance changes +override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + updateColorsForAppearance() + } +} + +// Use dynamic colors +let dynamicColor = UIColor { traitCollection in + switch traitCollection.userInterfaceStyle { + case .dark: + return UIColor.black // OLED: True black = pixels off = 0 power + default: + return UIColor.white + } +} +``` + +### Frame Rate Control with CADisplayLink + +From WWDC22-10083: + +```swift +class AnimationController { + private var displayLink: CADisplayLink? + + func startAnimation() { + displayLink = CADisplayLink(target: self, selector: #selector(update)) + + // Control frame rate + displayLink?.preferredFrameRateRange = CAFrameRateRange( + minimum: 10, // Minimum acceptable + maximum: 30, // Maximum needed + preferred: 30 // Ideal rate + ) + + displayLink?.add(to: .current, forMode: .default) + } + + @objc private func update(_ displayLink: CADisplayLink) { + // Update animation + updateAnimationFrame() + } + + func stopAnimation() { + displayLink?.invalidate() + displayLink = nil + } +} +``` + +### Stop Animations When Not Visible + +```swift +class AnimatedViewController: UIViewController { + private var animator: UIViewPropertyAnimator? + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + startAnimations() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopAnimations() // Critical for energy + } + + private func stopAnimations() { + animator?.stopAnimation(true) + animator = nil + } +} +``` + +--- + +## Part 7: Disk I/O Efficiency APIs + +### Batch Writes + +```swift +// BAD: Multiple small writes +for item in items { + let data = try JSONEncoder().encode(item) + try data.write(to: fileURL) // Writes each item separately +} + +// GOOD: Single batched write +let allData = try JSONEncoder().encode(items) +try allData.write(to: fileURL) // One write operation +``` + +### SQLite WAL Mode + +```swift +import SQLite3 + +// Enable Write-Ahead Logging +var db: OpaquePointer? +sqlite3_open(dbPath, &db) + +var statement: OpaquePointer? +sqlite3_prepare_v2(db, "PRAGMA journal_mode=WAL", -1, &statement, nil) +sqlite3_step(statement) +sqlite3_finalize(statement) +``` + +### XCTStorageMetric for Testing + +```swift +import XCTest + +class DiskWriteTests: XCTestCase { + func testDiskWritePerformance() { + measure(metrics: [XCTStorageMetric()]) { + // Code that writes to disk + saveUserData() + } + } +} +``` + +--- + +## Part 8: Low Power Mode & Thermal Response APIs + +### Low Power Mode Detection + +```swift +import Foundation + +class PowerStateManager { + private var cancellables = Set() + + init() { + // Check initial state + updateForPowerState() + + // Observe changes + NotificationCenter.default.publisher( + for: .NSProcessInfoPowerStateDidChange + ) + .sink { [weak self] _ in + self?.updateForPowerState() + } + .store(in: &cancellables) + } + + private func updateForPowerState() { + if ProcessInfo.processInfo.isLowPowerModeEnabled { + reduceEnergyUsage() + } else { + restoreNormalOperation() + } + } + + private func reduceEnergyUsage() { + // Increase timer intervals + // Reduce animation frame rates + // Defer network requests + // Stop location updates if not critical + // Reduce refresh frequency + } +} +``` + +### Thermal State Response + +```swift +import Foundation + +class ThermalManager { + init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(thermalStateChanged), + name: ProcessInfo.thermalStateDidChangeNotification, + object: nil + ) + } + + @objc private func thermalStateChanged() { + switch ProcessInfo.processInfo.thermalState { + case .nominal: + // Normal operation + restoreFullFunctionality() + + case .fair: + // Slightly elevated, minor reduction + reduceNonEssentialWork() + + case .serious: + // Significant reduction needed + suspendBackgroundTasks() + reduceAnimationQuality() + + case .critical: + // Maximum reduction + minimizeAllActivity() + showThermalWarningIfAppropriate() + + @unknown default: + break + } + } +} +``` + +--- + +## Part 9: MetricKit Monitoring APIs + +### Basic Setup + +```swift +import MetricKit + +class MetricsManager: NSObject, MXMetricManagerSubscriber { + static let shared = MetricsManager() + + func startMonitoring() { + MXMetricManager.shared.add(self) + } + + func didReceive(_ payloads: [MXMetricPayload]) { + for payload in payloads { + processPayload(payload) + } + } + + func didReceive(_ payloads: [MXDiagnosticPayload]) { + for payload in payloads { + processDiagnostic(payload) + } + } +} +``` + +### Processing Energy Metrics + +```swift +func processPayload(_ payload: MXMetricPayload) { + // CPU metrics + if let cpu = payload.cpuMetrics { + let foregroundTime = cpu.cumulativeCPUTime + let backgroundTime = cpu.cumulativeCPUInstructions + logMetric("cpu_foreground", value: foregroundTime) + } + + // Location metrics + if let location = payload.locationActivityMetrics { + let backgroundLocationTime = location.cumulativeBackgroundLocationTime + logMetric("background_location_seconds", value: backgroundLocationTime) + } + + // Network metrics + if let network = payload.networkTransferMetrics { + let cellularUpload = network.cumulativeCellularUpload + let cellularDownload = network.cumulativeCellularDownload + let wifiUpload = network.cumulativeWiFiUpload + let wifiDownload = network.cumulativeWiFiDownload + + logMetric("cellular_upload", value: cellularUpload) + logMetric("cellular_download", value: cellularDownload) + } + + // Disk metrics + if let disk = payload.diskIOMetrics { + let writes = disk.cumulativeLogicalWrites + logMetric("disk_writes", value: writes) + } + + // GPU metrics + if let gpu = payload.gpuMetrics { + let gpuTime = gpu.cumulativeGPUTime + logMetric("gpu_time", value: gpuTime) + } +} +``` + +### Xcode Organizer Integration + +View field metrics in Xcode: +1. Window → Organizer +2. Select your app +3. Click "Battery Usage" in sidebar +4. Compare versions, filter by device/OS + +Categories shown: +- Audio +- Networking +- Processing (CPU + GPU) +- Display +- Bluetooth +- Location +- Camera +- Torch +- NFC +- Other + +--- + +## Part 10: Push Notifications APIs + +### Alert Notifications Setup + +From WWDC20-10095: + +```swift +import UserNotifications + +class NotificationManager: NSObject, UNUserNotificationCenterDelegate { + + func setup() { + UNUserNotificationCenter.current().delegate = self + UIApplication.shared.registerForRemoteNotifications() + } + + func requestPermission() { + UNUserNotificationCenter.current().requestAuthorization( + options: [.alert, .sound, .badge] + ) { granted, error in + print("Permission granted: \(granted)") + } + } +} + +// AppDelegate +func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data +) { + let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() + sendTokenToServer(token) +} + +func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error +) { + print("Failed to register: \(error)") +} +``` + +### Background Push Notifications + +```swift +// Handle background notification +func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void +) { + // Check for content-available flag + guard let aps = userInfo["aps"] as? [String: Any], + aps["content-available"] as? Int == 1 else { + completionHandler(.noData) + return + } + + Task { + do { + let hasNewData = try await fetchLatestContent() + completionHandler(hasNewData ? .newData : .noData) + } catch { + completionHandler(.failed) + } + } +} +``` + +### Server Payload Examples + +```json +// Alert notification (user-visible) +{ + "aps": { + "alert": { + "title": "New Message", + "body": "You have a new message from John" + }, + "sound": "default", + "badge": 1 + }, + "message_id": "12345" +} + +// Background notification (silent) +{ + "aps": { + "content-available": 1 + }, + "update_type": "new_content" +} +``` + +### Push Priority Headers + +| Priority | Header | Use Case | +|----------|--------|----------| +| High (10) | `apns-priority: 10` | Time-sensitive alerts | +| Low (5) | `apns-priority: 5` | Deferrable updates | + +**Energy tip**: Use priority 5 for all non-urgent notifications. System batches low-priority pushes for energy efficiency. + +--- + +## Troubleshooting Checklist + +### Issue: App at Top of Battery Settings + +- [ ] Run Power Profiler to identify dominant subsystem +- [ ] Check for timers without tolerance +- [ ] Check for polling patterns +- [ ] Check for continuous location +- [ ] Check for background audio session +- [ ] Verify BGTasks complete promptly + +### Issue: Device Gets Hot + +- [ ] Check GPU Power Impact for sustained high values +- [ ] Look for continuous animations +- [ ] Check for blur effects over dynamic content +- [ ] Verify Metal frame limiting +- [ ] Check CPU for tight loops + +### Issue: Background Battery Drain + +- [ ] Audit background modes in Info.plist +- [ ] Verify audio session deactivated when not playing +- [ ] Check location accuracy and stop calls +- [ ] Verify beginBackgroundTask calls end promptly +- [ ] Review BGTask scheduling + +### Issue: High Cellular Usage + +- [ ] Check allowsExpensiveNetworkAccess setting +- [ ] Verify discretionary flag on background downloads +- [ ] Look for polling patterns +- [ ] Check for large automatic downloads + +--- + +## Expert Review Checklist + +### Timers (10 items) +- [ ] Tolerance ≥10% on all timers +- [ ] Timers invalidated in deinit +- [ ] No timers running when app backgrounded +- [ ] Using Combine Timer where possible +- [ ] No sub-second intervals without justification +- [ ] Event-driven alternatives considered +- [ ] No synchronization via timer polling +- [ ] Timer invalidated before creating new one +- [ ] Repeating timers have clear stop condition +- [ ] Background timer usage justified + +### Network (10 items) +- [ ] waitsForConnectivity = true +- [ ] allowsExpensiveNetworkAccess appropriate +- [ ] allowsConstrainedNetworkAccess appropriate +- [ ] Non-urgent downloads use discretionary +- [ ] Push notifications instead of polling +- [ ] Requests batched where possible +- [ ] Payloads compressed +- [ ] Background URLSession for large transfers +- [ ] Retry logic has exponential backoff +- [ ] Connection reuse via single URLSession + +### Location (10 items) +- [ ] Accuracy appropriate for use case +- [ ] distanceFilter set +- [ ] Updates stopped when not needed +- [ ] pausesLocationUpdatesAutomatically = true +- [ ] Background location only if essential +- [ ] Significant-change for background +- [ ] CLMonitor for region monitoring +- [ ] Location permission matches actual need +- [ ] Stationary detection utilized +- [ ] Location icon explained to users + +### Background Execution (10 items) +- [ ] endBackgroundTask called promptly +- [ ] Expiration handlers implemented +- [ ] BGTasks use requiresExternalPower when possible +- [ ] EMRCA principles followed +- [ ] Background modes limited to needed +- [ ] Audio session deactivated when idle +- [ ] Progress saved incrementally +- [ ] Tasks complete within time limits +- [ ] Low Power Mode checked before heavy work +- [ ] Thermal state monitored + +### Display/GPU (10 items) +- [ ] Dark Mode supported +- [ ] Animations stop when view hidden +- [ ] Frame rates appropriate for content +- [ ] Secondary animations lower priority +- [ ] Blur effects minimized +- [ ] Metal has frame limiting +- [ ] Brightness-independent design +- [ ] No hidden animations consuming power +- [ ] GPU-intensive work has visibility checks +- [ ] ProMotion considered in frame rate decisions + +--- + +## WWDC Session Reference + +| Session | Year | Topic | +|---------|------|-------| +| 226 | 2025 | Power Profiler workflow, on-device tracing | +| 227 | 2025 | BGContinuedProcessingTask, EMRCA principles | +| 10083 | 2022 | Dark Mode, frame rates, deferral | +| 10095 | 2020 | Push notifications primer | +| 707 | 2019 | Background execution advances | +| 417 | 2019 | Battery life, MetricKit | + +--- + +**Last Updated**: 2025-12-26 +**Platforms**: iOS 26+, iPadOS 26+ diff --git a/.claude/skills/axiom-energy-ref/agents/openai.yaml b/.claude/skills/axiom-energy-ref/agents/openai.yaml new file mode 100644 index 0000000..d15b40e --- /dev/null +++ b/.claude/skills/axiom-energy-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Energy Reference" + short_description: "Complete energy optimization API reference" diff --git a/.claude/skills/axiom-energy/.openskills.json b/.claude/skills/axiom-energy/.openskills.json new file mode 100644 index 0000000..36edc27 --- /dev/null +++ b/.claude/skills/axiom-energy/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-energy", + "installedAt": "2026-04-12T08:06:12.950Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-energy/SKILL.md b/.claude/skills/axiom-energy/SKILL.md new file mode 100644 index 0000000..1648111 --- /dev/null +++ b/.claude/skills/axiom-energy/SKILL.md @@ -0,0 +1,851 @@ +--- +name: axiom-energy +description: Use when app drains battery, device gets hot, users report energy issues, or auditing power consumption - systematic Power Profiler diagnosis, subsystem identification (CPU/GPU/Network/Location/Display), anti-pattern fixes for iOS/iPadOS +license: MIT +metadata: + version: "1.0.0" +--- + +# Energy Optimization + +## Overview + +Energy issues manifest as battery drain, hot devices, and poor App Store reviews. **Core principle**: Measure before optimizing. Use Power Profiler to identify the dominant subsystem (CPU/GPU/Network/Location/Display), then apply targeted fixes. + +**Key insight**: Developers often don't know where to START auditing. This skill provides systematic diagnosis, not guesswork. + +**Requirements**: iOS 26+, Xcode 26+, Power Profiler in Instruments + +## Example Prompts + +Real questions developers ask that this skill answers: + +#### 1. "My app is always at the top of Battery Settings. How do I find what's draining power?" +→ The skill covers Power Profiler workflow to identify dominant subsystem and targeted fixes + +#### 2. "Users report my app makes their phone hot. Where do I start debugging?" +→ The skill provides decision tree: CPU vs GPU vs Network diagnosis with specific patterns + +#### 3. "I have timers and location updates. Are they causing battery drain?" +→ The skill covers timer tolerance, location accuracy trade-offs, and audit checklists + +#### 4. "My app drains battery in the background even when users aren't using it." +→ The skill covers background execution patterns, BGTasks, and EMRCA principles + +#### 5. "How do I measure if my optimization actually improved battery life?" +→ The skill demonstrates before/after Power Profiler comparison workflow + +--- + +## Red Flags — High Energy Likely + +If you see ANY of these, suspect energy inefficiency: + +- **Battery Settings**: Your app consistently at top of battery consumers +- **Device temperature**: Phone gets warm during normal app use +- **User reviews**: Mentions of "battery drain", "hot phone", "kills my battery" +- **Xcode Energy Gauge**: Shows sustained high or very high impact +- **Background runtime**: App runs longer than expected when not visible +- **Network activity**: Frequent small requests instead of batched operations +- **Location icon**: Appears in status bar when app shouldn't need location + +#### Difference from normal energy use +- **Normal**: App uses energy during active use, minimal when backgrounded +- **Problem**: App uses significant energy even when user isn't interacting + +## Mandatory First Steps + +**ALWAYS run Power Profiler FIRST** before optimizing code: + +### Step 1: Record a Power Trace (5 minutes) + +``` +1. Connect iPhone wirelessly to Xcode (wireless debugging) +2. Xcode → Product → Profile (Cmd+I) +3. Select Blank template +4. Click "+" → Add "Power Profiler" instrument +5. Optional: Add "CPU Profiler" for correlation +6. Click Record +7. Use your app normally for 2-3 minutes +8. Click Stop +``` + +**Why wireless**: When device is charging via cable, power metrics show 0. Use wireless debugging for accurate readings. + +### Step 2: Identify Dominant Subsystem + +Expand the Power Profiler track and examine per-app metrics: + +| Lane | Meaning | High Value Indicates | +|------|---------|---------------------| +| CPU Power Impact | Processor activity | Computation, timers, parsing | +| GPU Power Impact | Graphics rendering | Animations, blur, Metal | +| Display Power Impact | Screen usage | Brightness, always-on content | +| Network Power Impact | Radio activity | Requests, downloads, polling | + +**Look for**: Which subsystem shows highest sustained values during your app's usage. + +### Step 3: Branch to Subsystem-Specific Fixes + +Once you identify the dominant subsystem, use the decision trees below. + +#### What this tells you +- **CPU dominant** → Check timers, polling, JSON parsing, eager loading +- **GPU dominant** → Check animations, blur effects, frame rates +- **Network dominant** → Check request frequency, polling vs push +- **Display dominant** → Check Dark Mode, brightness, screen-on time +- **Location** (shown in CPU) → Check accuracy, update frequency + +#### Why diagnostics first +- Finding root cause with Power Profiler: **15-20 minutes** +- Guessing and testing random optimizations: **4+ hours, often wrong subsystem** + +--- + +## Energy Decision Tree + +``` +User reports energy issue? +│ +├─ CPU Power Impact dominant? +│ ├─ Continuous high impact? +│ │ ├─ Timers running? → Pattern 1: Timer Efficiency +│ │ ├─ Polling data? → Pattern 2: Push vs Poll +│ │ └─ Processing in loop? → Pattern 3: Lazy Loading +│ ├─ Spikes during specific actions? +│ │ ├─ JSON parsing? → Cache parsed results +│ │ ├─ Image processing? → Move to background, cache +│ │ └─ Database queries? → Index, batch, prefetch +│ └─ High background CPU? +│ ├─ Location updates? → Pattern 4: Location Efficiency +│ ├─ BGTasks running too long? → Pattern 5: Background Execution +│ └─ Audio session active? → Stop when not playing +│ +├─ Network Power Impact dominant? +│ ├─ Many small requests? +│ │ └─ Batch into fewer large requests +│ ├─ Polling pattern detected? +│ │ └─ Convert to push notifications → Pattern 2 +│ ├─ Downloads in foreground? +│ │ └─ Use discretionary background URLSession +│ └─ High cellular usage? +│ └─ Defer to WiFi when possible +│ +├─ GPU Power Impact dominant? +│ ├─ Continuous animations? +│ │ └─ Stop when view not visible +│ ├─ Blur effects (UIVisualEffectView)? +│ │ └─ Reduce or remove, use solid colors +│ ├─ High frame rate animations? +│ │ └─ Audit secondary frame rates → Pattern 6 +│ └─ Metal rendering? +│ └─ Implement frame limiting +│ +├─ Display Power Impact dominant? +│ ├─ Light backgrounds on OLED? +│ │ └─ Implement Dark Mode (up to 70% savings) +│ ├─ High brightness content? +│ │ └─ Use darker UI elements +│ └─ Screen always on? +│ └─ Allow screen to sleep when appropriate +│ +└─ Location causing drain? (check CPU lane + location icon) + ├─ Continuous updates? + │ └─ Switch to significant-change monitoring + ├─ High accuracy (kCLLocationAccuracyBest)? + │ └─ Reduce to kCLLocationAccuracyHundredMeters + └─ Background location? + └─ Evaluate if truly needed → Pattern 4 +``` + +--- + +## Common Energy Patterns (With Fixes) + +### Pattern 1: Timer Efficiency + +**Problem**: Timers wake the CPU from idle states, consuming significant energy. + +#### ❌ Anti-Pattern — Timer without tolerance +```swift +// BAD: Timer fires exactly every 1.0 seconds +// Prevents system from batching with other timers +Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + self.updateUI() +} +``` + +#### ✅ Fix — Set tolerance for timer batching +```swift +// GOOD: 10% tolerance allows system to batch timers +let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + self.updateUI() +} +timer.tolerance = 0.1 // 10% tolerance minimum + +// BETTER: Use Combine Timer with tolerance +Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .default) + .autoconnect() + .sink { [weak self] _ in + self?.updateUI() + } + .store(in: &cancellables) +``` + +#### ✅ Best — Use event-driven instead of polling +```swift +// BEST: Don't use timer at all — react to events +NotificationCenter.default.publisher(for: .dataDidUpdate) + .sink { [weak self] _ in + self?.updateUI() + } + .store(in: &cancellables) +``` + +**Key points**: +- Set tolerance to **at least 10%** of interval +- Timer tolerance allows system to batch multiple timers into single wake +- Prefer event-driven patterns over polling timers +- Always invalidate timers when no longer needed + +--- + +### Pattern 2: Push vs Poll + +**Problem**: Polling (checking server every N seconds) keeps radios active and drains battery. + +#### ❌ Anti-Pattern — Polling every 5 seconds +```swift +// BAD: Polls server every 5 seconds +// Radio stays active, massive battery drain +Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in + self?.fetchLatestData() // Network request every 5 seconds +} +``` + +#### ✅ Fix — Use background push notifications +```swift +// GOOD: Server pushes when data changes +// Radio only active when there's actual new data + +// 1. Register for remote notifications +UIApplication.shared.registerForRemoteNotifications() + +// 2. Handle background notification +func application(_ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + + guard let _ = userInfo["content-available"] else { + completionHandler(.noData) + return + } + + Task { + do { + let hasNewData = try await fetchLatestData() + completionHandler(hasNewData ? .newData : .noData) + } catch { + completionHandler(.failed) + } + } +} +``` + +**Server payload for background push**: +```json +{ + "aps": { + "content-available": 1 + }, + "custom-data": "your-payload" +} +``` + +**Key points**: +- Background pushes are **discretionary** — system delivers at optimal time +- Use `apns-priority: 5` for non-urgent updates (energy efficient) +- Use `apns-priority: 10` only for time-sensitive alerts +- Polling every 5 seconds uses **100x more energy** than push + +--- + +### Pattern 3: Lazy Loading & Caching + +**Problem**: Loading all data upfront causes CPU spikes and memory pressure. + +#### ❌ Anti-Pattern — Eager loading (from WWDC25-226) +```swift +// BAD: Creates and renders ALL views upfront +// From WWDC25-226: This caused CPU spike and hang +VStack { + ForEach(videos) { video in + VideoCardView(video: video) // Creates ALL thumbnails immediately + } +} +``` + +#### ✅ Fix — Lazy loading +```swift +// GOOD: Only creates visible views +// From WWDC25-226: Reduced CPU power impact from 21 to 4.3 +LazyVStack { + ForEach(videos) { video in + VideoCardView(video: video) // Creates on-demand + } +} +``` + +#### ❌ Anti-Pattern — Repeated parsing (from WWDC25-226) +```swift +// BAD: Parses JSON file on every location update +// From WWDC25-226: Caused continuous CPU drain during commute +func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] { + // Called every location change! + let data = try? Data(contentsOf: rulesFileURL) + let rules = try? JSONDecoder().decode([RecommendationRule].self, from: data) + return filteredVideos(using: rules) +} +``` + +#### ✅ Fix — Cache parsed data +```swift +// GOOD: Parse once, reuse cached result +// From WWDC25-226: Eliminated CPU drain +private lazy var cachedRules: [RecommendationRule] = { + let data = try? Data(contentsOf: rulesFileURL) + return (try? JSONDecoder().decode([RecommendationRule].self, from: data)) ?? [] +}() + +func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] { + return filteredVideos(using: cachedRules) // No parsing! +} +``` + +**Key points**: +- Use `LazyVStack`, `LazyHStack`, `LazyVGrid` for large collections +- Cache parsed JSON, decoded data, computed results +- Move expensive operations out of frequently-called methods + +--- + +### Pattern 4: Location Efficiency + +**Problem**: Continuous location updates keep GPS active, draining battery rapidly. + +#### ❌ Anti-Pattern — Continuous high-accuracy updates +```swift +// BAD: Continuous updates with best accuracy +// GPS stays active constantly, massive battery drain +let locationManager = CLLocationManager() +locationManager.desiredAccuracy = kCLLocationAccuracyBest +locationManager.startUpdatingLocation() // Never stops! +``` + +#### ✅ Fix — Appropriate accuracy and significant-change +```swift +// GOOD: Reduced accuracy, significant-change monitoring +let locationManager = CLLocationManager() + +// Use appropriate accuracy (100m is fine for most apps) +locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters + +// Use distance filter to reduce updates +locationManager.distanceFilter = 100 // Only update every 100 meters + +// For background: Use significant-change monitoring +locationManager.startMonitoringSignificantLocationChanges() + +// Stop when done +func stopTracking() { + locationManager.stopUpdatingLocation() + locationManager.stopMonitoringSignificantLocationChanges() +} +``` + +#### ✅ Better — iOS 26+ CLLocationUpdate with stationary detection +```swift +// BEST: Modern async API with automatic stationary detection +for try await update in CLLocationUpdate.liveUpdates() { + if update.stationary { + // Device stopped moving — system pauses updates automatically + // Switch to CLMonitor for region monitoring + break + } + handleLocation(update.location) +} +``` + +**Accuracy comparison (battery impact)**: +| Accuracy | Battery Impact | Use Case | +|----------|---------------|----------| +| `kCLLocationAccuracyBest` | Very High | Navigation apps only | +| `kCLLocationAccuracyNearestTenMeters` | High | Fitness tracking | +| `kCLLocationAccuracyHundredMeters` | Medium | Store locators | +| `kCLLocationAccuracyKilometer` | Low | Weather apps | +| Significant-change | Very Low | Background updates | + +--- + +### Pattern 5: Background Execution (EMRCA) + +**Problem**: Background tasks that run too long or too often drain battery. + +#### EMRCA Principles (from WWDC25-227) + +Your background work must be: +- **E**fficient — Design lightweight, purpose-driven tasks +- **M**inimal — Keep background work to a minimum +- **R**esilient — Save incremental progress; respond to expiration signals +- **C**ourteous — Honor user preferences and system conditions +- **A**daptive — Understand and adapt to system priorities + +#### ❌ Anti-Pattern — Long-running background task +```swift +// BAD: Requests unlimited background time +// System will terminate after ~30 seconds anyway +var backgroundTask: UIBackgroundTaskIdentifier = .invalid + +func applicationDidEnterBackground(_ application: UIApplication) { + backgroundTask = application.beginBackgroundTask { + // Expiration handler — but task runs too long + } + + // Long operation that may not complete + performLongOperation() +} +``` + +#### ✅ Fix — Proper background task handling +```swift +// GOOD: Finish quickly, save progress, notify system +var backgroundTask: UIBackgroundTaskIdentifier = .invalid + +func applicationDidEnterBackground(_ application: UIApplication) { + backgroundTask = application.beginBackgroundTask(withName: "Save State") { [weak self] in + // Expiration handler — clean up immediately + self?.saveProgress() + if let task = self?.backgroundTask { + application.endBackgroundTask(task) + } + self?.backgroundTask = .invalid + } + + // Quick operation + saveEssentialState() + + // End task as soon as done — don't wait for expiration + application.endBackgroundTask(backgroundTask) + backgroundTask = .invalid +} +``` + +#### ✅ For Long Operations — Use BGProcessingTask +```swift +// BEST: Let system schedule at optimal time (charging, WiFi) +func scheduleBackgroundProcessing() { + let request = BGProcessingTaskRequest(identifier: "com.app.maintenance") + request.requiresNetworkConnectivity = true + request.requiresExternalPower = true // Only when charging + + try? BGTaskScheduler.shared.submit(request) +} + +// Register handler at app launch +BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.app.maintenance", + using: nil +) { task in + self.handleMaintenance(task: task as! BGProcessingTask) +} +``` + +#### ✅ iOS 26+ — BGContinuedProcessingTask for user-initiated work +```swift +// NEW iOS 26: Continue user-initiated tasks with progress UI +let request = BGContinuedProcessingTaskRequest( + identifier: "com.app.export", + title: "Exporting Photos", + subtitle: "23 of 100 photos" +) + +try? BGTaskScheduler.shared.submit(request) +``` + +--- + +### Pattern 6: Frame Rate Auditing + +**Problem**: Secondary animations running at higher frame rates than needed increase GPU power. + +#### ❌ Anti-Pattern — Uncontrolled frame rates +```swift +// BAD: Secondary animation runs at 60fps +// When primary content only needs 30fps, this wastes power +UIView.animate(withDuration: 2.0, delay: 0, options: [.repeat]) { + self.subtitleLabel.alpha = 0.5 +} completion: { _ in + self.subtitleLabel.alpha = 1.0 +} +``` + +#### ✅ Fix — Control frame rate with CADisplayLink +```swift +// GOOD: Explicitly set preferred frame rate +let displayLink = CADisplayLink(target: self, selector: #selector(updateAnimation)) +displayLink.preferredFrameRateRange = CAFrameRateRange( + minimum: 10, + maximum: 30, // Match primary content + preferred: 30 +) +displayLink.add(to: .current, forMode: .default) +``` + +**From WWDC22-10083**: Up to **20% battery savings** by aligning secondary animation frame rates with primary content. + +--- + +## Audit Checklists + +### Timer Audit +- [ ] All timers have tolerance set (≥10% of interval)? +- [ ] Timers invalidated when no longer needed? +- [ ] Using Combine Timer instead of NSTimer where possible? +- [ ] No polling patterns that could use push notifications? +- [ ] Timers stopped when app enters background? + +### Network Audit +- [ ] Requests batched instead of many small requests? +- [ ] Using discretionary URLSession for non-urgent downloads? +- [ ] `waitsForConnectivity` set to avoid failed connection attempts? +- [ ] `allowsExpensiveNetworkAccess` set to false for deferrable work? +- [ ] Push notifications instead of polling? + +### Location Audit +- [ ] Using appropriate accuracy (not `kCLLocationAccuracyBest` unless navigation)? +- [ ] `distanceFilter` set to reduce update frequency? +- [ ] Stopping updates when no longer needed? +- [ ] Using significant-change for background updates? +- [ ] Background location justified and explained to users? + +### Background Execution Audit +- [ ] `endBackgroundTask` called promptly when work completes? +- [ ] Long operations use `BGProcessingTask` with `requiresExternalPower`? +- [ ] Background modes in Info.plist limited to what's actually needed? +- [ ] Audio session deactivated when not playing? +- [ ] EMRCA principles followed? + +### Display/GPU Audit +- [ ] Dark Mode supported (70% OLED power savings)? +- [ ] Animations stopped when view not visible? +- [ ] Secondary animations use appropriate frame rates? +- [ ] Blur effects minimized or removed? +- [ ] Metal rendering has frame limiting? + +### Disk I/O Audit +- [ ] Writes batched instead of frequent small writes? +- [ ] SQLite using WAL journaling mode? +- [ ] Avoiding rapid file creation/deletion? +- [ ] Using SwiftData/Core Data instead of serialized files for frequent updates? + +--- + +## Pressure Scenarios + +### Scenario 1: "Just poll every 5 seconds for real-time updates" + +**The temptation**: "Push notifications are complex. Polling is simpler." + +**The reality**: +- Polling every 5 seconds: Radio active **100% of time** +- Push notifications: Radio active **only when data changes** +- Users WILL see your app at top of Battery Settings +- App Store reviews WILL mention "battery hog" + +**Time cost comparison**: +- Implement polling: 30 minutes +- Implement push: 2-4 hours +- Fix bad reviews + reputation damage: Weeks + +**Pushback template**: "Push notification setup takes a few hours, but polling will guarantee we're at the top of Battery Settings. Users actively uninstall apps that drain battery. The 2-hour investment prevents ongoing reputation damage." + +--- + +### Scenario 2: "Use continuous location for best accuracy" + +**The temptation**: "Users expect accurate location. Let's use `kCLLocationAccuracyBest`." + +**The reality**: +- `kCLLocationAccuracyBest`: GPS + WiFi + Cellular triangulation = **massive drain** +- `kCLLocationAccuracyHundredMeters`: Good enough for 95% of use cases +- Location icon in status bar = users checking Battery Settings + +**Time cost comparison**: +- Implement high accuracy: 10 minutes +- Debug "why does my app drain battery" complaints: Hours +- Refactor to appropriate accuracy: 30 minutes + +**Pushback template**: "100-meter accuracy is sufficient for [use case]. Navigation apps like Google Maps need best accuracy, but we're showing [store locations / weather / general area]. The accuracy difference is imperceptible to users, but battery difference is massive." + +--- + +### Scenario 3: "Keep animations running, users expect smooth UI" + +**The temptation**: "Animations make the app feel alive and polished." + +**The reality**: +- Animations running when view not visible = pure waste +- High frame rate secondary animations = GPU drain +- GPU power is significant portion of total device power + +**Time cost comparison**: +- Add animation: 15 minutes +- Add visibility checks: 5 minutes extra +- Debug "phone gets hot" reports: Hours + +**Pushback template**: "We can keep the animation, but should pause it when the view isn't visible. This is a 5-minute change that prevents GPU drain when users aren't looking at the screen." + +--- + +### Scenario 4: "Ship now, optimize later" + +**The temptation**: "Energy optimization is polish. We can do it in v1.1." + +**The reality**: +- Battery drain is **immediately visible** to users +- First impressions drive reviews +- "Battery hog" reputation is hard to shake +- Power Profiler baseline takes **15 minutes** + +**Time cost comparison**: +- Power Profiler check before launch: 15 minutes +- Fix energy issues post-launch: Days (plus reputation damage) +- Regain user trust: Months + +**Pushback template**: "A 15-minute Power Profiler session before launch catches major energy issues. If we ship with battery problems, users will see us at top of Battery Settings on day one and leave 1-star reviews. Let me do a quick check — it's faster than damage control." + +--- + +## Real-World Examples + +### Example 1: Video Streaming App with Eager Loading (WWDC25-226) + +**Symptom**: CPU power impact jumped from 1 to 21 when opening Library pane. UI hung. + +**Diagnosis using Power Profiler**: +1. Recorded trace while opening Library pane +2. CPU Power Impact lane showed massive spike +3. Time Profiler showed `VideoCardView` body called hundreds of times +4. Root cause: `VStack` creating ALL video thumbnails upfront + +**Fix**: +```swift +// Before: VStack (eager) +VStack { + ForEach(videos) { video in + VideoCardView(video: video) + } +} + +// After: LazyVStack (on-demand) +LazyVStack { + ForEach(videos) { video in + VideoCardView(video: video) + } +} +``` + +**Result**: CPU power impact dropped from 21 to 4.3. UI no longer hung. + +--- + +### Example 2: Location-Based Suggestions with Repeated Parsing (WWDC25-226) + +**Symptom**: User commuting reported massive battery drain. Developer couldn't reproduce at desk. + +**Diagnosis using on-device Power Profiler**: +1. User collected trace during commute (Settings → Developer → Performance Trace) +2. Trace showed periodic CPU spikes correlating with movement +3. Time Profiler showed `videoSuggestionsForLocation` consuming CPU +4. Root cause: JSON file parsed on EVERY location update + +**Fix**: +```swift +// Before: Parse on every call +func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] { + let data = try? Data(contentsOf: rulesFileURL) + let rules = try? JSONDecoder().decode([RecommendationRule].self, from: data) + return filteredVideos(using: rules) +} + +// After: Parse once, cache +private lazy var cachedRules: [RecommendationRule] = { + let data = try? Data(contentsOf: rulesFileURL) + return (try? JSONDecoder().decode([RecommendationRule].self, from: data)) ?? [] +}() + +func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] { + return filteredVideos(using: cachedRules) +} +``` + +**Result**: Eliminated CPU spikes during movement. Battery drain resolved. + +--- + +### Example 3: Music App with Always-Active Audio Session + +**Symptom**: App drains battery even when not playing music. + +**Diagnosis**: +1. Power Profiler showed sustained background CPU activity +2. Audio session remained active after playback stopped +3. System kept audio hardware powered on + +**Fix**: +```swift +// Before: Never deactivate +func playTrack(_ track: Track) { + try? AVAudioSession.sharedInstance().setActive(true) + player.play() +} + +func stopPlayback() { + player.stop() + // Audio session still active! +} + +// After: Deactivate when done +func stopPlayback() { + player.stop() + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) +} + +// Even better: Use AVAudioEngine auto-shutdown +let engine = AVAudioEngine() +engine.isAutoShutdownEnabled = true // Automatically powers down when idle +``` + +**Result**: Background audio hardware powered down. Battery drain eliminated. + +--- + +## Responding to Low Power Mode + +Detect and adapt when user enables Low Power Mode: + +```swift +// Check current state +if ProcessInfo.processInfo.isLowPowerModeEnabled { + reduceEnergyUsage() +} + +// React to changes +NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange) + .sink { [weak self] _ in + if ProcessInfo.processInfo.isLowPowerModeEnabled { + self?.reduceEnergyUsage() + } else { + self?.restoreNormalOperation() + } + } + .store(in: &cancellables) + +func reduceEnergyUsage() { + // Pause optional activities + // Reduce animation frame rates + // Increase timer intervals + // Defer network requests + // Stop location updates if not critical +} +``` + +--- + +## Monitoring Energy in Production + +### MetricKit Setup + +```swift +import MetricKit + +class EnergyMetricsManager: NSObject, MXMetricManagerSubscriber { + static let shared = EnergyMetricsManager() + + func startMonitoring() { + MXMetricManager.shared.add(self) + } + + func didReceive(_ payloads: [MXMetricPayload]) { + for payload in payloads { + if let cpuMetrics = payload.cpuMetrics { + // Monitor CPU time + let foregroundCPU = cpuMetrics.cumulativeCPUTime + logMetric("foreground_cpu", value: foregroundCPU) + } + + if let locationMetrics = payload.locationActivityMetrics { + // Monitor location usage + let backgroundLocation = locationMetrics.cumulativeBackgroundLocationTime + logMetric("background_location", value: backgroundLocation) + } + } + } +} +``` + +### Xcode Organizer + +Check **Battery Usage** pane in Xcode Organizer for field data: +- Foreground vs background energy breakdown +- Category breakdown (Audio, Networking, Processing, Display, etc.) +- Version comparison to detect regressions + +--- + +## Quick Reference + +### Power Profiler Workflow +``` +1. Connect device wirelessly +2. Product → Profile → Blank → Add Power Profiler +3. Record 2-3 minutes of usage +4. Identify dominant subsystem (CPU/GPU/Network/Display) +5. Apply targeted fix from patterns above +6. Record again to verify improvement +``` + +### Key Energy Savings +| Optimization | Potential Savings | +|--------------|------------------| +| Dark Mode on OLED | Up to 70% display power | +| Frame rate alignment | Up to 20% GPU power | +| Push vs poll | 100x network efficiency | +| Location accuracy reduction | 50-90% GPS power | +| Timer tolerance | Significant CPU savings | +| Lazy loading | Eliminates startup CPU spikes | + +### Related Skills +- `axiom-energy-ref` — Complete API reference with all code examples +- `axiom-energy-diag` — Symptom-based troubleshooting decision trees +- `axiom-background-processing` — Background task mechanics (why tasks don't run) +- `axiom-performance-profiling` — General Instruments workflows +- `axiom-memory-debugging` — Memory leak diagnosis (often related to energy) +- `axiom-networking` — Network optimization patterns +- `axiom-timer-patterns` — Timer crash prevention and lifecycle safety + +--- + +## WWDC Sessions + +- **WWDC25-226** "Profile and optimize power usage in your app" — Power Profiler workflow +- **WWDC25-227** "Finish tasks in the background" — BGContinuedProcessingTask, EMRCA +- **WWDC22-10083** "Power down: Improve battery consumption" — Dark Mode, frame rates, deferral +- **WWDC20-10095** "The Push Notifications primer" — Push vs poll +- **WWDC19-417** "Improving Battery Life and Performance" — MetricKit + +--- + +**Last Updated**: 2025-12-26 +**Platforms**: iOS 26+, iPadOS 26+ +**Status**: Production-ready energy optimization patterns diff --git a/.claude/skills/axiom-energy/agents/openai.yaml b/.claude/skills/axiom-energy/agents/openai.yaml new file mode 100644 index 0000000..cfe5b2c --- /dev/null +++ b/.claude/skills/axiom-energy/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Energy" + short_description: "App drains battery, device gets hot, users report energy issues, or auditing power consumption" diff --git a/.claude/skills/axiom-eventkit-ref/.openskills.json b/.claude/skills/axiom-eventkit-ref/.openskills.json new file mode 100644 index 0000000..0a707e3 --- /dev/null +++ b/.claude/skills/axiom-eventkit-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-eventkit-ref", + "installedAt": "2026-04-12T08:06:15.285Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-eventkit-ref/SKILL.md b/.claude/skills/axiom-eventkit-ref/SKILL.md new file mode 100644 index 0000000..5f9600a --- /dev/null +++ b/.claude/skills/axiom-eventkit-ref/SKILL.md @@ -0,0 +1,611 @@ +--- +name: axiom-eventkit-ref +description: Use when needing EventKit API details — EKEventStore, EKEvent, EKReminder, EventKitUI view controllers, EKCalendarChooser, authorization methods, predicate-based fetching, recurrence rules, Siri Event Suggestions donation, EKVirtualConferenceProvider, location-based reminders, and EKErrorDomain codes +license: MIT +--- + +# EventKit API Reference + +## Overview + +EventKit provides programmatic access to the Calendar and Reminders databases. EventKitUI provides system view controllers for calendar UI. This reference covers the complete API surface for both frameworks. + +For access tier decision tree and best practices, see the **eventkit** discipline skill. + +**Platform**: iOS 4.0+, iPadOS 4.0+, macOS 10.8+, Mac Catalyst 13.1+, watchOS 2.0+, visionOS 1.0+ + +--- + +# Part 1: EKEventStore + +The central hub for all calendar and reminder operations. Create one per app and reuse it. + +## Initialization + +```swift +let store = EKEventStore() // Standard +let store = EKEventStore(sources: [source]) // Scoped to specific sources +``` + +## Authorization (iOS 17+) + +```swift +// Events +try await store.requestWriteOnlyAccessToEvents() // Returns Bool +try await store.requestFullAccessToEvents() // Returns Bool + +// Reminders (full access only) +try await store.requestFullAccessToReminders() // Returns Bool + +// Check status +let status = EKEventStore.authorizationStatus(for: .event) // Static method +// Returns: .notDetermined, .restricted, .denied, .fullAccess, .writeOnly +// Deprecated: .authorized (maps to .fullAccess conceptually) +``` + +## Info.plist Keys + +| Key | When Required | +|-----|---------------| +| `NSCalendarsWriteOnlyAccessUsageDescription` | Write-only access, iOS 17+ | +| `NSCalendarsFullAccessUsageDescription` | Full event access, iOS 17+ | +| `NSRemindersFullAccessUsageDescription` | Reminder access, iOS 17+ | +| `NSCalendarsUsageDescription` | Calendar access, iOS 10-16 (keep for backward compat) | +| `NSRemindersUsageDescription` | Reminder access, iOS 10-16 (keep for backward compat) | +| `NSContactsUsageDescription` | Required if using EventKitUI on iOS <17 | + +**Missing key on iOS 17+**: Silent denial (no prompt, no error, no crash). +**Missing key on iOS 10-16**: Crash. + +## Calendar & Source Access + +```swift +store.calendars(for: .event) // [EKCalendar] — all event calendars +store.calendars(for: .reminder) // [EKCalendar] — all reminder calendars +store.calendar(withIdentifier: id) // EKCalendar? +store.defaultCalendarForNewEvents // EKCalendar? — user's default +store.defaultCalendarForNewReminders() // EKCalendar? +store.sources // [EKSource] — all accounts +store.delegateSources // [EKSource] — delegate accounts +``` + +## Calendar Management + +```swift +try store.saveCalendar(calendar, commit: true) +try store.removeCalendar(calendar, commit: true) +``` + +## Event Operations + +```swift +// Fetch by identifier +store.event(withIdentifier: id) // EKEvent? — first occurrence for recurring +store.calendarItem(withIdentifier: id) // EKCalendarItem? — event or reminder +store.calendarItems(withExternalIdentifier: extId) // [EKCalendarItem] + +// Save and remove +try store.save(event, span: .thisEvent, commit: true) +try store.remove(event, span: .thisEvent, commit: true) +// span: .thisEvent | .futureEvents (controls recurring event behavior) +``` + +## Event Fetching (Synchronous — run on background thread) + +```swift +let predicate = store.predicateForEvents( + withStart: startDate, end: endDate, calendars: nil // nil = all calendars +) +let events = store.events(matching: predicate) +// Results are NOT sorted — sort manually: +let sorted = events.sorted { $0.compareStartDate(with: $1) == .orderedAscending } +``` + +**Only Apple-provided predicates work.** Custom `NSPredicate` instances are rejected. + +## Reminder Fetching (Asynchronous) + +```swift +// Predicates +store.predicateForReminders(in: calendars) // nil = all +store.predicateForIncompleteReminders( + withDueDateStarting: start, ending: end, calendars: nil +) +store.predicateForCompletedReminders( + withCompletionDateStarting: start, ending: end, calendars: nil +) + +// Fetch (async callback) +let fetchId = store.fetchReminders(matching: predicate) { reminders in + // reminders: [EKReminder]? +} +store.cancelFetchRequest(fetchId) // Cancel if needed +``` + +## Batch Operations + +```swift +try store.save(event1, span: .thisEvent, commit: false) +try store.save(event2, span: .thisEvent, commit: false) +try store.commit() // Atomic commit +store.reset() // Rollback on failure +``` + +## Change Notifications + +```swift +NotificationCenter.default.addObserver( + self, selector: #selector(storeChanged), + name: .EKEventStoreChanged, object: store +) +// Posted when external processes modify the calendar database +// Call event.refresh() on cached objects — returns false if deleted +``` + +--- + +# Part 2: EKEvent + +Represents a calendar event. Inherits from `EKCalendarItem`. + +## Creation + +```swift +let event = EKEvent(eventStore: store) +``` + +## Key Properties + +| Property | Type | Notes | +|----------|------|-------| +| `title` | `String` | Required for save | +| `startDate` | `Date` | Required for save | +| `endDate` | `Date` | Required for save | +| `calendar` | `EKCalendar` | Required for direct save (not EventKitUI) | +| `isAllDay` | `Bool` | | +| `timeZone` | `TimeZone?` | Defaults to system time zone | +| `location` | `String?` | Full address enables Maps features | +| `structuredLocation` | `EKStructuredLocation?` | Geo-precise location | +| `notes` | `String?` | | +| `url` | `URL?` | | +| `eventIdentifier` | `String` | Stable across fetches | +| `status` | `EKEventStatus` | `.none`, `.confirmed`, `.tentative`, `.canceled` | +| `availability` | `EKEventAvailability` | `.notSupported` (default), `.busy`, `.free`, `.tentative`, `.unavailable` | +| `occurrenceDate` | `Date` | For recurring event instances | +| `isDetached` | `Bool` | True if modified from recurring series | +| `organizer` | `EKParticipant?` | Read-only | +| `birthdayContactIdentifier` | `String?` | For birthday calendar events | + +## Inherited from EKCalendarItem + +| Property | Type | Notes | +|----------|------|-------| +| `calendarItemIdentifier` | `String` | Unique identifier | +| `calendarItemExternalIdentifier` | `String` | External (sync) identifier | +| `creationDate` | `Date?` | | +| `lastModifiedDate` | `Date?` | | +| `alarms` | `[EKAlarm]?` | | +| `recurrenceRules` | `[EKRecurrenceRule]?` | | +| `hasAlarms` | `Bool` | | +| `hasRecurrenceRules` | `Bool` | | +| `attendees` | `[EKParticipant]?` | Read-only | + +## Methods + +```swift +event.compareStartDate(with: otherEvent) // ComparisonResult +event.refresh() // Bool — false if deleted +``` + +--- + +# Part 3: EKReminder + +Represents a reminder. Inherits from `EKCalendarItem`. + +## Creation + +```swift +let reminder = EKReminder(eventStore: store) +reminder.title = "Review PR" +reminder.calendar = store.defaultCalendarForNewReminders() // Required +``` + +## Key Properties + +| Property | Type | Notes | +|----------|------|-------| +| `startDateComponents` | `DateComponents?` | Task start | +| `dueDateComponents` | `DateComponents?` | Due date — use `DateComponents`, NOT `Date` | +| `isCompleted` | `Bool` | Setting true auto-populates `completionDate` | +| `completionDate` | `Date?` | Auto-set when `isCompleted = true` | +| `priority` | `Int` | Use `EKReminderPriority` raw values | + +## EKReminderPriority + +| Case | Raw Value | +|------|-----------| +| `.none` | 0 | +| `.high` | 1 | +| `.medium` | 5 | +| `.low` | 9 | + +## Save/Remove + +```swift +try store.save(reminder, commit: true) +try store.remove(reminder, commit: true) +// No span parameter — reminders don't have recurring instances like events +``` + +--- + +# Part 4: EKAlarm + +Notification alarm for events or reminders. + +```swift +// Time-based +let absoluteAlarm = EKAlarm(absoluteDate: date) // Specific date/time +let relativeAlarm = EKAlarm(relativeOffset: -3600) // 1 hour before (seconds) + +// Location-based (EKAlarm.proximity available since iOS 6.0+) +let location = EKStructuredLocation(title: "Office") +location.geoLocation = CLLocation(latitude: 37.33, longitude: -122.03) +location.radius = 500 // meters + +let locationAlarm = EKAlarm() +locationAlarm.structuredLocation = location +locationAlarm.proximity = .enter // .enter or .leave + +reminder.addAlarm(locationAlarm) +``` + +--- + +# Part 5: EKRecurrenceRule + +```swift +let rule = EKRecurrenceRule( + recurrenceWith: .weekly, // .daily, .weekly, .monthly, .yearly + interval: 1, // Every 1 week + daysOfTheWeek: [EKRecurrenceDayOfWeek(.monday), EKRecurrenceDayOfWeek(.wednesday)], + daysOfTheMonth: nil, + monthsOfTheYear: nil, + weeksOfTheYear: nil, + daysOfTheYear: nil, + setPositions: nil, + end: EKRecurrenceEnd(occurrenceCount: 10) // or EKRecurrenceEnd(end: Date) +) +event.addRecurrenceRule(rule) +``` + +--- + +# Part 6: EKCalendar and EKSource + +## EKCalendar Properties + +| Property | Type | Notes | +|----------|------|-------| +| `title` | `String` | | +| `color` | `UIColor` / `cgColor: CGColor` | | +| `type` | `EKCalendarType` | `.local`, `.calDAV`, `.exchange`, `.subscription`, `.birthday` | +| `allowsContentModifications` | `Bool` | Can write to this calendar? | +| `isImmutable` | `Bool` | System calendar (birthday, holidays) | +| `source` | `EKSource` | Parent account | + +## EKSource Properties + +| Property | Type | +|----------|------| +| `title` | `String` | +| `sourceType` | `EKSourceType` — `.local`, `.exchange`, `.calDAV`, `.mobileMe`, `.subscribed`, `.birthdays` | +| `sourceIdentifier` | `String` | + +--- + +# Part 7: EventKitUI View Controllers + +## EKEventEditViewController + +Create/edit events. **No permission required on iOS 17+** (renders out-of-process). + +**Inherits from**: `UINavigationController` (NOT `UIViewController`) + +```swift +let editVC = EKEventEditViewController() +editVC.event = event // nil = new event +editVC.eventStore = store // Required +editVC.editViewDelegate = self +present(editVC, animated: true) +``` + +### EKEventEditViewDelegate + +```swift +func eventEditViewController( + _ controller: EKEventEditViewController, + didCompleteWith action: EKEventEditViewAction +) { + // action: .canceled, .saved, .deleted + dismiss(animated: true) +} + +func eventEditViewControllerDefaultCalendar( + forNewEvents controller: EKEventEditViewController +) -> EKCalendar { + return store.defaultCalendarForNewEvents! +} +``` + +## EKEventViewController + +Display event details. **Requires full access**. + +**Inherits from**: `UIViewController` (can push onto nav stack) + +```swift +let viewVC = EKEventViewController() +viewVC.event = event // Required +viewVC.allowsEditing = true +viewVC.allowsCalendarPreview = true +viewVC.delegate = self +navigationController?.pushViewController(viewVC, animated: true) +``` + +### EKEventViewDelegate + +```swift +func eventViewController( + _ controller: EKEventViewController, + didCompleteWith action: EKEventViewAction +) { + // action: .done, .responded, .deleted +} +``` + +**Note**: `EKEventViewController` automatically handles `EKEventStoreChanged` notifications — no manual refresh needed. + +## EKCalendarChooser + +Calendar selection UI. **Requires write-only or full access**. + +```swift +let chooser = EKCalendarChooser( + selectionStyle: .single, // .single or .multiple + displayStyle: .writableCalendarsOnly, // .allCalendars or .writableCalendarsOnly + entityType: .event, // .event or .reminder + eventStore: store +) +chooser.selectedCalendars = [store.defaultCalendarForNewEvents!] +chooser.showsDoneButton = true +chooser.delegate = self +present(UINavigationController(rootViewController: chooser), animated: true) +``` + +**Gotcha**: Under write-only access, `displayStyle` is ignored — always shows writable only. + +--- + +# Part 8: Virtual Conference Extension + +For apps supporting voice/video calls — integrates directly into Calendar's location picker. + +## Extension Setup + +1. Add Virtual Conference Extension target in Xcode +2. Extension point: `com.apple.calendar.virtualconference` +3. Template generates `EKVirtualConferenceProvider` subclass + +## EKVirtualConferenceProvider + +**Platform**: iOS 15.0+, macOS 12.0+, watchOS 8.0+, visionOS 1.0+ + +```swift +class MyConferenceProvider: EKVirtualConferenceProvider { + override func fetchAvailableRoomTypes() async throws + -> [EKVirtualConferenceRoomTypeDescriptor] { + return [ + EKVirtualConferenceRoomTypeDescriptor( + title: "Personal Room", + identifier: "personal_room" + ) + ] + } + + override func fetchVirtualConference( + identifier: EKVirtualConferenceRoomTypeIdentifier + ) async throws -> EKVirtualConferenceDescriptor { + let url = EKVirtualConferenceURLDescriptor( + title: nil, // Optional — useful when multiple join URLs + url: URL(string: "https://myapp.com/join/\(roomId)")! + ) + return EKVirtualConferenceDescriptor( + title: nil, // Optional — distinguishes multiple room types + urlDescriptors: [url], + conferenceDetails: "Enter code 12345 to join" + ) + } +} +``` + +**Use Universal Links** for join URLs so your app opens directly. + +**Syncing**: Events with virtual conference info sync to devices where your app may not be installed. + +--- + +# Part 9: Siri Event Suggestions + +Add reservation-style events to Calendar without requesting any permission. Events appear in the Calendar inbox like invitations. + +**Supported types**: restaurant, hotel, flight, train, bus, boat, rental car, ticketed events + +```swift +// 1. Create reservation reference +let reference = INSpeakableString( + vocabularyIdentifier: "booking-\(reservationId)", + spokenPhrase: "Dinner at Caffè Macs", + pronunciationHint: nil +) + +// 2. Create reservation +let duration = INDateComponentsRange(start: startComponents, end: endComponents) +let location = MKPlacemark(coordinate: clLocation.coordinate, postalAddress: address) + +let reservation = INRestaurantReservation( + itemReference: reference, + reservationStatus: .confirmed, + reservationHolderName: "Jane Appleseed", + reservationDuration: duration, + restaurantLocation: location +) + +// 3. Create intent + response +let intent = INGetReservationDetailsIntent( + reservationContainerReference: reference +) +let response = INGetReservationDetailsIntentResponse(code: .success, userActivity: nil) +response.reservations = [reservation] + +// 4. Donate interaction +let interaction = INInteraction(intent: intent, response: response) +interaction.donate() +``` + +### Reservation Types + +| Type | Class | +|------|-------| +| Restaurant | `INRestaurantReservation` | +| Hotel | `INLodgingReservation` | +| Flight | `INFlightReservation` | +| Train | `INTrainReservation` | +| Bus | `INBusReservation` (iOS 14+) | +| Boat | `INBoatReservation` (iOS 14+) | +| Rental Car | `INRentalCarReservation` | +| Ticketed Event | `INTicketedEventReservation` | + +### Update/Cancel + +Use the same `reservationId` across donations: +- **Update**: Donate with updated details, same `reservationId` +- **Cancel**: Set `reservationStatus = .canceled` and re-donate + +### Web Markup (iOS 14+) + +Embed schema.org JSON-LD or Microdata in HTML for Safari and Mail: +```html + +``` + +**Requires**: Domain registration with Apple, HTTPS, valid DKIM for emails. + +### Show in App / Show in Safari + +- When app is installed: Calendar shows "Show in App" button — launches app with `INGetReservationDetailsIntent` +- When app is not installed: If `url` property is set on `INReservation`, Calendar shows "Show in Safari" + +--- + +# Part 10: Location-Based Reminders + +**Platform**: iOS 6.0+ (EKAlarm.proximity, EKStructuredLocation) + +**Required permissions**: Location When In Use + Full Reminders Access + +```swift +// Create location-triggered reminder +let reminder = EKReminder(eventStore: store) +reminder.title = "Pick up dry cleaning" +reminder.calendar = store.defaultCalendarForNewReminders() + +let location = EKStructuredLocation(title: "Dry Cleaners") +location.geoLocation = CLLocation(latitude: 37.33, longitude: -122.03) +location.radius = 200 // meters + +let alarm = EKAlarm() +alarm.structuredLocation = location +alarm.proximity = .enter // .enter or .leave +reminder.addAlarm(alarm) + +try store.save(reminder, commit: true) +``` + +### Fetching Location Reminders + +```swift +let predicate = store.predicateForReminders(in: nil) +let allReminders = try await fetchReminders(matching: predicate) +let locationReminders = allReminders.filter { reminder in + reminder.alarms?.contains { alarm in + alarm.structuredLocation != nil && alarm.proximity != .none + } ?? false +} +``` + +--- + +# Part 11: Error Reference + +## EKErrorDomain Codes + +| Code | Name | Meaning | +|------|------|---------| +| 0 | `eventNotMutable` | Event is read-only | +| 1 | `noCalendar` | Calendar property not set | +| 2 | `noStartDate` | Missing start date | +| 3 | `noEndDate` | Missing end date | +| 4 | `datesInverted` | End date before start date | +| 12 | `calendarReadOnly` | Calendar doesn't allow modifications | +| 13 | `calendarIsImmutable` | System calendar (birthday, etc.) | +| 15 | `sourceDoesNotAllowCalendarAddDelete` | Can't create/delete calendars on this source | +| 18 | `recurringReminderRequiresDueDate` | Recurring reminders need due date | +| 19 | `structuredLocationsNotSupported` | Location alarms not supported | +| 21 | `alarmProximityNotSupported` | Proximity alarms not supported | +| 22 | `eventStoreNotAuthorized` | No permission | +| 24 | `objectBelongsToDifferentStore` | Cross-store object usage | +| 25 | `invitesCannotBeMoved` | Can't move events with attendees | +| 26 | `invalidSpan` | Invalid span value | + +--- + +# Part 12: Platform Availability Matrix + +| API | iOS | macOS | watchOS | visionOS | +|-----|-----|-------|---------|----------| +| EKEventStore | 4.0+ | 10.8+ | 2.0+ | 1.0+ | +| Write-only access | 17.0+ | 14.0+ | 10.0+ | 1.0+ | +| Full access (new API) | 17.0+ | 14.0+ | 10.0+ | 1.0+ | +| EKEventEditViewController | 4.0+ | (Catalyst 13.1+) | — | 1.0+ | +| EKEventViewController | 4.0+ | (Catalyst 13.1+) | — | 1.0+ | +| EKCalendarChooser | 4.0+ | (Catalyst 13.0+) | — | 1.0+ | +| EKVirtualConferenceProvider | 15.0+ | 12.0+ | 8.0+ | 1.0+ | +| Location-based reminders | 6.0+ | 10.8+ | — | — | +| Siri Event Suggestions | 12.0+ | 11.0+ (Catalyst) | — | — | +| Schema.org markup | 14.0+ | 11.0+ (Safari/Mail) | — | — | + +--- + +## Resources + +**WWDC**: 2023-10052, 2020-10197 + +**Docs**: /eventkit, /eventkitui, /eventkit/ekeventstore, /eventkit/ekevent, /eventkit/ekreminder, /eventkit/ekvirtualconferenceprovider, /technotes/tn3152, /technotes/tn3153 + +**Skills**: eventkit, contacts-ref, extensions-widgets-ref diff --git a/.claude/skills/axiom-eventkit-ref/agents/openai.yaml b/.claude/skills/axiom-eventkit-ref/agents/openai.yaml new file mode 100644 index 0000000..23d3590 --- /dev/null +++ b/.claude/skills/axiom-eventkit-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "EventKit Reference" + short_description: "Needing EventKit API details" diff --git a/.claude/skills/axiom-eventkit/.openskills.json b/.claude/skills/axiom-eventkit/.openskills.json new file mode 100644 index 0000000..ef4ec09 --- /dev/null +++ b/.claude/skills/axiom-eventkit/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-eventkit", + "installedAt": "2026-04-12T08:06:14.700Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-eventkit/SKILL.md b/.claude/skills/axiom-eventkit/SKILL.md new file mode 100644 index 0000000..43106cf --- /dev/null +++ b/.claude/skills/axiom-eventkit/SKILL.md @@ -0,0 +1,351 @@ +--- +name: axiom-eventkit +description: Use when working with ANY calendar event, reminder, EventKit permission, or EventKitUI controller. Covers access tiers (no-access, write-only, full), permission migration from pre-iOS 17, store lifecycle, reminder patterns, EventKitUI controller selection, Siri Event Suggestions, virtual conference extensions. +license: MIT +--- + +# EventKit — Discipline + +## Core Philosophy + +> "Request the minimum access needed, and only when it's needed." + +**Mental model**: EventKit has three access tiers. Most apps need only the first (no access + system UI). Requesting more than you need means more users deny your request, and more code to maintain. + +## When to Use This Skill + +Use this skill when: +- Adding events or reminders to the user's calendar +- Choosing between EventKitUI, write-only, or full access +- Requesting calendar or reminder permissions +- Fetching, querying, or displaying existing events +- Migrating from pre-iOS 17 permission APIs +- Creating virtual conference extensions +- Implementing Siri Event Suggestions for reservations +- Debugging "access denied" or missing events + +Do NOT use this skill for: +- Contacts framework questions (use **contacts**) +- General SwiftUI architecture (use **swiftui-architecture**) +- Background task scheduling (use **background-processing**) + +## Related Skills + +- **eventkit-ref** — Complete EventKit/EventKitUI API reference +- **contacts** — Contacts framework discipline skill +- **privacy-ux** — General iOS privacy patterns and Permission UX +- **extensions-widgets** — WidgetKit if combining calendar with widgets +- **background-processing** — If scheduling background calendar sync + +--- + +## Access Tier Decision Tree + +```dot +digraph access_decision { + rankdir=TB; + "What does your app need?" [shape=diamond]; + "Add single events to Calendar?" [shape=diamond]; + "Show custom create/edit UI?" [shape=diamond]; + "Read existing events/calendars?" [shape=diamond]; + + "No access + EventKitUI" [shape=box, label="Tier 1: No Access\nPresent EKEventEditViewController\nNo permission prompt needed"]; + "No access + Siri Suggestions" [shape=box, label="Tier 1: No Access\nSiri Event Suggestions\nFor reservations only"]; + "Write-only access" [shape=box, label="Tier 2: Write-Only\nrequestWriteOnlyAccessToEvents()\nCan save but not read"]; + "Full access" [shape=box, label="Tier 3: Full Access\nrequestFullAccessToEvents()\nor requestFullAccessToReminders()"]; + + "What does your app need?" -> "Add single events to Calendar?" [label="events"]; + "What does your app need?" -> "Full access" [label="reminders\n(always full)"]; + "Add single events to Calendar?" -> "No access + EventKitUI" [label="yes, one at a time"]; + "Add single events to Calendar?" -> "Show custom create/edit UI?" [label="no, batch or silent"]; + "Show custom create/edit UI?" -> "Write-only access" [label="yes, or batch save"]; + "Show custom create/edit UI?" -> "Read existing events/calendars?" [label="no"]; + "Read existing events/calendars?" -> "Full access" [label="yes"]; + "Read existing events/calendars?" -> "Write-only access" [label="no"]; + "Add single events to Calendar?" -> "No access + Siri Suggestions" [label="reservation-style\n(restaurant, flight, hotel)"]; +} +``` + +**Key rule**: Reminders ALWAYS require full access. There is no write-only tier for reminders. + +--- + +## The Three Access Tiers + +### Tier 1: No Access (Preferred) + +Present `EKEventEditViewController` — it runs out-of-process on iOS 17+ and requires zero permissions. + +```swift +let store = EKEventStore() +let event = EKEvent(eventStore: store) +event.title = "Team Standup" +event.startDate = startDate +event.endDate = Calendar.current.date(byAdding: .hour, value: 1, to: startDate) ?? startDate +event.timeZone = TimeZone(identifier: "America/Los_Angeles") +event.location = "Conference Room A" + +let editVC = EKEventEditViewController() +editVC.event = event +editVC.eventStore = store +editVC.editViewDelegate = self +present(editVC, animated: true) +``` + +**Why this is best**: No permission prompt. No denial risk. System handles Calendar selection and save. Works on iOS 4+. + +For reservations (restaurant, flight, hotel, event tickets), use **Siri Event Suggestions** instead — events appear in Calendar inbox without any permission. See the eventkit-ref skill for the INReservation donation pattern. + +### Tier 2: Write-Only Access (iOS 17+) + +Use only when you need: custom editing UI, batch saves, or silent event creation. + +```swift +let store = EKEventStore() +guard try await store.requestWriteOnlyAccessToEvents() else { + // User denied — handle gracefully + return +} +let event = EKEvent(eventStore: store) +event.calendar = store.defaultCalendarForNewEvents // REQUIRED for write-only +event.title = "Recurring Standup" +event.startDate = startDate +event.endDate = endDate +try store.save(event, span: .thisEvent) +``` + +**Write-only constraints**: +- Returns a single virtual calendar, not the user's real calendars +- Event queries return empty results +- System chooses destination calendar for created events +- Cannot read events back, even ones your app created + +**Info.plist required**: `NSCalendarsWriteOnlyAccessUsageDescription` + +### Tier 3: Full Access + +Use only when your app's core feature requires reading, modifying, or deleting existing events. + +```swift +let store = EKEventStore() +guard try await store.requestFullAccessToEvents() else { return } + +// Now you can fetch events +let interval = Calendar.current.dateInterval(of: .month, for: Date())! +let predicate = store.predicateForEvents(withStart: interval.start, end: interval.end, calendars: nil) +let events = store.events(matching: predicate) + .sorted { $0.compareStartDate(with: $1) == .orderedAscending } +``` + +**Info.plist required**: `NSCalendarsFullAccessUsageDescription` + +For reminders: +```swift +guard try await store.requestFullAccessToReminders() else { return } +``` + +**Info.plist required**: `NSRemindersFullAccessUsageDescription` + +--- + +## Anti-Patterns + +| Pattern | Time Cost | Why It's Wrong | Fix | +|---------|-----------|----------------|-----| +| Requesting full access for "add to calendar" | 1-2 sprint days recovering denied users | Full access prompts are denied 30%+ of the time — users distrust reading ALL calendar data | Use EventKitUI or write-only | +| Missing Info.plist key on iOS 17+ | 1-2 hours debugging | Automatic silent denial, no crash, no error, no prompt | Add the correct usage description key | +| Missing Info.plist key on iOS 16 and below | Immediate crash | App crashes on permission request | Add `NSCalendarsUsageDescription` | +| Calling deprecated `requestAccess(to:)` on iOS 17 | Throws error | The old API throws, does not prompt | Use `requestFullAccessToEvents()` or `requestWriteOnlyAccessToEvents()` | +| Creating multiple EKEventStore instances | Stale data bugs | Objects from one store cannot be used with another | Create one store, reuse it | +| Using `Date` math instead of `DateComponents` for durations | DST bugs | Adding 3600 seconds doesn't always equal 1 hour | Use `Calendar.current.date(byAdding:)` | +| Not sorting `events(matching:)` results | Wrong display order | Results are NOT chronologically ordered | Sort with `compareStartDate(with:)` | +| Setting `dueDateComponents` with `Date` instead of `DateComponents` | Silent failure | Reminders use `DateComponents`, not `Date` | Convert via `Calendar.current.dateComponents(...)` | +| Not registering for `EKEventStoreChanged` notification | Stale UI | External Calendar changes are invisible | Register and refetch on notification | +| Ignoring `EKSpan` on recurring events | Modifying all occurrences | `.thisEvent` vs `.futureEvents` controls scope | Always choose explicitly | + +--- + +## Reminder Patterns + +Reminders ALWAYS require `requestFullAccessToReminders()`. + +### Creating a Reminder + +```swift +let reminder = EKReminder(eventStore: store) +reminder.title = "Review PR" +reminder.calendar = store.defaultCalendarForNewReminders() // Required + +// Due dates use DateComponents, NOT Date +if let dueDate = dueDate { + reminder.dueDateComponents = Calendar.current.dateComponents( + [.year, .month, .day, .hour, .minute], from: dueDate + ) +} + +reminder.priority = EKReminderPriority.medium.rawValue +try store.save(reminder, commit: true) +``` + +### Fetching Reminders (Async) + +Unlike events, reminder fetches are asynchronous: + +```swift +let predicate = store.predicateForReminders(in: nil) // nil = all calendars +let reminders = try await withCheckedThrowingContinuation { continuation in + store.fetchReminders(matching: predicate) { reminders in + if let reminders { + continuation.resume(returning: reminders) + } else { + continuation.resume(throwing: TodayError.failedReadingReminders) + } + } +} +``` + +### Creating Reminder Lists + +Reminder lists are `EKCalendar` objects filtered by entity type: + +```swift +let newList = EKCalendar(for: .reminder, eventStore: store) +newList.title = "Sprint Tasks" + +// Source selection matters — prefer .local or .calDAV +guard let source = store.sources.first(where: { + $0.sourceType == .local || $0.sourceType == .calDAV +}) ?? store.defaultCalendarForNewReminders()?.source else { + throw EventKitError.noValidSource +} + +newList.source = source +try store.saveCalendar(newList, commit: true) +``` + +--- + +## Store Lifecycle + +### Singleton Pattern + +Create one `EKEventStore` and reuse it. Objects from one store instance cannot be used with another. + +### Change Notifications + +```swift +NotificationCenter.default.addObserver( + self, selector: #selector(storeChanged), + name: .EKEventStoreChanged, object: store +) + +@objc func storeChanged(_ notification: Notification) { + // Refetch your current date range + // Individual objects: call refresh() — if false, refetch +} +``` + +### Batch Operations + +```swift +// Pass commit: false for batch, then commit once +try store.save(event1, span: .thisEvent, commit: false) +try store.save(event2, span: .thisEvent, commit: false) +try store.commit() // Atomic save +// On failure: store.reset() to rollback +``` + +--- + +## Migration from Pre-iOS 17 + +| Before iOS 17 | iOS 17+ Replacement | +|----------------|---------------------| +| `requestAccess(to: .event)` | `requestFullAccessToEvents()` or `requestWriteOnlyAccessToEvents()` | +| `requestAccess(to: .reminder)` | `requestFullAccessToReminders()` | +| `NSCalendarsUsageDescription` | `NSCalendarsFullAccessUsageDescription` or `NSCalendarsWriteOnlyAccessUsageDescription` | +| `NSRemindersUsageDescription` | `NSRemindersFullAccessUsageDescription` | +| `authorizationStatus == .authorized` | Check for `.fullAccess` or `.writeOnly` | + +**Runtime compatibility**: +```swift +if #available(iOS 17.0, *) { + granted = try await store.requestFullAccessToEvents() +} else { + granted = try await store.requestAccess(to: .event) +} +``` + +**Keep old Info.plist keys** alongside new ones to support iOS 16 and below. + +**Gotcha**: Apps built with older Xcode SDKs map both `.writeOnly` and `.fullAccess` to `.authorized`. This means an app linked against an old SDK may fail to fetch events even after users granted full access — because the app sees `.authorized` but the system gave `.writeOnly`. + +--- + +## EventKitUI Decision Guide + +| Controller | Purpose | Permission Required | +|------------|---------|---------------------| +| `EKEventEditViewController` | Create/edit events | None (iOS 17+ out-of-process) | +| `EKEventViewController` | Display event details | Full access | +| `EKCalendarChooser` | Calendar selection | Write-only or full | + +**Gotcha**: `EKEventEditViewController` inherits from `UINavigationController`, not `UIViewController`. Do NOT embed it inside another navigation controller. + +**Gotcha**: `EKEventViewController` inherits from `UIViewController` and CAN be pushed onto a navigation stack. + +**Gotcha**: Under write-only access, `EKCalendarChooser` ignores `displayStyle` and always shows writable calendars only. + +--- + +## Pressure Scenarios + +### Scenario 1: "Just request full access, we might need it later" + +**Pressure**: Product manager asks for full access "just in case." + +**Why resist**: Full access prompts are denied 30%+ of the time. Write-only or EventKitUI gets you event creation with near-zero denials. You can always upgrade later if a reading feature is added. + +**Response**: "Full access shows a scary prompt about reading ALL calendar data. For adding events, EventKitUI needs no prompt at all. Let's start there and upgrade if we ship a feature that reads events." + +### Scenario 2: "The deprecated API still works, we'll migrate later" + +**Pressure**: Deadline pressure to skip migration from `requestAccess(to:)`. + +**Why resist**: On iOS 17, calling `requestAccess(to: .event)` throws an error — no prompt, no access, broken feature. Users on iOS 17+ get a silent failure. + +**Response**: "The deprecated API throws on iOS 17. It's not 'deprecated but works' — it's broken. The fix is a 3-line `#available` check." + +### Scenario 3: "Just create a new EKEventStore for each screen" + +**Pressure**: Different view controllers each create their own store for isolation. + +**Why resist**: Objects from one store cannot be used with another. Events fetched from store A cannot be saved by store B. Change notifications only fire on the store that's registered. + +**Response**: "EventKit requires a single shared store. Objects are bound to the store that created them. Create one and inject it." + +--- + +## Error Handling + +Key `EKErrorDomain` codes to handle: + +| Code | Meaning | Fix | +|------|---------|-----| +| `eventStoreNotAuthorized` | No permission | Check and request access first | +| `noCalendar` | Calendar not set on event | Set `event.calendar` before save | +| `noStartDate` / `noEndDate` | Missing dates | Set both before save | +| `datesInverted` | End before start | Validate date order | +| `calendarReadOnly` / `calendarIsImmutable` | Can't write to this calendar | Use `allowsContentModifications` check | +| `objectBelongsToDifferentStore` | Cross-store usage | Use single store instance | +| `recurringReminderRequiresDueDate` | Recurring reminder missing due date | Set `dueDateComponents` | + +--- + +## Resources + +**WWDC**: 2023-10052, 2020-10197 + +**Docs**: /eventkit, /eventkitui, /technotes/tn3152, /technotes/tn3153 + +**Skills**: eventkit-ref, contacts, privacy-ux, extensions-widgets diff --git a/.claude/skills/axiom-eventkit/agents/openai.yaml b/.claude/skills/axiom-eventkit/agents/openai.yaml new file mode 100644 index 0000000..01d446c --- /dev/null +++ b/.claude/skills/axiom-eventkit/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "EventKit" + short_description: "Working with ANY calendar event, reminder, EventKit permission, or EventKitUI controller" diff --git a/.claude/skills/axiom-extensions-widgets-ref/.openskills.json b/.claude/skills/axiom-extensions-widgets-ref/.openskills.json new file mode 100644 index 0000000..5c9d83a --- /dev/null +++ b/.claude/skills/axiom-extensions-widgets-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-extensions-widgets-ref", + "installedAt": "2026-04-12T08:06:16.419Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-extensions-widgets-ref/SKILL.md b/.claude/skills/axiom-extensions-widgets-ref/SKILL.md new file mode 100644 index 0000000..2088135 --- /dev/null +++ b/.claude/skills/axiom-extensions-widgets-ref/SKILL.md @@ -0,0 +1,1286 @@ +--- +name: axiom-extensions-widgets-ref +description: Use when implementing widgets, Live Activities, Control Center controls, or app extensions - comprehensive API reference for WidgetKit, ActivityKit, App Groups, and extension lifecycle for iOS 14+ +license: MIT +compatibility: iOS 14+, iPadOS 14+, watchOS 9+, macOS 11+, visionOS 2+ +metadata: + version: "1.0.0" +--- + +# Extensions & Widgets API Reference + +## Overview + +This skill provides comprehensive API reference for Apple's widget and extension ecosystem: + +- **Standard Widgets** (iOS 14+) — Home Screen, Lock Screen, StandBy widgets +- **Interactive Widgets** (iOS 17+) — Buttons and toggles with App Intents +- **Live Activities** (iOS 16.1+) — Real-time updates on Lock Screen and Dynamic Island +- **Control Center Widgets** (iOS 18+) — System-wide quick controls +- **Liquid Glass Widgets** (iOS 26+) — Accented rendering, glass effects, container backgrounds +- **visionOS Widgets** (visionOS 2+) — Mounting styles, textures, proximity awareness +- **App Extensions** — Shared data, lifecycle, entitlements + +Widgets are SwiftUI **archived snapshots** rendered on a timeline by the system. Extensions are sandboxed executables bundled with your app. + +## When to Use This Skill + +✅ **Use this skill when**: +- Implementing any type of widget (Home Screen, Lock Screen, StandBy) +- Creating Live Activities for ongoing events +- Building Control Center controls +- Sharing data between app and extensions +- Understanding widget timelines and refresh policies +- Integrating widgets with App Intents +- Adopting Liquid Glass rendering in widgets +- Supporting watchOS or visionOS widgets +- Implementing visionOS mounting styles, textures, or proximity awareness + +❌ **Do NOT use this skill for**: +- Pure App Intents questions (use **app-intents-ref** skill) +- SwiftUI layout issues (use **swiftui-layout** skill) +- Performance optimization (use **swiftui-performance** skill) +- Debugging crashes (use **xcode-debugging** skill) + +## Related Skills + +- **app-intents-ref** — App Intents for interactive widgets and configuration +- **swift-concurrency** — Async/await patterns for widget data loading +- **swiftui-performance** — Optimizing widget rendering +- **swiftui-layout** — Complex widget layouts +- **extensions-widgets** — Discipline skill with anti-patterns and debugging + +## Key Terminology + +- **Timeline** — Series of entries defining when/what content to display; system shows entries at specified times +- **TimelineProvider** — Protocol supplying timeline entries (placeholder, snapshot, timeline generation) +- **TimelineEntry** — Struct with widget data + display date +- **Timeline Budget** — Daily limit (40-70) for timeline reloads +- **Budget-Exempt** — Reloads that don't count (user-initiated, app foregrounding, system-initiated) +- **Widget Family** — Size/shape (systemSmall, systemMedium, accessoryCircular, etc.) +- **App Groups** — Entitlement for shared data container between app and extensions +- **ActivityAttributes** — Static data (set once) + dynamic ContentState (updated during lifecycle) +- **ContentState** — Changing part of ActivityAttributes; must be under 4KB total +- **Dynamic Island** — iPhone 14 Pro+ Live Activity display; compact, minimal, and expanded sizes +- **ControlWidget** — iOS 18+ widgets for Control Center, Lock Screen, and Action Button +- **Supplemental Activity Families** — Enables Live Activities on Apple Watch or CarPlay + +--- + +# Part 1: Standard Widgets (iOS 14+) + +## Widget Configuration Types + +### StaticConfiguration + +For widgets that don't require user configuration. + +```swift +@main +struct MyWidget: Widget { + let kind: String = "MyWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: Provider()) { entry in + MyWidgetEntryView(entry: entry) + } + .configurationDisplayName("My Widget") + .description("This widget displays...") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} +``` + +### AppIntentConfiguration (iOS 17+) + +For widgets with user configuration using App Intents. + +```swift +struct MyConfigurableWidget: Widget { + let kind: String = "MyConfigurableWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: kind, + intent: SelectProjectIntent.self, + provider: Provider() + ) { entry in + MyWidgetEntryView(entry: entry) + } + .configurationDisplayName("Project Status") + .description("Shows your selected project") + } +} +``` + +**Migration from IntentConfiguration**: iOS 16 and earlier used `IntentConfiguration` with SiriKit intents. Migrate to `AppIntentConfiguration` for iOS 17+. + +### ActivityConfiguration + +For Live Activities (covered in Live Activities section). + +## Choosing the Right Configuration + +No user configuration needed? Use `StaticConfiguration`. Simple static options? Use `AppIntentConfiguration` with `WidgetConfigurationIntent`. Dynamic options from app data? Use `AppIntentConfiguration` + `EntityQuery`. + +**Quick Reference**: +- **StaticConfiguration** — No customization (weather, battery status) +- **AppIntentConfiguration** (simple) — Fixed options (timer presets, theme selection) +- **AppIntentConfiguration** (EntityQuery) — Dynamic list from app data (project/contact/playlist picker) +- **ActivityConfiguration** — Live ongoing events (delivery tracking, workout progress, sports scores) + +## Widget Families + +### System Families (Home Screen) +- **`systemSmall`** (~170×170, iOS 14+) — Single piece of info, icon +- **`systemMedium`** (~360×170, iOS 14+) — Multiple data points, chart +- **`systemLarge`** (~360×380, iOS 14+) — Detailed view, list +- **`systemExtraLarge`** (~720×380, iOS 15+ iPad only) — Rich layouts, multiple views + +### Accessory Families (Lock Screen, iOS 16+) +- **`accessoryCircular`** (~48×48pt) — Circular complication, icon or gauge +- **`accessoryRectangular`** (~160×72pt) — Above clock, text + icon +- **`accessoryInline`** (single line) — Above date, text only + +### Example: Supporting Multiple Families + +```swift +struct MyWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in + if #available(iOSApplicationExtension 16.0, *) { + switch entry.family { + case .systemSmall: + SmallWidgetView(entry: entry) + case .systemMedium: + MediumWidgetView(entry: entry) + case .accessoryCircular: + CircularWidgetView(entry: entry) + case .accessoryRectangular: + RectangularWidgetView(entry: entry) + default: + Text("Unsupported") + } + } else { + LegacyWidgetView(entry: entry) + } + } + .supportedFamilies([ + .systemSmall, + .systemMedium, + .accessoryCircular, + .accessoryRectangular + ]) + } +} +``` + +## Timeline System + +### TimelineProvider Protocol + +Provides entries that define when the system should render your widget. + +```swift +struct Provider: TimelineProvider { + // Placeholder while loading + func placeholder(in context: Context) -> SimpleEntry { + SimpleEntry(date: Date(), emoji: "😀") + } + + // Shown in widget gallery + func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { + let entry = SimpleEntry(date: Date(), emoji: "📷") + completion(entry) + } + + // Actual timeline + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + var entries: [SimpleEntry] = [] + let currentDate = Date() + + // Create entry every hour for 5 hours + for hourOffset in 0 ..< 5 { + let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! + let entry = SimpleEntry(date: entryDate, emoji: "⏰") + entries.append(entry) + } + + let timeline = Timeline(entries: entries, policy: .atEnd) + completion(timeline) + } +} +``` + +### TimelineReloadPolicy + +Controls when the system requests a new timeline: +- **`.atEnd`** — Reload after last entry +- **`.after(date)`** — Reload at specific date +- **`.never`** — No automatic reload (manual only) + +### Manual Reload + +```swift +import WidgetKit + +// Reload all widgets of this kind +WidgetCenter.shared.reloadAllTimelines() + +// Reload specific kind +WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget") +``` + +## Performance & Budget Quick Reference + +### Timeline Refresh Budget +- **Daily budget**: 40-70 reloads/day (varies by system load and engagement) +- **Budget-exempt**: User-initiated reload, app foregrounding, widget added, system reboot +- **Strategic** (4x/hour) — ~48 reloads/day, low battery impact +- **Aggressive** (12x/hour) — Budget exhausted by 6 PM, high impact +- **On-demand only** — 5-10 reloads/day, minimal impact +- Reload on significant data changes and time-based events. Avoid speculative or cosmetic reloads. + +```swift +// ✅ GOOD: Strategic intervals (15-60 min) +let entries = (0..<8).map { offset in + let date = Calendar.current.date(byAdding: .minute, value: offset * 15, to: now)! + return SimpleEntry(date: date, data: data) +} +``` + +### Memory Limits +- ~30MB for standard widgets, ~50MB for Live Activities — system terminates if exceeded +- Load only what you need (e.g., `loadRecentItems(limit: 10)`, not entire database) + +### Network Requests +**Never make network requests in widget views** — they won't complete before rendering. Fetch data in `getTimeline()` instead. + +### Timeline Generation +Complete `getTimeline()` in under 5 seconds. Cache expensive computations in the main app, read pre-computed data from shared container, limit to 10-20 entries. + +### View Rendering +Precompute everything in `TimelineEntry`, keep views simple. No expensive operations in `body`. + +### Images +- Use asset catalog images or SF Symbols (fast) +- Small images from shared container are acceptable +- `AsyncImage` does NOT work in widgets +- Large images cause memory termination + +--- + +# Part 2: Interactive Widgets (iOS 17+) + +## Button and Toggle + +Interactive widgets use SwiftUI `Button` and `Toggle` with App Intents. + +### Button with App Intent + +```swift +Button(intent: IncrementIntent()) { + Label("Increment", systemImage: "plus.circle") +} +``` + +The intent updates shared data via App Groups in its `perform()` method. See **axiom-app-intents-ref** for full `AppIntent` definition syntax. + +### Toggle with App Intent + +Same pattern as Button — use a `Toggle` bound to state, invoke intent on change: + +```swift +Toggle(isOn: $isEnabled) { + Text("Feature") +} +.onChange(of: isEnabled) { newValue in + Task { try? await ToggleFeatureIntent(enabled: newValue).perform() } +} +``` + +The intent follows the same `AppIntent` structure with a `@Parameter(title: "Enabled") var enabled: Bool`. See **axiom-app-intents-ref** for full `AppIntent` definition syntax. + +## invalidatableContent Modifier + +Provides visual feedback during App Intent execution. + +```swift +struct MyWidgetView: View { + var entry: Provider.Entry + + var body: some View { + VStack { + Text(entry.status) + .invalidatableContent() // Dims during intent execution + + Button(intent: RefreshIntent()) { + Image(systemName: "arrow.clockwise") + } + } + } +} +``` + +**Effect**: Content with `.invalidatableContent()` becomes slightly transparent while the associated intent executes, providing user feedback. + +## Animation System + +### contentTransition for Numeric Text + +```swift +Text("\(entry.value)") + .contentTransition(.numericText(value: Double(entry.value))) +``` + +**Effect**: Numbers smoothly count up or down instead of instantly changing. + +### View Transitions + +```swift +VStack { + if entry.showDetail { + DetailView() + .transition(.scale.combined(with: .opacity)) + } +} +.animation(.spring(response: 0.3), value: entry.showDetail) +``` + +--- + +# Part 3: Configurable Widgets (iOS 17+) + +## WidgetConfigurationIntent + +Define configuration parameters for your widget. + +```swift +import AppIntents + +struct SelectProjectIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource = "Select Project" + static var description = IntentDescription("Choose which project to display") + + @Parameter(title: "Project") + var project: ProjectEntity? + + // Provide default value + static var parameterSummary: some ParameterSummary { + Summary("Show \(\.$project)") + } +} +``` + +## Entity and EntityQuery + +Provide dynamic options for configuration. + +```swift +struct ProjectEntity: AppEntity { + var id: String + var name: String + + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Project") + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } +} + +struct ProjectQuery: EntityQuery { + func entities(for identifiers: [String]) async throws -> [ProjectEntity] { + // Return projects matching these IDs + return await ProjectStore.shared.projects(withIDs: identifiers) + } + + func suggestedEntities() async throws -> [ProjectEntity] { + // Return all available projects + return await ProjectStore.shared.allProjects() + } +} +``` + +## Using Configuration in Provider + +```swift +struct Provider: AppIntentTimelineProvider { + func timeline(for configuration: SelectProjectIntent, in context: Context) async -> Timeline { + let project = configuration.project // Use selected project + let entries = await generateEntries(for: project) + return Timeline(entries: entries, policy: .atEnd) + } +} +``` + +--- + +# Part 4: Live Activities (iOS 16.1+) + +## ActivityAttributes + +Defines static and dynamic data for a Live Activity. + +```swift +import ActivityKit + +struct PizzaDeliveryAttributes: ActivityAttributes { + // Static data - set when activity starts, never changes + struct ContentState: Codable, Hashable { + // Dynamic data - updated throughout activity lifecycle + var status: DeliveryStatus + var estimatedDeliveryTime: Date + var driverName: String? + } + + // Static attributes + var orderNumber: String + var pizzaType: String +} +``` + +**Key constraint**: `ActivityAttributes` total data size must be under **4KB** to start successfully. + +## Starting Activities + +### Request Authorization + +```swift +import ActivityKit + +let authorizationInfo = ActivityAuthorizationInfo() +let areActivitiesEnabled = authorizationInfo.areActivitiesEnabled +``` + +### Start an Activity + +```swift +let attributes = PizzaDeliveryAttributes( + orderNumber: "12345", + pizzaType: "Pepperoni" +) + +let initialState = PizzaDeliveryAttributes.ContentState( + status: .preparing, + estimatedDeliveryTime: Date().addingTimeInterval(30 * 60) +) + +let activity = try Activity.request( + attributes: attributes, + content: ActivityContent(state: initialState, staleDate: nil), + pushType: nil // or .token for push notifications +) +``` + +## Error Handling + +### Common Activity Errors + +Always check `ActivityAuthorizationInfo().areActivitiesEnabled` before requesting. Handle these errors from `Activity.request()`: + +- **`ActivityAuthorizationError`** — User denied Live Activities permission +- **`ActivityError.dataTooLarge`** — ActivityAttributes exceeds 4KB; reduce attribute size +- **`ActivityError.tooManyActivities`** — System limit reached (typically 2-3 simultaneous) + +Store `activity.id` after successful request for later updates. + +## Updating Activities + +### Update with New Content + +```swift +// Find active activity by stored ID +guard let activity = Activity.activities + .first(where: { $0.id == storedActivityID }) else { return } + +let updatedState = PizzaDeliveryAttributes.ContentState( + status: .onTheWay, + estimatedDeliveryTime: Date().addingTimeInterval(10 * 60), + driverName: "John" +) + +await activity.update( + ActivityContent( + state: updatedState, + staleDate: Date().addingTimeInterval(60) // Mark stale after 1 min + ) +) +``` + +### Alert Configuration + +```swift +await activity.update(updatedContent, alertConfiguration: AlertConfiguration( + title: "Pizza is here!", + body: "Your \(attributes.pizzaType) pizza has arrived", + sound: .default +)) +``` + +### Monitoring Activity Lifecycle + +Use `activity.activityStateUpdates` async sequence to observe state changes (`.active`, `.ended`, `.dismissed`, `.stale`). Clean up stored activity IDs on `.ended` or `.dismissed`. Cancel the monitoring task in `deinit`. + +## Ending Activities + +### Dismissal Policies + +```swift +await activity.end( + ActivityContent(state: finalState, staleDate: nil), + dismissalPolicy: .default +) +``` + +Dismissal policy options: +- **`.immediate`** — Removes instantly +- **`.default`** — Stays on Lock Screen for ~4 hours +- **`.after(date)`** — Removes at specific time (e.g., `.after(Date().addingTimeInterval(3600))`) + +## Push Notifications for Live Activities + +### Request Push Token + +```swift +let activity = try Activity.request( + attributes: attributes, + content: initialContent, + pushType: .token // Request push token +) + +// Monitor for push token +for await pushToken in activity.pushTokenUpdates { + let tokenString = pushToken.map { String(format: "%02x", $0) }.joined() + // Send to your server + await sendTokenToServer(tokenString, activityID: activity.id) +} +``` + +### Frequent Push Updates (iOS 18.2+) + +Standard limit is ~10-12 pushes/hour. For live events (sports, stocks), add the `com.apple.developer.activity-push-notification-frequent-updates` entitlement for significantly higher limits. + +--- + +# Part 5: Dynamic Island (iOS 16.1+) + +## Presentation Types + +Live Activities appear in the Dynamic Island with three size classes: + +### Compact (Leading + Trailing) + +Shown when another Live Activity is expanded or when multiple activities are active. + +```swift +DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Image(systemName: "timer") + } + DynamicIslandExpandedRegion(.trailing) { + Text("\(entry.timeRemaining)") + } + // ... +} compactLeading: { + Image(systemName: "timer") +} compactTrailing: { + Text("\(entry.timeRemaining)") + .frame(width: 40) +} +``` + +### Minimal + +Shown when more than two Live Activities are active (circular avatar). + +```swift +DynamicIsland { + // ... +} minimal: { + Image(systemName: "timer") + .foregroundStyle(.tint) +} +``` + +### Expanded + +Shown when user long-presses the compact view. + +```swift +DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Image(systemName: "timer") + .font(.title) + } + + DynamicIslandExpandedRegion(.trailing) { + VStack(alignment: .trailing) { + Text("\(entry.timeRemaining)") + .font(.title2.monospacedDigit()) + Text("remaining") + .font(.caption) + } + } + + DynamicIslandExpandedRegion(.center) { + // Optional center content + } + + DynamicIslandExpandedRegion(.bottom) { + HStack { + Button(intent: PauseIntent()) { + Label("Pause", systemImage: "pause.fill") + } + Button(intent: StopIntent()) { + Label("Stop", systemImage: "stop.fill") + } + } + } +} +``` + +## Design Principles (From WWDC 2023-10194) + +### Concentric Alignment + +Content should nest concentrically inside the Dynamic Island's rounded shape with even margins. Use `Circle()` or `RoundedRectangle(cornerRadius:)` — never sharp `Rectangle()` which pokes into corners. + +### Biological Motion + +Dynamic Island animations should feel organic and elastic. Use `.spring(response: 0.6, dampingFraction: 0.7)` or `.interpolatingSpring(stiffness: 300, damping: 25)` instead of linear animations. + +--- + +# Part 6: Control Center Widgets (iOS 18+) + +## ControlWidget Protocol + +Controls appear in Control Center, Lock Screen, and Action Button (iPhone 15 Pro+). + +### StaticControlConfiguration + +For simple controls without configuration. + +```swift +import WidgetKit +import AppIntents + +struct TorchControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: "TorchControl") { + ControlWidgetButton(action: ToggleTorchIntent()) { + Label("Flashlight", systemImage: "flashlight.on.fill") + } + } + .displayName("Flashlight") + .description("Toggle flashlight") + } +} +``` + +### AppIntentControlConfiguration + +For configurable controls. + +```swift +struct TimerControl: ControlWidget { + var body: some ControlWidgetConfiguration { + AppIntentControlConfiguration( + kind: "TimerControl", + intent: ConfigureTimerIntent.self + ) { configuration in + ControlWidgetButton(action: StartTimerIntent(duration: configuration.duration)) { + Label("\(configuration.duration)m Timer", systemImage: "timer") + } + } + } +} +``` + +## ControlWidgetButton + +For discrete actions (one-shot operations). + +```swift +ControlWidgetButton(action: PlayMusicIntent()) { + Label("Play", systemImage: "play.fill") +} +.tint(.purple) +``` + +## ControlWidgetToggle + +For boolean state. + +```swift +struct AirplaneModeControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: "AirplaneModeControl") { + ControlWidgetToggle( + isOn: AirplaneModeIntent.isEnabled, + action: AirplaneModeIntent() + ) { isOn in + Label(isOn ? "On" : "Off", systemImage: "airplane") + } + } + } +} +``` + +## Value Providers (Async State) + +For controls needing async state, pass a `ControlValueProvider` to `StaticControlConfiguration`: + +```swift +struct ThermostatProvider: ControlValueProvider { + func currentValue() async throws -> ThermostatValue { + let temp = try await HomeManager.shared.currentTemperature() + return ThermostatValue(temperature: temp) + } + var previewValue: ThermostatValue { ThermostatValue(temperature: 72) } +} +``` + +The provider value is passed to your control's closure: `{ value in ControlWidgetButton(...) }`. + +## Configurable Controls + +Use `AppIntentControlConfiguration` with a `WidgetConfigurationIntent` (same pattern as configurable widgets). Add `.promptsForUserConfiguration()` to show configuration UI when the user adds the control. + +## Control Refinements + +- `.controlWidgetActionHint("Toggles flashlight")` — VoiceOver accessibility hint +- `.displayName("My Control")` / `.description("...")` — Shown in Control Center UI + +--- + +# Part 7: iOS 18+ Updates + +## Accented Rendering and Liquid Glass + +Widget rendering modes span multiple iOS versions: `widgetAccentable()` (iOS 16+), `WidgetAccentedRenderingMode` (iOS 18+), and Liquid Glass effects like `glassEffect()` and `GlassEffectContainer` (iOS 26+). Detect the mode and adapt layout accordingly. + +### Detecting Rendering Mode + +```swift +struct MyWidgetView: View { + @Environment(\.widgetRenderingMode) var renderingMode + + var body: some View { + if renderingMode == .accented { + // Simplified layout — opaque images tinted white, background replaced with glass + } else { + // Standard full-color layout + } + } +} +``` + +### widgetAccentable(_:) + +Marks views as part of the **accent group**. In accented mode, accent-group views are tinted separately from primary-group views, creating visual hierarchy. + +```swift +HStack { + VStack(alignment: .leading) { + Text("Title") + .font(.headline) + .widgetAccentable() // Accent group — tinted in accented mode + Text("Subtitle") + // Primary group by default + } + Image(systemName: "star.fill") + .widgetAccentable() // Also accent group +} +``` + +### WidgetAccentedRenderingMode + +Controls how images render in accented mode. Apply to `Image` views: + +```swift +Image("myPhoto") + .widgetAccentedRenderingMode(.accented) // Tinted with accent color +Image("myIcon") + .widgetAccentedRenderingMode(.monochrome) // Rendered as monochrome +Image("myBadge") + .widgetAccentedRenderingMode(.fullColor) // Keeps original colors (opt-out) +``` + +**Best practices**: Display full-color images only in `.fullColor` rendering mode. Use `.widgetAccentable()` strategically for visual hierarchy. Test with multiple accent colors and background images. + +### Container Backgrounds + +```swift +VStack { /* content */ } + .containerBackground(for: .widget) { + Color.blue.opacity(0.2) + } +``` + +In accented mode, the system removes the background and replaces it with themed glass. To prevent removal (excludes widget from iPad Lock Screen, StandBy): + +```swift +.containerBackgroundRemovable(false) +``` + +### Liquid Glass in Custom Widget Elements + +```swift +Text("Label") + .padding() + .glassEffect() // Default capsule shape + +Image(systemName: "star.fill") + .frame(width: 60, height: 60) + .glassEffect(.regular, in: .rect(cornerRadius: 12)) + +Button("Action") { } + .buttonStyle(.glass) +``` + +Combine multiple glass elements with `GlassEffectContainer`: + +```swift +GlassEffectContainer(spacing: 20.0) { + HStack(spacing: 20.0) { + Image(systemName: "cloud") + .frame(width: 60, height: 60) + .glassEffect() + Image(systemName: "sun") + .frame(width: 60, height: 60) + .glassEffect() + } +} +``` + +## Cross-Platform Support + +### visionOS Widgets (visionOS 2+) + +visionOS widgets are 3D objects placed in physical space — mounted on surfaces or floating. They support unique spatial features. + +#### Mounting Styles + +Widgets can be elevated (on top of surfaces) or recessed (embedded into vertical surfaces like walls): + +```swift +.supportedMountingStyles([.elevated, .recessed]) // Default is both +// .supportedMountingStyles([.recessed]) // Wall-only widget +``` + +If limited to `.recessed`, users cannot place the widget on horizontal surfaces. + +#### Widget Textures + +Two visual textures for spatial appearance: + +```swift +.widgetTexture(.glass) // Default — transparent glass-like appearance +.widgetTexture(.paper) // Poster-like look, effective with extra-large sizes +``` + +#### Proximity Awareness (levelOfDetail) + +Widgets adapt to user distance automatically. The system animates transitions between detail levels: + +```swift +@Environment(\.levelOfDetail) var levelOfDetail + +var body: some View { + VStack { + Text(entry.value) + .font(levelOfDetail == .simplified ? .largeTitle : .title) + } +} +``` + +Values: `.default` (close viewing) and `.simplified` (distance viewing — use larger text, fewer details). + +#### visionOS Widget Families + +visionOS supports all system families plus extra-large sizes: + +```swift +.supportedFamilies([ + .systemSmall, .systemMedium, .systemLarge, + .systemExtraLarge, + .systemExtraLargePortrait // visionOS-specific portrait orientation +]) +``` + +Extra-large families are particularly effective with `.widgetTexture(.paper)` for poster-like displays. + +#### Background Detection + +Detect whether the widget background is visible (removed in accented mode): + +```swift +@Environment(\.showsWidgetContainerBackground) var showsBackground +``` + +### CarPlay (iOS 18+) +Add `.supplementalActivityFamilies([.medium])` to `ActivityConfiguration`. Uses StandBy-style full-width dashboard presentation. + +### macOS Menu Bar +Live Activities from paired iPhone appear automatically in macOS Sequoia+ menu bar. No code changes required. + +### watchOS Controls (11+) +`ControlWidget` works identically on watchOS — available in Control Center, Action Button, and Smart Stack. Same `StaticControlConfiguration` / `ControlWidgetButton` pattern as iOS. + +## Relevance Widgets (iOS 18+) + +Use `.relevanceConfiguration(for:score:attributes:)` to help the system promote widgets in Smart Stack. Attributes include `.location(CLLocation)`, `.timeOfDay(DateInterval)`, and `.activity(String)` for context-aware ranking. + +## Push Notification Updates (iOS 18+) + +Implement `PKPushRegistryDelegate` and handle `.widgetKit` push type to receive server-to-widget pushes. Update shared container data and call `WidgetCenter.shared.reloadAllTimelines()`. Pushes to iPhone automatically sync to Apple Watch and CarPlay. + +--- + +# Part 8: App Groups & Data Sharing + +## App Groups Entitlement + +Required for sharing data between your app and extensions. + +### Configuration + +1. Xcode: Targets → Signing & Capabilities → Add "App Groups" +2. Identifier format: `group.com.company.appname` +3. Enable for BOTH main app target AND extension target + +## Shared Containers + +### Access Shared Container + +```swift +let sharedContainer = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp" +)! + +let dataFileURL = sharedContainer.appendingPathComponent("widgetData.json") +``` + +### UserDefaults with App Groups + +```swift +// Main app - write data +let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")! +shared.set("Updated value", forKey: "myKey") + +// Widget extension - read data +let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")! +let value = shared.string(forKey: "myKey") +``` + +### Core Data with App Groups + +Point `NSPersistentStoreDescription` at the shared container URL: + +```swift +let sharedStoreURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp" +)!.appendingPathComponent("MyApp.sqlite") + +let description = NSPersistentStoreDescription(url: sharedStoreURL) +container.persistentStoreDescriptions = [description] +``` + +## IPC Communication + +- **Background URL Session** — Set `config.sharedContainerIdentifier` to your App Group ID for downloads accessible by extensions +- **Darwin Notification Center** — Use `CFNotificationCenterPostNotification` / `CFNotificationCenterAddObserver` with `CFNotificationCenterGetDarwinNotifyCenter()` for simple cross-process signals (e.g., notify widget to call `WidgetCenter.shared.reloadAllTimelines()`) + +--- + +# Part 9: watchOS Integration + +## supplementalActivityFamilies (watchOS 11+) + +Add `.supplementalActivityFamilies([.small])` to `ActivityConfiguration` to show Live Activities on Apple Watch Smart Stack (same modifier used for CarPlay with `.medium`). + +## activityFamily Environment + +Use `@Environment(\.activityFamily)` to adapt layout — check for `.small` (watchOS) vs iPhone layout. + +## Always On Display + +Use `@Environment(\.isLuminanceReduced)` to simplify views for Always On Display — reduce detail, use white text, larger fonts. Combine with `@Environment(\.colorScheme)` for proper dark mode handling. + +## Update Budgeting (watchOS) + +watchOS updates sync automatically with iPhone via push notifications. Updates may be delayed if watch is out of Bluetooth range. + +--- + +# Part 10: Practical Workflows + +## Building Your First Widget + +For a complete step-by-step tutorial with working code examples, see Apple's [Building Widgets Using WidgetKit and SwiftUI](https://developer.apple.com/documentation/widgetkit/building-widgets-using-widgetkit-and-swiftui) sample project. + +**Key steps**: Add widget extension target, configure App Groups, implement TimelineProvider, design SwiftUI view, update from main app. See Expert Review Checklist below for production requirements. + +--- + +## Expert Review Checklist + +### Before Shipping Widgets + +**Architecture**: +- [ ] App Groups entitlement configured in app AND extension +- [ ] Group identifier matches exactly in both targets +- [ ] Shared container used for ALL data sharing +- [ ] No `UserDefaults.standard` in widget code + +**Performance**: +- [ ] Timeline generation completes in < 5 seconds +- [ ] No network requests in widget views +- [ ] Timeline has reasonable refresh intervals (≥ 15 min) +- [ ] Entry count reasonable (< 20-30 entries) +- [ ] Memory usage under limits (~30MB widgets, ~50MB activities) +- [ ] Images optimized (asset catalog or SF Symbols preferred) + +**Data & State**: +- [ ] Widget handles missing/nil data gracefully +- [ ] Entry dates in chronological order +- [ ] Placeholder view looks reasonable +- [ ] Snapshot view representative of actual use + +**User Experience**: +- [ ] Widget appears in widget gallery +- [ ] configurationDisplayName clear and concise +- [ ] description explains widget purpose +- [ ] All supported families tested and look correct +- [ ] Text readable on both light and dark backgrounds +- [ ] Interactive elements (buttons/toggles) work correctly + +**Live Activities** (if applicable): +- [ ] ActivityAttributes under 4KB +- [ ] Authorization checked before starting +- [ ] Activity ends when event completes +- [ ] Proper dismissal policy set +- [ ] watchOS support configured if relevant (supplementalActivityFamilies) +- [ ] Dynamic Island layouts tested (compact, minimal, expanded) + +**Liquid Glass** (if applicable): +- [ ] `widgetAccentable()` applied for visual hierarchy in accented mode +- [ ] `WidgetAccentedRenderingMode` set on images (`.accented`, `.monochrome`, or `.fullColor`) +- [ ] Tested with multiple accent colors and background images +- [ ] Container background configured with `.containerBackground(for: .widget)` + +**visionOS** (if applicable): +- [ ] Mounting styles configured (`.elevated`, `.recessed`, or both) +- [ ] Widget texture chosen (`.glass` or `.paper`) +- [ ] `levelOfDetail` handled for proximity-aware layouts +- [ ] Extra-large families supported if appropriate (`.systemExtraLarge`, `.systemExtraLargePortrait`) +- [ ] Tested at different distances for proximity transitions + +**Control Center Widgets** (if applicable): +- [ ] ControlValueProvider async and fast (< 1 second) +- [ ] previewValue provides reasonable fallback +- [ ] displayName and description set +- [ ] Tested in Control Center, Lock Screen, Action Button + +**Testing**: +- [ ] Tested on actual device (not just simulator) +- [ ] Tested adding/removing widget +- [ ] Tested app data changes → widget updates +- [ ] Tested force-quit app → widget still works +- [ ] Tested low memory scenarios +- [ ] Tested all iOS versions you support +- [ ] Tested with no internet connection + +--- + +## Testing Guidance + +### Unit Testing Pattern + +Test `placeholder()`, `getSnapshot()`, and `getTimeline()` methods. Save test data to shared container, call `getTimeline()` with a mock context, assert entries are non-empty and contain expected data. Use `waitForExpectations(timeout: 5.0)` for async timeline generation. + +### Manual Testing Checklist +- Add widget to Home Screen, verify widget gallery, all supported sizes, data matches app +- Change data in main app, observe widget updates, force-quit app, reboot device +- Delete all app data (graceful handling), disable network (offline), Low Power Mode, multiple instances +- Monitor memory in Xcode Debug Navigator, check timeline generation time in Console, test on older devices + +### Debugging Tips +- Add `print()` logging in `getTimeline()` to verify it's being called and data is loaded +- Verify App Groups: print `FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)` in both app and widget — paths must match +- After data changes in main app, call `WidgetCenter.shared.reloadAllTimelines()` + +--- + +# Part 11: Troubleshooting + +**Widget not appearing in gallery**: Check `WidgetBundle` includes it, verify `supportedFamilies()`, check extension's "Skip Install" = NO, verify deployment target matches app. + +## Widget Not Refreshing + +**Symptoms**: Widget shows stale data, doesn't update + +**Diagnostic Steps**: +1. Check timeline policy (`.atEnd` vs `.after()` vs `.never`) +2. Verify you're not exceeding daily budget (40-70 reloads) +3. Check if `getTimeline()` is being called (add logging) +4. Ensure App Groups configured correctly for shared data + +**Solution**: +```swift +// Manual reload from main app when data changes +import WidgetKit + +WidgetCenter.shared.reloadAllTimelines() +// or +WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget") +``` + +## Data Not Shared Between App and Widget + +**Symptoms**: Widget shows default/empty data + +**Diagnostic Steps**: +1. Verify App Groups entitlement in BOTH targets +2. Check group identifier matches exactly +3. Ensure using same suiteName in both targets +4. Check file path if using shared container + +**Solution**: +```swift +// Both app AND extension must use: +let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")! + +// NOT: +let shared = UserDefaults.standard // ❌ Different containers +``` + +## Live Activity Won't Start + +**Symptoms**: `Activity.request()` throws error + +**Common Errors**: + +**"Activity size exceeds 4KB"**: +```swift +// ❌ BAD: Large images in attributes +struct MyAttributes: ActivityAttributes { + var productImage: UIImage // Too large! +} + +// ✅ GOOD: Use asset catalog names +struct MyAttributes: ActivityAttributes { + var productImageName: String // Reference to asset +} +``` + +**"Activities not enabled"**: +```swift +// Check authorization first +let authInfo = ActivityAuthorizationInfo() +guard authInfo.areActivitiesEnabled else { + throw ActivityError.notEnabled +} +``` + +## Interactive Widget Button Not Working + +**Symptoms**: Tapping button does nothing + +**Diagnostic Steps**: +1. Verify App Intent's `perform()` returns `IntentResult` +2. Check intent is imported in widget target +3. Ensure button uses `intent:` parameter, not `action:` +4. Check Console for intent execution errors + +**Solution**: +```swift +// ✅ CORRECT: Use intent parameter +Button(intent: MyIntent()) { + Label("Action", systemImage: "star") +} + +// ❌ WRONG: Don't use action closure +Button(action: { /* This won't work in widgets */ }) { + Label("Action", systemImage: "star") +} +``` + +**Control Center widget slow**: Use async in `ControlValueProvider.currentValue()`, never block with `Thread.sleep`. Provide fast `previewValue` fallback. + +**Widget shows wrong size**: Switch on `@Environment(\.widgetFamily)` in view, adapt layout per family, avoid hardcoded sizes. + +**Timeline entries out of order**: Ensure entry dates are chronological. Use incrementing offsets from `Date()`. + +**watchOS Live Activity not showing**: Add `.supplementalActivityFamilies([.small])` to `ActivityConfiguration`, verify watchOS 11+, check Bluetooth/pairing. + +## Performance Issues + +**Symptoms**: Widget rendering slow, battery drain + +**Common Causes**: +- Too many timeline entries (> 100) +- Network requests in view code +- Heavy computation in `getTimeline()` +- Refresh intervals too frequent (< 15 min) + +**Solution**: +```swift +// ✅ GOOD: Strategic intervals +let entries = (0..<8).map { offset in + let date = Calendar.current.date(byAdding: .minute, value: offset * 15, to: now)! + return SimpleEntry(date: date, data: precomputedData) +} + +// ❌ BAD: Too frequent, too many entries +let entries = (0..<100).map { offset in + let date = Calendar.current.date(byAdding: .minute, value: offset, to: now)! + return SimpleEntry(date: date, data: fetchFromNetwork()) // Network in timeline +} +``` + +--- + +## Debugging Widgets + +### Simulator vs Device + +- **Simulator**: Widgets refresh immediately; no budget limits apply. Useful for layout testing but misleading for refresh behavior. +- **Device**: Budget-limited (40-70 reloads/day). Test on device before shipping to verify real-world refresh timing. +- **Xcode Previews**: Work for layout but skip `getTimeline()`. Test timeline logic with unit tests or device runs. + +### Common Debugging Workflow + +1. Add `print()` in `getTimeline()` — verify it's called and data loads +2. Check Console.app filtered by widget extension process name +3. Use `WidgetCenter.shared.getCurrentConfigurations()` to verify registration +4. If widget shows old data after app update, verify App Groups container paths match + +### Data Sharing Patterns + +**SwiftData in Widgets** (iOS 17+): +- Create `ModelContainer` in widget with same schema as main app +- Use shared App Groups container: `ModelConfiguration(url: containerURL)` +- Widget reads only — never write from widget to avoid conflicts +- Main app calls `WidgetCenter.shared.reloadAllTimelines()` after writes + +**GRDB/SQLite in Widgets**: +- Share database file via App Groups container +- Use `DatabasePool` (not `DatabaseQueue`) for concurrent reads +- Widget opens read-only connection: `try DatabasePool(path: dbPath, configuration: readOnlyConfig)` +- Set `configuration.readonly = true` in widget to prevent accidental writes + +--- + +## Resources + +**WWDC**: 2025-278, 2024-10157, 2024-10068, 2024-10098, 2023-10028, 2023-10194, 2022-10184, 2022-10185 + +**Docs**: /widgetkit, /activitykit, /appintents + +**Skills**: axiom-app-intents-ref, axiom-swift-concurrency, axiom-swiftui-performance, axiom-swiftui-layout, axiom-extensions-widgets + +--- + +**Version**: 0.9 | **Platforms**: iOS 14+, iPadOS 14+, watchOS 9+, macOS 11+, visionOS 2+ diff --git a/.claude/skills/axiom-extensions-widgets-ref/agents/openai.yaml b/.claude/skills/axiom-extensions-widgets-ref/agents/openai.yaml new file mode 100644 index 0000000..8e8c100 --- /dev/null +++ b/.claude/skills/axiom-extensions-widgets-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Extensions Widgets Reference" + short_description: "Implementing widgets, Live Activities, Control Center controls, or app extensions" diff --git a/.claude/skills/axiom-extensions-widgets/.openskills.json b/.claude/skills/axiom-extensions-widgets/.openskills.json new file mode 100644 index 0000000..e071183 --- /dev/null +++ b/.claude/skills/axiom-extensions-widgets/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-extensions-widgets", + "installedAt": "2026-04-12T08:06:15.831Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-extensions-widgets/SKILL.md b/.claude/skills/axiom-extensions-widgets/SKILL.md new file mode 100644 index 0000000..7e087ba --- /dev/null +++ b/.claude/skills/axiom-extensions-widgets/SKILL.md @@ -0,0 +1,1054 @@ +--- +name: axiom-extensions-widgets +description: Use when implementing widgets, Live Activities, or Control Center controls - enforces correct patterns for timeline management, data sharing, and extension lifecycle to prevent common crashes and memory issues +license: MIT +compatibility: iOS 14+, iPadOS 14+, watchOS 9+ +metadata: + version: "1.0.0" +--- + +# Extensions & Widgets — Discipline + +## Core Philosophy + +> "Widgets are not mini apps. They're glanceable views into your app's data, rendered at strategic moments and displayed by the system. Extensions run in sandboxed environments with limited memory and execution time." + +**Mental model**: Think of widgets as **archived snapshots** on a timeline, not live views. Your widget doesn't "run" continuously — it renders, gets archived, and the system displays the snapshot. + +**Extension sandboxing**: Extensions have: +- Limited memory (~30MB) +- No network access in widget views (fetch in TimelineProvider only) +- Separate bundle container from main app +- Require App Groups for data sharing + +## When to Use This Skill + +✅ **Use this skill when**: +- Implementing any widget (Home Screen, Lock Screen, StandBy, Control Center) +- Creating Live Activities +- Debugging why widgets show stale data +- Widget not appearing in gallery +- Interactive buttons not responding +- Live Activity fails to start +- Control Center control is unresponsive +- Sharing data between app and widget/extension + +❌ **Do NOT use this skill for**: +- Pure App Intents implementation (use **app-intents-ref**) +- SwiftUI layout questions (use **swiftui-layout**) +- Performance profiling (use **swiftui-performance**) +- General debugging (use **xcode-debugging**) + +## Related Skills + +- **extensions-widgets-ref** — Comprehensive API reference +- **app-intents-ref** — App Intents for interactive widgets +- **swift-concurrency** — Async patterns for data fetching +- **swiftdata** — Using SwiftData with App Groups + +## Example Prompts + +#### 1. "My widget isn't updating" +→ This skill covers timeline policies, refresh budgets, manual reload, and App Groups configuration + +#### 2. "How do I share data between app and widget?" +→ This skill explains App Groups entitlement, shared UserDefaults, and container URLs + +#### 3. "Widget shows old data even after I update the app" +→ This skill covers container paths, UserDefaults suite names, and WidgetCenter reload + +#### 4. "Live Activity fails to start" +→ This skill covers 4KB data limit, ActivityAttributes constraints, authorization checks + +#### 5. "Control Center control takes forever to respond" +→ This skill covers async ValueProvider patterns and optimistic UI + +#### 6. "Interactive widget button does nothing" +→ This skill covers App Intent perform() implementation and WidgetCenter reload + +--- + +# Red Flags / Anti-Patterns + +## Pattern 1: Network Calls in Widget View + +**Time cost**: 2-4 hours debugging why widgets are blank or show errors + +### Symptom +- Widget renders but shows no data +- Console errors: "NSURLSession not available in widget extension" +- Widget appears blank intermittently + +### ❌ BAD Code + +```swift +struct MyWidgetView: View { + @State private var data: String? + + var body: some View { + VStack { + if let data = data { + Text(data) + } + } + .onAppear { + // ❌ WRONG — Network in widget view + Task { + let (data, _) = try await URLSession.shared.data(from: apiURL) + self.data = String(data: data, encoding: .utf8) + } + } + } +} +``` + +**Why it fails**: Widget views are rendered, archived, and reused. Network calls in views are unreliable and may not execute. + +### ✅ GOOD Code + +```swift +// Main app — prefetch and save +func updateWidgetData() async { + let data = try await fetchFromAPI() + let shared = UserDefaults(suiteName: "group.com.myapp")! + shared.set(data, forKey: "widgetData") + + WidgetCenter.shared.reloadAllTimelines() +} + +// Widget TimelineProvider — read from shared storage +struct Provider: TimelineProvider { + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + let shared = UserDefaults(suiteName: "group.com.myapp")! + let data = shared.string(forKey: "widgetData") ?? "No data" + + let entry = SimpleEntry(date: Date(), data: data) + let timeline = Timeline(entries: [entry], policy: .atEnd) + completion(timeline) + } +} +``` + +**Pattern**: Fetch data in main app, save to shared storage, read in widget. + +**Can TimelineProvider make network requests?** + +Yes, but with important caveats: + +```swift +struct Provider: TimelineProvider { + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + Task { + // ✅ Network requests ARE allowed here + let data = try await fetchFromAPI() + let entry = SimpleEntry(date: Date(), data: data) + completion(Timeline(entries: [entry], policy: .atEnd)) + } + } +} +``` + +**Constraints**: +- **30-second timeout** - System kills extension if getTimeline() doesn't complete +- **No background sessions** - Can't download large files +- **Battery cost** - Every timeline reload uses battery +- **Not guaranteed** - May fail on poor connections + +**Best practice**: Prefetch in main app (faster, more reliable), use TimelineProvider network as fallback only. + +--- + +## Pattern 2: Missing App Groups + +**Time cost**: 1-2 hours debugging why widget shows empty/default data + +### Symptom +- Widget always shows placeholder or default values +- Changes in main app don't reflect in widget +- UserDefaults reads return nil in widget + +### ❌ BAD Code + +```swift +// Main app +UserDefaults.standard.set("Updated", forKey: "myKey") + +// Widget extension +let value = UserDefaults.standard.string(forKey: "myKey") // Returns nil! +``` + +**Why it fails**: `UserDefaults.standard` accesses different containers in app vs. extension. + +### ✅ GOOD Code + +```swift +// 1. Enable App Groups entitlement in BOTH targets: +// - Main app target: Signing & Capabilities → + App Groups → "group.com.myapp" +// - Widget extension target: Same group identifier + +// 2. Main app +let shared = UserDefaults(suiteName: "group.com.myapp")! +shared.set("Updated", forKey: "myKey") + +// 3. Widget extension +let shared = UserDefaults(suiteName: "group.com.myapp")! +let value = shared.string(forKey: "myKey") // Returns "Updated" +``` + +**Verification**: +```swift +let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "group.com.myapp" +) +print("Shared container: \(containerURL?.path ?? "MISSING")") +// Should print path, not "MISSING" +``` + +--- + +## Pattern 3: Over-Refreshing (Budget Exhaustion) + +**Time cost**: Poor user experience, battery drain, widgets stop updating + +### Symptom +- Widget updates frequently at first, then stops +- Console logs: "Timeline reload budget exhausted" +- Widget becomes stale after a few hours + +### ❌ BAD Code + +```swift +func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + var entries: [SimpleEntry] = [] + + // ❌ WRONG — 60 entries at 1-minute intervals + for minuteOffset in 0..<60 { + let date = Calendar.current.date(byAdding: .minute, value: minuteOffset, to: Date())! + entries.append(SimpleEntry(date: date, data: "Data")) + } + + let timeline = Timeline(entries: entries, policy: .atEnd) + completion(timeline) +} +``` + +**Why it's bad**: System gives 40-70 reloads/day. This approach uses 24 reloads/hour → exhausts budget in 2-3 hours. + +### ✅ GOOD Code + +```swift +func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + var entries: [SimpleEntry] = [] + + // ✅ CORRECT — 8 entries at 15-minute intervals (2 hours coverage) + for offset in 0..<8 { + let date = Calendar.current.date(byAdding: .minute, value: offset * 15, to: Date())! + entries.append(SimpleEntry(date: date, data: getData())) + } + + let timeline = Timeline(entries: entries, policy: .atEnd) + completion(timeline) +} +``` + +**Guidelines**: +- 15-60 minute intervals for most widgets +- 5-15 minutes for time-sensitive data (stocks, sports) +- Use `.atEnd` policy for automatic reload +- Let system decide optimal refresh based on user engagement + +--- + +## Pattern 4: Blocking Main Thread in Controls + +**Time cost**: Control Center control unresponsive, poor UX + +### Symptom +- Tapping control in Control Center shows spinner for seconds +- Control seems "stuck" or frozen +- No immediate visual feedback + +### ❌ BAD Code + +```swift +struct ThermostatControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: "Thermostat") { + ControlWidgetButton(action: GetTemperatureIntent()) { + // ❌ WRONG — Synchronous fetch blocks UI + let temp = HomeManager.shared.currentTemperature() // Blocking call + Label("\(temp)°", systemImage: "thermometer") + } + } + } +} +``` + +**Why it's bad**: Button renders on main thread. Blocking network/database calls freeze UI. + +### ✅ GOOD Code + +```swift +struct ThermostatProvider: ControlValueProvider { + func currentValue() async throws -> ThermostatValue { + // ✅ CORRECT — Async fetch, non-blocking + let temp = try await HomeManager.shared.fetchTemperature() + return ThermostatValue(temperature: temp) + } + + var previewValue: ThermostatValue { + ThermostatValue(temperature: 72) // Instant fallback + } +} + +struct ThermostatValue: ControlValueProviderValue { + var temperature: Int +} + +struct ThermostatControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: "Thermostat", provider: ThermostatProvider()) { value in + ControlWidgetButton(action: AdjustTemperatureIntent()) { + Label("\(value.temperature)°", systemImage: "thermometer") + } + } + } +} +``` + +**Pattern**: Use `ControlValueProvider` for async data, provide instant `previewValue` fallback. + +--- + +## Pattern 5: Missing Dismissal Policy (Zombie Live Activities) + +**Time cost**: User annoyance, negative reviews + +### Symptom +- Live Activities stay on Lock Screen for hours after event ends +- Users must manually dismiss completed activities +- Activity shows "Delivered" but won't disappear + +### ❌ BAD Code + +```swift +// Start activity +let activity = try Activity.request(attributes: attributes, content: initialContent) + +// Later... event completes +// ❌ WRONG — Never call .end() +// Activity stays forever until user dismisses +``` + +**Why it's bad**: Activities persist indefinitely unless explicitly ended. + +### ✅ GOOD Code + +```swift +// When event completes +let finalState = DeliveryAttributes.ContentState( + status: .delivered, + deliveredAt: Date() +) + +await activity.end( + ActivityContent(state: finalState, staleDate: nil), + dismissalPolicy: .default // Removes after ~4 hours +) + +// Or for immediate removal +await activity.end(nil, dismissalPolicy: .immediate) + +// Or remove at specific time +let dismissTime = Date().addingTimeInterval(30 * 60) // 30 min +await activity.end(nil, dismissalPolicy: .after(dismissTime)) +``` + +**Best practices**: +- `.immediate` — Transient events (timer completed, song finished) +- `.default` — Most activities (shows "completed" state for ~4 hours) +- `.after(date)` — Specific end time (meeting ends, flight lands) + +--- + +## Pattern 6: Exceeding 4KB Data Limit (Live Activities) + +**Time cost**: Activity fails to start silently, hard to debug + +### Symptom +- `Activity.request()` throws error +- Console: "Activity attributes exceed size limit" +- Activity never appears on Lock Screen + +### ❌ BAD Code + +```swift +struct GameAttributes: ActivityAttributes { + struct ContentState: Codable, Hashable { + var teamALogo: Data // ❌ Large image data + var teamBLogo: Data + var playByPlay: [String] // ❌ Unbounded array + var statistics: [String: Any] // ❌ Large dictionary + } + + var gameID: String + var venueName: String +} + +// Fails if total size > 4KB +let activity = try Activity.request(attributes: attrs, content: content) +``` + +**Why it fails**: ActivityAttributes + ContentState combined must be < 4KB. + +### ✅ GOOD Code + +```swift +struct GameAttributes: ActivityAttributes { + struct ContentState: Codable, Hashable { + var teamAScore: Int // ✅ Small primitives + var teamBScore: Int + var quarter: Int + var timeRemaining: String // "2:34" + var lastPlay: String? // Single most recent play + } + + var gameID: String // ✅ Reference, not full data + var teamAName: String + var teamBName: String +} + +// Use asset catalog for images in view +struct GameLiveActivityView: View { + var context: ActivityViewContext + + var body: some View { + HStack { + Image(context.attributes.teamAName) // Asset catalog + Text("\(context.state.teamAScore)") + // ... + } + } +} +``` + +**Strategies**: +- Store IDs/references, not full objects +- Use asset catalogs for images (not embedded Data) +- Keep ContentState minimal (only changeable data) +- Use computed properties in views for derived data + +### Size Targets (Safety Margins) + +**Hard limit**: 4096 bytes (4KB) + +**Target guidance**: +- ✅ **< 2KB**: Safe with room to grow - recommended for v1.0 +- ⚠️ **2-3KB**: Acceptable but monitor closely as you add features +- 🔴 **3.5KB+**: Risky - future fields may push you over limit + +**Why safety margins matter**: You'll add fields later (new features, more data). Starting at 3.8KB leaves zero room for growth. + +**Checking size**: +```swift +let attributes = GameAttributes(gameID: "123", teamAName: "Hawks", teamBName: "Eagles") +let state = GameAttributes.ContentState(teamAScore: 14, teamBScore: 10, quarter: 2, timeRemaining: "5:23", lastPlay: nil) + +let encoder = JSONEncoder() +if let attributesData = try? encoder.encode(attributes), + let stateData = try? encoder.encode(state) { + let totalSize = attributesData.count + stateData.count + print("Total size: \(totalSize) bytes") + + if totalSize < 2048 { + print("✅ Safe with room to grow") + } else if totalSize < 3072 { + print("⚠️ Acceptable but monitor") + } else if totalSize < 3584 { + print("🔴 Risky - optimize now") + } else { + print("❌ CRITICAL - will likely fail") + } +} +``` + +**Optimization priorities** (when over 2KB): +1. Replace `String` descriptions with enums (if fixed set) +2. Shorten string values ("Team A" → "A") +3. Use smaller types (Int → Int8 if range allows) +4. Remove optional fields that are rarely used + +--- + +## Pattern 7: Widget Not Appearing in Gallery + +**Time cost**: 30 minutes debugging invisible widget + +### Symptom +- Widget builds successfully +- No errors in console +- Widget doesn't appear in widget picker/gallery +- Can't add to Home Screen + +### ❌ BAD Code + +```swift +@main +struct MyWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in + MyWidgetView(entry: entry) + } + .configurationDisplayName("My Widget") + .description("Shows data") + // ❌ MISSING: supportedFamilies() — widget won't appear! + } +} +``` + +**Why it fails**: Without supportedFamilies(), system doesn't know which sizes to offer. + +### ✅ GOOD Code + +```swift +@main +struct MyWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in + MyWidgetView(entry: entry) + } + .configurationDisplayName("My Widget") + .description("Shows data") + .supportedFamilies([.systemSmall, .systemMedium]) // ✅ Required + } +} +``` + +**Other common causes**: +- Widget target's "Skip Install" set to YES (should be NO) +- Widget extension not added to app's "Embed App Extensions" +- Clean build folder needed (`Cmd+Shift+K`) + +--- + +# Decision Tree + +``` +Widget/Extension Issue? +│ +├─ Widget not appearing in gallery? +│ ├─ Check WidgetBundle registered in @main +│ ├─ Verify supportedFamilies() includes intended families +│ └─ Clean build folder, restart Xcode +│ +├─ Widget not refreshing? +│ ├─ Timeline policy set to .never? +│ │ └─ Change to .atEnd or .after(date) +│ ├─ Budget exhausted? (too frequent reloads) +│ │ └─ Increase interval between entries (15-60 min) +│ └─ Manual reload +│ └─ WidgetCenter.shared.reloadAllTimelines() +│ +├─ Widget shows empty/old data? +│ ├─ App Groups configured in BOTH targets? +│ │ ├─ No → Add "App Groups" entitlement +│ │ └─ Yes → Verify same group ID +│ ├─ Using UserDefaults.standard? +│ │ └─ Change to UserDefaults(suiteName: "group.com.myapp") +│ └─ Shared container path correct? +│ └─ Print containerURL, verify not nil +│ +├─ Interactive button not working? +│ ├─ App Intent perform() returns value? +│ │ └─ Must return IntentResult +│ ├─ perform() updates shared data? +│ │ └─ Update App Group storage +│ └─ Calls WidgetCenter.reloadTimelines()? +│ └─ Reload to reflect changes +│ +├─ Live Activity fails to start? +│ ├─ Data size > 4KB? +│ │ └─ Reduce ActivityAttributes + ContentState +│ ├─ Authorization enabled? +│ │ └─ Check ActivityAuthorizationInfo().areActivitiesEnabled +│ └─ pushType correct? +│ └─ nil for local updates, .token for push +│ +├─ Control Center control unresponsive? +│ ├─ Async operation blocking UI? +│ │ └─ Use ControlValueProvider with async currentValue() +│ └─ Provide previewValue for instant fallback +│ +└─ watchOS Live Activity not showing? + ├─ supplementalActivityFamilies includes .small? + └─ Apple Watch paired and in range? +``` + +--- + +# Mandatory First Steps + +Before debugging any widget or extension issue, complete this checklist: + +## Widget Debugging Checklist + +- ☐ **App Groups enabled** in BOTH main app AND extension targets + ```bash + # Verify entitlements + codesign -d --entitlements - /path/to/YourApp.app + # Should show com.apple.security.application-groups + ``` + +- ☐ **Widget in Widget Gallery** (not just on Home Screen) + - Long-press Home Screen → + button → Find your widget + - Verify it appears with correct name and description + +- ☐ **Console logs** for timeline errors + ```bash + # Xcode Console + # Filter: "widget" OR "timeline" + # Look for: "Timeline reload failed", "Budget exhausted" + ``` + +- ☐ **Manual reload test** + ```swift + WidgetCenter.shared.reloadAllTimelines() + ``` + - If this fixes it → problem is timeline policy or refresh budget + +- ☐ **Shared container accessible** + ```swift + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "group.com.myapp" + ) + print("Container: \(container?.path ?? "NIL")") + // Must print valid path, not "NIL" + ``` + +## Live Activity Debugging Checklist + +- ☐ **ActivityAttributes < 4KB** + ```swift + let encoded = try JSONEncoder().encode(attributes) + print("Size: \(encoded.count) bytes") // Must be < 4096 + ``` + +- ☐ **Authorization check** + ```swift + let authInfo = ActivityAuthorizationInfo() + print("Enabled: \(authInfo.areActivitiesEnabled)") + ``` + +- ☐ **pushType matches server integration** + - `nil` → local updates only + - `.token` → expects push notifications + +- ☐ **Dismissal policy implemented** + - Every activity.end() must specify policy + +## Control Center Widget Checklist + +- ☐ **ControlValueProvider for async data** +- ☐ **previewValue provides instant fallback** +- ☐ **App Intent perform() is async** +- ☐ **No blocking network/database calls in views** + +--- + +# Pressure Scenarios + +## Scenario 1: "Widget shows wrong data in production" + +### Situation +- App released to App Store +- Users report widget displaying incorrect/stale information +- Works fine in development + +### Pressure Signals +- 🚨 **App Store reviews** — 1-star reviews mentioning broken widget +- ⏰ **Time pressure** — Need hotfix ASAP +- 👔 **Executive visibility** — Management asking for status updates + +### Rationalization Traps (DO NOT) + +1. *"Just force a timeline reload more often"* + - **Why it fails**: Exhausts budget, makes problem worse + +2. *"The widget worked in testing"* + - **Why it fails**: Development vs. production App Groups mismatch + +3. *"Users should just restart their phone"* + - **Why it fails**: Not a fix, damages reputation + +### MANDATORY Systematic Fix + +#### Step 1: Verify App Groups (30 min) + +```swift +// Add logging to BOTH app and widget +let group = "group.com.myapp.production" // Must match exactly +let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: group +) + +print("[\(Bundle.main.bundleIdentifier ?? "?")] Container: \(container?.path ?? "NIL")") + +// Log EVERY read/write +let shared = UserDefaults(suiteName: group)! +print("Writing key 'lastUpdate' = \(Date())") +shared.set(Date(), forKey: "lastUpdate") +``` + +**Verify**: Run app, then widget. Both should print SAME container path. + +#### Step 2: Check Container Paths + +```bash +# Device logs (Xcode → Window → Devices and Simulators → View Device Logs) +# Filter: Your app bundle ID +# Look for: Container path mismatches +``` + +Common issues: +- App uses `group.com.myapp.dev` +- Widget uses `group.com.myapp.production` +- **Fix**: Ensure EXACT same group ID in both .entitlements files + +#### Step 3: Add Version Stamp + +```swift +// Main app — stamp every write +struct WidgetData: Codable { + var value: String + var timestamp: Date + var appVersion: String +} + +let data = WidgetData( + value: "Latest", + timestamp: Date(), + appVersion: Bundle.main.appVersion +) +shared.set(try JSONEncoder().encode(data), forKey: "widgetData") + +// Widget — verify version +if let data = shared.data(forKey: "widgetData"), + let decoded = try? JSONDecoder().decode(WidgetData.self, from: data) { + print("Widget reading data from app version: \(decoded.appVersion)") +} +``` + +#### Step 4: Force Reload on App Launch + +```swift +// AppDelegate / @main App +func applicationDidBecomeActive(_ application: UIApplication) { + WidgetCenter.shared.reloadAllTimelines() +} +``` + +### Communication Template + +**To stakeholders**: +``` +Status: Investigating widget data sync issue + +Root cause: App Groups configuration mismatch between app and widget extension in production build + +Fix: Updated both targets to use identical group identifier, added logging to prevent recurrence + +Timeline: Hotfix submitted to App Store review (24-48h) + +Workaround for users: Force-quit app and relaunch (triggers widget refresh) +``` + +### Time Saved +- **Without systematic fix**: 4-8 hours of trial-and-error, multiple resubmissions +- **With this process**: 1-2 hours to identify, fix, and verify + +--- + +## Scenario 2: "Live Activity must update instantly" + +### Situation +- Sports score app +- Users expect scores to update within seconds of real game events +- Current timeline-based approach too slow + +### Pressure +- **Competitive**: "Other apps update faster" +- **Deadline**: Marketing promised "real-time" updates + +### Rationalization Traps (DO NOT) + +1. *"Just create entries every 5 seconds"* + - **Why it fails**: Not real-time, exhausts battery, doesn't scale + +2. *"Add WebSocket to widget view"* + - **Why it fails**: Extensions can't maintain persistent connections + +3. *"Lower refresh interval to 1 second"* + - **Why it fails**: Timeline system not designed for sub-minute updates + +### MANDATORY Solution: Phased Approach + +**Critical reality check**: Push notification entitlement approval takes **3-7 days**. Never promise features before approval. + +#### Phase 1: Ship with Local Updates (No Approval Required) + +**Ship immediately** with app-driven updates: + +```swift +// Start activity WITHOUT push (no entitlement needed) +let activity = try Activity.request( + attributes: attributes, + content: initialContent, + pushType: nil // Local updates only +) + +// In your app when data changes (user opens app, pulls to refresh) +await activity.update(ActivityContent( + state: updatedState, + staleDate: nil +)) +``` + +**Set expectations**: Updates occur when user interacts with app. This is **acceptable** for v1.0 and requires zero approval. + +#### Phase 2: Add Push After Approval (3-7 Days) + +**After entitlement approved**, switch to push: + +#### Step 1: Enable Push for Live Activities + +```swift +// 1. Entitlement: "com.apple.developer.activity-push-notification" + +// 2. Request activity with push token +let activity = try Activity.request( + attributes: attributes, + content: initialContent, + pushType: .token +) + +// 3. Monitor for token +Task { + for await pushToken in activity.pushTokenUpdates { + let tokenString = pushToken.map { String(format: "%02x", $0) }.joined() + await sendTokenToServer(activityID: activity.id, token: tokenString) + } +} +``` + +#### Step 2: Server-Side Push (Phase 2 Only) + +```json +{ + "aps": { + "timestamp": 1633046400, + "event": "update", + "content-state": { + "teamAScore": 14, + "teamBScore": 10, + "quarter": 2, + "timeRemaining": "5:23" + }, + "alert": { + "title": "Touchdown!", + "body": "Team A scores" + } + } +} +``` + +**Standard push limit**: ~10-12 per hour + +#### Step 3: Request Frequent Updates Entitlement (Phase 2, iOS 18.2+) + +For apps requiring more frequent pushes (sports, stocks): + +```xml +com.apple.developer.activity-push-notification-frequent-updates + +``` + +**Requires justification** in App Store Connect: "Live sports scores require immediate updates for user engagement" + +#### Verification + +```swift +// Log push receipt in Live Activity widget +#if DEBUG +let logURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "group.com.myapp" +)!.appendingPathComponent("push_log.txt") + +let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) +try! "\(timestamp): Received push\n".append(to: logURL) +#endif +``` + +### Communication Template + +**To marketing/exec (Phase 1)**: +``` +Launch Timeline: +- Phase 1 (immediate): Live Activities with app-driven updates. Updates appear when users open app or pull to refresh. +- Phase 2 (3-7 days): Push notification integration after Apple approval. Updates arrive within 1-3 seconds of server events. + +Recommendation: Launch Phase 1 to market, communicate Phase 2 as "coming soon" once approved. +``` + +**To marketing/exec (Phase 2)**: +``` +"Real-time" positioning requires clarification: + +Technical: Live Activities update via push notifications with 1-3 second latency from server to device + +Constraints: Apple's push system has rate limits (~10/hour standard, axiom-higher with special entitlement) + +Competitive analysis: Competitors likely use same system with similar limitations + +Recommendation: Position as "near real-time" (accurate) vs "instant" (misleading) +``` + +### Reality Check +- Push notifications are fastest mechanism available +- 1-3 second latency is normal +- Budget limits exist for battery optimization +- Users prefer longer battery life over millisecond-faster scores + +> For comprehensive push notification setup (APNs auth, payload format, token management, service extensions), see axiom-push-notifications and axiom-push-notifications-ref. This skill covers the ActivityKit UI and state management side. + +--- + +## Scenario 3: "Control Center control is slow" + +### Situation +- Smart home control for lights +- Tapping control in Control Center takes 3-5 seconds to respond +- Users expect instant feedback + +### MANDATORY Fix: Optimistic UI + Async Value Provider + +#### Problem Code + +```swift +struct LightControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: "Light") { + ControlWidgetToggle( + isOn: LightManager.shared.isOn, // ❌ Blocking fetch + action: ToggleLightIntent() + ) { isOn in + Label(isOn ? "On" : "Off", systemImage: "lightbulb.fill") + } + } + } +} +``` + +#### Fixed Code + +```swift +// 1. Value Provider for async state +struct LightProvider: ControlValueProvider { + func currentValue() async throws -> LightValue { + // Async fetch from HomeKit/server + let isOn = try await HomeManager.shared.fetchLightState() + return LightValue(isOn: isOn) + } + + var previewValue: LightValue { + // Instant fallback from cache + let shared = UserDefaults(suiteName: "group.com.myapp")! + return LightValue(isOn: shared.bool(forKey: "lastKnownLightState")) + } +} + +struct LightValue: ControlValueProviderValue { + var isOn: Bool +} + +// 2. Optimistic Intent +struct ToggleLightIntent: AppIntent { + static var title: LocalizedStringResource = "Toggle Light" + + func perform() async throws -> some IntentResult { + // Immediately update cache (optimistic) + let shared = UserDefaults(suiteName: "group.com.myapp")! + let currentState = shared.bool(forKey: "lastKnownLightState") + let newState = !currentState + shared.set(newState, forKey: "lastKnownLightState") + + // Then update actual device (async) + try await HomeManager.shared.setLight(isOn: newState) + + return .result() + } +} + +// 3. Control with provider +struct LightControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: "Light", provider: LightProvider()) { value in + ControlWidgetToggle( + isOn: value.isOn, + action: ToggleLightIntent() + ) { isOn in + Label(isOn ? "On" : "Off", systemImage: "lightbulb.fill") + .tint(isOn ? .yellow : .gray) + } + } + } +} +``` + +**Result**: Control responds instantly with cached state, actual device updates in background. + +--- + +# Final Checklist + +Before shipping widgets or Live Activities: + +## Pre-Release +- ☐ App Groups entitlement in BOTH targets (app + extension) +- ☐ Shared UserDefaults uses `suiteName` (not `.standard`) +- ☐ Timeline entries ≥ 5 minutes apart (avoid budget exhaustion) +- ☐ No network calls in widget views (only in TimelineProvider) +- ☐ ActivityAttributes + ContentState < 4KB +- ☐ Live Activities call `.end()` with appropriate dismissal policy +- ☐ Control Center controls use ControlValueProvider for async data +- ☐ Tested on actual device (not just simulator) — **Required because**: + - Simulator doesn't enforce timeline budget limits + - Push notifications don't work in simulator + - App Groups container paths differ (simulator vs device) + - Memory limits not enforced in simulator + - Background refresh behavior different +- ☐ Tested all supported widget families +- ☐ Verified widget appears in Widget Gallery + +## Post-Release Monitoring +- ☐ Monitor for "Timeline reload budget exhausted" errors +- ☐ Track widget data staleness in analytics +- ☐ Watch App Store reviews for widget-related complaints +- ☐ Log App Group container access for debugging + +## Common Failure Modes +- Missing App Groups → Widget shows default data +- Wrong group ID → App and widget can't communicate +- Over-refreshing → Widget stops updating after hours +- Network in view → Widget renders blank +- No dismissal policy → Zombie Live Activities +- Blocking main thread → Unresponsive controls + +--- + +**Remember**: Widgets are NOT mini apps. They're glanceable snapshots rendered by the system. Extensions run in sandboxed environments with strict resource limits. Follow the patterns in this skill to avoid the most common pitfalls. + +--- + +## Resources + +**Skills**: axiom-extensions-widgets-ref, axiom-push-notifications, axiom-push-notifications-ref, axiom-background-processing diff --git a/.claude/skills/axiom-extensions-widgets/agents/openai.yaml b/.claude/skills/axiom-extensions-widgets/agents/openai.yaml new file mode 100644 index 0000000..2c59882 --- /dev/null +++ b/.claude/skills/axiom-extensions-widgets/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Extensions Widgets" + short_description: "Implementing widgets, Live Activities, or Control Center controls" diff --git a/.claude/skills/axiom-file-protection-ref/.openskills.json b/.claude/skills/axiom-file-protection-ref/.openskills.json new file mode 100644 index 0000000..84bd9ab --- /dev/null +++ b/.claude/skills/axiom-file-protection-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-file-protection-ref", + "installedAt": "2026-04-12T08:06:16.975Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-file-protection-ref/SKILL.md b/.claude/skills/axiom-file-protection-ref/SKILL.md new file mode 100644 index 0000000..9fe48e2 --- /dev/null +++ b/.claude/skills/axiom-file-protection-ref/SKILL.md @@ -0,0 +1,544 @@ +--- +name: axiom-file-protection-ref +description: Use when asking about 'FileProtectionType', 'file encryption iOS', 'NSFileProtection', 'data protection', 'secure file storage', 'encrypt files at rest', 'complete protection', 'file security' - comprehensive reference for iOS file encryption and data protection APIs +license: MIT +compatibility: iOS 4.0+, iPadOS 4.0+, macOS 10.0+ +metadata: + version: "1.0.0" + last-updated: "2025-12-12" +--- + +# iOS File Protection Reference + +**Purpose**: Comprehensive reference for file encryption and data protection APIs +**Availability**: iOS 4.0+ (all protection levels), latest enhancements in iOS 26 +**Context**: Built on iOS Data Protection architecture using hardware encryption + +## When to Use This Skill + +Use this skill when you need to: +- Protect sensitive user data at rest +- Choose appropriate FileProtectionType for files +- Understand when files are accessible/encrypted +- Debug "file not accessible" errors after device lock +- Implement secure file storage +- Compare Keychain vs file protection approaches +- Handle background file access requirements + +## Overview + +iOS Data Protection provides **hardware-accelerated file encryption** tied to the device passcode. When a user sets a passcode, every file can be encrypted with keys protected by that passcode. + +**Key concepts**: +- Files are encrypted **automatically** when protection is enabled +- Encryption keys are derived from device hardware + user passcode +- Files become **inaccessible** when device is locked (depending on protection level) +- No performance cost (hardware AES encryption) + +--- + +## Protection Levels Comparison + +| Level | Encrypted Until | Accessible When | Use For | Background Access | +|-------|-----------------|-----------------|---------|-------------------| +| **complete** | Device unlocked | Only while unlocked | Sensitive data (health, finances) | ❌ No | +| **completeUnlessOpen** | File closed | After first unlock, while open | Large downloads, videos | ✅ If already open | +| **completeUntilFirstUserAuthentication** | First unlock after boot | After first unlock | Most app data | ✅ Yes | +| **none** | Never | Always | Public caches, temp files | ✅ Yes | + +### Detailed Level Descriptions + +#### .complete + +**Full Description**: +> "The file is stored in an encrypted format on disk and cannot be read from or written to while the device is locked or booting." + +**Use For**: +- User health data +- Financial information +- Password vaults +- Sensitive documents +- Personal photos (if app requires maximum security) + +**Behavior**: +- Encrypted: ✅ Always +- Accessible: Only when device unlocked +- Background access: ❌ No (app can't read while locked) +- Available after boot: ❌ No (until user unlocks) + +**Code Example**: + +```swift +// ✅ CORRECT: Maximum security for sensitive data +func saveSensitiveData(_ data: Data, to url: URL) throws { + try data.write(to: url, options: .completeFileProtection) +} + +// Or set on existing file +try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.complete], + ofItemAtPath: url.path +) +``` + +**Tradeoffs**: +- ✅ Maximum security +- ❌ Can't access in background +- ❌ User sees errors if app tries to access while locked + +#### .completeUnlessOpen + +**Full Description**: +> "The file is stored in an encrypted format on disk after it is closed." + +**Use For**: +- Large file downloads (continue in background) +- Video files being played +- Documents being edited +- Any file that needs background access while open + +**Behavior**: +- Encrypted: ✅ When closed +- Accessible: After first unlock, remains accessible while open +- Background access: ✅ Yes (if file was already open) +- Available after boot: ❌ No (until first unlock) + +**Code Example**: + +```swift +// ✅ CORRECT: Download in background, but encrypted when closed +func startBackgroundDownload(url: URL, destination: URL) throws { + try Data().write(to: destination, options: .completeFileProtectionUnlessOpen) + + // Open file handle for writing + let fileHandle = try FileHandle(forWritingTo: destination) + + // Download continues in background + // File remains accessible because it's open + // When closed, file becomes encrypted + + // Later, when download complete: + try fileHandle.close() // Now encrypted until next unlock +} +``` + +**Tradeoffs**: +- ✅ Good security (encrypted when not in use) +- ✅ Background access (if already open) +- ⚠️ Vulnerable while open + +#### .completeUntilFirstUserAuthentication + +**Full Description**: +> "The file is stored in an encrypted format on disk and cannot be accessed until after the device has booted." + +**Use For**: +- Most application data +- User preferences +- Downloaded content +- Database files +- Anything that needs background access + +**Behavior**: +- Encrypted: ✅ Always +- Accessible: After first unlock following boot +- Background access: ✅ Yes (after first unlock) +- Available after boot: ❌ No (until user unlocks once) + +**This is the recommended default for most files.** + +**Code Example**: + +```swift +// ✅ CORRECT: Balanced security for most app data +func saveAppData(_ data: Data, to url: URL) throws { + try data.write( + to: url, + options: .completeFileProtectionUntilFirstUserAuthentication + ) +} + +// ✅ This file can be accessed in background after first unlock +func backgroundTaskCanAccessFile() { + // This works even if device is locked (after first unlock) + let data = try? Data(contentsOf: url) +} +``` + +**Tradeoffs**: +- ✅ Protected during boot (device stolen while off) +- ✅ Background access (normal operation) +- ⚠️ Accessible while locked (less protection than .complete) + +#### .none + +**Full Description**: +> "The file has no special protections associated with it." + +**Use For**: +- Public cache data +- Temporary files +- Non-sensitive downloads +- Thumbnails +- Only when absolutely necessary + +**Behavior**: +- Encrypted: ❌ Never +- Accessible: ✅ Always +- Background access: ✅ Always +- Available after boot: ✅ Always + +**Code Example**: + +```swift +// ⚠️ USE SPARINGLY: Only for truly non-sensitive data +func cachePublicThumbnail(_ data: Data, to url: URL) throws { + try data.write(to: url, options: .noFileProtection) +} +``` + +**Tradeoffs**: +- ✅ Always accessible +- ❌ No encryption +- ❌ Vulnerable if device is stolen + +--- + +## Setting File Protection + +### At File Creation + +```swift +// ✅ RECOMMENDED: Set protection when writing +let sensitiveData = userData.jsonData() +try sensitiveData.write( + to: fileURL, + options: .completeFileProtection +) +``` + +### On Existing Files + +```swift +// ✅ CORRECT: Change protection on existing file +try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.complete], + ofItemAtPath: fileURL.path +) +``` + +### Default Protection for Directory + +```swift +// ✅ CORRECT: Set default protection for directory +// New files inherit this protection +try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: directoryURL.path +) +``` + +### Checking Current Protection + +```swift +// ✅ Check file's current protection level +func checkFileProtection(at url: URL) throws -> FileProtectionType? { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return attributes[.protectionKey] as? FileProtectionType +} + +// Usage +if let protection = try? checkFileProtection(at: fileURL) { + switch protection { + case .complete: + print("Maximum protection") + case .completeUntilFirstUserAuthentication: + print("Standard protection") + default: + print("Other protection") + } +} +``` + +--- + +## File Protection vs Keychain + +### Decision Matrix + +| Use Case | Recommended | Why | +|----------|-------------|-----| +| Passwords, tokens, keys | **Keychain** | Designed for small secrets | +| Small sensitive values (1 KB | **File Protection** | Keychain not designed for large data | +| User documents | **File Protection** | Natural file-based storage | +| Structured secrets | **Keychain** | Query by key, access control | + +### Code Comparison + +```swift +// ✅ CORRECT: Small secrets in Keychain +let passwordData = password.data(using: .utf8)! +let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "userPassword", + kSecValueData as String: passwordData, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked +] +SecItemAdd(query as CFDictionary, nil) + +// ✅ CORRECT: Files with file protection +let userData = try JSONEncoder().encode(user) +try userData.write(to: fileURL, options: .completeFileProtection) +``` + +**Keychain advantages**: +- More granular access control (Face ID/Touch ID) +- Separate encryption (not tied to file system) +- Survives app deletion (if configured) + +**File protection advantages**: +- Works with existing file operations +- Handles large data efficiently +- Automatic with minimal code + +--- + +## Background Access Considerations + +### iOS Background Modes and File Protection + +```swift +// ❌ WRONG: .complete files can't be accessed in background +class BackgroundTask { + func performBackgroundSync() { + // This FAILS if file has .complete protection and device is locked + let data = try? Data(contentsOf: sensitiveFileURL) + // data will be nil if device locked + } +} + +// ✅ CORRECT: Use .completeUntilFirstUserAuthentication +// Files accessible in background after first unlock +try data.write( + to: fileURL, + options: .completeFileProtectionUntilFirstUserAuthentication +) +``` + +### Handling Protection Errors + +```swift +// ✅ CORRECT: Handle protection errors gracefully +func readFile(at url: URL) -> Data? { + do { + return try Data(contentsOf: url) + } catch let error as NSError { + if error.domain == NSCocoaErrorDomain && + error.code == NSFileReadNoPermissionError { + // File is protected and device is locked + print("File protected, device locked") + return nil + } + throw error + } +} +``` + +--- + +## iCloud and File Protection + +### How Protection Works with iCloud + +**Local file protection**: +- Applied to local cached copies +- Does NOT affect iCloud-stored versions +- iCloud has its own encryption (in transit and at rest) + +**iCloud encryption**: +- All iCloud data encrypted at rest (Apple-managed keys) +- End-to-end encryption available for some data types (Advanced Data Protection) +- File protection only affects local device + +```swift +// ✅ CORRECT: Protection on iCloud file affects local copy only +func saveToICloud(data: Data, filename: String) throws { + guard let iCloudURL = FileManager.default.url( + forUbiquityContainerIdentifier: nil + ) else { return } + + let fileURL = iCloudURL.appendingPathComponent(filename) + + // This protection applies to local cached copy + try data.write(to: fileURL, options: .completeFileProtection) + + // iCloud has separate encryption for cloud storage +} +``` + +--- + +## Common Patterns + +### Pattern 1: Default Protection for New Apps + +```swift +// ✅ RECOMMENDED: Set default protection at app launch +func configureDefaultFileProtection() { + let fileManager = FileManager.default + + let directories: [FileManager.SearchPathDirectory] = [ + .documentDirectory, + .applicationSupportDirectory + ] + + for directory in directories { + guard let url = fileManager.urls( + for: directory, + in: .userDomainMask + ).first else { continue } + + try? fileManager.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: url.path + ) + } +} + +// Call during app initialization +func application(_ application: UIApplication, didFinishLaunchingWithOptions...) { + configureDefaultFileProtection() + return true +} +``` + +### Pattern 2: Encrypting Database Files + +```swift +// ✅ CORRECT: Protect SwiftData/SQLite database +let appSupportURL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask +)[0] + +let databaseURL = appSupportURL.appendingPathComponent("app.sqlite") + +// Set protection before creating database +try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: appSupportURL.path +) + +// Now create database - it inherits protection +let container = try ModelContainer( + for: MyModel.self, + configurations: ModelConfiguration(url: databaseURL) +) +``` + +### Pattern 3: Downgrading Protection for Background Tasks + +```swift +// ⚠️ SOMETIMES NECESSARY: Lower protection for background access +func enableBackgroundAccess(for url: URL) throws { + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: url.path + ) +} + +// Only do this if: +// 1. Background access is truly required +// 2. Data sensitivity allows it +// 3. You've considered security tradeoffs +``` + +--- + +## Debugging File Protection Issues + +### Issue: File Not Accessible in Background + +**Symptom**: Background tasks fail to read files + +```swift +// Debug: Check current protection +if let protection = try? FileManager.default.attributesOfItem( + atPath: url.path +)[.protectionKey] as? FileProtectionType { + print("Protection: \(protection)") + if protection == .complete { + print("❌ Can't access in background when locked") + } +} +``` + +**Solution**: Use `.completeUntilFirstUserAuthentication` instead + +### Issue: Files Inaccessible After Restart + +**Symptom**: App can't access files immediately after device reboot + +**Cause**: Using `.complete` or `.completeUntilFirstUserAuthentication` (works as designed) + +**Solution**: This is expected behavior. Either: +1. Wait for user to unlock device +2. Handle gracefully with appropriate UI +3. Use `.none` for files that must be accessible (security tradeoff) + +--- + +## Entitlements + +File protection generally works without special entitlements, but some features require: + +### Data Protection Entitlement + +```xml + +com.apple.developer.default-data-protection +NSFileProtectionComplete +``` + +**When needed**: +- Using `.complete` protection +- Some iOS versions for any protection (check documentation) + +**How to add**: +1. Xcode → Target → Signing & Capabilities +2. "+ Capability" → Data Protection +3. Select protection level + +--- + +## Quick Reference Table + +| Scenario | Recommended Protection | Accessible When Locked? | Background Access? | +|----------|------------------------|-------------------------|---------------------| +| User health data | `.complete` | ❌ No | ❌ No | +| Financial records | `.complete` | ❌ No | ❌ No | +| Most app data | `.completeUntilFirstUserAuthentication` | ✅ Yes (after first unlock) | ✅ Yes | +| Downloads (large files) | `.completeUnlessOpen` | ✅ While open | ✅ While open | +| Database files | `.completeUntilFirstUserAuthentication` | ✅ Yes | ✅ Yes | +| Downloaded images | `.completeUntilFirstUserAuthentication` | ✅ Yes | ✅ Yes | +| Public caches | `.none` | ✅ Yes | ✅ Yes | +| Temp files | `.none` | ✅ Yes | ✅ Yes | + +--- + +## Related Skills + +- `axiom-storage` — Decide when to use file protection vs other security measures +- `axiom-storage-management-ref` — File lifecycle, purging, and disk management +- `axiom-storage-diag` — Debug file access issues +- `axiom-keychain` — Secure credential storage (tokens, passwords, keys) +- `axiom-keychain-ref` — Complete SecItem API reference +- `axiom-cryptokit` — Encryption and signing with CryptoKit + +--- + +**Last Updated**: 2025-12-12 +**Skill Type**: Reference +**Minimum iOS**: 4.0 (all protection levels) +**Latest Updates**: iOS 26 diff --git a/.claude/skills/axiom-file-protection-ref/agents/openai.yaml b/.claude/skills/axiom-file-protection-ref/agents/openai.yaml new file mode 100644 index 0000000..5f3990a --- /dev/null +++ b/.claude/skills/axiom-file-protection-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "File Protection Reference" + short_description: "Asking about 'FileProtectionType', 'file encryption iOS', 'NSFileProtection', 'data protection', 'secure file storage..." diff --git a/.claude/skills/axiom-fix-build/.openskills.json b/.claude/skills/axiom-fix-build/.openskills.json new file mode 100644 index 0000000..3f3cc6c --- /dev/null +++ b/.claude/skills/axiom-fix-build/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-fix-build", + "installedAt": "2026-04-12T08:06:16.977Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-fix-build/SKILL.md b/.claude/skills/axiom-fix-build/SKILL.md new file mode 100644 index 0000000..0a905a6 --- /dev/null +++ b/.claude/skills/axiom-fix-build/SKILL.md @@ -0,0 +1,436 @@ +--- +name: axiom-fix-build +description: Use when the user mentions Xcode build failures, build errors, or environment issues. +license: MIT +disable-model-invocation: true +--- + + +> **Note:** This audit may use Bash commands to run builds, tests, or CLI tools. +# Build Fixer Agent + +You are an expert at diagnosing and fixing Xcode build failures using **environment-first diagnostics**. + +## Core Principle + +**80% of "mysterious" Xcode issues are environment problems (stale Derived Data, stuck simulators, zombie processes), not code bugs.** + +Environment cleanup takes 2-5 minutes. Code debugging for environment issues wastes 30-120 minutes. + +## Your Mission + +When the user reports a build failure: +1. Run mandatory environment checks FIRST (never skip) +2. Identify the specific issue type +3. Apply the appropriate fix automatically +4. Verify the fix worked +5. Report results clearly + +## Mandatory First Steps + +**ALWAYS run these diagnostic commands FIRST** before any investigation: + +```bash +# Optional: Detect CI/CD environment (adjusts diagnostics) +echo "CI env: ${CI:-not set}, GitHub Actions: ${GITHUB_ACTIONS:-not set}" + +# 0. Verify you're in the project directory +ls -la | grep -E "\.xcodeproj|\.xcworkspace" +# If nothing shows, you're in wrong directory + +# 1. Check for zombie xcodebuild processes (with elapsed time) +ps -eo pid,etime,command | grep -E "xcodebuild|Simulator" | grep -v grep +# Format: PID ELAPSED COMMAND +# ELAPSED shows how long process has been running (e.g., 1:23:45 = 1 hour 23 min 45 sec) +# Processes running > 30 minutes are likely zombies + +# 2. Check Derived Data size (>10GB = stale) +du -sh ~/Library/Developer/Xcode/DerivedData + +# 3. Check simulator states (stuck Booting?) - JSON for reliable parsing +xcrun simctl list devices -j | jq '.devices | to_entries[] | .value[] | select(.state == "Booted" or .state == "Booting" or .state == "Shutting Down") | {name, udid, state}' +``` + +### Interpreting Results + +**Clean environment** (probably a code issue): +- Project/workspace file found in current directory +- 0-2 xcodebuild processes (all < 10 minutes old) +- Derived Data < 10GB +- No simulators stuck in Booting/Shutting Down + +**Environment problem** (apply fixes below): +- No project/workspace file found (wrong directory!) +- 10+ xcodebuild processes OR any process > 30 minutes old (zombies) +- Derived Data > 10GB (stale cache) +- Simulators stuck in Booting state +- Any intermittent failures + +## Red Flags: Environment Not Code + +If user mentions ANY of these, it's definitely an environment issue: +- "It works on my machine but not CI" +- "Tests passed yesterday, failing today with no code changes" +- "Build succeeds but old code executes" +- "Build sometimes succeeds, sometimes fails" +- "Simulator stuck at splash screen" +- "Unable to install app" + +## CI/CD Environment Detection + +When running in CI/CD environments, some diagnostics don't apply and fixes need adjustment. + +### Detecting CI/CD Context + +Check for environment variables that indicate CI/CD: + +```bash +# Check if running in CI/CD +if [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ] || [ -n "$JENKINS_URL" ] || [ -n "$GITLAB_CI" ]; then + echo "Running in CI/CD environment" +else + echo "Running on local machine" +fi +``` + +### CI/CD-Specific Adjustments + +**When in CI/CD:** + +1. **Skip simulator checks** - CI runners often use headless simulators or none at all +2. **Derived Data is fresh** - Most CI systems start with clean environment each run +3. **Focus on:** + - SPM cache issues (common in CI) + - Package resolution failures + - Xcode version mismatches + - Missing provisioning profiles + - Code signing issues + +**CI/CD-Specific Fixes:** + +```bash +# For CI/CD package resolution issues +rm -rf .build/ +rm -rf ~/Library/Caches/org.swift.swiftpm/ +xcodebuild -resolvePackageDependencies -scheme + +# For CI/CD build failures +xcodebuild clean build -scheme \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + -allowProvisioningUpdates +``` + +**Downloading Simulator Runtimes (CI/CD Setup):** + +For CI/CD environments that need specific simulator runtimes: + +```bash +# Download iOS simulator runtime for current Xcode +xcodebuild -downloadPlatform iOS + +# Download specific iOS version +xcodebuild -downloadPlatform iOS -buildVersion 18.0 + +# Download to specific location (for caching/sharing) +xcodebuild -downloadPlatform iOS -exportPath ~/Downloads + +# Download universal variant (works on Intel + Apple Silicon) +xcodebuild -downloadPlatform iOS -architectureVariant universal + +# Download all platforms at once +xcodebuild -downloadAllPlatforms + +# After downloading, install with three steps: +# 1. Select Xcode version +xcode-select -s /Applications/Xcode.app + +# 2. Run first launch setup +xcodebuild -runFirstLaunch + +# 3. Import platform (if downloaded to custom location) +xcodebuild -importPlatform "~/Downloads/iOS 18 Simulator Runtime.dmg" + +# Check for newer components between releases +xcodebuild -runFirstLaunch -checkForNewerComponents +``` + +**Use for**: CI/CD initial setup, missing simulator errors, version-specific testing + +**Red Flags for CI/CD:** +- "Works locally but fails in CI" → Usually SPM cache or Xcode version mismatch +- "Intermittent CI failures" → Network issues downloading packages +- "CI hangs indefinitely" → Timeout on package resolution, check network + +### When to Report CI/CD Context + +If running in CI/CD, mention this in your diagnosis: + +```markdown +### Environment Context +- Running in: [GitHub Actions/Jenkins/GitLab CI/Local] +- Diagnostics adjusted for CI/CD environment +``` + +## Fix Workflows + +### 1. For Zombie Processes + +If you see 10+ xcodebuild processes OR any processes with elapsed time > 30 minutes: + +```bash +# First, review process ages from the check above +# Look for ELAPSED times like 35:12 (35 min) or 1:23:45 (1 hr 23 min) - these are zombies + +# Kill all xcodebuild processes +killall -9 xcodebuild + +# Verify they're gone (with elapsed time) +ps -eo pid,etime,command | grep xcodebuild | grep -v grep + +# Also kill stuck Simulator processes if needed +killall -9 Simulator +``` + +### 2. For Stale Derived Data / "No such module" Errors + +If Derived Data is large OR user reports "No such module" OR intermittent failures: + +```bash +# First, find the scheme name +xcodebuild -list + +# If xcodebuild -list fails, check: +# 1. Are you in the project directory? (should have .xcodeproj or .xcworkspace) +# 2. Run: ls -la | grep -E "\.xcodeproj|\.xcworkspace" +# 3. If missing, cd to correct directory +# 4. If .xcworkspace exists, use: xcodebuild -list -workspace YourApp.xcworkspace +# 5. If .xcodeproj exists, use: xcodebuild -list -project YourApp.xcodeproj + +# Clean everything (use the actual scheme name from above) +xcodebuild clean -scheme +rm -rf ~/Library/Developer/Xcode/DerivedData/* +rm -rf .build/ build/ + +# Rebuild with appropriate destination +xcodebuild build -scheme \ + -destination 'platform=iOS Simulator,name=iPhone 16' +``` + +**CRITICAL**: +- Use the actual scheme name from `xcodebuild -list`, not a placeholder +- If `xcodebuild -list` fails, verify you're in the correct directory with a workspace/project file + +### 3. For SPM Cache Issues / "No such module" with Swift Packages + +If user reports "No such module" with Swift Package Manager dependencies OR packages won't resolve: + +```bash +# Clean SPM cache (this fixes 90% of SPM issues) +rm -rf ~/Library/Caches/org.swift.swiftpm/ +rm -rf ~/Library/Developer/Xcode/DerivedData/* +rm -rf .build/ + +# Reset package resolution +xcodebuild -resolvePackageDependencies -scheme + +# Verify packages resolved +xcodebuild -list + +# Rebuild +xcodebuild build -scheme \ + -destination 'platform=iOS Simulator,name=iPhone 16' +``` + +**When to use this**: +- "No such module" errors for Swift Package dependencies +- Package resolution failures +- "Package.resolved" conflicts +- After switching git branches with different package versions + +### 4. For Simulator Issues + +If user reports "Unable to boot simulator" or simulators stuck: + +```bash +# Shutdown all simulators +xcrun simctl shutdown all + +# List devices with JSON for reliable parsing +xcrun simctl list devices -j | jq '.devices | to_entries[] | .value[] | select(.isAvailable == true) | {name, udid, state}' + +# Get UUID for a specific device (e.g., iPhone 16) using JSON +UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.name | contains("iPhone 16")) | select(.isAvailable == true) | .udid' | head -1) + +if [ -z "$UDID" ]; then + echo "No iPhone 16 simulator found. Available simulators:" + xcrun simctl list devices -j | jq '.devices | to_entries[] | .value[] | select(.isAvailable == true) | {name, udid}' +else + echo "iPhone 16 UUID: $UDID" + # Erase the stuck simulator using the extracted UUID + xcrun simctl erase "$UDID" +fi + +# Find and erase all simulators stuck in Booting state +xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booting") | .udid' | while read UDID; do + echo "Erasing stuck simulator: $UDID" + xcrun simctl erase "$UDID" +done + +# Nuclear option if nothing works +killall -9 Simulator +``` + +### 5. For Test Failures (No Code Changes) + +If tests are failing but user hasn't changed code: + +```bash +# Clean Derived Data first +rm -rf ~/Library/Developer/Xcode/DerivedData/* + +# Run tests again +xcodebuild test -scheme \ + -destination 'platform=iOS Simulator,name=iPhone 16' +``` + +### 6. For Old Code Executing + +If build succeeds but old code runs: + +```bash +# This is ALWAYS a Derived Data issue +rm -rf ~/Library/Developer/Xcode/DerivedData/* + +# Force clean rebuild +xcodebuild clean build -scheme +``` + +## Decision Tree + +Use this to determine which fix to apply: + +``` +User reports build failure +↓ +Run mandatory checks (directory, processes, Derived Data, simulators) +↓ +Identify issue: +├─ No project/workspace file → Report "wrong directory" to user +├─ (following checks apply if directory verified) +↓ +├─ 10+ xcodebuild processes OR any process > 30min → Kill zombie processes (§1) +├─ Derived Data > 10GB → Clean Derived Data + rebuild (§2) +├─ "No such module" (SPM) → Clean SPM cache + resolve packages (§3) +├─ "No such module" (local) → Clean Derived Data + rebuild (§2) +├─ Package resolution failures → Clean SPM cache (§3) +├─ Intermittent failures → Clean Derived Data + rebuild (§2) +├─ Old code executing → Clean Derived Data + rebuild (§6) +├─ "Unable to boot simulator" → Shutdown/erase simulator (§4) +├─ Tests failing (no code changes) → Clean + retest (§5) +└─ All checks clean → Report "environment is clean, likely code issue" +``` + +## Output Format + +Provide a clear, structured report: + +```markdown +## Build Failure Diagnosis Complete + +### Environment Context +- Running in: [Local/GitHub Actions/Jenkins/GitLab CI/etc.] +- CI/CD detected: [yes/no] + +### Environment Check Results +- Project directory: [verified/not found] +- Xcodebuild processes: [count] (oldest: [elapsed time]) (clean/zombie) +- Derived Data size: [size] (clean/stale) +- Simulator state: [status] (clean/stuck) (skip if CI/CD) + +### Issue Identified +[Specific issue type] + +### Fix Applied +1. [Command 1 with actual output] +2. [Command 2 with actual output] +3. [Command 3 with actual output] + +### Verification +[Result of rebuild/retest - success or needs more work] + +### Next Steps +[What user should do next] +``` + +## Audit Guidelines + +1. **ALWAYS run the 4 mandatory checks first** - never skip (directory, processes, Derived Data, simulators) +2. **Detect CI/CD context** - check for CI environment variables and adjust diagnostics +3. **Check process elapsed time** - processes > 30 minutes are zombies, kill them +4. **Use actual scheme names** from `xcodebuild -list` - never use placeholders +5. **Handle xcodebuild -list failures** - verify directory and provide recovery steps +6. **Show command output** - don't just say "I ran X", show the result +7. **Verify fixes worked** - run the build/test again to confirm +8. **If fix doesn't work** - escalate to user with specific next steps + +## When to Stop and Report + +If you encounter: +- Permission denied errors → Report to user +- Xcode not installed → Report to user +- `xcodebuild -list` fails (no workspace/project found) → Report to user, verify correct directory +- Network issues preventing package resolution → Report to user +- Workspace file corruption → Report to user (needs manual intervention) +- All environment checks clean + fix attempts fail → Report "environment is clean, recommend systematic code debugging" + +## Error Pattern Recognition + +Common errors and their fixes: + +| Error Message | Fix | Section | +|---------------|-----|---------| +| `xcodebuild: error: Could not resolve package dependencies` | Wrong directory or Clean SPM cache | §0/§3 | +| `The workspace named "X" does not contain a scheme` | Wrong directory, verify location | §0 | +| `BUILD FAILED` (no details) | Clean Derived Data | §2 | +| `No such module: ` (SPM package) | Clean SPM cache + resolve | §3 | +| `No such module: ` (local) | Clean Derived Data | §2 | +| `Package resolution failed` | Clean SPM cache | §3 | +| `Unable to boot simulator` | Erase simulator (skip in CI/CD) | §4 | +| `Command PhaseScriptExecution failed` | Clean Derived Data | §2 | +| `Multiple commands produce` | Check for duplicate files (manual) | - | +| Old code executing | Delete Derived Data | §6 | +| Tests hang indefinitely | Reboot simulator (or timeout in CI/CD) | §4 | +| `Works locally but fails in CI` | SPM cache or Xcode version mismatch | §3/CI | +| `Intermittent CI failures` | Network issues, retry package download | CI | + +## Example Interaction + +**User**: "My build is failing with MODULE_NOT_FOUND" + +**Your response**: +1. Run 3 mandatory checks +2. Identify: Derived Data issue (common with "No such module" errors) +3. Apply fix: Clean Derived Data, clean build, rebuild +4. Verify: Run build command, show success/failure +5. Report results + +**Never**: +- Guess without running diagnostics +- Skip the verification step +- Leave user without clear next steps +- Use placeholder scheme names in commands + +## Resources + +**WWDC**: 2019-413 (Testing in Xcode) + +**Docs**: /xcode/downloading-and-installing-additional-xcode-components, /xcode/troubleshooting-simulator + +**Tech Notes**: TN2339 (Building from Command Line with Xcode) + +## Related + +For test execution: `test-runner` agent +For test debugging: `test-debugger` agent +For simulator testing: `simulator-tester` agent +For SPM conflicts: `spm-conflict-resolver` agent diff --git a/.claude/skills/axiom-fix-build/agents/openai.yaml b/.claude/skills/axiom-fix-build/agents/openai.yaml new file mode 100644 index 0000000..8f1e54a --- /dev/null +++ b/.claude/skills/axiom-fix-build/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Fix Build" + short_description: "The user mentions Xcode build failures, build errors, or environment issues." diff --git a/.claude/skills/axiom-foundation-models-diag/.openskills.json b/.claude/skills/axiom-foundation-models-diag/.openskills.json new file mode 100644 index 0000000..47fd9a7 --- /dev/null +++ b/.claude/skills/axiom-foundation-models-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-foundation-models-diag", + "installedAt": "2026-04-12T08:06:18.203Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-foundation-models-diag/SKILL.md b/.claude/skills/axiom-foundation-models-diag/SKILL.md new file mode 100644 index 0000000..50d30f9 --- /dev/null +++ b/.claude/skills/axiom-foundation-models-diag/SKILL.md @@ -0,0 +1,1030 @@ +--- +name: axiom-foundation-models-diag +description: Use when debugging Foundation Models issues — context exceeded, guardrail violations, slow generation, availability problems, unsupported language, or unexpected output. Systematic diagnostics with production crisis defense. +license: MIT +compatibility: iOS 26+, macOS 26+, iPadOS 26+, axiom-visionOS 26+ +metadata: + version: "1.0.0" + last-updated: "2025-12-03" +--- + +# Foundation Models Diagnostics + +## Overview + +Foundation Models issues manifest as context window exceeded errors, guardrail violations, slow generation, availability failures, and unexpected output. **Core principle** 80% of Foundation Models problems stem from misunderstanding model capabilities (3B parameter device-scale model, not world knowledge), context limits (4096 tokens), or availability requirements—not framework bugs. + +## Red Flags — Suspect Foundation Models Issue + +If you see ANY of these, suspect a Foundation Models misunderstanding, not framework breakage: +- Generation takes >5 seconds +- Error: `exceededContextWindowSize` +- Error: `guardrailViolation` +- Error: `unsupportedLanguageOrLocale` +- Model gives hallucinated/wrong output +- UI freezes during generation +- Feature works in simulator but not on device +- ❌ **FORBIDDEN** "Foundation Models is broken, we need a different AI" + - Foundation Models powers Apple Intelligence across millions of devices + - Wrong output = wrong use case (world knowledge vs summarization) + - Do not rationalize away the issue—diagnose it + +**Critical distinction** Foundation Models is a **device-scale model** (3B parameters) optimized for summarization, extraction, classification—NOT world knowledge or complex reasoning. Using it for the wrong task guarantees poor results. + +## Mandatory First Steps + +**ALWAYS run these FIRST** (before changing code): + +```swift +// 1. Check availability +let availability = SystemLanguageModel.default.availability + +switch availability { +case .available: + print("✅ Available") +case .unavailable(let reason): + print("❌ Unavailable: \(reason)") + // Possible reasons: + // - Device not Apple Intelligence-capable + // - Region not supported + // - User not opted in +} + +// Record: "Available? Yes/no, reason if not" + +// 2. Check supported languages +let supported = SystemLanguageModel.default.supportedLanguages +print("Supported languages: \(supported)") +print("Current locale: \(Locale.current.language)") + +if !supported.contains(Locale.current.language) { + print("⚠️ Current language not supported!") +} + +// Record: "Language supported? Yes/no" + +// 3. Check context usage +let session = LanguageModelSession() +// After some interactions: +print("Transcript entries: \(session.transcript.entries.count)") + +// Rough estimation (not exact): +let transcriptText = session.transcript.entries + .map { $0.content } + .joined() +print("Approximate chars: \(transcriptText.count)") +print("Rough token estimate: \(transcriptText.count / 3)") +// 4096 token limit ≈ 12,000 characters + +// Record: "Approaching context limit? Yes/no" + +// 4. Profile with Instruments +// Run with Foundation Models Instrument template +// Check: +// - Initial model load time +// - Token counts (input/output) +// - Generation time per request +// - Areas for optimization + +// Record: "Latency profile: [numbers from Instruments]" + +// 5. Inspect transcript for debugging +print("Full transcript:") +for entry in session.transcript.entries { + print("Entry: \(entry.content.prefix(100))...") +} + +// Record: "Any unusual entries? Repeated content?" +``` + +#### What this tells you +- **Unavailable** → Proceed to Pattern 1a/1b/1c (availability issues) +- **Context exceeded** → Proceed to Pattern 2a (token limit) +- **Guardrail error** → Proceed to Pattern 2b (content policy) +- **Language error** → Proceed to Pattern 2c (unsupported language) +- **Wrong output** → Proceed to Pattern 3a/3b/3c (output quality) +- **Slow generation** → Proceed to Pattern 4a/4b/4c/4d (performance) +- **UI frozen** → Proceed to Pattern 5a (main thread blocking) + +#### MANDATORY INTERPRETATION + +Before changing ANY code, identify ONE of these: + +1. If `availability = .unavailable` → Device/region/opt-in issue (not code bug) +2. If error is `exceededContextWindowSize` → Too many tokens (condense transcript) +3. If error is `guardrailViolation` → Content policy triggered (not model failure) +4. If error is `unsupportedLanguageOrLocale` → Language not supported (check supported list) +5. If output is hallucinated → Wrong use case (world knowledge vs extraction) +6. If generation >5 seconds → Not streaming or need optimization +7. If UI frozen → Calling on main thread (use Task {}) + +#### If diagnostics are contradictory or unclear +- STOP. Do NOT proceed to patterns yet +- Add detailed logging to every `respond()` call +- Run with Instruments Foundation Models template +- Establish baseline: what's actually happening vs what you assumed + +## Decision Tree + +``` +Foundation Models problem? +│ +├─ Won't start? +│ ├─ .unavailable → Availability issue +│ │ ├─ Device not capable? → Pattern 1a (device requirement) +│ │ ├─ Region restriction? → Pattern 1b (regional availability) +│ │ └─ User not opted in? → Pattern 1c (Settings check) +│ │ +├─ Generation fails? +│ ├─ exceededContextWindowSize → Context limit +│ │ └─ Long conversation or verbose prompts? → Pattern 2a (condense) +│ │ +│ ├─ guardrailViolation → Content policy +│ │ └─ Sensitive or inappropriate content? → Pattern 2b (handle gracefully) +│ │ +│ ├─ unsupportedLanguageOrLocale → Language issue +│ │ └─ Non-English or unsupported language? → Pattern 2c (language check) +│ │ +│ └─ Other error → General error handling +│ └─ Unknown error type? → Pattern 2d (catch-all) +│ +├─ Output wrong? +│ ├─ Hallucinated facts → Wrong model use +│ │ └─ Asking for world knowledge? → Pattern 3a (use case mismatch) +│ │ +│ ├─ Wrong structure → Parsing issue +│ │ └─ Manual JSON parsing? → Pattern 3b (use @Generable) +│ │ +│ ├─ Missing data → Tool needed +│ │ └─ Need external information? → Pattern 3c (tool calling) +│ │ +│ └─ Inconsistent output → Sampling issue +│ └─ Different results each time? → Pattern 3d (temperature/greedy) +│ +├─ Too slow? +│ ├─ Initial delay (1-2s) → Model loading +│ │ └─ First request slow? → Pattern 4a (prewarm) +│ │ +│ ├─ Long wait for results → Not streaming +│ │ └─ User waits 3-5s? → Pattern 4b (streaming) +│ │ +│ ├─ Verbose schema → Token overhead +│ │ └─ Large @Generable type? → Pattern 4c (includeSchemaInPrompt) +│ │ +│ └─ Complex prompt → Too much processing +│ └─ Massive prompt or task? → Pattern 4d (break down) +│ +└─ UI frozen? + └─ Main thread blocked → Async issue + └─ App unresponsive during generation? → Pattern 5a (Task {}) +``` + +## Diagnostic Patterns + +### Pattern 1a: Device Not Capable + +**Symptom**: +- `SystemLanguageModel.default.availability = .unavailable` +- Reason: Device not Apple Intelligence-capable + +**Diagnosis**: +```swift +let availability = SystemLanguageModel.default.availability + +switch availability { +case .available: + print("✅ Available") +case .unavailable(let reason): + print("❌ Reason: \(reason)") + // Check if device-related +} +``` + +**Fix**: +```swift +// ❌ BAD - No availability UI +let session = LanguageModelSession() // Crashes on unsupported devices + +// ✅ GOOD - Graceful UI +struct AIFeatureView: View { + @State private var availability = SystemLanguageModel.default.availability + + var body: some View { + switch availability { + case .available: + AIContentView() + case .unavailable: + VStack { + Image(systemName: "cpu") + Text("AI features require Apple Intelligence") + .font(.headline) + Text("Available on iPhone 15 Pro and later") + .font(.caption) + .foregroundColor(.secondary) + } + } + } +} +``` + +**Time cost**: 5-10 minutes to add UI + +--- + +### Pattern 1b: Regional Availability + +**Symptom**: +- Feature works for some users, not others +- .unavailable due to region restrictions + +**Diagnosis**: +Foundation Models requires: +- Supported region (e.g., US, UK, Australia initially) +- May expand over time + +**Fix**: +```swift +// ✅ GOOD - Clear messaging +switch SystemLanguageModel.default.availability { +case .available: + // proceed +case .unavailable(let reason): + // Show region-specific message + Text("AI features not yet available in your region") + Text("Check Settings → Apple Intelligence for availability") +} +``` + +**Time cost**: 5 minutes + +--- + +### Pattern 1c: User Not Opted In + +**Symptom**: +- Device capable, region supported +- Still .unavailable + +**Diagnosis**: +User must opt in to Apple Intelligence in Settings + +**Fix**: +```swift +// ✅ GOOD - Direct user to settings +switch SystemLanguageModel.default.availability { +case .available: + // proceed +case .unavailable: + VStack { + Text("Enable Apple Intelligence") + Text("Settings → Apple Intelligence → Enable") + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + } +} +``` + +**Time cost**: 10 minutes + +--- + +### Pattern 2a: Context Window Exceeded + +**Symptom**: +``` +Error: LanguageModelSession.GenerationError.exceededContextWindowSize +``` + +**Diagnosis**: +- 4096 token limit (input + output) +- Long conversations accumulate tokens +- Verbose prompts eat into limit + +**Fix**: +```swift +// ❌ BAD - Unhandled error +let response = try await session.respond(to: prompt) +// Crashes after ~10-15 turns + +// ✅ GOOD - Condense transcript +var session = LanguageModelSession() + +do { + let response = try await session.respond(to: prompt) +} catch LanguageModelSession.GenerationError.exceededContextWindowSize { + // Condense and continue + session = condensedSession(from: session) + let response = try await session.respond(to: prompt) +} + +func condensedSession(from previous: LanguageModelSession) -> LanguageModelSession { + let entries = previous.transcript.entries + + guard entries.count > 2 else { + return LanguageModelSession(transcript: previous.transcript) + } + + // Keep: first (instructions) + last (recent context) + var condensed = [entries.first!, entries.last!] + + let transcript = Transcript(entries: condensed) + return LanguageModelSession(transcript: transcript) +} +``` + +**Time cost**: 15-20 minutes to implement condensing + +--- + +### Pattern 2b: Guardrail Violation + +**Symptom**: +``` +Error: LanguageModelSession.GenerationError.guardrailViolation +``` + +**Diagnosis**: +- User input triggered content policy +- Violence, hate speech, illegal activities +- Model refuses to generate + +**Fix**: +```swift +// ✅ GOOD - Graceful handling +do { + let response = try await session.respond(to: userInput) + print(response.content) +} catch LanguageModelSession.GenerationError.guardrailViolation { + // Show user-friendly message + print("I can't help with that request") + // Log for review (but don't show user input to avoid storing harmful content) +} +``` + +**Time cost**: 5-10 minutes + +--- + +### Pattern 2c: Unsupported Language + +**Symptom**: +``` +Error: LanguageModelSession.GenerationError.unsupportedLanguageOrLocale +``` + +**Diagnosis**: +User input in language model doesn't support + +**Fix**: +```swift +// ❌ BAD - No language check +let response = try await session.respond(to: userInput) +// Crashes if unsupported language + +// ✅ GOOD - Check first +let supported = SystemLanguageModel.default.supportedLanguages + +guard supported.contains(Locale.current.language) else { + // Show disclaimer + print("Language not supported. Currently supports: \(supported)") + return +} + +// Also handle errors +do { + let response = try await session.respond(to: userInput) +} catch LanguageModelSession.GenerationError.unsupportedLanguageOrLocale { + print("Please use English or another supported language") +} +``` + +**Time cost**: 10 minutes + +--- + +### Pattern 2d: General Error Handling + +**Symptom**: +Unknown error types + +**Fix**: +```swift +// ✅ GOOD - Comprehensive error handling +do { + let response = try await session.respond(to: prompt) + print(response.content) +} catch LanguageModelSession.GenerationError.exceededContextWindowSize { + // Handle context overflow + session = condensedSession(from: session) +} catch LanguageModelSession.GenerationError.guardrailViolation { + // Handle content policy + showMessage("Cannot generate that content") +} catch LanguageModelSession.GenerationError.unsupportedLanguageOrLocale { + // Handle language issue + showMessage("Language not supported") +} catch { + // Catch-all for unexpected errors + print("Unexpected error: \(error)") + showMessage("Something went wrong. Please try again.") +} +``` + +**Time cost**: 10-15 minutes + +--- + +### Pattern 3a: Hallucinated Output (Wrong Use Case) + +**Symptom**: +- Model gives factually incorrect answers +- Makes up information + +**Diagnosis**: +Using model for world knowledge (wrong use case) + +**Fix**: +```swift +// ❌ BAD - Wrong use case +let prompt = "Who is the president of France?" +let response = try await session.respond(to: prompt) +// Will hallucinate or give outdated info + +// ✅ GOOD - Use server LLM for world knowledge +// Foundation Models is for: +// - Summarization +// - Extraction +// - Classification +// - Content generation + +// OR: Use Tool calling with external data source +struct GetFactTool: Tool { + let name = "getFact" + let description = "Fetch factual information from verified source" + + @Generable + struct Arguments { + let query: String + } + + func call(arguments: Arguments) async throws -> ToolOutput { + // Fetch from Wikipedia API, news API, etc. + let fact = await fetchFactFromAPI(arguments.query) + return ToolOutput(fact) + } +} +``` + +**Time cost**: 20-30 minutes to implement tool OR switch to appropriate AI + +--- + +### Pattern 3b: Wrong Structure (Not Using @Generable) + +**Symptom**: +- Parsing errors +- Invalid JSON +- Wrong keys + +**Diagnosis**: +Manual JSON parsing instead of @Generable + +**Fix**: +```swift +// ❌ BAD - Manual parsing +let prompt = "Generate person as JSON" +let response = try await session.respond(to: prompt) +let data = response.content.data(using: .utf8)! +let person = try JSONDecoder().decode(Person.self, from: data) // CRASHES + +// ✅ GOOD - @Generable +@Generable +struct Person { + let name: String + let age: Int +} + +let response = try await session.respond( + to: "Generate a person", + generating: Person.self +) +// response.content is type-safe Person, guaranteed structure +``` + +**Time cost**: 10 minutes to convert to @Generable + +--- + +### Pattern 3c: Missing Data (Need Tool) + +**Symptom**: +- Model doesn't have required information +- Output is vague or generic + +**Diagnosis**: +Need external data (weather, locations, contacts) + +**Fix**: +```swift +// ❌ BAD - No external data +let response = try await session.respond( + to: "What's the weather in Tokyo?" +) +// Will make up weather data + +// ✅ GOOD - Tool calling +import WeatherKit + +struct GetWeatherTool: Tool { + let name = "getWeather" + let description = "Get current weather for a city" + + @Generable + struct Arguments { + let city: String + } + + func call(arguments: Arguments) async throws -> ToolOutput { + // Fetch real weather + let weather = await WeatherService.shared.weather(for: arguments.city) + return ToolOutput("Temperature: \(weather.temperature)°F") + } +} + +let session = LanguageModelSession(tools: [GetWeatherTool()]) +let response = try await session.respond(to: "What's the weather in Tokyo?") +// Uses real weather data +``` + +**Time cost**: 20-30 minutes to implement tool + +--- + +### Pattern 3d: Inconsistent Output (Sampling) + +**Symptom**: +- Different output every time for same prompt +- Need consistent results for testing + +**Diagnosis**: +Random sampling (default behavior) + +**Fix**: +```swift +// Default: Random sampling +let response1 = try await session.respond(to: "Write a haiku") +let response2 = try await session.respond(to: "Write a haiku") +// Different every time + +// ✅ For deterministic output (testing/demos) +let response = try await session.respond( + to: "Write a haiku", + options: GenerationOptions(sampling: .greedy) +) +// Same output for same prompt (given same model version) + +// ✅ For low variance +let response = try await session.respond( + to: "Classify this article", + options: GenerationOptions(temperature: 0.5) +) +// Slightly varied but focused + +// ✅ For high creativity +let response = try await session.respond( + to: "Write a creative story", + options: GenerationOptions(temperature: 2.0) +) +// Very diverse output +``` + +**Time cost**: 2-5 minutes + +--- + +### Pattern 4a: Initial Latency (Prewarm) + +**Symptom**: +- First generation takes 1-2 seconds to start +- Subsequent requests faster + +**Diagnosis**: +Model loading time + +**Fix**: +```swift +// ❌ BAD - Load on user interaction +Button("Generate") { + Task { + let session = LanguageModelSession() // 1-2s delay here + let response = try await session.respond(to: prompt) + } +} + +// ✅ GOOD - Prewarm on init +class ViewModel: ObservableObject { + private var session: LanguageModelSession? + + init() { + // Prewarm before user interaction + Task { + self.session = LanguageModelSession(instructions: "...") + } + } + + func generate(prompt: String) async throws -> String { + guard let session = session else { + // Fallback if not ready + self.session = LanguageModelSession() + return try await self.session!.respond(to: prompt).content + } + return try await session.respond(to: prompt).content + } +} +``` + +**Time cost**: 10 minutes +**Latency saved**: 1-2 seconds on first request + +--- + +### Pattern 4b: Long Generation (Streaming) + +**Symptom**: +- User waits 3-5 seconds seeing nothing +- Then entire result appears at once + +**Diagnosis**: +Not streaming long generations + +**Fix**: +```swift +// ❌ BAD - No streaming +let response = try await session.respond( + to: "Generate 5-day itinerary", + generating: Itinerary.self +) +// User waits 4 seconds seeing nothing + +// ✅ GOOD - Streaming +@Generable +struct Itinerary { + var destination: String + var days: [DayPlan] +} + +let stream = session.streamResponse( + to: "Generate 5-day itinerary to Tokyo", + generating: Itinerary.self +) + +for try await partial in stream { + // Update UI incrementally + self.itinerary = partial +} +// User sees destination in 0.5s, then days progressively +``` + +**Time cost**: 15-20 minutes +**Perceived latency**: 0.5s vs 4s + +--- + +### Pattern 4c: Large Schema Overhead + +**Symptom**: +- Subsequent requests with same @Generable type slow + +**Diagnosis**: +Schema re-inserted into prompt every time + +**Fix**: +```swift +// First request - schema inserted automatically +let first = try await session.respond( + to: "Generate first person", + generating: Person.self +) + +// ✅ Subsequent requests - skip schema insertion +let second = try await session.respond( + to: "Generate another person", + generating: Person.self, + options: GenerationOptions(includeSchemaInPrompt: false) +) +``` + +**Time cost**: 2 minutes +**Latency saved**: 10-20% per request + +--- + +### Pattern 4d: Complex Prompt (Break Down) + +**Symptom**: +- Generation takes >5 seconds +- Poor quality results + +**Diagnosis**: +Prompt too complex for single generation + +**Fix**: +```swift +// ❌ BAD - One massive prompt +let prompt = """ + Generate complete 7-day itinerary with hotels, restaurants, + activities, transportation, budget, tips, and local customs + """ +// 5-8 seconds, poor quality + +// ✅ GOOD - Break into steps +let overview = try await session.respond( + to: "Generate high-level 7-day plan for Tokyo" +) + +var dayDetails: [DayPlan] = [] +for day in 1...7 { + let detail = try await session.respond( + to: "Detail activities and restaurants for day \(day) in Tokyo", + generating: DayPlan.self + ) + dayDetails.append(detail.content) +} +// Total time similar, but better quality and progressive results +``` + +**Time cost**: 20-30 minutes +**Quality improvement**: Significantly better + +--- + +### Pattern 5a: UI Frozen (Main Thread Blocking) + +**Symptom**: +- App unresponsive during generation +- UI freezes for seconds + +**Diagnosis**: +Calling `respond()` on main thread synchronously + +**Fix**: +```swift +// ❌ BAD - Blocking main thread +Button("Generate") { + let response = try await session.respond(to: prompt) + // UI frozen for 2-5 seconds! +} + +// ✅ GOOD - Async task +Button("Generate") { + Task { + do { + let response = try await session.respond(to: prompt) + // Update UI on main thread + await MainActor.run { + self.result = response.content + } + } catch { + print("Error: \(error)") + } + } +} +``` + +**Time cost**: 5 minutes +**UX improvement**: Massive (no frozen UI) + +--- + +## Production Crisis Scenario + +### Context + +**Situation**: You just launched an AI-powered feature using Foundation Models. Within 2 hours: +- 20% of users report "AI feature doesn't work" +- App Store reviews dropping: "New AI broken" +- VP of Product emailing: "What's the ETA on fix?" +- Engineering manager: "Should we roll back?" + +**Pressure Signals**: +- 🚨 **Revenue impact**: Feature is key selling point for new app version +- ⏰ **Time pressure**: "Fix it NOW" +- 👔 **Executive visibility**: VP watching +- 📉 **Public reputation**: App Store reviews visible to all + +### Rationalization Traps + +**DO NOT** fall into these traps: + +1. **"Disable the feature"** + - Loses product differentiation + - Admits defeat + - Doesn't learn what went wrong + +2. **"Roll back to previous version"** + - Loses weeks of work + - Doesn't fix root cause + - Users still angry + +3. **"It works for me"** + - Simulator ≠ real devices + - Your device ≠ all devices + - Ignores real problem + +4. **"Switch to ChatGPT API"** + - Violates privacy + - Expensive at scale + - Doesn't address availability issue + +### MANDATORY Protocol + +#### Phase 1: Identify (5 minutes) + +```swift +// Check error distribution +// What percentage seeing what error? + +// Run this on test devices: +let availability = SystemLanguageModel.default.availability + +switch availability { +case .available: + print("✅ Available") +case .unavailable(let reason): + print("❌ Unavailable: \(reason)") +} + +// Hypothesis: +// - If 20% unavailable → Availability issue (device/region/opt-in) +// - If 20% getting errors → Code bug +// - If 20% seeing wrong results → Use case mismatch +``` + +**Results**: Discover that 20% of users have devices without Apple Intelligence support. + +--- + +#### Phase 2: Confirm (5 minutes) + +```swift +// Check which devices affected +// iPhone 15 Pro+ = ✅ Available +// iPhone 15 = ❌ Unavailable +// iPhone 14 = ❌ Unavailable + +// Conclusion: Availability issue, not code bug +``` + +**Root cause**: Feature assumes all users have Apple Intelligence. 20% don't. + +--- + +#### Phase 3: Device Requirements (5 minutes) + +Verify: +- Apple Intelligence requires iPhone 15 Pro or later +- Or iPad with M1+ chip +- Or Mac with Apple silicon + +#### 20% of user base = older devices + +--- + +#### Phase 4: Implement Fix (15 minutes) + +```swift +// ✅ Add availability check + graceful UI +struct AIFeatureView: View { + @State private var availability = SystemLanguageModel.default.availability + + var body: some View { + switch availability { + case .available: + // Show AI feature + AIContentView() + + case .unavailable: + // Graceful fallback + VStack { + Image(systemName: "sparkles") + .font(.largeTitle) + .foregroundColor(.secondary) + + Text("AI-Powered Features") + .font(.headline) + + Text("Available on iPhone 15 Pro and later") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + // Offer alternative + Button("Use Standard Mode") { + // Show non-AI fallback + } + } + } + } +} +``` + +--- + +#### Phase 5: Deploy (20 minutes) + +1. Test on multiple devices (15 min) + - iPhone 15 Pro: ✅ Shows AI feature + - iPhone 14: ✅ Shows graceful message + - iPad Pro M1: ✅ Shows AI feature + +2. Submit hotfix build (5 min) + +--- + +### Communication Template + +**To VP of Product (immediate)**: +``` +Root cause identified: + +The AI feature requires Apple Intelligence (iPhone 15 Pro+). +20% of our users have older devices. We didn't check availability. + +Fix: Added availability check with graceful fallback UI. + +Timeline: +- Hotfix ready: Now +- TestFlight: 10 minutes +- App Store submission: 30 minutes +- Review: 24-48 hours (requesting expedited) + +Impact mitigation: +- 80% of users see working AI feature +- 20% see clear message + standard mode fallback +- No functionality lost, just graceful degradation +``` + +**To Engineering Team**: +``` +Post-mortem items: +1. Add availability check to launch checklist +2. Test on non-Apple-Intelligence devices +3. Document device requirements clearly +4. Add analytics for availability status +``` + +### Time Saved + +- **Panic path (disable/rollback)**: 2 hours of meetings + lost work +- **Proper diagnosis**: 45 minutes root cause → fix → deploy + +### What We Learned + +1. **Always check availability** before creating session +2. **Test on real devices** across device generations +3. **Graceful degradation** better than feature removal +4. **Clear messaging** to users about requirements + +--- + +## Quick Reference Table + +| Symptom | Cause | Check | Pattern | Time | +|---------|-------|-------|---------|------| +| Won't start | .unavailable | SystemLanguageModel.default.availability | 1a | 5 min | +| Region issue | Not supported region | Check supported regions | 1b | 5 min | +| Not opted in | Apple Intelligence disabled | Settings check | 1c | 10 min | +| Context exceeded | >4096 tokens | Transcript length | 2a | 15 min | +| Guardrail error | Content policy | User input type | 2b | 10 min | +| Language error | Unsupported language | supportedLanguages | 2c | 10 min | +| Hallucinated output | Wrong use case | Task type check | 3a | 20 min | +| Wrong structure | No @Generable | Manual parsing? | 3b | 10 min | +| Missing data | No tool | External data needed? | 3c | 30 min | +| Inconsistent | Random sampling | Need deterministic? | 3d | 5 min | +| Initial delay | Model loading | First request slow? | 4a | 10 min | +| Long wait | No streaming | >1s generation? | 4b | 20 min | +| Schema overhead | Re-inserting schema | Subsequent requests? | 4c | 2 min | +| Complex prompt | Too much at once | >5s generation? | 4d | 30 min | +| UI frozen | Main thread | Thread check | 5a | 5 min | + +--- + +## Cross-References + +**Related Axiom Skills**: +- `axiom-foundation-models` — Discipline skill for anti-patterns, proper usage patterns, pressure scenarios +- `axiom-foundation-models-ref` — Complete API reference with all WWDC 2025 code examples + +**Apple Resources**: +- Foundation Models Framework Documentation +- WWDC 2025-286: Meet the Foundation Models framework +- WWDC 2025-301: Deep dive into the Foundation Models framework +- Instruments Foundation Models Template + +--- + +**Last Updated**: 2025-12-03 +**Version**: 1.0.0 +**Skill Type**: Diagnostic diff --git a/.claude/skills/axiom-foundation-models-diag/agents/openai.yaml b/.claude/skills/axiom-foundation-models-diag/agents/openai.yaml new file mode 100644 index 0000000..048ccd8 --- /dev/null +++ b/.claude/skills/axiom-foundation-models-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Foundation Models Diagnostics" + short_description: "Debugging Foundation Models issues" diff --git a/.claude/skills/axiom-foundation-models-ref/.openskills.json b/.claude/skills/axiom-foundation-models-ref/.openskills.json new file mode 100644 index 0000000..c5573be --- /dev/null +++ b/.claude/skills/axiom-foundation-models-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-foundation-models-ref", + "installedAt": "2026-04-12T08:06:18.818Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-foundation-models-ref/SKILL.md b/.claude/skills/axiom-foundation-models-ref/SKILL.md new file mode 100644 index 0000000..5669b28 --- /dev/null +++ b/.claude/skills/axiom-foundation-models-ref/SKILL.md @@ -0,0 +1,1089 @@ +--- +name: axiom-foundation-models-ref +description: Reference — Complete Foundation Models framework guide covering LanguageModelSession, @Generable, @Guide, Tool protocol, streaming, dynamic schemas, built-in use cases, and all WWDC 2025 code examples +license: MIT +compatibility: iOS 26+, macOS 26+, iPadOS 26+, axiom-visionOS 26+ +metadata: + version: "1.0.0" + last-updated: "2025-12-03" +--- + +# Foundation Models Framework — Complete API Reference + +## Overview + +The Foundation Models framework provides access to Apple's on-device Large Language Model (3 billion parameters, 2-bit quantized) with a Swift API. This reference covers every API, all WWDC 2025 code examples, and comprehensive implementation patterns. + +### Model Specifications + +3B parameter model, 2-bit quantized, 4096 token context (input + output combined). Optimized for on-device summarization, extraction, classification, and generation. NOT suited for world knowledge, complex reasoning, math, or translation. Runs entirely on-device — no network, no cost, no data leaves device. + +--- + +## When to Use This Reference + +Use this reference when: +- Implementing Foundation Models features +- Understanding API capabilities +- Looking up specific code examples +- Planning architecture with Foundation Models +- Migrating from prototype to production +- Debugging implementation issues + +**Related Skills**: +- `axiom-foundation-models` — Discipline skill with anti-patterns, pressure scenarios, decision trees +- `axiom-foundation-models-diag` — Diagnostic skill for troubleshooting issues + +--- + +## LanguageModelSession + +### Overview + +`LanguageModelSession` is the core class for interacting with the model. It maintains conversation history (transcript), handles multi-turn interactions, and manages model state. + +### Creating a Session + +**Basic Creation**: +```swift +import FoundationModels + +let session = LanguageModelSession() +``` + +**With Custom Instructions**: +```swift +let session = LanguageModelSession(instructions: """ + You are a friendly barista in a pixel art coffee shop. + Respond to the player's question concisely. + """ +) +``` + +#### From WWDC 301:1:05 + +**With Tools**: +```swift +let session = LanguageModelSession( + tools: [GetWeatherTool()], + instructions: "Help user with weather forecasts." +) +``` + +#### From WWDC 286:15:03 + +**With Specific Model/Use Case**: +```swift +let session = LanguageModelSession( + model: SystemLanguageModel(useCase: .contentTagging) +) +``` + +#### From WWDC 286:18:39 + +### Instructions vs Prompts + +**Instructions**: +- Come from **developer** +- Define model's role, style, constraints +- Mostly static +- First entry in transcript +- Model trained to obey instructions over prompts (security feature) + +**Prompts**: +- Come from **user** (or dynamic app state) +- Specific requests for generation +- Dynamic input +- Each call to `respond(to:)` adds prompt to transcript + +**Security Consideration**: +- **NEVER** interpolate untrusted user input into instructions +- User input should go in prompts only +- Prevents prompt injection attacks + +### respond(to:) Method + +**Basic Text Generation**: +```swift +func respond(userInput: String) async throws -> String { + let session = LanguageModelSession(instructions: """ + You are a friendly barista in a world full of pixels. + Respond to the player's question. + """ + ) + let response = try await session.respond(to: userInput) + return response.content +} +``` + +#### From WWDC 301:1:05 + +**Return Type**: `Response` with `.content` property + +### respond(to:generating:) Method + +**Structured Output with @Generable**: +```swift +@Generable +struct SearchSuggestions { + @Guide(description: "A list of suggested search terms", .count(4)) + var searchTerms: [String] +} + +let prompt = """ + Generate a list of suggested search terms for an app about visiting famous landmarks. + """ + +let response = try await session.respond( + to: prompt, + generating: SearchSuggestions.self +) + +print(response.content) // SearchSuggestions instance +``` + +#### From WWDC 286:5:51 + +**Return Type**: `Response` with `.content` property + +### Generation Options + +See [Sampling & Generation Options](#sampling--generation-options) for `GenerationOptions` including `sampling:`, `temperature:`, and `includeSchemaInPrompt:`. + +--- + +## Multi-Turn Interactions + +### Retaining Context + +```swift +let session = LanguageModelSession() + +// First turn +let firstHaiku = try await session.respond(to: "Write a haiku about fishing") +print(firstHaiku.content) +// Silent waters gleam, +// Casting lines in morning mist— +// Hope in every cast. + +// Second turn - model remembers context +let secondHaiku = try await session.respond(to: "Do another one about golf") +print(secondHaiku.content) +// Silent morning dew, +// Caddies guide with gentle words— +// Paths of patience tread. + +print(session.transcript) // Shows full history +``` + +#### From WWDC 286:17:46 + +**How it works**: +- Each `respond()` call adds entry to transcript +- Model uses entire transcript for context +- Enables conversational interactions + +### Transcript Property + +```swift +let transcript = session.transcript + +for entry in transcript.entries { + print("Entry: \(entry.content)") +} +``` + +**Use cases**: +- Debugging generation issues +- Displaying conversation history in UI +- Exporting chat logs +- Condensing for context management + +--- + +## isResponding Property + +Gate UI on `session.isResponding` to prevent concurrent requests: + +```swift +Button("Go!") { + Task { haiku = try await session.respond(to: prompt).content } +} +.disabled(session.isResponding) +``` + +#### From WWDC 286:18:22 + +--- + +## @Generable Macro + +### Overview + +`@Generable` enables structured output from the model using Swift types. The macro generates a schema at compile-time and uses **constrained decoding** to guarantee structural correctness. + +### Basic Usage + +**On Structs**: +```swift +@Generable +struct Person { + let name: String + let age: Int +} + +let response = try await session.respond( + to: "Generate a person", + generating: Person.self +) + +let person = response.content // Type-safe Person instance +``` + +#### From WWDC 301:8:14 + +**On Enums**: +```swift +@Generable +struct NPC { + let name: String + let encounter: Encounter + + @Generable + enum Encounter { + case orderCoffee(String) + case wantToTalkToManager(complaint: String) + } +} +``` + +#### From WWDC 301:10:49 + +### Supported Types + +**Primitives**: +- `String` +- `Int`, `Float`, `Double`, `Decimal` +- `Bool` + +**Collections**: +- `[ElementType]` (arrays) + +**Composed Types**: +```swift +@Generable +struct Itinerary { + var destination: String + var days: Int + var budget: Float + var rating: Double + var requiresVisa: Bool + var activities: [String] + var emergencyContact: Person + var relatedItineraries: [Itinerary] // Recursive! +} +``` + +#### From WWDC 286:6:18 + +### @Guide Constraints + +`@Guide` constrains generated properties. Supports `description:` (natural language), `.range()` (numeric bounds), `.count()` / `.maximumCount()` (array length), and `Regex` (pattern matching). + +```swift +@Generable +struct NPC { + @Guide(description: "A full name") + let name: String + + @Guide(.range(1...10)) + let level: Int + + @Guide(.count(3)) + let attributes: [String] +} +``` + +#### From WWDC 301:11:20 + +### Constrained Decoding + +**How it works**: +1. `@Generable` macro generates schema at compile-time +2. Schema defines valid token sequences +3. During generation, model creates probability distribution for next token +4. Framework **masks out invalid tokens** based on schema +5. Model can only pick tokens valid according to schema +6. Guarantees structural correctness - no hallucinated keys, no invalid JSON + +**From WWDC 286**: "Constrained decoding prevents structural mistakes. Model is prevented from generating invalid field names or wrong types." + +**Benefits**: +- Zero parsing code needed +- No runtime parsing errors +- Type-safe Swift objects +- Compile-time safety (changes to struct caught by compiler) + +### Property Declaration Order + +**Properties generated in order declared**: +```swift +@Generable +struct Itinerary { + var name: String // Generated FIRST + var days: [DayPlan] // Generated SECOND + var summary: String // Generated LAST +} +``` + +**Why it matters**: +- Later properties can reference earlier ones +- Better model quality: Summaries after content +- Better streaming UX: Important properties first + +#### From WWDC 286:11:00 + +--- + +## Streaming + +### Overview + +Foundation Models uses **snapshot streaming** (not delta streaming). Instead of raw deltas, the framework streams `PartiallyGenerated` types with optional properties that fill in progressively. + +### PartiallyGenerated Type + +The `@Generable` macro automatically creates a `PartiallyGenerated` nested type: + +```swift +@Generable +struct Itinerary { + var name: String + var days: [DayPlan] +} + +// Compiler generates: +extension Itinerary { + struct PartiallyGenerated { + var name: String? // All properties optional! + var days: [DayPlan]? + } +} +``` + +#### From WWDC 286:9:20 + +### streamResponse Method + +```swift +@Generable +struct Itinerary { + var name: String + var days: [Day] +} + +let stream = session.streamResponse( + to: "Craft a 3-day itinerary to Mt. Fuji.", + generating: Itinerary.self +) + +for try await partial in stream { + print(partial) // Incrementally updated Itinerary.PartiallyGenerated +} +``` + +#### From WWDC 286:9:40 + +**Return Type**: `AsyncSequence` + +### SwiftUI Integration + +```swift +struct ItineraryView: View { + let session: LanguageModelSession + let dayCount: Int + let landmarkName: String + + @State + private var itinerary: Itinerary.PartiallyGenerated? + + var body: some View { + VStack { + if let name = itinerary?.name { + Text(name).font(.title) + } + + if let days = itinerary?.days { + ForEach(days, id: \.self) { day in + DayView(day: day) + } + } + + Button("Start") { + Task { + do { + let prompt = """ + Generate a \(dayCount) itinerary \ + to \(landmarkName). + """ + + let stream = session.streamResponse( + to: prompt, + generating: Itinerary.self + ) + + for try await partial in stream { + self.itinerary = partial + } + } catch { + print(error) + } + } + } + } + } +} +``` + +#### From WWDC 286:10:05 + +### Best Practices + +**1. Use SwiftUI animations**: +```swift +if let name = itinerary?.name { + Text(name) + .transition(.opacity) +} +``` + +**2. View identity for arrays**: +```swift +// ✅ GOOD - Stable identity +ForEach(days, id: \.id) { day in + DayView(day: day) +} + +// ❌ BAD - Identity changes +ForEach(days.indices, id: \.self) { index in + DayView(day: days[index]) +} +``` + +**3. Property order optimization**: +```swift +// ✅ GOOD - Title first for streaming +@Generable +struct Article { + var title: String // Shows immediately + var summary: String // Shows second + var fullText: String // Shows last +} +``` + +#### From WWDC 286:11:00 + +--- + +## Tool Protocol + +### Overview + +Tools let the model autonomously execute your custom code to fetch external data or perform actions. Tools integrate with MapKit, WeatherKit, Contacts, EventKit, or any custom API. + +### Protocol Definition + +```swift +protocol Tool { + var name: String { get } + var description: String { get } + + associatedtype Arguments: Generable + + func call(arguments: Arguments) async throws -> ToolOutput +} +``` + +### Example: GetWeatherTool + +```swift +import FoundationModels +import WeatherKit +import CoreLocation + +struct GetWeatherTool: Tool { + let name = "getWeather" + let description = "Retrieve the latest weather information for a city" + + @Generable + struct Arguments { + @Guide(description: "The city to fetch the weather for") + var city: String + } + + func call(arguments: Arguments) async throws -> ToolOutput { + let places = try await CLGeocoder().geocodeAddressString(arguments.city) + let weather = try await WeatherService.shared.weather(for: places.first!.location!) + let temperature = weather.currentWeather.temperature.value + + let content = GeneratedContent(properties: ["temperature": temperature]) + let output = ToolOutput(content) + + // Or if your tool's output is natural language: + // let output = ToolOutput("\(arguments.city)'s temperature is \(temperature) degrees.") + + return output + } +} +``` + +#### From WWDC 286:13:42 + +### Attaching Tools to Session + +```swift +let session = LanguageModelSession( + tools: [GetWeatherTool()], + instructions: "Help the user with weather forecasts." +) + +let response = try await session.respond( + to: "What is the temperature in Cupertino?" +) + +print(response.content) +// It's 71˚F in Cupertino! +``` + +#### From WWDC 286:15:03 + +**How it works**: +1. Session initialized with tools +2. User prompt: "What's Tokyo's weather?" +3. Model analyzes prompt, decides weather data needed +4. Model generates tool call: `getWeather(city: "Tokyo")` +5. Framework calls `call()` method +6. Your code fetches real data from API +7. Tool output inserted into transcript +8. Model generates final response using tool output + +**From WWDC 301**: "Model autonomously decides when and how often to call tools. Can call multiple tools per request, even in parallel." + +### Stateful Tools + +Use `class` instead of `struct` to maintain state across tool calls. The tool instance persists for the session lifetime, enabling patterns like tracking previously returned results: + +```swift +class FindContactTool: Tool { + let name = "findContact" + let description = "Finds a contact from a specified age generation." + var pickedContacts = Set() + + @Generable + struct Arguments { + let generation: Generation + @Generable + enum Generation { case babyBoomers, genX, millennial, genZ } + } + + func call(arguments: Arguments) async throws -> ToolOutput { + // Fetch, filter out already-picked, return new contact + pickedContacts.insert(pickedContact.givenName) + return ToolOutput(pickedContact.givenName) + } +} +``` + +#### From WWDC 301:18:47, 301:21:55 + +### ToolOutput + +**Two forms**: + +1. **Natural language** (String): +```swift +return ToolOutput("Temperature is 71°F") +``` + +2. **Structured** (GeneratedContent): +```swift +let content = GeneratedContent(properties: ["temperature": 71]) +return ToolOutput(content) +``` + +### Tool Naming Best Practices + +**DO**: +- Short, readable names: `getWeather`, `findContact` +- Use verbs: `get`, `find`, `fetch`, `create` +- One sentence descriptions +- Keep descriptions concise (they're in prompt) + +**DON'T**: +- Abbreviations: `gtWthr` +- Implementation details in description +- Long descriptions (increases token count) + +**From WWDC 301**: "Tool name and description put verbatim in prompt. Longer strings mean more tokens, which increases latency." + +### Multiple Tools + +```swift +let session = LanguageModelSession( + tools: [ + GetWeatherTool(), + FindRestaurantTool(), + FindHotelTool() + ], + instructions: "Plan travel itineraries." +) + +// Model autonomously decides which tools to call and when +``` + +### Tool Calling Behavior + +**Key facts**: +- Tool can be called **multiple times** per request +- Multiple tools can be called **in parallel** +- Model decides **when** to call (not guaranteed to call) +- Arguments guaranteed valid via @Generable + +**From WWDC 301**: "When tools called in parallel, your call method may execute concurrently. Keep this in mind when accessing data." + +--- + +## Dynamic Schemas + +### Overview + +`DynamicGenerationSchema` enables creating schemas at runtime instead of compile-time. Useful for user-defined structures, level creators, or dynamic forms. + +### Creating and Using Dynamic Schemas + +Build properties with `DynamicGenerationSchema.Property`, compose into schemas, then validate with `GenerationSchema`: + +```swift +// Build schema at runtime +let questionProp = DynamicGenerationSchema.Property( + name: "question", schema: DynamicGenerationSchema(type: String.self) +) +let answersProp = DynamicGenerationSchema.Property( + name: "answers", schema: DynamicGenerationSchema( + arrayOf: DynamicGenerationSchema(referenceTo: "Answer") + ) +) + +let riddleSchema = DynamicGenerationSchema(name: "Riddle", properties: [questionProp, answersProp]) +let answerSchema = DynamicGenerationSchema(name: "Answer", properties: [/* text, isCorrect */]) + +// Validate and use +let schema = try GenerationSchema(root: riddleSchema, dependencies: [answerSchema]) +let response = try await session.respond(to: "Generate a riddle", schema: schema) + +let question = try response.content.value(String.self, forProperty: "question") +``` + +#### From WWDC 301:14:50, 301:15:10 + +### Dynamic vs Static @Generable + +**Use @Generable when**: +- Structure known at compile-time +- Want type safety +- Want automatic parsing + +**Use Dynamic Schemas when**: +- Structure only known at runtime +- User-defined schemas +- Maximum flexibility + +**From WWDC 301**: "Compile-time @Generable gives type safety. Dynamic schemas give runtime flexibility. Both use same constrained decoding guarantees." + +--- + +## Sampling & Generation Options + +**Greedy (deterministic)** — use for tests and demos. Only deterministic within same model version: +```swift +let response = try await session.respond( + to: prompt, + options: GenerationOptions(sampling: .greedy) +) +``` + +**Temperature** — controls variance. `0.1-0.5` focused, `1.0` default, `1.5-2.0` creative: +```swift +let response = try await session.respond( + to: prompt, + options: GenerationOptions(temperature: 0.5) +) +``` + +#### From WWDC 301:6:14 + +--- + +## Built-in Use Cases + +### Content Tagging Adapter + +**Specialized adapter for**: +- Tag generation +- Entity extraction +- Topic detection + +```swift +@Generable +struct Result { + let topics: [String] +} + +let session = LanguageModelSession( + model: SystemLanguageModel(useCase: .contentTagging) +) + +let response = try await session.respond( + to: articleText, + generating: Result.self +) +``` + +#### From WWDC 286:19:19 + +### Custom Use Cases + +**With custom instructions**: +```swift +@Generable +struct Top3ActionEmotionResult { + @Guide(.maximumCount(3)) + let actions: [String] + @Guide(.maximumCount(3)) + let emotions: [String] +} + +let session = LanguageModelSession( + model: SystemLanguageModel(useCase: .contentTagging), + instructions: "Tag the 3 most important actions and emotions in the given input text." +) + +let response = try await session.respond( + to: text, + generating: Top3ActionEmotionResult.self +) +``` + +#### From WWDC 286:19:35 + +--- + +## Error Handling + +### GenerationError Types + +Catch `LanguageModelSession.GenerationError` cases: +- **`.exceededContextWindowSize`** — Context limit (4096 tokens) exceeded. Condense transcript or create new session. +- **`.guardrailViolation`** — Content policy triggered. Show graceful message. +- **`.unsupportedLanguageOrLocale`** — Language not supported. Check `supportedLanguages`. + +#### From WWDC 301:3:37, 301:7:06 + +### Context Window Management + +#### Strategy 1: Fresh Session +```swift +var session = LanguageModelSession() + +do { + let response = try await session.respond(to: prompt) + print(response.content) +} catch LanguageModelSession.GenerationError.exceededContextWindowSize { + // New session, no history + session = LanguageModelSession() +} +``` + +#### From WWDC 301:3:37 + +#### Strategy 2: Condensed Session +```swift +do { + let response = try await session.respond(to: prompt) +} catch LanguageModelSession.GenerationError.exceededContextWindowSize { + // New session with some history + session = newSession(previousSession: session) +} + +private func newSession(previousSession: LanguageModelSession) -> LanguageModelSession { + let allEntries = previousSession.transcript.entries + var condensedEntries = [Transcript.Entry]() + + if let firstEntry = allEntries.first { + condensedEntries.append(firstEntry) // Instructions + + if allEntries.count > 1, let lastEntry = allEntries.last { + condensedEntries.append(lastEntry) // Recent context + } + } + + let condensedTranscript = Transcript(entries: condensedEntries) + // Note: transcript includes instructions + return LanguageModelSession(transcript: condensedTranscript) +} +``` + +#### From WWDC 301:3:55 + +### Fallback Architecture + +When Foundation Models is unavailable (older device, user opted out, unsupported region), provide graceful degradation: + +```swift +func summarize(_ text: String) async throws -> String { + let model = SystemLanguageModel.default + switch model.availability { + case .available: + let session = LanguageModelSession() + let response = try await session.respond(to: "Summarize: \(text)") + return response.content + case .unavailable: + // Fallback: truncate with ellipsis, or call server API + return String(text.prefix(200)) + "..." + } +} +``` + +**Architecture pattern**: Wrap Foundation Models behind a protocol so you can swap implementations: + +```swift +protocol TextSummarizer { + func summarize(_ text: String) async throws -> String +} + +struct OnDeviceSummarizer: TextSummarizer { /* Foundation Models */ } +struct ServerSummarizer: TextSummarizer { /* Server API fallback */ } +struct TruncationSummarizer: TextSummarizer { /* Simple truncation */ } +``` + +### Nested @Generable Troubleshooting + +Nested `@Generable` types must each independently conform to `@Generable`: + +```swift +// ✅ Both types marked @Generable +@Generable struct Itinerary { + var days: [DayPlan] +} + +@Generable struct DayPlan { + var activities: [String] +} + +// ❌ Will fail — nested type not @Generable +@Generable struct Itinerary { + var days: [DayPlan] // DayPlan must also be @Generable +} +struct DayPlan { var activities: [String] } +``` + +**Common issue**: Arrays of non-Generable types compile but fail at runtime. Check all types in the graph. + +--- + +## Availability + +### Checking Availability + +```swift +struct AvailabilityExample: View { + private let model = SystemLanguageModel.default + + var body: some View { + switch model.availability { + case .available: + Text("Model is available").foregroundStyle(.green) + case .unavailable(let reason): + Text("Model is unavailable").foregroundStyle(.red) + Text("Reason: \(reason)") + } + } +} +``` + +#### From WWDC 286:19:56 + +### Supported Languages + +```swift +let supportedLanguages = SystemLanguageModel.default.supportedLanguages +guard supportedLanguages.contains(Locale.current.language) else { + // Show message + return +} +``` + +#### From WWDC 301:7:06 + +### Requirements + +**Device Requirements**: +- Apple Intelligence-enabled device +- iPhone 15 Pro or later +- iPad with M1+ chip +- Mac with Apple silicon + +**Region Requirements**: +- Supported region (check Apple Intelligence availability) + +**User Requirements**: +- User opted in to Apple Intelligence in Settings + +--- + +## Performance & Profiling + +### Foundation Models Instrument + +**Access**: Instruments app → Foundation Models template + +**Metrics**: +- Initial model load time +- Token counts (input/output) +- Generation time per request +- Latency breakdown +- Optimization opportunities + +**From WWDC 286**: "New Instruments profiling template lets you observe areas of optimization and quantify improvements." + +### Optimization: Prewarming + +**Problem**: First request takes 1-2s to load model + +**Solution**: Create session before user interaction + +```swift +class ViewModel: ObservableObject { + private var session: LanguageModelSession? + + init() { + // Prewarm on init + Task { + self.session = LanguageModelSession(instructions: "...") + } + } + + func generate(prompt: String) async throws -> String { + let response = try await session!.respond(to: prompt) + return response.content + } +} +``` + +**From WWDC 259**: "Prewarming session before user interaction reduces initial latency." + +**Time saved**: 1-2 seconds off first generation + +### Optimization: includeSchemaInPrompt + +**Problem**: Large @Generable schemas increase token count + +**Solution**: Skip schema insertion for subsequent requests + +```swift +// First request - schema inserted +let first = try await session.respond( + to: "Generate first person", + generating: Person.self +) + +// Subsequent requests - skip schema +let second = try await session.respond( + to: "Generate another person", + generating: Person.self, + options: GenerationOptions(includeSchemaInPrompt: false) +) +``` + +**From WWDC 259**: "Setting includeSchemaInPrompt to false decreases token count and latency for subsequent requests." + +**Time saved**: 10-20% per request + +### Optimization: Property Order + +Declare important properties first in `@Generable` structs. With streaming, perceived latency drops from 2.5s to 0.2s when title appears before full text. See [Streaming Best Practices](#best-practices) for examples. + +--- + +## Feedback & Analytics + +`LanguageModelFeedbackAttachment` lets you report model quality issues to Apple via Feedback Assistant. Create with `input`, `output`, `sentiment` (`.positive`/`.negative`), `issues` (category + explanation), and `desiredOutputExamples`. Encode as JSON and attach to a Feedback Assistant report. + +#### From WWDC 286:22:13 + +--- + +## Xcode Playgrounds + +### Overview + +Xcode Playgrounds enable rapid iteration on prompts without rebuilding entire app. + +### Basic Usage + +```swift +import FoundationModels +import Playgrounds + +#Playground { + let session = LanguageModelSession() + let response = try await session.respond( + to: "What's a good name for a trip to Japan? Respond only with a title" + ) +} +``` + +#### From WWDC 286:2:28 + +Playgrounds can also access types defined in your app (like @Generable structs). + +--- + +## API Quick Reference + +- **`LanguageModelSession`** — Main interface: `respond(to:)` → `Response`, `respond(to:generating:)` → `Response`, `streamResponse(to:generating:)` → `AsyncSequence`. Properties: `transcript`, `isResponding`. +- **`SystemLanguageModel`** — `default.availability` (`.available`/`.unavailable(reason)`), `default.supportedLanguages`, `init(useCase:)` +- **`GenerationOptions`** — `sampling` (`.greedy`/`.random`), `temperature`, `includeSchemaInPrompt` +- **`@Generable`** — Macro enabling structured output with constrained decoding +- **`@Guide`** — Property constraints: `description:`, `.range()`, `.count()`, `.maximumCount()`, `Regex` +- **`Tool` protocol** — `name`, `description`, `Arguments: Generable`, `call(arguments:) → ToolOutput` +- **`DynamicGenerationSchema`** — Runtime schema definition with `GeneratedContent` output +- **`GenerationError`** — `.exceededContextWindowSize`, `.guardrailViolation`, `.unsupportedLanguageOrLocale` + +--- + +## Migration Strategies + +### From Server LLMs + +- **Migrate when**: Privacy required, offline needed, per-request costs are a concern, and use case fits (summarization/extraction/classification) +- **Stay on server when**: Need world knowledge, complex reasoning, or >4096 token context + +### From Manual JSON Parsing + +Use `@Generable` with `respond(to:generating:)` instead of prompting for JSON and parsing manually. See `axiom-foundation-models` Scenario 2 for the complete migration pattern. + +--- + +## Resources + +**WWDC**: 286, 259, 301 + +**Skills**: axiom-foundation-models, axiom-foundation-models-diag + +--- + +**Last Updated**: 2025-12-03 +**Version**: 1.0.0 +**Skill Type**: Reference +**Content**: All WWDC 2025 code examples included diff --git a/.claude/skills/axiom-foundation-models-ref/agents/openai.yaml b/.claude/skills/axiom-foundation-models-ref/agents/openai.yaml new file mode 100644 index 0000000..f88f23c --- /dev/null +++ b/.claude/skills/axiom-foundation-models-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Foundation Models Reference" + short_description: "Reference — Complete Foundation Models framework guide covering LanguageModelSession, @Generable, @Guide, Tool protoc..." diff --git a/.claude/skills/axiom-foundation-models/.openskills.json b/.claude/skills/axiom-foundation-models/.openskills.json new file mode 100644 index 0000000..19987a9 --- /dev/null +++ b/.claude/skills/axiom-foundation-models/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-foundation-models", + "installedAt": "2026-04-12T08:06:17.593Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-foundation-models/SKILL.md b/.claude/skills/axiom-foundation-models/SKILL.md new file mode 100644 index 0000000..3308ac1 --- /dev/null +++ b/.claude/skills/axiom-foundation-models/SKILL.md @@ -0,0 +1,1125 @@ +--- +name: axiom-foundation-models +description: Use when implementing on-device AI with Apple's Foundation Models framework — prevents context overflow, blocking UI, wrong model use cases, and manual JSON parsing when @Generable should be used. iOS 26+, macOS 26+, iPadOS 26+, axiom-visionOS 26+ +license: MIT +compatibility: iOS 26+, macOS 26+, iPadOS 26+, axiom-visionOS 26+ +metadata: + version: "1.0.0" + last-updated: "2025-12-03" +--- + +# Foundation Models — On-Device AI for Apple Platforms + +## When to Use This Skill + +Use when: +- Implementing on-device AI features with Foundation Models +- Adding text summarization, classification, or extraction capabilities +- Creating structured output from LLM responses +- Building tool-calling patterns for external data integration +- Streaming generated content for better UX +- Debugging Foundation Models issues (context overflow, slow generation, wrong output) +- Deciding between Foundation Models vs server LLMs (ChatGPT, Claude, etc.) + +#### Related Skills +- Use `axiom-foundation-models-diag` for systematic troubleshooting (context exceeded, guardrail violations, availability problems) +- Use `axiom-foundation-models-ref` for complete API reference with all WWDC code examples + +--- + +## Red Flags — Anti-Patterns That Will Fail + +### ❌ Using for World Knowledge +**Why it fails**: The on-device model is 3 billion parameters, optimized for summarization, extraction, classification — **NOT** world knowledge or complex reasoning. + +**Example of wrong use**: +```swift +// ❌ BAD - Asking for world knowledge +let session = LanguageModelSession() +let response = try await session.respond(to: "What's the capital of France?") +``` + +**Why**: Model will hallucinate or give low-quality answers. It's trained for content generation, not encyclopedic knowledge. + +**Correct approach**: Use server LLMs (ChatGPT, Claude) for world knowledge, or provide factual data through Tool calling. + +--- + +### ❌ Blocking Main Thread +**Why it fails**: `session.respond()` is `async` but if called synchronously on main thread, freezes UI for seconds. + +**Example of wrong use**: +```swift +// ❌ BAD - Blocking main thread +Button("Generate") { + let response = try await session.respond(to: prompt) // UI frozen! +} +``` + +**Why**: Generation takes 1-5 seconds. User sees frozen app, bad reviews follow. + +**Correct approach**: +```swift +// ✅ GOOD - Async on background +Button("Generate") { + Task { + let response = try await session.respond(to: prompt) + // Update UI with response + } +} +``` + +--- + +### ❌ Manual JSON Parsing +**Why it fails**: Prompting for JSON and parsing with JSONDecoder leads to hallucinated keys, invalid JSON, no type safety. + +**Example of wrong use**: +```swift +// ❌ BAD - Manual JSON parsing +let prompt = "Generate a person with name and age as JSON" +let response = try await session.respond(to: prompt) +let data = response.content.data(using: .utf8)! +let person = try JSONDecoder().decode(Person.self, from: data) // CRASHES! +``` + +**Why**: Model might output `{firstName: "John"}` when you expect `{name: "John"}`. Or invalid JSON entirely. + +**Correct approach**: +```swift +// ✅ GOOD - @Generable guarantees structure +@Generable +struct Person { + let name: String + let age: Int +} + +let response = try await session.respond( + to: "Generate a person", + generating: Person.self +) +// response.content is type-safe Person instance +``` + +--- + +### ❌ Ignoring Availability Check +**Why it fails**: Foundation Models only runs on Apple Intelligence devices in supported regions. App crashes or shows errors without check. + +**Example of wrong use**: +```swift +// ❌ BAD - No availability check +let session = LanguageModelSession() // Might fail! +``` + +**Correct approach**: +```swift +// ✅ GOOD - Check first +switch SystemLanguageModel.default.availability { +case .available: + let session = LanguageModelSession() + // proceed +case .unavailable(let reason): + // Show graceful UI: "AI features require Apple Intelligence" +} +``` + +--- + +### ❌ Single Huge Prompt +**Why it fails**: 4096 token context window (input + output). One massive prompt hits limit, gives poor results. + +**Example of wrong use**: +```swift +// ❌ BAD - Everything in one prompt +let prompt = """ + Generate a 7-day itinerary for Tokyo including hotels, restaurants, + activities for each day, transportation details, budget breakdown... + """ +// Exceeds context, poor quality +``` + +**Correct approach**: Break into smaller tasks, use tools for external data, multi-turn conversation. + +--- + +### ❌ Not Handling Generation Errors +**Why it fails**: Three errors MUST be handled or your app will crash in production. + +```swift +do { + let response = try await session.respond(to: prompt) +} catch LanguageModelSession.GenerationError.exceededContextWindowSize { + // Multi-turn transcript grew beyond 4096 tokens + // → Condense transcript and create new session (see Pattern 5) +} catch LanguageModelSession.GenerationError.guardrailViolation { + // Content policy triggered + // → Show graceful message: "I can't help with that request" +} catch LanguageModelSession.GenerationError.unsupportedLanguageOrLocale { + // User input in unsupported language + // → Show disclaimer, check SystemLanguageModel.default.supportedLanguages +} +``` + +--- + +## Mandatory First Steps + +Before writing any Foundation Models code, complete these steps: + +### 1. Check Availability + +See "Ignoring Availability Check" in Red Flags above for the required pattern. Foundation Models requires Apple Intelligence-enabled device, supported region, and user opt-in. + +--- + +### 2. Identify Use Case +**Ask yourself**: What is my primary goal? + +| Use Case | Foundation Models? | Alternative | +|----------|-------------------|-------------| +| Summarization | ✅ YES | | +| Extraction (key info from text) | ✅ YES | | +| Classification (categorize content) | ✅ YES | | +| Content tagging | ✅ YES (built-in adapter!) | | +| World knowledge | ❌ NO | ChatGPT, Claude, Gemini | +| Complex reasoning | ❌ NO | Server LLMs | +| Mathematical computation | ❌ NO | Calculator, symbolic math | + +**Critical**: If your use case requires world knowledge or advanced reasoning, **stop**. Foundation Models is the wrong tool. + +--- + +### 3. Design @Generable Schema +If you need structured output (not just plain text): + +**Bad approach**: Prompt for "JSON" and parse manually +**Good approach**: Define @Generable type + +```swift +@Generable +struct SearchSuggestions { + @Guide(description: "Suggested search terms", .count(4)) + var searchTerms: [String] +} +``` + +**Why**: Constrained decoding guarantees structure. No parsing errors, no hallucinated keys. + +--- + +### 4. Consider Tools for External Data +If your feature needs external information: +- Weather → WeatherKit tool +- Locations → MapKit tool +- Contacts → Contacts API tool +- Calendar → EventKit tool + +**Don't** try to get this information from the model (it will hallucinate). +**Do** define Tool protocol implementations. + +--- + +### 5. Plan Streaming for Long Generations +If generation takes >1 second, use streaming: + +```swift +let stream = session.streamResponse( + to: prompt, + generating: Itinerary.self +) + +for try await partial in stream { + // Update UI incrementally + self.itinerary = partial +} +``` + +**Why**: Users see progress immediately, perceived latency drops dramatically. + +--- + +## Decision Tree + +``` +Need on-device AI? +│ +├─ World knowledge/reasoning? +│ └─ ❌ NOT Foundation Models +│ → Use ChatGPT, Claude, Gemini, etc. +│ → Reason: 3B parameter model, not trained for encyclopedic knowledge +│ +├─ Summarization? +│ └─ ✅ YES → Pattern 1 (Basic Session) +│ → Example: Summarize article, condense email +│ → Time: 10-15 minutes +│ +├─ Structured extraction? +│ └─ ✅ YES → Pattern 2 (@Generable) +│ → Example: Extract name, date, amount from invoice +│ → Time: 15-20 minutes +│ +├─ Content tagging? +│ └─ ✅ YES → Pattern 3 (contentTagging use case) +│ → Example: Tag article topics, extract entities +│ → Time: 10 minutes +│ +├─ Need external data? +│ └─ ✅ YES → Pattern 4 (Tool calling) +│ → Example: Fetch weather, query contacts, get locations +│ → Time: 20-30 minutes +│ +├─ Long generation? +│ └─ ✅ YES → Pattern 5 (Streaming) +│ → Example: Generate itinerary, create story +│ → Time: 15-20 minutes +│ +└─ Dynamic schemas (runtime-defined structure)? + └─ ✅ YES → Pattern 6 (DynamicGenerationSchema) + → Example: Level creator, user-defined forms + → Time: 30-40 minutes +``` + +--- + +## Pattern 1: Basic Session + +**Use when**: Simple text generation, summarization, or content analysis. + +### Core Concepts + +**LanguageModelSession**: +- Stateful — retains transcript of all interactions +- Instructions vs prompts: + - **Instructions** (from developer): Define model's role, static guidance + - **Prompts** (from user): Dynamic input for generation +- Model trained to obey instructions over prompts (security feature) + +### Implementation + +```swift +import FoundationModels + +func respond(userInput: String) async throws -> String { + let session = LanguageModelSession(instructions: """ + You are a friendly barista in a pixel art coffee shop. + Respond to the player's question concisely. + """ + ) + let response = try await session.respond(to: userInput) + return response.content +} +``` + +### Key Points + +1. **Instructions are optional** — Reasonable defaults if omitted +2. **Never interpolate user input into instructions** — Security risk (prompt injection) +3. **Keep instructions concise** — Each token adds latency + +### Multi-Turn Interactions + +```swift +let session = LanguageModelSession() + +// First turn +let first = try await session.respond(to: "Write a haiku about fishing") +print(first.content) +// "Silent waters gleam, +// Casting lines in morning mist— +// Hope in every cast." + +// Second turn - model remembers context +let second = try await session.respond(to: "Do another one about golf") +print(second.content) +// "Silent morning dew, +// Caddies guide with gentle words— +// Paths of patience tread." + +// Inspect full transcript +print(session.transcript) +``` + +**Why this works**: Session retains transcript automatically. Model uses context from previous turns. + +### When to Use This Pattern + +✅ **Good for**: +- Simple Q&A +- Text summarization +- Content analysis +- Single-turn generation + +❌ **Not good for**: +- Structured output (use Pattern 2) +- Long conversations (will hit context limit) +- External data needs (use Pattern 4) + +--- + +## Pattern 2: @Generable Structured Output + +**Use when**: You need structured data from model, not just plain text. + +### The Problem + +Without @Generable: +```swift +// ❌ BAD - Unreliable +let prompt = "Generate a person with name and age as JSON" +let response = try await session.respond(to: prompt) +// Might get: {"firstName": "John"} when you expect {"name": "John"} +// Might get invalid JSON entirely +// Must parse manually, prone to crashes +``` + +### The Solution: @Generable + +```swift +@Generable +struct Person { + let name: String + let age: Int +} + +let session = LanguageModelSession() +let response = try await session.respond( + to: "Generate a person", + generating: Person.self +) + +let person = response.content // Type-safe Person instance! +``` + +### How It Works (Constrained Decoding) + +1. `@Generable` macro generates schema at compile-time +2. Schema passed to model automatically +3. Model generates tokens constrained by schema +4. Framework parses output into Swift type +5. **Guaranteed structural correctness** — No hallucinated keys, no parsing errors + +"Constrained decoding masks out invalid tokens. Model can only pick tokens valid according to schema." + +### Supported Types + +Supports `String`, `Int`, `Float`, `Double`, `Bool`, arrays, nested `@Generable` types, enums with associated values, and recursive types. See `axiom-foundation-models-ref` for complete list with examples. + +### @Guide Constraints + +Control generated values with `@Guide`. Supports descriptions, numeric ranges, array counts, and regex patterns: + +```swift +@Generable +struct NPC { + @Guide(description: "A full name") + let name: String + + @Guide(.range(1...10)) + let level: Int + + @Guide(.count(3)) + let attributes: [String] +} +``` + +**Runtime validation**: `@Guide` constraints are enforced during generation via constrained decoding — the model cannot produce out-of-range values. However, always validate business logic on the result since the model may produce semantically wrong but structurally valid output. + +See `axiom-foundation-models-ref` for complete `@Guide` reference (ranges, regex, maximum counts). + +### Property Order Matters + +Properties generated **in declaration order**: +```swift +@Generable +struct Itinerary { + var destination: String // Generated first + var days: [DayPlan] // Generated second + var summary: String // Generated last +} +``` + +"You may find model produces best summaries when they're last property." + +**Why**: Later properties can reference earlier ones. Put most important properties first for streaming. + +--- + +## Pattern 3: Streaming with PartiallyGenerated + +**Use when**: Generation takes >1 second and you want progressive UI updates. + +### The Problem + +Without streaming: +```swift +// User waits 3-5 seconds seeing nothing +let response = try await session.respond(to: prompt, generating: Itinerary.self) +// Then entire result appears at once +``` + +**User experience**: Feels slow, frozen UI. + +### The Solution: Streaming + +```swift +@Generable +struct Itinerary { + var name: String + var days: [DayPlan] +} + +let stream = session.streamResponse( + to: "Generate a 3-day itinerary to Mt. Fuji", + generating: Itinerary.self +) + +for try await partial in stream { + print(partial) // Incrementally updated +} +``` + +### PartiallyGenerated Type + +`@Generable` macro automatically creates a `PartiallyGenerated` type where all properties are optional (they fill in as the model generates them). See `axiom-foundation-models-ref` for details. + +### SwiftUI Integration + +```swift +struct ItineraryView: View { + let session: LanguageModelSession + @State private var itinerary: Itinerary.PartiallyGenerated? + + var body: some View { + VStack { + if let name = itinerary?.name { + Text(name) + .font(.title) + } + + if let days = itinerary?.days { + ForEach(days, id: \.self) { day in + DayView(day: day) + } + } + + Button("Generate") { + Task { + let stream = session.streamResponse( + to: "Generate 3-day itinerary to Tokyo", + generating: Itinerary.self + ) + + for try await partial in stream { + self.itinerary = partial + } + } + } + } + } +} +``` + +### View Identity + +**Critical for arrays**: +```swift +// ✅ GOOD - Stable identity +ForEach(days, id: \.id) { day in + DayView(day: day) +} + +// ❌ BAD - Identity changes, animations break +ForEach(days.indices, id: \.self) { index in + DayView(day: days[index]) +} +``` + +### When to Use Streaming + +✅ **Use for**: +- Itineraries +- Stories +- Long descriptions +- Multi-section content + +❌ **Skip for**: +- Simple Q&A (< 1 sentence) +- Quick classification +- Content tagging + +### Streaming Error Handling + +Handle errors during streaming gracefully — partial results may already be displayed: + +```swift +do { + for try await partial in stream { + self.itinerary = partial + } +} catch LanguageModelSession.GenerationError.guardrailViolation { + // Partial content may be visible — show non-disruptive error + self.errorMessage = "Generation stopped by content policy" +} catch LanguageModelSession.GenerationError.exceededContextWindowSize { + // Too much context — create fresh session and retry + session = LanguageModelSession() +} +``` + +--- + +## Pattern 4: Tool Calling + +**Use when**: Model needs external data (weather, locations, contacts) to generate response. + +### The Problem + +```swift +// ❌ BAD - Model will hallucinate +let response = try await session.respond( + to: "What's the temperature in Cupertino?" +) +// Output: "It's about 72°F" (completely made up!) +``` + +**Why**: 3B parameter model doesn't have real-time weather data. + +### The Solution: Tool Calling + +Let model **autonomously call your code** to fetch external data. + +```swift +import FoundationModels +import WeatherKit +import CoreLocation + +struct GetWeatherTool: Tool { + let name = "getWeather" + let description = "Retrieve latest weather for a city" + + @Generable + struct Arguments { + @Guide(description: "The city to fetch weather for") + var city: String + } + + func call(arguments: Arguments) async throws -> ToolOutput { + let places = try await CLGeocoder().geocodeAddressString(arguments.city) + let weather = try await WeatherService.shared.weather(for: places.first!.location!) + let temp = weather.currentWeather.temperature.value + + return ToolOutput("\(arguments.city)'s temperature is \(temp) degrees.") + } +} +``` + +### Attaching Tool to Session + +```swift +let session = LanguageModelSession( + tools: [GetWeatherTool()], + instructions: "Help user with weather forecasts." +) + +let response = try await session.respond( + to: "What's the temperature in Cupertino?" +) + +print(response.content) +// "It's 71°F in Cupertino!" +``` + +**Model autonomously**: +1. Recognizes it needs weather data +2. Calls `GetWeatherTool` +3. Receives real temperature +4. Incorporates into natural response + +### Key Concepts + +- **Tool protocol**: Requires `name`, `description`, `@Generable Arguments`, and `call()` method +- **ToolOutput**: Return `String` (natural language) or `GeneratedContent` (structured) +- **Multiple tools**: Session accepts array of tools; model autonomously decides which to call +- **Stateful tools**: Use `class` (not `struct`) when tools need to maintain state across calls + +See `axiom-foundation-models-ref` for `Tool` protocol reference, `ToolOutput` forms, stateful tool patterns, and additional examples. + +### Tool Calling Flow + +``` +1. Session initialized with tools +2. User prompt: "What's Tokyo's weather?" +3. Model analyzes: "Need weather data" +4. Model generates tool call: getWeather(city: "Tokyo") +5. Framework calls your tool's call() method +6. Your tool fetches real data from API +7. Tool output inserted into transcript +8. Model generates final response using tool output +``` + +"Model decides autonomously when and how often to call tools. Can call multiple tools per request, even in parallel." + +### Tool Calling Guarantees + +✅ **Guaranteed**: +- Valid tool names (no hallucinated tools) +- Valid arguments (via @Generable) +- Structural correctness + +❌ **Not guaranteed**: +- Tool will be called (model might not need it) +- Specific argument values (model decides based on context) + +### When to Use Tools + +✅ **Use for**: +- Weather data +- Map/location queries +- Contact information +- Calendar events +- External APIs + +❌ **Don't use for**: +- Data model already has +- Information in prompt/instructions +- Simple calculations (model can do these) + +--- + +## Pattern 5: Context Management + +**Use when**: Multi-turn conversations that might exceed 4096 token limit. + +### The Problem + +```swift +// Long conversation... +for i in 1...100 { + let response = try await session.respond(to: "Question \(i)") + // Eventually... + // Error: exceededContextWindowSize +} +``` + +**Context window**: 4096 tokens (input + output combined) +**Average**: ~3 characters per token in English + +**Rough calculation**: +- 4096 tokens ≈ 12,000 characters +- ≈ 2,000-3,000 words total + +**Long conversation** or **verbose prompts/responses** → Exceed limit + +### Handling Context Overflow + +#### Basic: Start fresh session +```swift +var session = LanguageModelSession() + +do { + let response = try await session.respond(to: prompt) + print(response.content) +} catch LanguageModelSession.GenerationError.exceededContextWindowSize { + // New session, no history + session = LanguageModelSession() +} +``` + +**Problem**: Loses entire conversation history. + +### Better: Condense Transcript + +```swift +var session = LanguageModelSession() + +do { + let response = try await session.respond(to: prompt) +} catch LanguageModelSession.GenerationError.exceededContextWindowSize { + // New session with condensed history + session = condensedSession(from: session) +} + +func condensedSession(from previous: LanguageModelSession) -> LanguageModelSession { + let allEntries = previous.transcript.entries + var condensedEntries = [Transcript.Entry]() + + // Always include first entry (instructions) + if let first = allEntries.first { + condensedEntries.append(first) + + // Include last entry (most recent context) + if allEntries.count > 1, let last = allEntries.last { + condensedEntries.append(last) + } + } + + let condensedTranscript = Transcript(entries: condensedEntries) + return LanguageModelSession(transcript: condensedTranscript) +} +``` + +**Why this works**: +- Instructions always preserved +- Recent context retained +- Total tokens drastically reduced + +For advanced strategies (summarizing middle entries with Foundation Models itself), see `axiom-foundation-models-ref`. + +### Preventing Context Overflow + +**1. Keep prompts concise**: +```swift +// ❌ BAD +let prompt = """ + I want you to generate a comprehensive detailed analysis of this article + with multiple sections including summary, key points, sentiment analysis, + main arguments, counter arguments, logical fallacies, and conclusions... + """ + +// ✅ GOOD +let prompt = "Summarize this article's key points" +``` + +**2. Use tools for data**: +Instead of putting entire dataset in prompt, use tools to fetch on-demand. + +**3. Break complex tasks into steps**: +```swift +// ❌ BAD - One massive generation +let response = try await session.respond( + to: "Create 7-day itinerary with hotels, restaurants, activities..." +) + +// ✅ GOOD - Multiple smaller generations +let overview = try await session.respond(to: "Create high-level 7-day plan") +for day in 1...7 { + let details = try await session.respond(to: "Detail activities for day \(day)") +} +``` + +--- + +## Pattern 6: Sampling & Generation Options + +**Use when**: You need control over output randomness/determinism. + +### When to Adjust Sampling + +| Goal | Setting | Use Cases | +|------|---------|-----------| +| Deterministic | `GenerationOptions(sampling: .greedy)` | Unit tests, demos, consistency-critical | +| Focused | `GenerationOptions(temperature: 0.5)` | Fact extraction, classification | +| Creative | `GenerationOptions(temperature: 2.0)` | Story generation, brainstorming, varied NPC dialog | + +**Default**: Random sampling (temperature 1.0) gives balanced results. + +**Caveat**: Greedy determinism only holds for same model version. OS updates may change output. + +See `axiom-foundation-models-ref` for complete `GenerationOptions` API reference. + +--- + +## Pressure Scenarios + +### Scenario 1: "Just Use ChatGPT API" + +**Context**: You're implementing a new AI feature. PM suggests using ChatGPT API for "better results." + +**Pressure signals**: +- 👔 **Authority**: PM outranks you +- 💸 **Existing integration**: Team already uses OpenAI for other features +- ⏰ **Speed**: "ChatGPT is proven, Foundation Models is new" + +**Rationalization traps**: +- "PM knows best" +- "ChatGPT gives better answers" +- "Faster to implement with existing code" + +**Why this fails**: + +1. **Privacy violation**: User data sent to external server + - Medical notes, financial docs, personal messages + - Violates user expectation of on-device privacy + - Potential GDPR/privacy law issues + +2. **Cost**: Every API call costs money + - Foundation Models is **free** + - Scale to millions of users = massive costs + +3. **Offline unavailable**: Requires internet + - Airplane mode, poor signal → feature broken + - Foundation Models works offline + +4. **Latency**: Network round-trip adds 500-2000ms + - Foundation Models: On-device, <100ms startup + +**When ChatGPT IS appropriate**: +- World knowledge required (e.g. "Who is the president of France?") +- Complex reasoning (multi-step logic, math proofs) +- Very long context (>4096 tokens) + +**Mandatory response**: + +``` +"I understand ChatGPT delivers great results for certain tasks. However, +for this feature, Foundation Models is the right choice for three critical reasons: + +1. **Privacy**: This feature processes [medical notes/financial data/personal content]. + Users expect this data stays on-device. Sending to external API violates that trust + and may have compliance issues. + +2. **Cost**: At scale, ChatGPT API calls cost $X per 1000 requests. Foundation Models + is free. For Y million users, that's $Z annually we can avoid. + +3. **Offline capability**: Foundation Models works without internet. Users in airplane + mode or with poor signal still get full functionality. + +**When to use ChatGPT**: If this feature required world knowledge or complex reasoning, +ChatGPT would be the right choice. But this is [summarization/extraction/classification], +which is exactly what Foundation Models is optimized for. + +**Time estimate**: Foundation Models implementation: 15-20 minutes. +Privacy compliance review for ChatGPT: 2-4 weeks." +``` + +**Time saved**: Privacy compliance review vs correct implementation: 2-4 weeks vs 20 minutes + +--- + +### Scenario 2: "Parse JSON Manually" + +**Context**: Teammate suggests prompting for JSON, parsing with JSONDecoder. Claims it's "simple and familiar." + +**Pressure signals**: +- ⏰ **Deadline**: Ship in 2 days +- 📚 **Familiarity**: "Everyone knows JSON" +- 🔧 **Existing code**: Already have JSON parsing utilities + +**Rationalization traps**: +- "JSON is standard" +- "We parse JSON everywhere already" +- "Faster than learning new API" + +**Why this fails**: + +1. **Hallucinated keys**: Model outputs `{firstName: "John"}` when you expect `{name: "John"}` + - JSONDecoder crashes: `keyNotFound` + - No compile-time safety + +2. **Invalid JSON**: Model might output: + ``` + Here's the person: {name: "John", age: 30} + ``` + - Not valid JSON (preamble text) + - Parsing fails + +3. **No type safety**: Manual string parsing, prone to errors + +**Real-world example**: +```swift +// ❌ BAD - Will fail +let prompt = "Generate a person with name and age as JSON" +let response = try await session.respond(to: prompt) + +// Model outputs: {"firstName": "John Smith", "years": 30} +// Your code expects: {"name": ..., "age": ...} +// CRASH: keyNotFound(name) +``` + +**Debugging time**: 2-4 hours finding edge cases, writing parsing hacks + +**Correct approach**: +```swift +// ✅ GOOD - 15 minutes, guaranteed to work +@Generable +struct Person { + let name: String + let age: Int +} + +let response = try await session.respond( + to: "Generate a person", + generating: Person.self +) +// response.content is type-safe Person, always valid +``` + +**Mandatory response**: + +``` +"I understand JSON parsing feels familiar, but for LLM output, @Generable is objectively +better for three technical reasons: + +1. **Constrained decoding guarantees structure**: Model can ONLY generate valid Person + instances. Impossible to get wrong keys, invalid JSON, or missing fields. + +2. **No parsing code needed**: Framework handles parsing automatically. Zero chance of + parsing bugs. + +3. **Compile-time safety**: If we change Person struct, compiler catches all issues. + Manual JSON parsing = runtime crashes. + +**Real cost**: Manual JSON approach will hit edge cases. Debugging 'keyNotFound' crashes +takes 2-4 hours. @Generable implementation takes 15 minutes and has zero parsing bugs. + +**Analogy**: This is like choosing Swift over Objective-C for new code. Both work, but +Swift's type safety prevents entire categories of bugs." +``` + +**Time saved**: 4-8 hours debugging vs 15 minutes correct implementation + +--- + +### Scenario 3: "One Big Prompt" + +**Context**: Feature requires extracting name, date, amount, category from invoice. Teammate suggests one prompt: "Extract all information." + +**Pressure signals**: +- 🏗️ **Architecture**: "Simpler with one API call" +- ⏰ **Speed**: "Why make it complicated?" +- 📉 **Complexity**: "More prompts = more code" + +**Rationalization traps**: +- "Simpler is better" +- "One prompt means less code" +- "Model is smart enough" + +**Why this fails**: + +1. **Context overflow**: Complex prompt + large invoice → Exceeds 4096 tokens +2. **Poor results**: Model tries to do too much at once, quality suffers +3. **Slow generation**: One massive response takes 5-8 seconds +4. **All-or-nothing**: If one field fails, entire generation fails + +**Better approach**: Break into tasks + use tools + +```swift +// ❌ BAD - One massive prompt +let prompt = """ + Extract from this invoice: + - Vendor name + - Invoice date + - Total amount + - Line items (description, quantity, price each) + - Payment terms + - Due date + - Tax amount + ... + """ +// 4 seconds, poor quality, might exceed context + +// ✅ GOOD - Structured extraction with focused prompts +@Generable +struct InvoiceBasics { + let vendor: String + let date: String + let amount: Double +} + +let basics = try await session.respond( + to: "Extract vendor, date, and amount", + generating: InvoiceBasics.self +) // 0.5 seconds, axiom-high quality + +@Generable +struct LineItem { + let description: String + let quantity: Int + let price: Double +} + +let items = try await session.respond( + to: "Extract line items", + generating: [LineItem].self +) // 1 second, axiom-high quality + +// Total: 1.5 seconds, better quality, graceful partial failures +``` + +**Mandatory response**: + +``` +"I understand the appeal of one simple API call. However, this specific task requires +a different approach: + +1. **Context limits**: Invoice + complex extraction prompt will likely exceed 4096 token + limit. Multiple focused prompts stay well under limit. + +2. **Better quality**: Model performs better with focused tasks. 'Extract vendor name' + gets 95%+ accuracy. 'Extract everything' gets 60-70%. + +3. **Faster perceived performance**: Multiple prompts with streaming show progressive + results. Users see vendor name in 0.5s, not waiting 5s for everything. + +4. **Graceful degradation**: If line items fail, we still have basics. All-or-nothing + approach means total failure. + +**Implementation**: Breaking into 3-4 focused extractions takes 30 minutes. One big +prompt takes 2-3 hours debugging why it hits context limit and produces poor results." +``` + +**Time saved**: 2-3 hours debugging vs 30 minutes proper design + +--- + +## Performance Optimization + +### Key Optimizations + +1. **Prewarm session**: Create `LanguageModelSession` at init, not when user taps button. Saves 1-2 seconds off first generation. + +2. **`includeSchemaInPrompt: false`**: For subsequent requests with the same `@Generable` type, set this in `GenerationOptions` to reduce token count by 10-20%. + +3. **Property order for streaming**: Put most important properties first in `@Generable` structs. User sees title in 0.2s instead of waiting 2.5s for full generation. + +4. **Foundation Models Instrument**: Use `Instruments > Foundation Models` template to profile latency, see token counts, and identify optimization opportunities. + +See `axiom-foundation-models-ref` for code examples of each optimization. + +--- + +## Checklist + +Before shipping Foundation Models features: + +### Required Checks +- [ ] **Availability checked** before creating session +- [ ] **Using @Generable** for structured output (not manual JSON) +- [ ] **Handling context overflow** (`exceededContextWindowSize`) +- [ ] **Handling guardrail violations** (`guardrailViolation`) +- [ ] **Handling unsupported language** (`unsupportedLanguageOrLocale`) +- [ ] **Streaming for long generations** (>1 second) +- [ ] **Not blocking UI** (using `Task {}` for async) +- [ ] **Tools for external data** (not prompting for weather/locations) +- [ ] **Prewarmed session** if latency-sensitive + +### Best Practices +- [ ] Instructions are concise (not verbose) +- [ ] Never interpolating user input into instructions +- [ ] Property order optimized for streaming UX +- [ ] Using appropriate temperature/sampling +- [ ] Tested on real device (not just simulator) +- [ ] Profiled with Instruments (Foundation Models template) +- [ ] Error handling shows graceful UI messages +- [ ] Tested offline (airplane mode) +- [ ] Tested with long conversations (context handling) + +### Model Capability +- [ ] **Not** using for world knowledge +- [ ] **Not** using for complex reasoning +- [ ] Use case is: summarization, extraction, classification, or generation +- [ ] Have fallback if unavailable (show message, disable feature) + +--- + +## Resources + +**WWDC**: 286, 259, 301 + +**Skills**: axiom-foundation-models-diag, axiom-foundation-models-ref + +--- + +**Last Updated**: 2025-12-03 +**Version**: 1.0.0 +**Target**: iOS 26+, macOS 26+, iPadOS 26+, axiom-visionOS 26+ diff --git a/.claude/skills/axiom-foundation-models/agents/openai.yaml b/.claude/skills/axiom-foundation-models/agents/openai.yaml new file mode 100644 index 0000000..2fc21f2 --- /dev/null +++ b/.claude/skills/axiom-foundation-models/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Foundation Models" + short_description: "Implementing on-device AI with Apple's Foundation Models framework" diff --git a/.claude/skills/axiom-getting-started/.openskills.json b/.claude/skills/axiom-getting-started/.openskills.json new file mode 100644 index 0000000..d368e81 --- /dev/null +++ b/.claude/skills/axiom-getting-started/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-getting-started", + "installedAt": "2026-04-12T08:06:19.539Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-getting-started/SKILL.md b/.claude/skills/axiom-getting-started/SKILL.md new file mode 100644 index 0000000..066e4c6 --- /dev/null +++ b/.claude/skills/axiom-getting-started/SKILL.md @@ -0,0 +1,319 @@ +--- +name: axiom-getting-started +description: Use when first installing Axiom, unsure which skill to use, want an overview of available skills, or need help finding the right skill for your situation — interactive onboarding that recommends skills based on your project and current focus +license: MIT +metadata: + version: "1.0.0" +--- + +# Getting Started with Axiom + +Welcome! This skill helps new users discover the most relevant Axiom skills for their situation. + +## How This Skill Works + +1. Ask the user 2-3 targeted questions about their project +2. Provide personalized skill recommendations (3-5 skills max) +3. Show example prompts they can try immediately +4. Include a complete skill reference for browsing + +## Step 1: Ask Questions + +Use the AskUserQuestion tool to gather context: + +### Question 1: Current Focus + +``` +Question: "What brings you to Axiom today?" +Header: "Focus" +Options: +- "Debugging an issue" → Prioritize diagnostic skills +- "Optimizing performance" → Prioritize profiling skills +- "Adding new features" → Prioritize reference skills +- "Code review / quality check" → Prioritize audit commands +- "Just exploring" → Show overview +``` + +### Question 2: Tech Stack + +``` +Question: "What's your primary tech stack?" +Header: "Stack" +Options: +- "SwiftUI (iOS 16+)" → SwiftUI-focused skills +- "UIKit" → UIKit-focused skills +- "Mixed SwiftUI + UIKit" → Both +- "Starting new project" → Best practices skills +``` + +### Question 3: Pain Points (Optional, Multi-Select) + +Only ask if "Debugging an issue" was selected: + +``` +Question: "Which areas are you struggling with?" +Header: "Pain Points" +Multi-select: true +Options: +- "Xcode/build issues" +- "Memory leaks" +- "UI/animation problems" +- "Database/persistence" +- "Networking" +- "Concurrency/async" +- "Accessibility" +``` + +## Step 2: Provide Personalized Recommendations + +Based on answers, recommend 3-5 skills using this matrix: + +### If "Debugging an issue" + +**Always recommend**: axiom:xcode-debugging (universal starting point) + +**Then add based on pain points**: +- Xcode/build → xcode-debugging, axiom-build-debugging +- Memory leaks → memory-debugging, axiom-objc-block-retain-cycles +- UI/animation (SwiftUI) → swiftui-debugging, axiom-swiftui-performance +- UI/animation (UIKit) → uikit-animation-debugging, axiom-auto-layout-debugging +- Database → database-migration, axiom-sqlitedata-migration (decision guide) +- Networking → networking, axiom-networking-diag +- Concurrency → swift-concurrency +- Accessibility → accessibility-diag + +### If "Optimizing performance" + +**SwiftUI stack**: +1. performance-profiling (decision trees for tools) +2. swiftui-performance (SwiftUI Instrument) +3. swiftui-debugging (view update issues) + +**UIKit/Mixed**: +1. performance-profiling (Instruments guide) +2. memory-debugging (leak detection) +3. uikit-animation-debugging (CAAnimation issues) + +### If "Adding new features" + +**Design decisions**: +- hig (quick design decisions, checklists) +- hig-ref (comprehensive HIG reference) + +**iOS 26+ features**: +- liquid-glass (material design system) +- foundation-models (on-device AI) +- swiftui-26-ref (complete iOS 26 guide) + +**Navigation patterns**: +- swiftui-nav (iOS 18+ Tab/Sidebar, deep linking) +- swiftui-nav-ref (comprehensive API reference) + +**Integrations**: +- app-intents-ref (Siri, Shortcuts, Spotlight) +- networking (Network.framework modern patterns) + +**Data persistence**: +- Ask: "Which persistence framework?" → swiftdata, axiom-sqlitedata, or grdb +- Migration: axiom-sqlitedata-migration, axiom-realm-migration-ref + +### If "Code review / quality check" + +**Start with audit commands** (quick wins): +1. `/axiom:audit-accessibility` — WCAG compliance +2. `/axiom:audit-concurrency` — Swift 6 violations +3. `/axiom:audit-memory` — Leak patterns +4. `/axiom:audit-core-data` — Migration safety +5. `/axiom:audit-networking` — Deprecated APIs + +**Then suggest**: +- Review skills based on what audits find + +### If "Just exploring" + +Show the complete skill index (see below) and explain categories. + +## Step 3: Output Format + +After gathering answers, output: + +```markdown +## Your Recommended Skills + +Based on your answers, here are the skills most relevant to you right now: + +### [Icon] [Category Name] +**axiom:[skill-name]** — [One-line description] +> Try: "[Example prompt they can use immediately]" + +[Repeat for 3-5 skills] + +### Quick Wins +Run these audit commands to find issues automatically: +- `/axiom:audit-[name]` — [What it finds] + +## What's Next + +1. **Try the example prompts above** — Copy/paste to see how skills work +2. **Run an audit command** — Get immediate actionable insights +3. **Describe your problem** — I'll suggest the right skill +4. **Browse the complete index below** — Explore all 34 skills + +--- + +[Include the Complete Skill Reference below] +``` + +## Complete Skill Reference + +Include this reference section in every response for browsing: + +### Debugging & Troubleshooting + +**Environment & Build Issues** +- **xcode-debugging** — BUILD FAILED, simulator hangs, zombie processes, environment-first diagnostics +- **build-debugging** — Dependency conflicts, CocoaPods/SPM failures, Multiple commands produce + +**Memory & Performance** +- **memory-debugging** — Memory growth, retain cycles, leak diagnosis with Instruments +- **performance-profiling** — Decision trees for Instruments (Time Profiler, Allocations, Core Data, Energy) +- **objc-block-retain-cycles** — Objective-C block memory leaks, weak-strong pattern + +**UI Debugging** +- **swiftui-debugging** — View update issues, struct mutation, binding identity, view recreation +- **swiftui-performance** — SwiftUI Instrument (iOS 26), long view bodies, Cause & Effect Graph +- **uikit-animation-debugging** — CAAnimation completion, spring physics, gesture+animation jank +- **auto-layout-debugging** — Auto Layout conflicts, constraint debugging (not yet in manifest) + +### Concurrency & Async +- **swift-concurrency** — Swift 6 strict concurrency, @concurrent, actor isolation, Sendable, data races + +### UI & Design (iOS 26+) + +**Liquid Glass (Material Design)** +- **liquid-glass** — Implementation, Regular vs Clear variants, design review defense +- **liquid-glass-ref** — Complete app-wide adoption guide (icons, controls, navigation, windows) + +**Layout & Navigation** +- **swiftui-layout** — ViewThatFits vs AnyLayout vs onGeometryChange, decision trees, iOS 26 free-form windows +- **swiftui-layout-ref** — Complete layout API reference +- **swiftui-nav** — NavigationStack vs NavigationSplitView, deep links, coordinator patterns, iOS 18+ Tab/Sidebar +- **swiftui-nav-ref** — Comprehensive navigation API reference +- **swiftui-nav-diag** — Navigation not responding, unexpected pops, deep link failures, state loss + +### Testing +- **ui-testing** — Recording UI Automation (Xcode 26), condition-based waiting, accessibility-first patterns + +### Persistence + +**Frameworks** +- **swiftdata** — @Model, @Query, @Relationship, CloudKit, iOS 26 features, Swift 6 concurrency +- **sqlitedata** — Point-Free SQLiteData, @Table, FTS5, CTEs, JSON aggregation, CloudKit sync +- **grdb** — Raw SQL, complex joins, ValueObservation, DatabaseMigrator, performance +- **database-migration** — Safe schema evolution for SQLite/GRDB, additive migrations, prevents data loss + +**Migration Guides** +- **sqlitedata-migration** — Decision guide, pattern equivalents, performance benchmarks +- **realm-migration-ref** — Realm → SwiftData migration (Realm Device Sync sunset Sept 2025) + +### Networking +- **networking** — Network.framework (iOS 12-26), NetworkConnection (iOS 26), structured concurrency +- **networking-diag** — Connection timeouts, TLS failures, data not arriving, performance issues +- **network-framework-ref** — Complete API reference, TLV framing, Coder protocol, Wi-Fi Aware + +### Apple Intelligence (iOS 26+) +- **foundation-models** — On-device AI, LanguageModelSession, @Generable, streaming, tool calling +- **foundation-models-diag** — Context exceeded, guardrails, slow generation, availability issues +- **foundation-models-ref** — Complete API reference, all 26 WWDC examples + +### Design & UI Guidelines +- **hig** — Quick design decisions, color/background/typography choices, HIG compliance checklists +- **hig-ref** — Comprehensive Human Interface Guidelines reference with code examples + +### Integrations +- **app-intents-ref** — Siri, Apple Intelligence, Shortcuts, Spotlight (iOS 16+) +- **swiftui-26-ref** — iOS 26 SwiftUI features, @Animatable, 3D layout, WebView, AttributedString +- **avfoundation-ref** — Audio APIs, bit-perfect DAC, iOS 26 spatial audio, ASAF/APAC + +### Diagnostics (Systematic Troubleshooting) +- **accessibility-diag** — VoiceOver, Dynamic Type, color contrast, WCAG compliance, App Store defense +- **core-data-diag** — Schema migration crashes, thread-confinement, N+1 queries + +### Audit Commands (Quick Scans) +- `/axiom:audit-accessibility` — VoiceOver labels, Dynamic Type, contrast, touch targets +- `/axiom:audit-concurrency` — Swift 6 violations, unsafe tasks, missing @MainActor +- `/axiom:audit-memory` — Timer leaks, observer leaks, closure captures, delegate cycles +- `/axiom:audit-core-data` — Migration risks, thread violations, N+1 queries +- `/axiom:audit-networking` — Deprecated APIs (SCNetworkReachability, CFSocket), anti-patterns +- `/axiom:audit-liquid-glass` — Glass adoption opportunities, toolbar improvements, blur migration + +## Skill Categories Explained + +- **Discipline skills** (no suffix) — Step-by-step workflows with pressure scenarios, TDD-tested +- **Diagnostic skills** (-diag suffix) — Systematic troubleshooting with production crisis defense +- **Reference skills** (-ref suffix) — Comprehensive API guides with WWDC examples + +## Quick Decision Trees + +**"My build is failing"** +→ Start: axiom:xcode-debugging +→ If dependency issue: axiom:build-debugging + +**"App is slow"** +→ Start: axiom:performance-profiling (decision trees) +→ If SwiftUI: axiom:swiftui-performance +→ If memory grows: axiom:memory-debugging + +**"Memory leak"** +→ Start: axiom:memory-debugging +→ If Objective-C blocks: axiom:objc-block-retain-cycles + +**"SwiftUI view issues"** +→ Start: axiom:swiftui-debugging +→ If performance: axiom:swiftui-performance + +**"Navigation problems"** +→ Start: axiom:swiftui-nav-diag (troubleshooting) +→ For patterns: axiom:swiftui-nav + +**"Which database?"** +→ Decision guide: axiom:sqlitedata-migration +→ Then: axiom:swiftdata, axiom:sqlitedata, or axiom:grdb + +**"iOS 26 design"** +→ Start: axiom:liquid-glass +→ Complete guide: axiom:liquid-glass-ref + +**"Code quality check"** +→ Run: `/axiom:audit-accessibility`, `/axiom:audit-concurrency`, `/axiom:audit-memory` +→ Fix issues with relevant skills + +## How Skills Work + +Axiom skills load automatically — you don't need to memorize names or commands. + +**Automatic triggering** (most common): Just describe your problem naturally. Claude detects which skill applies and loads it. +- "My SwiftData CloudKit sync isn't working" → loads `cloud-sync-diag` +- "I'm getting Sendable errors in Swift 6" → loads `swift-concurrency` + +**Explicit invocation**: If you know the skill name, invoke it directly: +- `/skill axiom-swift-concurrency` +- `/skill axiom-liquid-glass` + +**Audit commands**: Run automated scans with slash commands: +- `/axiom:audit-memory` — scans for memory leak patterns +- `/axiom:audit-concurrency` — scans for Swift 6 violations + +**Key insight**: You don't need to know skill names. Describe what you're working on and Axiom routes to the right skill automatically. + +## Tips + +- **Describe your problem** — Claude will suggest the right skill +- **Run audits first** — Quick wins with automated scans +- **Start with diagnostic skills** — When troubleshooting specific issues +- **Use reference skills** — When implementing new features +- **All skills are searchable** — Just describe what you need + +--- + +**Total**: 50 skills, 12 audit commands, covering the complete iOS development lifecycle from design to deployment diff --git a/.claude/skills/axiom-getting-started/agents/openai.yaml b/.claude/skills/axiom-getting-started/agents/openai.yaml new file mode 100644 index 0000000..ed3c45a --- /dev/null +++ b/.claude/skills/axiom-getting-started/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Getting Started" + short_description: "First installing Axiom, unsure which skill to use, want an overview of available skills, or need help finding the rig..." diff --git a/.claude/skills/axiom-grdb/.openskills.json b/.claude/skills/axiom-grdb/.openskills.json new file mode 100644 index 0000000..b5315f2 --- /dev/null +++ b/.claude/skills/axiom-grdb/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-grdb", + "installedAt": "2026-04-12T08:06:20.544Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-grdb/SKILL.md b/.claude/skills/axiom-grdb/SKILL.md new file mode 100644 index 0000000..edccbf7 --- /dev/null +++ b/.claude/skills/axiom-grdb/SKILL.md @@ -0,0 +1,677 @@ +--- +name: axiom-grdb +description: Use when writing raw SQL queries with GRDB, complex joins, ValueObservation for reactive queries, DatabaseMigrator patterns, query profiling under performance pressure, or dropping down from SQLiteData for performance - direct SQLite access for iOS/macOS +license: MIT +metadata: + version: "1.1.0" + last-updated: "TDD-tested with complex query performance scenarios" +--- + +# GRDB + +## Overview + +Direct SQLite access using [GRDB.swift](https://github.com/groue/GRDB.swift) — a toolkit for SQLite databases with type-safe queries, migrations, and reactive observation. + +**Core principle** Type-safe Swift wrapper around raw SQL with full SQLite power when you need it. + +**Requires** iOS 13+, Swift 5.7+ +**License** MIT (free and open source) + +## When to Use GRDB + +#### Use raw GRDB when you need +- ✅ Complex SQL joins across 4+ tables +- ✅ Window functions (ROW_NUMBER, RANK, LAG/LEAD) +- ✅ Reactive queries with ValueObservation +- ✅ Full control over SQL for performance +- ✅ Advanced migration logic beyond schema changes + +**Note:** SQLiteData now supports GROUP BY (`.group(by:)`) and HAVING (`.having()`) via the query builder — see the `axiom-sqlitedata-ref` skill. + +#### Use SQLiteData instead when +- Type-safe `@Table` models are sufficient +- CloudKit sync needed +- Prefer declarative queries over SQL + +#### Use SwiftData when +- Simple CRUD with native Apple integration +- Don't need raw SQL control + +**For migrations** See the `axiom-database-migration` skill for safe schema evolution patterns. + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "I need to query messages with their authors and count of reactions in one query. How do I write the JOIN?" +→ The skill shows complex JOIN queries with multiple tables and aggregations + +#### 2. "I want to observe a filtered list and update the UI whenever notes with a specific tag change." +→ The skill covers ValueObservation patterns for reactive query updates + +#### 3. "I'm importing thousands of chat records and need custom migration logic. How do I use DatabaseMigrator?" +→ The skill explains migration registration, data transforms, and safe rollback patterns + +#### 4. "My query is slow (takes 10+ seconds). How do I profile and optimize it?" +→ The skill covers EXPLAIN QUERY PLAN, database.trace for profiling, and index creation + +#### 5. "I need to fetch tasks grouped by due date with completion counts, ordered by priority. Raw SQL seems easier than type-safe queries." +→ The skill demonstrates when GRDB's raw SQL is clearer than type-safe wrappers + +--- + +## Database Setup + +### DatabaseQueue (Single Connection) + +```swift +import GRDB + +// File-based database +let dbPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] +let dbQueue = try DatabaseQueue(path: "\(dbPath)/db.sqlite") + +// In-memory database (tests) +let dbQueue = try DatabaseQueue() +``` + +### DatabasePool (Connection Pool) + +```swift +// For apps with heavy concurrent access +let dbPool = try DatabasePool(path: dbPath) +``` + +**Use Queue for** Most apps (simpler, sufficient) +**Use Pool for** Heavy concurrent writes from multiple threads + +## Record Types + +### Using Codable + +```swift +struct Track: Codable { + var id: String + var title: String + var artist: String + var duration: TimeInterval +} + +// Fetch +let tracks = try dbQueue.read { db in + try Track.fetchAll(db, sql: "SELECT * FROM tracks") +} + +// Insert +try dbQueue.write { db in + try track.insert(db) // Codable conformance provides insert +} +``` + +### FetchableRecord (Read-Only) + +```swift +struct TrackInfo: FetchableRecord { + var title: String + var artist: String + var albumTitle: String + + init(row: Row) { + title = row["title"] + artist = row["artist"] + albumTitle = row["album_title"] + } +} + +let results = try dbQueue.read { db in + try TrackInfo.fetchAll(db, sql: """ + SELECT tracks.title, tracks.artist, albums.title as album_title + FROM tracks + JOIN albums ON tracks.albumId = albums.id + """) +} +``` + +### PersistableRecord (Write) + +```swift +struct Track: Codable, PersistableRecord { + var id: String + var title: String + + // Customize table name + static let databaseTableName = "tracks" +} + +try dbQueue.write { db in + var track = Track(id: "1", title: "Song") + try track.insert(db) + + track.title = "Updated" + try track.update(db) + + try track.delete(db) +} +``` + +## Raw SQL Queries + +### Reading Data + +```swift +// Fetch all rows +let rows = try dbQueue.read { db in + try Row.fetchAll(db, sql: "SELECT * FROM tracks WHERE genre = ?", arguments: ["Rock"]) +} + +// Fetch single value +let count = try dbQueue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tracks") +} + +// Fetch into Codable +let tracks = try dbQueue.read { db in + try Track.fetchAll(db, sql: "SELECT * FROM tracks ORDER BY title") +} +``` + +### Writing Data + +```swift +try dbQueue.write { db in + try db.execute(sql: """ + INSERT INTO tracks (id, title, artist, duration) + VALUES (?, ?, ?, ?) + """, arguments: ["1", "Song", "Artist", 240]) +} +``` + +### Transactions + +```swift +try dbQueue.write { db in + // Automatic transaction - all or nothing + for track in tracks { + try track.insert(db) + } + // Commits automatically on success, rolls back on error +} +``` + +## Type-Safe Query Interface + +### Filtering + +```swift +let request = Track + .filter(Column("genre") == "Rock") + .filter(Column("duration") > 180) + +let tracks = try dbQueue.read { db in + try request.fetchAll(db) +} +``` + +### Sorting + +```swift +let request = Track + .order(Column("title").asc) + .limit(10) +``` + +### Joins + +```swift +struct TrackWithAlbum: FetchableRecord { + var trackTitle: String + var albumTitle: String +} + +let request = Track + .joining(required: Track.belongsTo(Album.self)) + .select(Column("title").forKey("trackTitle"), Column("album_title").forKey("albumTitle")) + +let results = try dbQueue.read { db in + try TrackWithAlbum.fetchAll(db, request) +} +``` + +## Complex Joins + +```swift +let sql = """ + SELECT + tracks.title as track_title, + albums.title as album_title, + artists.name as artist_name, + COUNT(plays.id) as play_count + FROM tracks + JOIN albums ON tracks.albumId = albums.id + JOIN artists ON albums.artistId = artists.id + LEFT JOIN plays ON plays.trackId = tracks.id + WHERE artists.genre = ? + GROUP BY tracks.id + HAVING play_count > 10 + ORDER BY play_count DESC + LIMIT 50 + """ + +struct TrackStats: FetchableRecord { + var trackTitle: String + var albumTitle: String + var artistName: String + var playCount: Int + + init(row: Row) { + trackTitle = row["track_title"] + albumTitle = row["album_title"] + artistName = row["artist_name"] + playCount = row["play_count"] + } +} + +let stats = try dbQueue.read { db in + try TrackStats.fetchAll(db, sql: sql, arguments: ["Rock"]) +} +``` + +## ValueObservation (Reactive Queries) + +### Basic Observation + +```swift +import GRDB +import Combine + +let observation = ValueObservation.tracking { db in + try Track.fetchAll(db) +} + +// Start observing with Combine +let cancellable = observation.publisher(in: dbQueue) + .sink( + receiveCompletion: { _ in }, + receiveValue: { tracks in + print("Tracks updated: \(tracks.count)") + } + ) +``` + +### SwiftUI Integration + +```swift +import GRDB +import GRDBQuery // https://github.com/groue/GRDBQuery + +@Query(Tracks()) +var tracks: [Track] + +struct Tracks: Queryable { + static var defaultValue: [Track] { [] } + + func publisher(in dbQueue: DatabaseQueue) -> AnyPublisher<[Track], Error> { + ValueObservation + .tracking { db in try Track.fetchAll(db) } + .publisher(in: dbQueue) + .eraseToAnyPublisher() + } +} +``` + +**See** [GRDBQuery documentation](https://github.com/groue/GRDBQuery) for SwiftUI reactive bindings. + +### Filtered Observation + +```swift +func observeGenre(_ genre: String) -> ValueObservation<[Track]> { + ValueObservation.tracking { db in + try Track + .filter(Column("genre") == genre) + .fetchAll(db) + } +} + +let cancellable = observeGenre("Rock") + .publisher(in: dbQueue) + .sink { tracks in + print("Rock tracks: \(tracks.count)") + } +``` + +## Migrations + +### DatabaseMigrator + +```swift +var migrator = DatabaseMigrator() + +// Migration 1: Create tables +migrator.registerMigration("v1") { db in + try db.create(table: "tracks") { t in + t.column("id", .text).primaryKey() + t.column("title", .text).notNull() + t.column("artist", .text).notNull() + t.column("duration", .real).notNull() + } +} + +// Migration 2: Add column +migrator.registerMigration("v2_add_genre") { db in + try db.alter(table: "tracks") { t in + t.add(column: "genre", .text) + } +} + +// Migration 3: Add index +migrator.registerMigration("v3_add_indexes") { db in + try db.create(index: "idx_genre", on: "tracks", columns: ["genre"]) +} + +// Run migrations +try migrator.migrate(dbQueue) +``` + +**For migration safety patterns** See the `axiom-database-migration` skill. + +### Migration with Data Transform + +```swift +migrator.registerMigration("v4_normalize_artists") { db in + // 1. Create new table + try db.create(table: "artists") { t in + t.column("id", .text).primaryKey() + t.column("name", .text).notNull() + } + + // 2. Extract unique artists + try db.execute(sql: """ + INSERT INTO artists (id, name) + SELECT DISTINCT + lower(replace(artist, ' ', '_')) as id, + artist as name + FROM tracks + """) + + // 3. Add foreign key to tracks + try db.alter(table: "tracks") { t in + t.add(column: "artistId", .text) + .references("artists", onDelete: .cascade) + } + + // 4. Populate foreign keys + try db.execute(sql: """ + UPDATE tracks + SET artistId = ( + SELECT id FROM artists + WHERE artists.name = tracks.artist + ) + """) +} +``` + +## Performance Patterns + +### Batch Writes + +```swift +try dbQueue.write { db in + for batch in tracks.chunked(into: 500) { + for track in batch { + try track.insert(db) + } + } +} +``` + +### Prepared Statements + +```swift +try dbQueue.write { db in + let statement = try db.makeStatement(sql: """ + INSERT INTO tracks (id, title, artist, duration) + VALUES (?, ?, ?, ?) + """) + + for track in tracks { + try statement.execute(arguments: [track.id, track.title, track.artist, track.duration]) + } +} +``` + +### Indexes + +```swift +try db.create(index: "idx_tracks_artist", on: "tracks", columns: ["artist"]) +try db.create(index: "idx_tracks_genre_duration", on: "tracks", columns: ["genre", "duration"]) + +// Unique index +try db.create(index: "idx_tracks_unique_title", on: "tracks", columns: ["title"], unique: true) +``` + +### Query Planning + +```swift +// Analyze query performance +let explanation = try dbQueue.read { db in + try String.fetchOne(db, sql: "EXPLAIN QUERY PLAN SELECT * FROM tracks WHERE artist = ?", arguments: ["Artist"]) +} +print(explanation) +``` + +## Dropping Down from SQLiteData + +When using SQLiteData but need GRDB for specific operations: + +```swift +import SQLiteData +import GRDB + +@Dependency(\.database) var database // SQLiteData Database + +// Access underlying GRDB DatabaseQueue +try await database.database.write { db in + // Full GRDB power here + try db.execute(sql: "CREATE INDEX idx_genre ON tracks(genre)") +} +``` + +#### Common scenarios +- Complex JOIN queries +- Custom migrations +- Bulk SQL operations +- ValueObservation setup + +## Quick Reference + +### Common Operations + +```swift +// Read single value +let count = try db.fetchOne(Int.self, sql: "SELECT COUNT(*) FROM tracks") + +// Read all rows +let rows = try Row.fetchAll(db, sql: "SELECT * FROM tracks WHERE genre = ?", arguments: ["Rock"]) + +// Write +try db.execute(sql: "INSERT INTO tracks VALUES (?, ?, ?)", arguments: [id, title, artist]) + +// Transaction +try dbQueue.write { db in + // All or nothing +} + +// Observe changes +ValueObservation.tracking { db in + try Track.fetchAll(db) +}.publisher(in: dbQueue) +``` + +## Resources + +**GitHub**: groue/GRDB.swift, groue/GRDBQuery + +**Docs**: sqlite.org/docs.html + +**Skills**: axiom-database-migration, axiom-sqlitedata, axiom-swiftdata + +## Production Performance: Query Optimization Under Pressure + +### Red Flags — When GRDB Queries Slow Down + +If you see ANY of these symptoms: +- ❌ Complex JOIN query takes 10+ seconds +- ❌ ValueObservation runs on every single change (battery drain) +- ❌ Can't explain why migration ran twice on old version + +#### DO NOT +1. Blindly add indexes (don't know which columns help) +2. Move logic to Swift (premature escape from database) +3. Over-engineer migrations (distrust the system) + +#### DO +1. Profile with `database.trace` +2. Use `EXPLAIN QUERY PLAN` to understand execution +3. Trust GRDB's migration versioning system + +### Profiling Complex Queries + +#### When query is slow (10+ seconds) + +```swift +var database = try DatabaseQueue(path: dbPath) + +// Enable tracing to see SQL execution +database.trace { print($0) } + +// Run the slow query +try database.read { db in + let results = try Track.fetchAll(db) // Watch output for execution time +} + +// Use EXPLAIN QUERY PLAN to understand execution: +try database.read { db in + let plan = try String(fetching: db, sql: "EXPLAIN QUERY PLAN SELECT ...") + print(plan) + // Look for SCAN (slow, full table) vs SEARCH (fast, indexed) +} +``` + +#### Add indexes strategically + +```swift +// Add index on frequently queried column +try database.write { db in + try db.execute(sql: "CREATE INDEX idx_plays_track_id ON plays(track_id)") +} +``` + +#### Time cost +- Profile: 10 min (enable trace, run query, read output) +- Understand: 5 min (interpret EXPLAIN QUERY PLAN) +- Fix: 5 min (add index) +- **Total: 20 minutes** (vs 30+ min blindly trying solutions) + +### ValueObservation Performance + +#### When using reactive queries, know the costs + +```swift +// Re-evaluates query on ANY write to database +ValueObservation.tracking { db in + try Track.fetchAll(db) +}.start(in: database, onError: { }, onChange: { tracks in + // Called for every change — CPU spike! +}) +``` + +#### Optimization patterns + +```swift +// Coalesce rapid updates (recommended) +ValueObservation.tracking { db in + try Track.fetchAll(db) +}.removeDuplicates() // Skip duplicate results + .debounce(for: 0.5, scheduler: DispatchQueue.main) // Batch updates + .start(in: database, ...) +``` + +#### Decision framework +- Small datasets (<1000 records): Use plain `.tracking` +- Medium datasets (1-10k records): Add `.removeDuplicates()` + `.debounce()` +- Large datasets (10k+ records): Use explicit table dependencies or predicates + +### Migration Versioning Guarantees + +#### Trust GRDB's DatabaseMigrator - it prevents re-running migrations + +```swift +var migrator = DatabaseMigrator() + +migrator.registerMigration("v1_initial") { db in + try db.execute(sql: "CREATE TABLE tracks (...)") +} + +migrator.registerMigration("v2_add_plays") { db in + try db.execute(sql: "CREATE TABLE plays (...)") +} + +// GRDB guarantees: +// - Each migration runs exactly ONCE +// - In order (v1, then v2) +// - Safe to call migrate() multiple times +try migrator.migrate(dbQueue) +``` + +#### You don't need defensive SQL (IF NOT EXISTS) +- GRDB tracks which migrations have run +- Running `migrate()` twice only executes new ones +- Over-engineering adds complexity without benefit + +#### Trust it. + +--- + +## Common Mistakes + +### ❌ Not using transactions for batch writes +```swift +for track in 50000Tracks { + try dbQueue.write { db in try track.insert(db) } // 50k transactions! +} +``` +**Fix** Single transaction with batches + +### ❌ Synchronous database access on main thread +```swift +let tracks = try dbQueue.read { db in try Track.fetchAll(db) } // Blocks UI +``` +**Fix** Use async/await or dispatch to background queue + +### ❌ Forgetting to add indexes +```swift +// Slow query without index +try Track.filter(Column("genre") == "Rock").fetchAll(db) +``` +**Fix** Create indexes on frequently queried columns + +### ❌ N+1 queries +```swift +for track in tracks { + let album = try Album.fetchOne(db, key: track.albumId) // N queries! +} +``` +**Fix** Use JOIN or batch fetch + +## tvOS + +**Local GRDB databases are not persistent on tvOS.** The system deletes Caches (including Application Support) under storage pressure. A local-only GRDB database will lose all data between app launches. + +**If targeting tvOS**, pair GRDB with CloudKit sync (via CKSyncEngine or SQLiteData's SyncEngine) so iCloud is the persistent store and the local database is a rebuildable cache. See `axiom-tvos` for full tvOS storage constraints. + +--- + +**Targets:** iOS 13+, Swift 5.7+ +**Framework:** GRDB.swift 6.0+ +**History:** See git log for changes diff --git a/.claude/skills/axiom-grdb/agents/openai.yaml b/.claude/skills/axiom-grdb/agents/openai.yaml new file mode 100644 index 0000000..a5e6a9f --- /dev/null +++ b/.claude/skills/axiom-grdb/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "GRDB" + short_description: "Writing raw SQL queries with GRDB, complex joins, ValueObservation for reactive queries, DatabaseMigrator patterns, q..." diff --git a/.claude/skills/axiom-hang-diagnostics/.openskills.json b/.claude/skills/axiom-hang-diagnostics/.openskills.json new file mode 100644 index 0000000..18330f1 --- /dev/null +++ b/.claude/skills/axiom-hang-diagnostics/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-hang-diagnostics", + "installedAt": "2026-04-12T08:06:21.200Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-hang-diagnostics/SKILL.md b/.claude/skills/axiom-hang-diagnostics/SKILL.md new file mode 100644 index 0000000..90fa781 --- /dev/null +++ b/.claude/skills/axiom-hang-diagnostics/SKILL.md @@ -0,0 +1,478 @@ +--- +name: axiom-hang-diagnostics +description: Use when app freezes, UI unresponsive, main thread blocked, watchdog termination, or diagnosing hang reports from Xcode Organizer or MetricKit +license: MIT +--- + +# Hang Diagnostics + +Systematic diagnosis and resolution of app hangs. A hang occurs when the main thread is blocked for more than 1 second, making the app unresponsive to user input. + +## Red Flags — Check This Skill When + +| Symptom | This Skill Applies | +|---------|-------------------| +| App freezes briefly during use | Yes — likely hang | +| UI doesn't respond to touches | Yes — main thread blocked | +| "App not responding" system dialog | Yes — severe hang | +| Xcode Organizer shows hang diagnostics | Yes — field hang reports | +| MetricKit MXHangDiagnostic received | Yes — aggregated hang data | +| Animations stutter or skip | Maybe — could be hitch, not hang | +| App feels slow but responsive | No — performance issue, not hang | + +## What Is a Hang + +A **hang** is when the main runloop cannot process events for more than 1 second. The user taps, but nothing happens. + +``` +User taps → Main thread busy/blocked → Event queued → 1+ second delay → HANG +``` + +**Key distinction**: The main thread handles ALL user input. If it's busy or blocked, the entire UI freezes. + +### Hang vs Hitch vs Lag + +| Issue | Duration | User Experience | Tool | +|-------|----------|-----------------|------| +| **Hang** | >1 second | App frozen, unresponsive | Time Profiler, System Trace | +| **Hitch** | 1-3 frames (16-50ms) | Animation stutters | Animation Hitches instrument | +| **Lag** | 100-500ms | Feels slow but responsive | Time Profiler | + +**This skill covers hangs.** For hitches, see `axiom-swiftui-performance`. For general lag, see `axiom-performance-profiling`. + +## The Two Causes of Hangs + +Every hang has one of two root causes: + +### 1. Main Thread Busy + +The main thread is doing work instead of processing events. + +**Subcategories**: + +| Type | Example | Fix | +|------|---------|-----| +| **Proactive work** | Pre-computing data user hasn't requested | Lazy initialization, compute on demand | +| **Irrelevant work** | Processing all notifications, not just relevant ones | Filter notifications, targeted observers | +| **Suboptimal API** | Using blocking API when async exists | Switch to async API | + +### 2. Main Thread Blocked + +The main thread is waiting for something else. + +**Subcategories**: + +| Type | Example | Fix | +|------|---------|-----| +| **Synchronous IPC** | Calling system service synchronously | Use async API variant | +| **File I/O** | `Data(contentsOf:)` on main thread | Move to background queue | +| **Network** | Synchronous URL request | Use URLSession async | +| **Lock contention** | Waiting for lock held by background thread | Reduce critical section, use actors | +| **Semaphore/dispatch_sync** | Blocking on background work | Restructure to async completion | + +## Decision Tree — Diagnosing Hangs + +``` +START: App hangs reported + │ + ├─→ Do you have hang diagnostics from Organizer or MetricKit? + │ │ + │ ├─→ YES: Examine stack trace + │ │ │ + │ │ ├─→ Stack shows your code running + │ │ │ → BUSY: Main thread doing work + │ │ │ → Profile with Time Profiler + │ │ │ + │ │ └─→ Stack shows waiting (semaphore, lock, dispatch_sync) + │ │ → BLOCKED: Main thread waiting + │ │ → Profile with System Trace + │ │ + │ └─→ NO: Can you reproduce? + │ │ + │ ├─→ YES: Profile with Time Profiler first + │ │ │ + │ │ ├─→ High CPU on main thread + │ │ │ → BUSY: Optimize the work + │ │ │ + │ │ └─→ Low CPU, thread blocked + │ │ → Use System Trace to find what's blocking + │ │ + │ └─→ NO: Enable MetricKit in app + │ → Wait for field reports + │ → Check Organizer > Hangs +``` + +## Tool Selection + +| Scenario | Primary Tool | Why | +|----------|-------------|-----| +| **Reproduces locally** | Time Profiler | See exactly what main thread is doing | +| **Blocked thread suspected** | System Trace | Shows thread state, lock contention | +| **Field reports only** | Xcode Organizer | Aggregated hang diagnostics | +| **Want in-app data** | MetricKit | MXHangDiagnostic with call stacks | +| **Need precise timing** | System Trace | Nanosecond-level thread analysis | + +## Time Profiler Workflow for Hangs + +1. **Launch Instruments** → Select Time Profiler template +2. **Record during hang** → Reproduce the freeze +3. **Stop recording** → Find the hang period in timeline +4. **Select hang region** → Drag to select frozen timespan +5. **Examine call tree** → Look for main thread work + +**What to look for**: +- Functions with high "Self Time" on main thread +- Unexpectedly deep call stacks +- System calls that shouldn't be on main thread + +## System Trace Workflow for Blocked Hangs + +1. **Launch Instruments** → Select System Trace template +2. **Record during hang** → Capture thread states +3. **Find main thread** → Filter to main thread +4. **Look for red/orange** → Blocked states +5. **Examine blocking reason** → Lock, semaphore, IPC + +**Thread states**: +- **Running (blue)**: Executing code +- **Preempted (orange)**: Runnable but not scheduled +- **Blocked (red)**: Waiting for resource + +## Common Hang Patterns and Fixes + +### Pattern 1: Synchronous File I/O + +**Before (hangs)**: +```swift +// Main thread blocks on file read +func loadUserData() { + let data = try! Data(contentsOf: largeFileURL) // BLOCKS + processData(data) +} +``` + +**After (async)**: +```swift +func loadUserData() { + Task.detached { + let data = try Data(contentsOf: largeFileURL) + await MainActor.run { + self.processData(data) + } + } +} +``` + +### Pattern 2: Unfiltered Notification Observer + +**Before (processes all)**: +```swift +NotificationCenter.default.addObserver( + self, + selector: #selector(handleChange), + name: .NSManagedObjectContextObjectsDidChange, + object: nil // Receives ALL contexts +) +``` + +**After (filtered)**: +```swift +NotificationCenter.default.addObserver( + self, + selector: #selector(handleChange), + name: .NSManagedObjectContextObjectsDidChange, + object: relevantContext // Only this context +) +``` + +### Pattern 3: Expensive Formatter Creation + +**Before (creates each time)**: +```swift +func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() // EXPENSIVE + formatter.dateStyle = .medium + return formatter.string(from: date) +} +``` + +**After (cached)**: +```swift +private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter +}() + +func formatDate(_ date: Date) -> String { + Self.dateFormatter.string(from: date) +} +``` + +### Pattern 4: dispatch_sync to Main Thread + +**Before (deadlock risk)**: +```swift +// From background thread +DispatchQueue.main.sync { // BLOCKS if main is blocked + updateUI() +} +``` + +**After (async)**: +```swift +DispatchQueue.main.async { + self.updateUI() +} +``` + +### Pattern 5: Semaphore for Async Result + +**Before (blocks main thread)**: +```swift +func fetchDataSync() -> Data { + let semaphore = DispatchSemaphore(value: 0) + var result: Data? + + URLSession.shared.dataTask(with: url) { data, _, _ in + result = data + semaphore.signal() + }.resume() + + semaphore.wait() // BLOCKS MAIN THREAD + return result! +} +``` + +**After (async/await)**: +```swift +func fetchData() async throws -> Data { + let (data, _) = try await URLSession.shared.data(from: url) + return data +} +``` + +### Pattern 6: Lock Contention + +**Before (shared lock)**: +```swift +class DataManager { + private let lock = NSLock() + private var cache: [String: Data] = [:] + + func getData(for key: String) -> Data? { + lock.lock() // Main thread waits for background + defer { lock.unlock() } + return cache[key] + } +} +``` + +**After (actor)**: +```swift +actor DataManager { + private var cache: [String: Data] = [:] + + func getData(for key: String) -> Data? { + cache[key] // Actor serializes access safely + } +} +``` + +### Pattern 7: App Launch Hang (Watchdog) + +**Before (too much work)**: +```swift +func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + loadAllUserData() // Expensive + setupAnalytics() // Network calls + precomputeLayouts() // CPU intensive + return true +} +``` + +**After (deferred)**: +```swift +func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Only essential setup + setupMinimalUI() + return true +} + +func applicationDidBecomeActive(_ application: UIApplication) { + // Defer non-essential work + Task { + await loadUserDataInBackground() + } +} +``` + +### Pattern 8: Image Processing on Main Thread + +**Before (blocks UI)**: +```swift +func processImage(_ image: UIImage) { + let filtered = applyExpensiveFilter(image) // BLOCKS + imageView.image = filtered +} +``` + +**After (background processing)**: +```swift +func processImage(_ image: UIImage) { + imageView.image = placeholder + + Task.detached(priority: .userInitiated) { + let filtered = applyExpensiveFilter(image) + await MainActor.run { + self.imageView.image = filtered + } + } +} +``` + +## Xcode Organizer Hang Diagnostics + +**Window > Organizer > Select App > Hangs** + +The Organizer shows aggregated hang data from users who opted into sharing diagnostics. + +**Reading the report**: +1. **Hang Rate**: Hangs per day per device +2. **Call Stack**: Where the hang occurred +3. **Device/OS breakdown**: Which configurations affected + +**Interpreting call stacks**: +- **Your code at top**: Main thread busy with your work +- **System API at top**: You called blocking API on main thread +- **pthread_mutex/semaphore**: Lock contention or explicit waiting + +## MetricKit Hang Diagnostics + +Adopt MetricKit to receive hang diagnostics in your app: + +```swift +import MetricKit + +class MetricsSubscriber: NSObject, MXMetricManagerSubscriber { + func didReceive(_ payloads: [MXDiagnosticPayload]) { + for payload in payloads { + if let hangDiagnostics = payload.hangDiagnostics { + for diagnostic in hangDiagnostics { + analyzeHang(diagnostic) + } + } + } + } + + private func analyzeHang(_ diagnostic: MXHangDiagnostic) { + // Duration of the hang + let duration = diagnostic.hangDuration + + // Call stack tree (needs symbolication) + let callStack = diagnostic.callStackTree + + // Send to your analytics + uploadHangDiagnostic(duration: duration, callStack: callStack) + } +} +``` + +**Key MXHangDiagnostic properties**: +- `hangDuration`: How long the hang lasted +- `callStackTree`: MXCallStackTree with frames +- `signatureIdentifier`: For grouping similar hangs + +## Watchdog Terminations + +The watchdog kills apps that hang during key transitions: + +| Transition | Time Limit | Consequence | +|------------|-----------|-------------| +| **App launch** | ~20 seconds | App killed, crash logged | +| **Background transition** | ~5 seconds | App killed | +| **Foreground transition** | ~10 seconds | App killed | + +**Watchdog disabled in**: +- Simulator +- Debugger attached +- Development builds (sometimes) + +**Watchdog kills are logged as crashes** with exception type `EXC_CRASH (SIGKILL)` and termination reason `Namespace RUNNINGBOARD, Code 3735883980` (hex `0xDEAD10CC` — indicates app held a file lock or SQLite database lock while being suspended). + +## Pressure Scenarios + +### Scenario 1: Manager Says "Just Add a Loading Spinner" + +**Situation**: App hangs during data load. Manager suggests adding spinner to "fix" it. + +**Why this fails**: Adding a spinner doesn't prevent the hang—the UI still freezes, the spinner won't animate, and the app remains unresponsive. + +**Correct response**: "A spinner won't animate during a hang because the main thread is blocked. We need to move this work off the main thread so the spinner can actually spin and the app stays responsive." + +### Scenario 2: "It Works Fine in Testing" + +**Situation**: QA can't reproduce the hang. Logs show it happens in production. + +**Analysis**: +1. Field devices have different data sizes +2. Network conditions vary (slow connection = longer sync) +3. Background apps consume memory/CPU +4. Watchdog is disabled in debug builds + +**Action**: +- Add MetricKit to capture field diagnostics +- Test with production-sized datasets +- Test without debugger attached +- Check Organizer for hang reports + +### Scenario 3: "We've Always Done It This Way" + +**Situation**: Legacy code calls synchronous API on main thread. Refactoring is "too risky." + +**Why it matters**: Even if it worked before: +- Data may have grown larger +- OS updates may have changed timing +- New devices have different characteristics +- Users notice more as apps get faster + +**Approach**: +1. Add metrics to measure current hang rate +2. Refactor incrementally with feature flags +3. A/B test to show improvement +4. Document risk of not fixing + +## Anti-Patterns to Avoid + +| Anti-Pattern | Why It's Wrong | Instead | +|--------------|----------------|---------| +| `DispatchQueue.main.sync` from background | Can deadlock, always blocks | Use `.async` | +| Semaphore to convert async to sync | Blocks calling thread | Stay async with completion/await | +| File I/O on main thread | Unpredictable latency | Background queue | +| Unfiltered notification observer | Processes irrelevant events | Filter by object/name | +| Creating formatters in loops | Expensive initialization | Cache and reuse | +| Synchronous network request | Blocks on network latency | URLSession async | + +## Hang Prevention Checklist + +Before shipping, verify: + +- [ ] No `Data(contentsOf:)` or file reads on main thread +- [ ] No `DispatchQueue.main.sync` from background threads +- [ ] No semaphore.wait() on main thread +- [ ] Formatters (DateFormatter, NumberFormatter) are cached +- [ ] Notification observers filter appropriately +- [ ] Launch work is minimized (defer non-essential) +- [ ] Image processing happens off main thread +- [ ] Database queries don't run on main thread +- [ ] MetricKit adopted for field diagnostics + +## Resources + +**WWDC**: 2021-10258, 2022-10082 + +**Docs**: /xcode/analyzing-responsiveness-issues-in-your-shipping-app, /metrickit/mxhangdiagnostic + +**Skills**: axiom-metrickit-ref, axiom-performance-profiling, axiom-swift-concurrency, axiom-lldb (interactive thread inspection at freeze point) diff --git a/.claude/skills/axiom-hang-diagnostics/agents/openai.yaml b/.claude/skills/axiom-hang-diagnostics/agents/openai.yaml new file mode 100644 index 0000000..7806fde --- /dev/null +++ b/.claude/skills/axiom-hang-diagnostics/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Hang Diagnostics" + short_description: "App freezes, UI unresponsive, main thread blocked, watchdog termination, or diagnosing hang reports from Xcode Organi..." diff --git a/.claude/skills/axiom-haptics/.openskills.json b/.claude/skills/axiom-haptics/.openskills.json new file mode 100644 index 0000000..2032c8e --- /dev/null +++ b/.claude/skills/axiom-haptics/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-haptics", + "installedAt": "2026-04-12T08:06:21.837Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-haptics/SKILL.md b/.claude/skills/axiom-haptics/SKILL.md new file mode 100644 index 0000000..fe9858a --- /dev/null +++ b/.claude/skills/axiom-haptics/SKILL.md @@ -0,0 +1,814 @@ +--- +name: axiom-haptics +description: Use when implementing haptic feedback, Core Haptics patterns, audio-haptic synchronization, or debugging haptic issues - covers UIFeedbackGenerator, CHHapticEngine, AHAP patterns, and Apple's Causality-Harmony-Utility design principles from WWDC 2021 +license: MIT +metadata: + version: "1.0.0" +--- + +# Haptics & Audio Feedback + +Comprehensive guide to implementing haptic feedback on iOS. Every Apple Design Award winner uses excellent haptic feedback - Camera, Maps, Weather all use haptics masterfully to create delightful, responsive experiences. + +## Overview + +Haptic feedback provides tactile confirmation of user actions and system events. When designed thoughtfully using the Causality-Harmony-Utility framework, axiom-haptics transform interfaces from functional to delightful. + +This skill covers both simple haptics (`UIFeedbackGenerator`) and advanced custom patterns (`Core Haptics`), with real-world examples and audio-haptic synchronization techniques. + +## When to Use This Skill + +- Adding haptic feedback to user interactions +- Choosing between UIFeedbackGenerator and Core Haptics +- Designing audio-haptic experiences that feel unified +- Creating custom haptic patterns with AHAP files +- Synchronizing haptics with animations and audio +- Debugging haptic issues (simulator vs device) +- Optimizing haptic performance and battery impact + +## System Requirements + +- **iOS 10+** for UIFeedbackGenerator +- **iOS 13+** for Core Haptics (CHHapticEngine) +- **iPhone 8+** for Core Haptics hardware support +- **Physical device required** - haptics cannot be felt in Simulator + +--- + +## Part 1: Design Principles (WWDC 2021/10278) + +Apple's audio and haptic design teams established three core principles for multimodal feedback: + +### Causality - Make it obvious what caused the feedback + +**Problem**: User can't tell what triggered the haptic +**Solution**: Haptic timing must match the visual/interaction moment + +**Example from WWDC**: +- ✅ Ball hits wall → haptic fires at collision moment +- ❌ Ball hits wall → haptic fires 100ms later (confusing) + +**Code pattern**: +```swift +// ✅ Immediate feedback on touch +@objc func buttonTapped() { + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() // Fire immediately + performAction() +} + +// ❌ Delayed feedback loses causality +@objc func buttonTapped() { + performAction() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() // Too late! + } +} +``` + +### Harmony - Senses work best when coherent + +**Problem**: Visual, audio, and haptic don't match +**Solution**: All three senses should feel like a unified experience + +**Example from WWDC**: +- Small ball → light haptic + high-pitched sound +- Large ball → heavy haptic + low-pitched sound +- Shield transformation → continuous haptic + progressive audio + +**Key insight**: A large object should **feel** heavy, **sound** low and resonant, and **look** substantial. All three senses reinforce the same experience. + +### Utility - Provide clear value + +**Problem**: Haptics used everywhere "just because we can" +**Solution**: Reserve haptics for significant moments that benefit the user + +**When to use haptics**: +- ✅ Confirming an important action (payment completed) +- ✅ Alerting to critical events (low battery) +- ✅ Providing continuous feedback (scrubbing slider) +- ✅ Enhancing delight (app launch flourish) + +**When NOT to use haptics**: +- ❌ Every single tap (overwhelming) +- ❌ Scrolling through long lists (battery drain) +- ❌ Background events user can't see (confusing) +- ❌ Decorative animations (no value) + +--- + +## Part 2: UIFeedbackGenerator (Simple Haptics) + +For most apps, `UIFeedbackGenerator` provides 3 simple haptic types without custom patterns. + +### UIImpactFeedbackGenerator + +Physical collision or impact sensation. + +**Styles** (ordered light → heavy): +- `.light` - Small, delicate tap +- `.medium` - Standard tap (most common) +- `.heavy` - Strong, solid impact +- `.rigid` - Firm, precise tap +- `.soft` - Gentle, cushioned tap + +**Usage pattern**: +```swift +class MyViewController: UIViewController { + let impactGenerator = UIImpactFeedbackGenerator(style: .medium) + + override func viewDidLoad() { + super.viewDidLoad() + // Prepare reduces latency for next impact + impactGenerator.prepare() + } + + @objc func userDidTap() { + impactGenerator.impactOccurred() + } +} +``` + +**Intensity variation** (iOS 13+): +```swift +// intensity: 0.0 (lightest) to 1.0 (strongest) +impactGenerator.impactOccurred(intensity: 0.5) +``` + +**Common use cases**: +- Button taps (`.medium`) +- Toggle switches (`.light`) +- Deleting items (`.heavy`) +- Confirming selections (`.rigid`) + +### UISelectionFeedbackGenerator + +Discrete selection changes (picker wheels, segmented controls). + +**Usage**: +```swift +class PickerViewController: UIViewController { + let selectionGenerator = UISelectionFeedbackGenerator() + + func pickerView(_ picker: UIPickerView, didSelectRow row: Int, + inComponent component: Int) { + selectionGenerator.selectionChanged() + } +} +``` + +**Feels like**: Clicking a physical wheel with detents + +**Common use cases**: +- Picker wheels +- Segmented controls +- Page indicators +- Step-through interfaces + +### UINotificationFeedbackGenerator + +System-level success/warning/error feedback. + +**Types**: +- `.success` - Task completed successfully +- `.warning` - Attention needed, but not critical +- `.error` - Critical error occurred + +**Usage**: +```swift +let notificationGenerator = UINotificationFeedbackGenerator() + +func submitForm() { + // Validate form + if isValid { + notificationGenerator.notificationOccurred(.success) + saveData() + } else { + notificationGenerator.notificationOccurred(.error) + showValidationErrors() + } +} +``` + +**Best practice**: Match haptic type to user outcome +- ✅ Payment succeeds → `.success` +- ✅ Form validation fails → `.error` +- ✅ Approaching storage limit → `.warning` + +### Performance: prepare() + +Call `prepare()` before the haptic to reduce latency: + +```swift +// ✅ Good - prepare before user action +@IBAction func buttonTouchDown(_ sender: UIButton) { + impactGenerator.prepare() // User's finger is down +} + +@IBAction func buttonTouchUpInside(_ sender: UIButton) { + impactGenerator.impactOccurred() // Immediate haptic +} + +// ❌ Bad - unprepared haptic may lag +@IBAction func buttonTapped(_ sender: UIButton) { + let generator = UIImpactFeedbackGenerator() + generator.impactOccurred() // May have 10-20ms delay +} +``` + +**Prepare timing**: System keeps engine ready for ~1 second after `prepare()`. + +--- + +## Part 3: Core Haptics (Custom Haptics) + +For apps needing custom patterns, `Core Haptics` provides full control over haptic waveforms. + +### Four Fundamental Elements + +1. **Engine** (`CHHapticEngine`) - Link to the phone's actuator +2. **Player** (`CHHapticPatternPlayer`) - Playback control +3. **Pattern** (`CHHapticPattern`) - Collection of events over time +4. **Events** (`CHHapticEvent`) - Building blocks specifying the experience + +### CHHapticEngine Lifecycle + +```swift +import CoreHaptics + +class HapticManager { + var engine: CHHapticEngine? + + func initializeHaptics() { + // Check device support + guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { + print("Device doesn't support haptics") + return + } + + do { + // Create engine + engine = try CHHapticEngine() + + // Handle interruptions (calls, Siri, etc.) + engine?.stoppedHandler = { reason in + print("Engine stopped: \(reason)") + self.restartEngine() + } + + // Handle reset (audio session changes) + engine?.resetHandler = { + print("Engine reset") + self.restartEngine() + } + + // Start engine + try engine?.start() + + } catch { + print("Failed to create haptic engine: \(error)") + } + } + + func restartEngine() { + do { + try engine?.start() + } catch { + print("Failed to restart engine: \(error)") + } + } +} +``` + +**Critical**: Always set `stoppedHandler` and `resetHandler` to handle system interruptions. + +### CHHapticEvent Types + +#### Transient Events + +Short, discrete feedback (like a tap). + +```swift +let intensity = CHHapticEventParameter( + parameterID: .hapticIntensity, + value: 1.0 // 0.0 to 1.0 +) + +let sharpness = CHHapticEventParameter( + parameterID: .hapticSharpness, + value: 0.5 // 0.0 (dull) to 1.0 (sharp) +) + +let event = CHHapticEvent( + eventType: .hapticTransient, + parameters: [intensity, sharpness], + relativeTime: 0.0 // Seconds from pattern start +) +``` + +**Parameters**: +- `hapticIntensity`: Strength (0.0 = barely felt, 1.0 = maximum) +- `hapticSharpness`: Character (0.0 = dull thud, 1.0 = crisp snap) + +#### Continuous Events + +Sustained feedback over time (like a vibration motor). + +```swift +let intensity = CHHapticEventParameter( + parameterID: .hapticIntensity, + value: 0.8 +) + +let sharpness = CHHapticEventParameter( + parameterID: .hapticSharpness, + value: 0.3 +) + +let event = CHHapticEvent( + eventType: .hapticContinuous, + parameters: [intensity, sharpness], + relativeTime: 0.0, + duration: 2.0 // Seconds +) +``` + +**Use cases**: +- Rolling texture as object moves +- Motor running +- Charging progress +- Long press feedback + +### Creating and Playing Patterns + +```swift +func playCustomPattern() { + // Create events + let tap1 = CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.5), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5) + ], + relativeTime: 0.0 + ) + + let tap2 = CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.7), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.7) + ], + relativeTime: 0.3 + ) + + let tap3 = CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 1.0) + ], + relativeTime: 0.6 + ) + + do { + // Create pattern from events + let pattern = try CHHapticPattern( + events: [tap1, tap2, tap3], + parameters: [] + ) + + // Create player + let player = try engine?.makePlayer(with: pattern) + + // Play + try player?.start(atTime: CHHapticTimeImmediate) + + } catch { + print("Failed to play pattern: \(error)") + } +} +``` + +### CHHapticAdvancedPatternPlayer - Looping + +For continuous feedback (rolling textures, motors), use advanced player: + +```swift +func startRollingTexture() { + let event = CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.4), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.2) + ], + relativeTime: 0.0, + duration: 0.5 + ) + + do { + let pattern = try CHHapticPattern(events: [event], parameters: []) + + // Use advanced player for looping + let player = try engine?.makeAdvancedPlayer(with: pattern) + + // Enable looping + try player?.loopEnabled = true + + // Start + try player?.start(atTime: CHHapticTimeImmediate) + + // Update intensity dynamically based on ball speed + updateTextureIntensity(player: player) + + } catch { + print("Failed to start texture: \(error)") + } +} + +func updateTextureIntensity(player: CHHapticAdvancedPatternPlayer?) { + let newIntensity = calculateIntensityFromBallSpeed() + + let intensityParam = CHHapticDynamicParameter( + parameterID: .hapticIntensityControl, + value: newIntensity, + relativeTime: 0 + ) + + try? player?.sendParameters([intensityParam], atTime: CHHapticTimeImmediate) +} +``` + +**Key difference**: `CHHapticPatternPlayer` plays once, `CHHapticAdvancedPatternPlayer` supports looping and dynamic parameter updates. + +--- + +## Part 4: AHAP Files (Apple Haptic Audio Pattern) + +AHAP (Apple Haptic Audio Pattern) files are JSON files combining haptic events and audio. + +### Basic AHAP Structure + +```json +{ + "Version": 1.0, + "Metadata": { + "Project": "My App", + "Created": "2024-01-15" + }, + "Pattern": [ + { + "Event": { + "Time": 0.0, + "EventType": "HapticTransient", + "EventParameters": [ + { + "ParameterID": "HapticIntensity", + "ParameterValue": 1.0 + }, + { + "ParameterID": "HapticSharpness", + "ParameterValue": 0.5 + } + ] + } + } + ] +} +``` + +### Adding Audio to AHAP + +```json +{ + "Version": 1.0, + "Pattern": [ + { + "Event": { + "Time": 0.0, + "EventType": "AudioCustom", + "EventParameters": [ + { + "ParameterID": "AudioVolume", + "ParameterValue": 0.8 + } + ], + "EventWaveformPath": "ShieldA.wav" + } + }, + { + "Event": { + "Time": 0.0, + "EventType": "HapticContinuous", + "EventDuration": 0.5, + "EventParameters": [ + { + "ParameterID": "HapticIntensity", + "ParameterValue": 0.6 + } + ] + } + } + ] +} +``` + +### Loading AHAP Files + +```swift +func loadAHAPPattern(named name: String) -> CHHapticPattern? { + guard let url = Bundle.main.url(forResource: name, withExtension: "ahap") else { + print("AHAP file not found") + return nil + } + + do { + return try CHHapticPattern(contentsOf: url) + } catch { + print("Failed to load AHAP: \(error)") + return nil + } +} + +// Usage +if let pattern = loadAHAPPattern(named: "ShieldTransient") { + let player = try? engine?.makePlayer(with: pattern) + try? player?.start(atTime: CHHapticTimeImmediate) +} +``` + +### Design Workflow (WWDC Example) + +1. **Create visual animation** (e.g., shield transformation, 500ms) +2. **Design audio** (convey energy gain and robustness) +3. **Design haptic** (feel the transformation) +4. **Test harmony** - Do all three senses work together? +5. **Iterate** - Swap AHAP assets until coherent +6. **Implement** - Update code to use final assets + +**Example iteration**: Shield initially used 3 transient pulses (haptic) + progressive continuous sound (audio) → no harmony. Solution: Switch to continuous haptic + ShieldA.wav audio → unified experience. + +--- + +## Part 5: Audio-Haptic Synchronization + +### Matching Animation Timing + +```swift +class ViewController: UIViewController { + let animationDuration: TimeInterval = 0.5 + + func performShieldTransformation() { + // Start haptic/audio simultaneously with animation + playShieldPattern() + + UIView.animate(withDuration: animationDuration) { + self.shieldView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2) + self.shieldView.alpha = 0.8 + } + } + + func playShieldPattern() { + if let pattern = loadAHAPPattern(named: "ShieldContinuous") { + let player = try? engine?.makePlayer(with: pattern) + try? player?.start(atTime: CHHapticTimeImmediate) + } + } +} +``` + +**Critical**: Fire haptic at the exact moment the visual change occurs, not before or after. + +### Coordinating with Audio + +```swift +import AVFoundation + +class AudioHapticCoordinator { + let audioPlayer: AVAudioPlayer + let hapticEngine: CHHapticEngine + + func playCoordinatedExperience() { + // Prepare both systems + hapticEngine.notifyWhenPlayersFinished { _ in + return .stopEngine + } + + // Start at exact same moment + let startTime = CACurrentMediaTime() + 0.05 // Small delay for sync + + // Start audio + audioPlayer.play(atTime: startTime) + + // Start haptic + if let pattern = loadAHAPPattern(named: "CoordinatedPattern") { + let player = try? hapticEngine.makePlayer(with: pattern) + try? player?.start(atTime: CHHapticTimeImmediate) + } + } +} +``` + +--- + +## Part 6: Common Patterns + +### Button Tap + +```swift +class HapticButton: UIButton { + let impactGenerator = UIImpactFeedbackGenerator(style: .medium) + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + impactGenerator.prepare() + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + impactGenerator.impactOccurred() + } +} +``` + +### Slider Scrubbing + +```swift +class HapticSlider: UISlider { + let selectionGenerator = UISelectionFeedbackGenerator() + var lastValue: Float = 0 + + @objc func valueChanged() { + let threshold: Float = 0.1 + + if abs(value - lastValue) >= threshold { + selectionGenerator.selectionChanged() + lastValue = value + } + } +} +``` + +### Pull-to-Refresh + +```swift +class PullToRefreshController: UIViewController { + let impactGenerator = UIImpactFeedbackGenerator(style: .medium) + var isRefreshing = false + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let threshold: CGFloat = -100 + let offset = scrollView.contentOffset.y + + if offset <= threshold && !isRefreshing { + impactGenerator.impactOccurred() + isRefreshing = true + beginRefresh() + } + } +} +``` + +### Success/Error Feedback + +```swift +func handleServerResponse(_ result: Result) { + let notificationGenerator = UINotificationFeedbackGenerator() + + switch result { + case .success: + notificationGenerator.notificationOccurred(.success) + showSuccessMessage() + case .failure: + notificationGenerator.notificationOccurred(.error) + showErrorAlert() + } +} +``` + +--- + +## Part 7: Testing & Debugging + +### Simulator Limitations + +**Haptics DO NOT work in Simulator**. You will see: +- No haptic feedback +- No warnings or errors +- Code runs normally + +**Solution**: Always test on physical device (iPhone 8 or newer). + +### Device Testing Checklist + +- [ ] Test with Haptics disabled in Settings → Sounds & Haptics +- [ ] Test with Low Power Mode enabled +- [ ] Test during incoming call (engine may stop) +- [ ] Test with audio playing in background +- [ ] Test with different intensity/sharpness values +- [ ] Verify battery impact (Instruments Energy Log) + +### Debug Logging + +```swift +func playHaptic() { + #if DEBUG + print("🔔 Playing haptic - Engine running: \(engine?.currentTime ?? -1)") + #endif + + do { + let player = try engine?.makePlayer(with: pattern) + try player?.start(atTime: CHHapticTimeImmediate) + + #if DEBUG + print("✅ Haptic started successfully") + #endif + } catch { + #if DEBUG + print("❌ Haptic failed: \(error.localizedDescription)") + #endif + } +} +``` + +--- + +## Troubleshooting + +### Engine fails to start + +**Symptom**: `CHHapticEngine.start()` throws error + +**Causes**: +1. Device doesn't support Core Haptics (< iPhone 8) +2. Haptics disabled in Settings +3. Low Power Mode enabled + +**Solution**: +```swift +func safelyStartEngine() { + guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { + print("Device doesn't support haptics") + return + } + + do { + try engine?.start() + } catch { + print("Engine start failed: \(error)") + // Fall back to UIFeedbackGenerator + useFallbackHaptics() + } +} +``` + +### Haptics not felt + +**Symptom**: Code runs but no haptic felt on device + +**Debug steps**: +1. Check Settings → Sounds & Haptics → System Haptics is ON +2. Check Low Power Mode is OFF +3. Verify device is iPhone 8 or newer +4. Check intensity > 0.3 (values below may be too subtle) +5. Test with UIFeedbackGenerator to isolate Core Haptics vs system issue + +### Audio out of sync with haptics + +**Symptom**: Audio plays but haptic delayed or vice versa + +**Causes**: +1. Not calling `prepare()` before haptic +2. Audio/haptic started at different times +3. Heavy main thread work blocking playback + +**Solution**: +```swift +// ✅ Synchronized start +func playCoordinated() { + impactGenerator.prepare() // Reduce latency + + // Start both simultaneously + audioPlayer.play() + impactGenerator.impactOccurred() +} +``` + +### Audio file errors with AHAP + +**Symptom**: AHAP pattern fails to load or play + +**Cause**: Audio file > 4.2 MB or > 23 seconds + +**Solution**: Keep audio files small and short. Use compressed formats (AAC) and trim to essential duration. + +--- + +## Resources + +**WWDC**: 2021-10278, 2019-520, 2019-223 + +**Docs**: /corehaptics, /corehaptics/chhapticengine + +**Skills**: axiom-swiftui-animation-ref, axiom-ui-testing, axiom-accessibility-diag diff --git a/.claude/skills/axiom-haptics/agents/openai.yaml b/.claude/skills/axiom-haptics/agents/openai.yaml new file mode 100644 index 0000000..9636b51 --- /dev/null +++ b/.claude/skills/axiom-haptics/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Haptics" + short_description: "Implementing haptic feedback, Core Haptics patterns, audio-haptic synchronization, or debugging haptic issues" diff --git a/.claude/skills/axiom-health-check/.openskills.json b/.claude/skills/axiom-health-check/.openskills.json new file mode 100644 index 0000000..e2e6abd --- /dev/null +++ b/.claude/skills/axiom-health-check/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-health-check", + "installedAt": "2026-04-12T08:06:21.838Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-health-check/SKILL.md b/.claude/skills/axiom-health-check/SKILL.md new file mode 100644 index 0000000..04df4ac --- /dev/null +++ b/.claude/skills/axiom-health-check/SKILL.md @@ -0,0 +1,133 @@ +--- +name: axiom-health-check +description: Use when the user wants a comprehensive project-wide audit, full health check, or scan across all domains. +license: MIT +disable-model-invocation: true +--- +# Health Check Meta-Audit Agent + +You are an orchestrator that launches specialized Axiom auditors in parallel, collects their findings, deduplicates by file:line, and produces a unified health report. + +## Files to Exclude + +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Phase 1: Detect Which Auditors to Run + +First, find all Swift source files with Glob (`**/*.swift`), then use Grep to detect framework signals. + +### Always Run + +These auditors apply to every iOS project: + +| Auditor | Reason | +|---------|--------| +| memory-auditor | Memory leaks affect all apps | +| security-privacy-scanner | Privacy compliance is mandatory | +| accessibility-auditor | Accessibility is required for App Store | +| swift-performance-analyzer | Performance affects all apps | +| modernization-helper | Deprecated API detection | +| codable-auditor | Serialization issues are universal | + +### Conditional (grep for signals) + +Run these only when their framework signals are present in the codebase: + +| Signal (grep pattern) | Auditor | +|----------------------|---------| +| `import SwiftUI` | swiftui-performance-analyzer, swiftui-architecture-auditor, swiftui-layout-auditor, swiftui-nav-auditor | +| `import SwiftData` or `@Model` | swiftdata-auditor | +| `import CoreData` or `.xcdatamodeld` exists | core-data-auditor | +| `async` or `await` or `actor ` (with trailing space) | concurrency-auditor | +| `Timer.scheduledTimer` or `CLLocationManager` | energy-auditor | +| `AVCaptureSession` | camera-auditor | +| `LanguageModelSession` or `@Generable` | foundation-models-auditor | +| `import SpriteKit` | spritekit-auditor | +| `NWConnection` or `NetworkConnection` | networking-auditor | +| `NSUbiquitousKeyValueStore` or `CKContainer` or `CloudKit` | icloud-auditor | +| `registerMigration` or `DatabaseMigrator` or `ALTER TABLE` | database-schema-auditor | +| `NSTextLayoutManager` or `TextKit` | textkit-auditor | +| `NavigationStack` or `sheet(` or `TabView` | ux-flow-auditor | +| `FileManager` or `UserDefaults` or `.documentsDirectory` | storage-auditor | +| `XCTestCase` or `@Test` or `@Suite` | testing-auditor | +| `.glassBackgroundEffect` or `GlassEffectContainer` | liquid-glass-auditor | +| Screenshots folder exists (`Screenshots/` or `marketing/`) | screenshot-validator | + +### User Exclusions + +If the user says "skip X" or "exclude X", remove that auditor from the run list. Acknowledge which auditors were excluded and why. + +## Phase 2: Launch Auditors in Parallel + +Use the Agent tool with `run_in_background: true` for each selected auditor. Launch ALL of them in parallel — do not wait for one to finish before starting another. + +Today's date tag for filenames: use ISO format `YYYY-MM-DD`. + +Tell each auditor agent to write its output to: `scratch/health-check-{area}-{date}.md` +where `{area}` is the auditor name (e.g., `memory`, `accessibility`, `concurrency`). + +While auditors run, inform the user: +- How many auditors were launched +- Which are "always run" vs "conditional" (and what signals triggered them) +- Which were skipped (no signal detected) or excluded (user request) + +## Phase 3: Collect and Deduplicate + +After all auditors complete: + +1. Use TaskOutput to collect the summary from each background agent launched in Phase 2. Wait for all agents to return before proceeding. +2. Read each `scratch/health-check-*-{date}.md` file +3. Parse findings — look for file:line references and severity levels +4. Identify duplicate file:line references across multiple auditor reports +5. Merge duplicates: keep all domain tags (e.g., "memory + concurrency") and the highest severity + +## Phase 4: Generate Unified Report + +Write to `scratch/health-check-{date}.md` with: + +### Executive Summary + +Top 5 most critical findings across all domains. Each with: +- Severity (CRITICAL/HIGH/MEDIUM/LOW) +- Domain(s) +- File:line +- One-line description + +### Findings by Domain + +Group findings by domain (memory, accessibility, concurrency, etc.). Within each domain, sort by severity (CRITICAL first). + +### Passed Audits + +List auditors that found zero issues — this is valuable signal. + +### Summary Table + +| Auditor | Trigger Reason | Findings | Severity Breakdown | Report File | +|---------|---------------|----------|-------------------|-------------| +| memory-auditor | always | 3 | 1 HIGH, 2 MEDIUM | scratch/health-check-memory-{date}.md | +| ... | ... | ... | ... | ... | + +## Output Limits + +If >100 total findings across all auditors: +- Show only CRITICAL and HIGH findings in the conversation response +- Reference the scratch files for MEDIUM and LOW findings +- Provide the summary table in full regardless + +If <=100 total findings: +- Show all findings grouped by domain in the conversation response + +## Guidelines + +1. Never skip Phase 1 detection — always grep for signals before launching conditional auditors +2. Launch all auditors in parallel — sequential launching wastes time +3. Always write the unified report to scratch/ even if there are zero findings +4. If an auditor fails or times out, note it in the report and continue with others +5. Deduplicate aggressively — the same file:line appearing in 3 auditors should be one finding with 3 domain tags + +## Related + +For individual audits: Use the specific auditor agent directly (e.g., `memory-auditor`, `accessibility-auditor`) +For build-specific issues: `build-fixer` agent +For test-specific issues: `test-failure-analyzer` agent diff --git a/.claude/skills/axiom-health-check/agents/openai.yaml b/.claude/skills/axiom-health-check/agents/openai.yaml new file mode 100644 index 0000000..87db969 --- /dev/null +++ b/.claude/skills/axiom-health-check/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Health Check" + short_description: "The user wants a comprehensive project-wide audit, full health check, or scan across all domains." diff --git a/.claude/skills/axiom-hig-ref/.openskills.json b/.claude/skills/axiom-hig-ref/.openskills.json new file mode 100644 index 0000000..684dae7 --- /dev/null +++ b/.claude/skills/axiom-hig-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-hig-ref", + "installedAt": "2026-04-12T08:06:23.157Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-hig-ref/SKILL.md b/.claude/skills/axiom-hig-ref/SKILL.md new file mode 100644 index 0000000..973f809 --- /dev/null +++ b/.claude/skills/axiom-hig-ref/SKILL.md @@ -0,0 +1,1236 @@ +--- +name: axiom-hig-ref +description: Reference — Comprehensive Apple Human Interface Guidelines covering colors (semantic, custom, patterns), backgrounds (material hierarchy, dynamic), typography (built-in styles, custom fonts, Dynamic Type), SF Symbols (rendering modes, color, axiom-localization), Dark Mode, accessibility, and platform-specific considerations +license: MIT +compatibility: iOS, iPadOS, macOS, watchOS, tvOS, axiom-visionOS +metadata: + version: "1.0.0" +--- + +# Apple Human Interface Guidelines — Comprehensive Reference + +## Overview + +The Human Interface Guidelines (HIG) define Apple's design philosophy and provide concrete guidance for creating intuitive, accessible, platform-appropriate experiences across all Apple devices. + +### Three Core Principles + +Every design decision should support these principles: + +**1. Clarity** +Content is paramount. Interface elements should defer to content, not compete with it. Every element has a purpose, unnecessary complexity is eliminated, and users should immediately know what they can do without extensive instructions. + +**2. Consistency** +Apps use standard UI elements and familiar patterns. Navigation follows platform conventions, gestures work as expected, and components appear in expected locations. This familiarity reduces cognitive load. + +**3. Deference** +The UI should not distract from essential content. Use subtle backgrounds, receding navigation when not needed, restrained branding, and let content be the hero. + +**From Apple HIG:** "Deference makes an app beautiful by ensuring the content stands out while the surrounding visual elements do not compete with it." + +### Design System Philosophy + +From WWDC25: "A systematic approach means designing with intention at every level, ensuring that all elements, from the tiniest control to the largest surface, are considered in relation to the whole." + +#### Related Skills +- Use `axiom-hig` for quick decisions and checklists +- Use `axiom-liquid-glass` for iOS 26 material implementation +- Use `axiom-liquid-glass-ref` for iOS 26 app-wide adoption +- Use `axiom-accessibility-diag` for accessibility troubleshooting + +--- + +## Color System + +### Semantic Colors Explained + +Instead of hardcoded color values, use **semantic colors** that describe the *purpose* of a color rather than its appearance. Semantic colors automatically adapt to light/dark mode and accessibility settings. + +**Key insight from WWDC19:** "Think of Dark Mode as having the lights dimmed rather than everything being flipped inside out." Colors are NOT simply inverted—table row backgrounds are lighter in both modes. + +### Label Colors (Foreground Content) + +Four semantic label levels for text and symbols, each progressively less prominent: + +| Style | Semantic Color | Usage | +|---|---|---| +| `.primary` | `label` | Titles, most prominent text | +| `.secondary` | `secondaryLabel` | Subtitles, less prominent | +| `.tertiary` | `tertiaryLabel` | Placeholder text | +| `.quaternary` | `quaternaryLabel` | Disabled text | + +```swift +Text("Title").foregroundStyle(.primary) // Black in Light, white in Dark +Text("Subtitle").foregroundStyle(.secondary) +``` + +### Background Colors (Primary → Tertiary) + +Background colors come in two sets — **ungrouped** (standard lists) and **grouped** (iOS Settings style): + +| Level | Ungrouped | Grouped | +|---|---|---| +| Primary | `.systemBackground` | `.systemGroupedBackground` | +| Secondary | `.secondarySystemBackground` | `.secondarySystemGroupedBackground` | +| Tertiary | `.tertiarySystemBackground` | `.tertiarySystemGroupedBackground` | + +Ungrouped: pure white/black in Light/Dark. Grouped: light gray/dark in Light/Dark. + +```swift +// Standard list → ungrouped backgrounds +List { Text("Item") } + .background(Color(.systemBackground)) + +// Settings-style list → grouped backgrounds +List { Section("Section") { Text("Item") } } + .listStyle(.grouped) +``` + +### Base vs Elevated Backgrounds + +There are actually **two sets** of background colors for layering interfaces: + +- **Base set:** Used for background apps/interfaces +- **Elevated set:** Used for foreground apps/interfaces + +**Why this matters:** + +In Light Mode, simple drop shadows create visual separation. In Dark Mode, drop shadows are less effective, so the system uses **lighter colors for elevated content**. + +**Example:** iPad multitasking: +- Mail app alone → base color set +- Contacts in slide-over → elevated colors (lighter, stands out) +- Both side-by-side → both use elevated colors for contrast around splitter +- Email compose sheet → elevated colors with overlay dimming + +**Critical:** Some darker colors may not contrast well when elevated. Always test designs in elevated state. Semi-opaque fill and separator colors adapt gracefully. + +### Tint Colors (Dynamic Adaptation) + +Tint colors are **dynamic** - they have variants for Light and Dark modes: + +```swift +// Tint color automatically adapts +Button("Primary Action") { + // action +} +.tint(.blue) +// Gets lighter in Dark Mode, darker in Light Mode +``` + +**Custom tint colors:** +When creating custom tint colors, select colors that work well in both modes. Use a contrast calculator to aim for **4.5:1 or higher** contrast ratio. Colors that work in Light Mode may have insufficient contrast in Dark Mode. + +### Fill Colors (Semi-Transparent) + +Fill colors are **semi-transparent** to contrast well against variable backgrounds: + +```swift +// System fill colors +Color(.systemFill) +Color(.secondarySystemFill) +Color(.tertiarySystemFill) +Color(.quaternarySystemFill) +``` + +**When to use:** Controls, buttons, and interactive elements that need to appear above dynamic backgrounds. + +### Separator Colors + +```swift +// Standard separator (semi-transparent) +Color(.separator) + +// Opaque separator +Color(.opaqueSeparator) +``` + +**Opaque separators** are used when transparency would create undesirable results (e.g., intersecting grid lines where overlapping semi-transparent colors create optical illusions). + +### When to Use Permanent Dark Backgrounds + +**Apple's explicit guidance:** +> "In rare cases, consider using only a dark appearance in the interface. For example, it can make sense for an app that enables **immersive media viewing** to use a permanently dark appearance that lets the UI recede and helps people focus on the media." + +**Examples from Apple's apps:** + +| App | Background | Rationale | +|-----|------------|-----------| +| Music | Dark | Album art should be visual focus | +| Photos | Dark | Images are hero content | +| Clock | Dark | Nighttime use, instrument feel | +| Stocks | Dark | Data visualization, charts | +| Camera | Dark | Reduces distraction during capture | + +**For all other apps:** Support both Light and Dark modes via system backgrounds. + +### Creating Custom Colors + +When you need custom colors: + +1. **Open Assets.xcassets** +2. **Add Color Set** +3. **Configure variants:** + - Light mode color + - Dark mode color + - High Contrast Light (optional but recommended) + - High Contrast Dark (optional but recommended) + +```swift +// Use custom color from asset catalog +Color("BrandAccent") +// Automatically uses correct variant +``` + +--- + +## Typography + +### System Fonts + +**San Francisco (SF):** The system sans-serif font family. +- SF Pro: General use +- SF Compact: watchOS and space-constrained layouts +- SF Mono: Code and monospaced text +- SF Rounded: Softer, friendlier feel +- Weights: Ultralight, Thin, Light, Regular, Medium, Semibold, Bold, Heavy, Black + +**New York (NY):** System serif font family for editorial content. + +**Both available as variable fonts** with seamless weight transitions. + +### Font Weight Recommendations + +**From Apple HIG:** "Avoid light font weights. Prefer Regular, Medium, Semibold, or Bold weights instead of Ultralight, Thin, or Light." + +**Why:** Light weights have legibility issues, especially at small sizes, in bright lighting, or for users with visual impairments. + +**Hierarchy:** +```swift +// Headers - Bold weight for prominence +Text("Header") + .font(.title.weight(.bold)) + +// Subheaders - Semibold +Text("Subheader") + .font(.title2.weight(.semibold)) + +// Body - Regular or Medium +Text("Body text") + .font(.body) + +// Captions - Regular (never Light) +Text("Caption") + .font(.caption) +``` + +### Text Styles for Hierarchy + +Use built-in text styles for automatic hierarchy and Dynamic Type support: + +```swift +.font(.largeTitle) .font(.title) .font(.title2) +.font(.title3) .font(.headline) .font(.body) +.font(.callout) .font(.subheadline) .font(.footnote) +.font(.caption) .font(.caption2) +``` + +All text styles scale automatically with Dynamic Type. + +### Dynamic Type Support + +**Requirement:** Apps must support text scaling of at least **200%** (iOS, iPadOS) or **140%** (watchOS). + +**Implementation:** +```swift +// ✅ CORRECT - Scales automatically +Text("Hello") + .font(.body) + +// ❌ WRONG - Fixed size, doesn't scale +Text("Hello") + .font(.system(size: 17)) +``` + +**Layout considerations:** +- Reduce multicolumn layouts at larger sizes +- Minimize text truncation +- Use stacked layouts instead of inline at large sizes +- Maintain consistent information hierarchy regardless of size + +**Not all content scales equally:** Prioritize what users actually care about. Secondary elements like tab titles shouldn't grow as much as primary content. + +### Custom Fonts + +When using custom fonts: +- Ensure legibility at various distances and conditions +- Implement Dynamic Type support +- Respond to Bold Text accessibility setting +- Test at all text sizes +- Match system font behaviors for accessibility + +**If your custom font is thin:** Increase size by ~2 points when pairing with uppercase Latin text. + +### Leading (Line Spacing) + +**Loose leading:** Wide columns (easier to track to next line) +**Tight leading:** Constrained height (avoid for 3+ lines) + +```swift +// Adjust leading for specific layouts +Text("Long content...") + .lineSpacing(8) // Add space between lines +``` + +--- + +## Shapes & Geometry + +### Three Shape Types (iOS 26) + +From WWDC25: "There's a quiet geometry to how our shapes fit together, driven by **concentricity**. By aligning radii and margins around a shared center, shapes can comfortably nest within each other." + +#### 1. Fixed Shapes + +Constant corner radius regardless of size: + +```swift +RoundedRectangle(cornerRadius: 12) +``` + +**Use when:** You need a specific, unchanging corner radius. + +#### 2. Capsules + +Radius is half the container's height: + +```swift +Capsule() +``` + +**Use when:** You want shapes that adapt to content while maintaining rounded ends. Perfect for buttons, pills, and controls. + +**Found throughout iOS 26:** Sliders, switches, grouped table views, tab bars, navigation bars. + +#### 3. Concentric Shapes + +Calculate radius by subtracting padding from parent's radius: + +```swift +.containerRelativeShape(.roundedRectangle) +``` + +**Use when:** Nesting shapes within containers to maintain visual harmony. + +### Concentricity Principle + +**Hardware ↔ Software harmony:** Apple's hardware features consistent bezel curvature. The same precision now guides UI, with curvature, size, and proportion aligning to create unified rhythm between what you hold and what you see. + +**Example of concentricity:** +``` +Window (rounded corners) + ├─ Sheet (concentric to window) + │ ├─ Card (concentric to sheet) + │ │ └─ Button (concentric to card) +``` + +### Platform-Specific Guidance + +**iOS:** +- **Capsules** for buttons, switches, grouped lists +- Creates hierarchy and focus in touch-friendly layouts + +**macOS:** +- **Mini, Small, Medium controls** → Rounded rectangles (dense layouts, inspector panels) +- **Large, X-Large controls** → Capsules (spacious areas, emphasis via Liquid Glass) + +### Optical Centering + +To preserve optical balance, views are: +- Mathematically centered when it makes sense +- Subtly offset when optical weight requires it + +**Example:** Asymmetric icons may need padding adjustments for optical centering rather than geometric centering. + +--- + +## Materials & Depth + +### Standard Materials + +Materials allow background content to show through, creating visual depth and hierarchy. + +#### Four Thickness Options + +1. **Ultra-thin** — Minimal separation, content clearly visible +2. **Thin** — Lighter-weight interactions +3. **Regular** — Default, works well in most circumstances +4. **Thick** — Most separation from background + +**Choosing thickness:** +- Content needs more contrast → thicker material +- Simpler content → thin/ultra-thin material + +```swift +// Apply material +.background(.ultraThinMaterial) +.background(.thinMaterial) +.background(.regularMaterial) +.background(.thickMaterial) +``` + +### Vibrancy with Materials + +**Key principle:** Use vibrant colors on top of materials for legibility. Solid colors can get muddy depending on background context. Vibrancy maintains contrast regardless of background. + +```swift +// Vibrant text on material +VStack { + Text("Primary") + .foregroundStyle(.primary) // Vibrant + Text("Secondary") + .foregroundStyle(.secondary) // Vibrant +} +.background(.regularMaterial) +``` + +### Liquid Glass (iOS 26+) + +**Purpose:** Creates a distinct functional layer for controls and navigation, floating above content. + +**Two variants:** + +1. **Regular Liquid Glass** + - Default, use in 95% of cases + - Full visual and adaptive effects + - Provides legibility regardless of context + - Works over any background + +2. **Clear Liquid Glass** + - Highly translucent + - No adaptive behaviors + - **Only use for components over visually rich backgrounds** (photos, videos) + - Requires dimming layer for legibility + +**Modals & Sheets (iOS 26+):** Sheets, alerts, and popovers automatically adopt Liquid Glass with Xcode 26 — remove custom `.presentationBackground()` or `UIBlurEffect` backgrounds. System handles material, concentric corner radius, and morphing transitions. Use elevated semantic colors for modal content backgrounds, not Liquid Glass on the sheet body. + +**Cross-reference:** For full Liquid Glass implementation patterns (sheets, alerts, popovers, morphing transitions), see `axiom-liquid-glass-ref`. For decision trees, see `axiom-liquid-glass`. + +--- + +## Layout Principles + +### Visual Hierarchy + +**Place items to convey their relative importance:** +- Important content → top and leading side +- Secondary content → below or trailing +- Tertiary content → separate views or progressive disclosure + +**From Apple HIG:** "Make essential information easy to find by giving it sufficient space and avoid obscuring it with nonessential details." + +### Grouping & Organization + +Group related items using: +- Negative space (whitespace) +- Colors and materials +- Separator lines + +**Ensure content and controls remain clearly distinct** through Liquid Glass material and scroll edge effects. + +### Content Extension to Edges + +"Extend content to fill the screen or window" with backgrounds and artwork reaching display edges. + +**Background extension views:** Use when content doesn't naturally span the full window. + +```swift +// Content extends to edges +VStack { + FullWidthImage() + .ignoresSafeArea() // Extends to screen edges +} +``` + +### Safe Areas & Layout Guides + +**Safe Areas:** Rectangular regions unobstructed by: +- Status bar +- Navigation bar +- Tab bar +- Toolbar +- Device features (Dynamic Island, notch, home indicator) + +**Layout Guides:** Define rectangular regions for positioning and spacing content with: +- Predefined margins +- Text width optimization +- Reading width constraints + +**Key principle:** "Respect key display and system features in each platform." + +```swift +// Respect safe areas +VStack { + Text("Content") +} +.safeAreaInset(edge: .bottom) { + BottomBar() +} +``` + +### Align Components + +"Align components with one another to make them easier to scan." + +**Grid alignment:** +- Text baselines align +- Controls align on common grid +- Spacing is consistent and rhythmic + +### Adaptability Requirements + +Design layouts that: +- "Adapt gracefully to context changes while remaining recognizably consistent" +- Support Dynamic Type text-size changes +- Work across multiple devices, orientations, and localizations +- Account for different screen sizes, resolutions, and system features + +--- + +## Accessibility + +### Vision Accessibility + +#### Text & Legibility + +**Requirements:** +- Support text enlargement of at least **200%** (140% for watchOS) +- Implement Dynamic Type for systemwide text adjustment +- Use font weights that enhance readability (avoid Light weights with custom fonts) + +#### Color Contrast + +**WCAG Level AA standards:** +- Normal text (14pt+): **4.5:1 minimum** +- Small text (<14pt): **7:1 recommended** +- Large text (18pt+ regular, 14pt+ bold): 3:1 acceptable + +**Implementation:** +```swift +// ✅ Use semantic colors (automatic contrast) +Text("Label").foregroundStyle(.primary) + +// ❌ Custom colors may fail contrast +Text("Label").foregroundStyle(.gray) // Check with calculator +``` + +**High contrast mode:** +Provide higher contrast color schemes when "Increase Contrast" accessibility setting is enabled. + +**Test in both Light and Dark modes.** + +#### Color Considerations + +**Critical:** "Convey information with more than color alone" to support colorblind users. + +**Solutions:** +- Use distinct shapes or icons alongside color +- Add text labels +- Employ system-defined colors with accessible variants +- Test with Color Blindness simulators + +**Example:** +```swift +// ❌ Only color indicates status +Circle().fill(isActive ? .green : .red) + +// ✅ Shape + color +HStack { + Image(systemName: isActive ? "checkmark.circle.fill" : "xmark.circle.fill") + Text(isActive ? "Active" : "Inactive") +} +.foregroundStyle(isActive ? .green : .red) +``` + +#### Screen Readers + +Describe interface and content for VoiceOver accessibility: + +```swift +Button { + share() +} label: { + Image(systemName: "square.and.arrow.up") +} +.accessibilityLabel("Share") +``` + +### Hearing Accessibility + +#### Media Alternatives + +For video/audio content, provide: +- Captions for dialogue +- Subtitles +- Audio descriptions for visual-only information +- Transcripts for longer-form media + +#### Audio Cues + +Pair audio signals with: +- Haptic feedback +- Visual indicators + +### Mobility Accessibility + +**Touch targets:** +- Minimum: **44x44 points** +- Spacing: 12-24 points padding around controls + +**Gestures:** +- Use simple gestures +- Offer alternatives (buttons alongside gestures) +- Support Voice Control +- Enable keyboard navigation + +**Assistive technologies:** +- VoiceOver +- Switch Control +- Full Keyboard Access + +### Cognitive Accessibility + +#### Interaction Design + +- "Keep actions simple and intuitive" +- Avoid time-based auto-dismissing views +- Prevent autoplay of audio/video without controls + +#### Motion & Visual Effects + +**Respect "Reduce Motion":** +- Minimize animations +- Avoid excessive flashing lights +- Support "Dim Flashing Lights" +- Reduce bounce effects +- Minimize z-axis depth changes + +```swift +// Check Reduce Motion setting +@Environment(\.accessibilityReduceMotion) var reduceMotion + +var body: some View { + content + .animation(reduceMotion ? nil : .spring(), value: isExpanded) +} +``` + +#### Game Accommodations + +Offer adjustable difficulty levels. + +### visionOS Specific + +Prioritize comfort: +- Maintain horizontal layouts +- Reduce animation speed +- Avoid head-anchored content (prevents assistive technology use) + +--- + +## Motion & Animation + +### Core Principles + +**Purposeful Animation:** "Add motion purposefully, supporting the experience without overshadowing it." + +**Avoid gratuitous animations** that distract or cause discomfort. Motion should enhance rather than dominate the interface. + +### Accessibility First + +**Make motion optional.** Supplement visual feedback with **haptics** and **audio** to communicate important information, ensuring all users can understand your interface regardless of motion preferences. + +### Best Practices for Feedback + +#### Realistic Motion + +Design animations aligned with user expectations and gestures. Feedback should be: +- "Brief and precise" +- Lightweight +- Effectively conveying information without distraction + +#### Frequency Considerations + +**Avoid animating frequent UI interactions.** Standard system elements already include subtle animations, so custom elements shouldn't add unnecessary motion to common actions. + +#### User Control + +"Let people cancel motion" by not forcing them to wait for animations to complete before proceeding, especially for repeated interactions. + +```swift +// ✅ Allow immediate tap, don't block on animation +Button("Next") { + withAnimation(.easeOut(duration: 0.2)) { + showNext = true + } +} +// User can tap again immediately, not forced to wait +``` + +### Platform-Specific Guidance + +#### visionOS + +- **Avoid motion at peripheral vision edges** — causes discomfort +- Use fades when relocating objects rather than visible movement +- Maintain stationary frames of reference +- Avoid sustained oscillations (especially at 0.2 Hz frequency) +- Prevent virtual world rotation (disrupts stability) + +#### watchOS + +SwiftUI provides animation capabilities; WatchKit offers `WKInterfaceImage` for layout animations and sequences. + +--- + +## Icons & Symbols + +### SF Symbols + +6,900+ vector symbols that match San Francisco font, scale with Dynamic Type, and adapt to Bold Text and Dark Mode automatically. Nine weights, three scales, four rendering modes, and 12+ animation effects. + +> **For comprehensive coverage** of rendering modes (Monochrome, Hierarchical, Palette, Multicolor), symbol effects (Bounce, Pulse, Wiggle, Draw On/Off), and custom symbol authoring, see `axiom-sf-symbols` (decision trees) and `axiom-sf-symbols-ref` (complete API). + +### Custom Interface Icons + +**Design principles:** Recognizable, simplified designs with familiar visual metaphors. Maintain uniform size, detail level, stroke thickness, and perspective. Match icon weight with adjacent text. Adjust padding for optical centering when visual weight is asymmetric. + +**Format:** Use **PDF or SVG** for automatic scaling. System components handle selected states automatically. + +### When to Use Icons vs Text + +From WWDC25: "A pencil might suggest annotate, and a checkmark can look like confirm—making actions like Select or Edit easy to misread. **When there's no clear shorthand, a text label is always the better choice.**" + +**Use icons when:** +- Symbol has clear, universal meaning (share, trash, settings) +- Space is constrained +- Icon aids quick scanning + +**Use text when:** +- Action has no clear symbol +- Multiple similar actions exist +- Clarity is more important than space + +### Accessibility + +**Always provide alternative text labels** enabling VoiceOver descriptions: + +```swift +Image(systemName: "star.fill") + .accessibilityLabel("Favorite") +``` + +--- + +## Gestures & Input + +### Core Gesture Design Principles + +**Consistency and Familiarity:** "People expect most gestures to work the same regardless of their current context." Standard gestures like tap, swipe, and drag should perform their expected functions across platforms. + +**Responsive Feedback:** "Handle gestures as responsively as possible" and provide immediate feedback during gesture performance so users can predict outcomes. + +### Standard Gestures + +Basic gestures supported across all platforms (though precise movements vary by device): +- Tap +- Swipe +- Drag +- Pinch +- Rotate (iOS/iPadOS) +- Long press + +### Touch Target Requirements + +**Minimum touch target sizes:** + +| Platform | Minimum Size | Spacing | +|----------|-------------|---------| +| iOS/iPadOS | 44x44 points | 12-24pt padding | +| macOS | Varies by control | System spacing | +| watchOS | System controls | Optimized for small screen | +| tvOS | Large (focus model) | 60pt+ spacing | + +```swift +// ✅ Adequate touch target +Button("Tap") { } + .frame(minWidth: 44, minHeight: 44) + +// ❌ Too small +Button("Tap") { } + .frame(width: 20, height: 20) // Fails accessibility +``` + +### Custom Gesture Guidelines + +Custom gestures should only be implemented when necessary and must be: +- **Discoverable** — Users can find them +- **Straightforward to perform** — Easy to execute +- **Distinct from other gestures** — No conflicts +- **Never the only method** — Provide alternatives for important actions + +**Warning:** Don't replace standard gestures with custom ones. Shortcuts should supplement, not replace, familiar interactions. + +### Accessibility + +**Critical:** "Give people more than one way to interact with your app." Never assume users can perform specific gestures. + +**Provide alternatives:** +- Voice control +- Keyboard navigation +- Button alternatives to gestures + +```swift +// ✅ Swipe action + button alternative +.swipeActions { + Button("Delete", role: .destructive) { + delete() + } +} +.contextMenu { + Button("Delete", role: .destructive) { + delete() + } +} +``` + +--- + +## Launch & Onboarding + +### Launch Screens + +**Mandatory for:** iOS, iPadOS, tvOS +**Not required for:** macOS, axiom-visionOS, watchOS + +**Design principle:** "Design a launch screen that's nearly identical to the first screen of your app or game" to avoid jarring visual transitions. + +#### Best Practices + +**Minimize branding:** +- Avoid logos +- No splash screens +- No artistic flourishes +- Purpose: Enhance perception of quick startup, not showcase brand + +**No text:** +- Launch screen content cannot be localized +- Avoid text entirely + +**Match appearance:** +- Respect device orientation +- Adapt to light/dark mode + +```swift +// Launch screen matches first screen +// Transitions smoothly without flash +``` + +### Onboarding + +Onboarding is a **separate experience** that follows the launch phase. Provides "a high-level view of your app or game" and can include a splash screen if needed. + +**When to use:** Only when you have meaningful context to communicate to new users. + +**What onboarding can include:** +- Branding and splash screens +- Educational content +- Permission requests +- Account setup + +**Timeline:** +1. Launch — System displays launch screen, transitions to first screen +2. Onboarding (optional) — Can include branding and education +3. Continued use — "Restore the previous state when your app restarts so people can continue where they left off" + +--- + +## Platform-Specific Guidance + +### iOS + +**Tab Bar Guidelines:** +- Maximum 5 tabs on iPhone (6th+ go in "More" automatically) +- Every tab must have icon AND text label — icon-only violates HIG +- Always visible — don't hide during navigation within a tab +- Tab order reflects usage frequency (most-used on left) +- Maintain tab state: preserve scroll position and navigation state when switching +- iOS 26: Liquid Glass automatic — don't add custom blur/material backgrounds +- iPad: tab bar → sidebar in landscape; use `TabView` with `Tab` for adaptation + +**Navigation Bar Guidelines:** +- Always use system back button (chevron) — don't replace with custom "X" +- Title describes current view content, not app name +- Large titles (`prefersLargeTitles`) for top-level views only; inline for pushed views +- 1-3 toolbar actions max; use `...` menu for additional +- iOS 26: Liquid Glass with toolbar morphing between views + +**System integration:** Widgets, Home Screen quick actions, Spotlight, Shortcuts, Activity views + +### iPadOS + +Extends iOS with larger display, sidebar navigation, split view, pointer/trackpad, arbitrary windows (iOS 26+). Don't just scale iOS layouts — leverage sidebars and split views. + +### macOS + +Pointer-first, keyboard-centric. Dense layouts, smaller controls than iOS. Multiple windows, menu bar, contextual menus, keyboard shortcuts essential. Controls: Mini/Small/Medium → rounded rectangles, Large/X-Large → capsules. + +### watchOS + +Very small display — glanceable, minimal interaction. Full-bleed content, minimal padding, Digital Crown interactions, complications for watch faces. Always-on display consideration. + +**Adapting from iPad/iOS:** Replace sidebars with page-based flow. Convert swipe/pinch to Digital Crown rotation. Use opacity/spacing for hierarchy (no materials/Liquid Glass). Complications replace dashboards. `@State`/`@Environment` reuse well; view hierarchy must be rewritten. + +### tvOS + +10-foot viewing distance, focus-based navigation, gestural remote. Large touch targets, prominent focus states, limited text input, directional navigation. + +**Focus Engine**: tvOS uses a UIKit Focus Engine for hardware navigation that coexists with SwiftUI's @FocusState. The Focus Engine is the ultimate authority — @FocusState assignments are ignored if the Focus Engine considers a view unfocusable. Use UIFocusGuide to bridge navigation gaps between isolated views. + +**TVUIKit**: tvOS-exclusive components — TVPosterView (parallax focus effects), TVDigitEntryViewController (PIN entry). No SwiftUI equivalents exist; bridge via UIViewRepresentable. + +**Text input**: Standard text fields trigger a fullscreen system keyboard. For better UX, use the shadow input pattern (Button UI + hidden CocoaTextField). See `axiom-tvos` for implementation details. + +**Storage**: No persistent local storage. All local files are cache that the system deletes. See `axiom-tvos` for data strategy. + +### visionOS + +Spatial computing with glass materials, 3D layouts, depth. Comfortable viewing depth, avoid head-anchored content, center content in field of view. + +--- + +## Inclusive Design + +### Language & Communication + +**Welcoming language requirements:** +- Use plain, direct, and respectful tone +- Don't suggest exclusivity based on education level +- Address people directly with "you/your" rather than "the user" +- Define specialized or technical terms when necessary +- Replace culture-specific expressions with plain alternatives + +**Avoid phrases with oppressive origins** (e.g., "peanut gallery"). + +**Exercise caution with humor** — it's subjective and difficult to translate across cultures. + +### Visual Representation + +**Portraying human diversity:** +- Feature people demonstrating range of racial backgrounds, body types, ages, physical capabilities +- Avoid stereotypical representations in occupations and behaviors + +**Avoiding assumptions:** +- Don't assume narrow definitions of family structures +- Don't assume universal experiences +- Replace culture-specific security questions with more universal experiences + +### Gender Identity & Pronouns + +**Best practices:** +- Avoid unnecessary gender references in copy +- Provide inclusive options: "nonbinary," "self-identify," "decline to state" +- Use nongendered imagery +- Allow customization of avatars and characters + +### Accessibility & Disability + +**Recognize:** +- Disabilities exist on spectrums +- Temporary/situational disabilities affect everyone + +**Include:** +- People with disabilities in diversity representations +- Adopt people-first approach in writing ("person with disability" vs "disabled person") + +### Localization & Global Considerations + +**Prepare software for:** +- Internationalization +- Translation into multiple languages + +**Cultural color awareness:** +- Colors carry culture-specific meanings +- White represents death in some cultures, purity in others +- Red signifies danger in some cultures, positive meanings elsewhere + +**Use plain language** and avoid stereotypes to facilitate smoother localization. + +--- + +## Branding + +### Core Principles + +**Voice & Tone:** Maintain consistent brand personality through written communication. + +**Visual Elements:** +- Consider accent color for UI components +- Custom font if strongly associated with brand (but system fonts work better for body copy due to legibility) + +### Key Restraint Guidelines + +**Most critical guidance — restraint:** + +**Defer to content:** "Using screen space for an element that does nothing but display a brand asset can mean there's less room for the content people care about." + +**Logo minimalism:** "Resist the temptation to display your logo throughout your app or game unless it's essential for providing context." + +**Familiar patterns:** Maintain standard UI behaviors and component placement even with stylized designs to keep interfaces approachable. + +**Launch screen caution:** Avoid using launch screens for branding since they disappear too quickly; consider onboarding screens instead for brand integration. + +### Appropriate Branding + +**Do:** +- Use your brand's accent color as app tint color +- Include branding in onboarding (not launch screen) +- Use brand voice in copy +- Feature brand in content, not chrome + +**Don't:** +- Display logo in navigation bar +- Override system backgrounds with brand colors +- Add splash screens +- Make branding compete with content + +### Legal Consideration + +Apple trademarks cannot appear in your app name or images—consult Apple's official trademark guidelines. + +--- + +## Troubleshooting Common HIG Issues + +### Color Contrast Failures + +**Symptom:** App Store rejection for accessibility violations, or colors don't meet WCAG standards. + +**Diagnosis:** Test with Accessibility Inspector, contrast calculators, both Light/Dark modes, and Increase Contrast enabled. See Accessibility > Vision section above for contrast ratio requirements. + +**Solution:** +```swift +// ❌ Custom gray may fail contrast +Text("Label").foregroundStyle(.gray) + +// ✅ Semantic colors (automatic compliance) +Text("Label").foregroundStyle(.secondary) + +// ✅ Verified custom color (~8:1 on white, WCAG AAA) +Text("Label").foregroundStyle(Color(red: 0.25, green: 0.25, blue: 0.25)) +``` + +### Touch Targets Too Small + +**Symptom:** Users report difficult tapping, App Store accessibility rejection. + +**Diagnosis:** +```swift +// Check button size +Button("Tap") { } + .frame(width: 30, height: 30) // ❌ Too small +``` + +**Solution:** +```swift +// ✅ Expand touch target to minimum 44x44 +Button("Tap") { } + .frame(minWidth: 44, minHeight: 44) + +// ✅ Alternative: Add padding +Button("Tap") { } + .padding() // System adds appropriate padding +``` + +### Dark Mode Issues + +**Symptom:** Colors look wrong in Dark Mode, insufficient contrast. + +**Diagnosis:** +- Hardcoded colors that don't adapt +- Custom colors without dark variants +- Not testing in both appearance modes + +**Solution:** +```swift +// ❌ PROBLEM: Hardcoded white text +Text("Label").foregroundStyle(.white) +// Invisible in Light Mode + +// ✅ SOLUTION: Semantic color +Text("Label").foregroundStyle(.primary) +// Black in Light, white in Dark + +// ✅ ALTERNATIVE: Asset catalog color with variants +Text("Label").foregroundStyle(Color("BrandText")) +// Define in Assets.xcassets with Light/Dark variants +``` + +### Light Font Weight Legibility + +**Symptom:** Text hard to read, especially at small sizes or in bright lighting. + +**Diagnosis:** +```swift +Text("Headline") + .font(.system(size: 17, weight: .ultralight)) // ❌ Too light +``` + +**Solution:** +```swift +// ✅ Use Regular minimum +Text("Headline") + .font(.system(size: 17, weight: .regular)) + +// ✅ Better: Use system text styles +Text("Headline") + .font(.headline) // Automatically uses appropriate weight +``` + +### Dynamic Type Not Working + +**Symptom:** Text doesn't scale when user changes text size in Settings. + +```swift +// ❌ Fixed size doesn't scale +Text("Label").font(.system(size: 17)) + +// ✅ Text styles scale automatically +Text("Label").font(.body) + +// ✅ Custom font with scaling +Text("Label").font(.custom("CustomFont", size: 17, relativeTo: .body)) +``` + +### Reduce Motion Not Respected + +**Symptom:** Users with motion sensitivity experience discomfort. + +**Diagnosis:** +- Animations always play regardless of setting +- No alternative for motion-sensitive users + +**Solution:** +```swift +// ✅ Check Reduce Motion setting +@Environment(\.accessibilityReduceMotion) var reduceMotion + +var body: some View { + content + .animation(reduceMotion ? nil : .spring(), value: isExpanded) +} + +// ✅ Alternative: Simpler animation +.animation(reduceMotion ? .linear(duration: 0.1) : .spring(), value: isExpanded) +``` + +### VoiceOver Labels Missing + +**Symptom:** VoiceOver announces unhelpful information like "Button" instead of action. + +**Diagnosis:** +```swift +// ❌ Image button without label +Button { + share() +} label: { + Image(systemName: "square.and.arrow.up") +} +// VoiceOver says: "Button" +``` + +**Solution:** +```swift +// ✅ Add accessibility label +Button { + share() +} label: { + Image(systemName: "square.and.arrow.up") +} +.accessibilityLabel("Share") +// VoiceOver says: "Share, Button" +``` + +### Information Only Conveyed by Color + +**Symptom:** Colorblind users can't distinguish status. + +**Diagnosis:** +```swift +// ❌ Only color indicates state +Circle() + .fill(isComplete ? .green : .red) +``` + +**Solution:** +```swift +// ✅ Use shape + color + text +HStack { + Image(systemName: isComplete ? "checkmark.circle.fill" : "xmark.circle.fill") + Text(isComplete ? "Complete" : "Incomplete") +} +.foregroundStyle(isComplete ? .green : .red) +``` + +### Launch Screen Branding Rejection + +**Symptom:** App Store rejects launch screen with logo or text. + +**Diagnosis:** +- Launch screen contains branding elements +- Launch screen has text that can't be localized + +**Solution:** +```swift +// ❌ Launch screen with logo (rejected) +// Launch.storyboard contains app logo + +// ✅ Launch screen matches first screen (approved) +// Launch.storyboard shows same background/layout as first screen +// No text, no logos, minimal branding + +// Move branding to onboarding screen instead +``` + +### Custom Appearance Toggle Issues + +**Symptom:** Users confused by app-specific dark mode setting, double settings. + +**Diagnosis:** +- App has its own Light/Dark toggle +- Conflicts with system Settings → Display & Brightness + +**Solution:** +```swift +// ❌ App-specific appearance toggle +.preferredColorScheme(userPreference == .dark ? .dark : .light) + +// ✅ Respect system preference +// Remove custom toggle, use system preference +// Let iOS Settings control appearance +``` + +--- + +## Resources + +**WWDC**: 356, 2019-808 + +**Docs**: /design/human-interface-guidelines, /design/human-interface-guidelines/color, /design/human-interface-guidelines/dark-mode, /design/human-interface-guidelines/materials, /design/human-interface-guidelines/typography, /design/human-interface-guidelines/layout, /design/human-interface-guidelines/accessibility, /design/human-interface-guidelines/icons + +**Skills**: axiom-hig, axiom-liquid-glass, axiom-liquid-glass-ref, axiom-swiftui-layout-ref, axiom-accessibility-diag, axiom-tvos + +--- + +**Last Updated**: Based on Apple HIG (2024-2025), WWDC25-356, WWDC19-808 +**Skill Type**: Reference (Comprehensive guide with code examples) diff --git a/.claude/skills/axiom-hig-ref/agents/openai.yaml b/.claude/skills/axiom-hig-ref/agents/openai.yaml new file mode 100644 index 0000000..6a2858e --- /dev/null +++ b/.claude/skills/axiom-hig-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "HIG Reference" + short_description: "Reference — Comprehensive Apple Human Interface Guidelines covering colors (semantic, custom, patterns), backgrounds ..." diff --git a/.claude/skills/axiom-hig/.openskills.json b/.claude/skills/axiom-hig/.openskills.json new file mode 100644 index 0000000..8fbc8ba --- /dev/null +++ b/.claude/skills/axiom-hig/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-hig", + "installedAt": "2026-04-12T08:06:22.503Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-hig/SKILL.md b/.claude/skills/axiom-hig/SKILL.md new file mode 100644 index 0000000..3d28dc0 --- /dev/null +++ b/.claude/skills/axiom-hig/SKILL.md @@ -0,0 +1,447 @@ +--- +name: axiom-hig +description: Use when making design decisions, reviewing UI for HIG compliance, choosing colors/backgrounds/typography, or defending design choices - quick decision frameworks and checklists for Apple Human Interface Guidelines +license: MIT +compatibility: iOS, iPadOS, macOS, watchOS, tvOS, axiom-visionOS. iOS 13+ (Dark Mode), iOS 17+ (latest semantic colors), iOS 26+ (Liquid Glass) +metadata: + version: "1.0.0" +--- + +# Apple Human Interface Guidelines — Quick Reference + +## When to Use This Skill + +Use when: +- Making visual design decisions (colors, backgrounds, typography) +- Reviewing UI for HIG compliance +- Answering "Should I use a dark background?" +- Choosing between design options +- Defending design decisions to stakeholders +- Quick lookups for common design questions + +#### Related Skills +- Use `axiom-hig-ref` for comprehensive details and code examples +- Use `axiom-liquid-glass` for iOS 26 material design implementation and version-conditional design (supporting both pre-Liquid Glass and Liquid Glass in the same app) +- Use `axiom-liquid-glass-ref` for iOS 26 app-wide adoption guide with backward compatibility strategy +- Use `axiom-accessibility-diag` for accessibility troubleshooting + +#### Version-Conditional Design +When supporting both iOS 25 (pre-Liquid Glass) and iOS 26+, see `axiom-liquid-glass` for the adoption strategy — it covers when to use `#available(iOS 26, *)`, how to degrade gracefully, and which system components adopt Liquid Glass automatically vs which need explicit opt-in. + +--- + +## Quick Decision Trees + +### Background Color Decision + +``` +Is your app media-focused (photos, videos, music)? +├─ Yes → Consider permanent dark appearance +│ WHY: "Lets UI recede, helps people focus on media" (Apple HIG) +│ EXAMPLES: Apple Music, Photos, Clock apps use dark +│ CODE: .preferredColorScheme(.dark) on root view +│ +└─ No → Use system backgrounds (respect user preference) + CODE: systemBackground (adapts to light/dark automatically) + GROUPED: systemGroupedBackground for iOS Settings-style lists +``` + +**Apple's guidance:** "In rare cases, consider using only a dark appearance in the interface. For example, it can make sense for an app that enables immersive media viewing to use a permanently dark appearance." + +### Color Selection Decision + +``` +Do you need a specific color value? +├─ No → Use semantic colors +│ label, secondaryLabel, tertiaryLabel, quaternaryLabel +│ systemBackground, secondarySystemBackground, tertiarySystemBackground +│ WHY: Automatically adapts to light/dark/high contrast +│ +└─ Yes → Create Color Set in asset catalog + 1. Open Assets.xcassets + 2. Add Color Set + 3. Configure variants: + ├─ Light mode color + ├─ Dark mode color + └─ High contrast (optional but recommended) +``` + +**Key principle:** "Use semantic color names like labelColor that automatically adjust to the current interface style." + +### Font Weight Decision + +``` +Which font weight should I use? +├─ ❌ AVOID: Ultralight, Thin, Light +│ WHY: Legibility issues, especially at small sizes +│ +├─ ✅ PREFER: Regular, Medium, Semibold, Bold +│ WHY: Maintains legibility across sizes and conditions +│ +└─ Headers: Semibold or Bold for hierarchy + Body: Regular or Medium +``` + +**Apple's guidance:** "Avoid light font weights. Prefer Regular, Medium, Semibold, or Bold weights instead of Ultralight, Thin, or Light." + +--- + +## Core Principles Checklist + +### Before Shipping Any UI + +**Verify every screen passes these checks:** + +#### Appearance +- [ ] Works in Light Mode +- [ ] Works in Dark Mode +- [ ] Passes with Increased Contrast enabled +- [ ] Passes with Reduce Transparency enabled + +#### Typography +- [ ] Supports Dynamic Type (text scales to 200%) +- [ ] No light font weights (Regular minimum) +- [ ] Hierarchy clear at all text sizes +- [ ] No truncation at large text sizes + +#### Accessibility +- [ ] Contrast ratio ≥ 4.5:1 minimum +- [ ] Contrast ratio ≥ 7:1 for small text (recommended) +- [ ] Touch targets ≥ 44x44 points +- [ ] Information conveyed by more than color alone +- [ ] VoiceOver labels for all interactive elements + +#### Motion +- [ ] Respects Reduce Motion setting +- [ ] Animations can be canceled/skipped +- [ ] No auto-playing video without controls + +#### Localization +- [ ] No hardcoded strings in images +- [ ] Right-to-left language support +- [ ] Proper text directionality + +--- + +## Common Design Questions + +### Q: Should my app have a dark background? + +**A:** Only for media-focused apps (photos, videos, music) where content should be the hero. Use system backgrounds for everything else. + +**Apple's own apps:** +| App | Background | Reason | +|-----|------------|--------| +| Music | Dark | Album art is focus | +| Photos | Dark | Images are hero | +| Clock | Dark | Nighttime use | +| Notes | System | Document editing | +| Settings | System | Utilitarian | + +**Code:** +```swift +// ❌ WRONG - Don't override unless media-focused +.background(Color.black) + +// ✅ CORRECT - Let system decide +.background(Color(.systemBackground)) +``` + +### Q: What's the right background color? + +**A:** Use `systemBackground` which adapts to light/dark automatically. For grouped content (like iOS Settings), use `systemGroupedBackground`. + +**Color hierarchy:** +- Primary: `systemBackground` - Main background +- Secondary: `secondarySystemBackground` - Grouping elements +- Tertiary: `tertiarySystemBackground` - Grouping within secondary + +```swift +// ✅ Standard list +List { } + .background(Color(.systemBackground)) + +// ✅ Grouped list (Settings style) +List { } + .listStyle(.grouped) + .background(Color(.systemGroupedBackground)) +``` + +### Q: How do I ensure legibility? + +**A:** Use semantic label colors, maintain 4.5:1 contrast, avoid light font weights. + +**Label hierarchy:** +```swift +// Most prominent +Text("Title").foregroundStyle(.primary) + +// Subtitles +Text("Subtitle").foregroundStyle(.secondary) + +// Tertiary information +Text("Detail").foregroundStyle(.tertiary) + +// Disabled text +Text("Disabled").foregroundStyle(.quaternary) +``` + +### Q: Should I use SF Symbols or custom icons? + +**A:** SF Symbols unless you need brand-specific imagery. They scale with Dynamic Type and adapt to appearance automatically. + +**Benefits of SF Symbols:** +- 5,000+ symbols included (SF Symbols 5) +- Automatic light/dark adaptation +- Scale with Dynamic Type +- Become bolder with Bold Text accessibility +- Nine weights matching San Francisco font + +**When to use custom:** +- Brand-specific imagery +- App-specific concepts not in SF Symbols +- Unique visual style requirement + +### Q: Light/Dark Mode or user choice? + +**A:** Always support both. Never create app-specific appearance settings. + +**Apple's guidance:** "Avoid creating app-specific appearance settings. Users expect apps to honor their systemwide Dark Mode choice. An app-specific appearance mode option creates more work for people because they have to adjust more than one setting to get the appearance they want." + +### Q: What contrast ratio do I need? + +**A:** 4.5:1 minimum for normal text, 7:1 recommended for small text. + +**WCAG Contrast Standards:** +- **AA (required):** 4.5:1 for normal text, 3:1 for large text (18pt+/14pt+ bold) +- **AAA (enhanced):** 7:1 for normal text, 4.5:1 for large text +- **Apple guidance:** Use semantic colors which automatically meet AA requirements + +**Testing:** Use online contrast calculators or Xcode's Accessibility Inspector. + +### Q: What's the minimum touch target size? + +**A:** 44x44 points on iOS/iPadOS, with spacing between targets. + +**Platform-specific:** +- iOS/iPadOS: 44x44 points minimum +- macOS: 20x20 points minimum; larger for primary actions +- watchOS: Use system controls (optimized for small screen) +- tvOS: 60+ point spacing for focus clarity + +--- + +## Design Review Checklist + +### When Reviewing Any Design + +Use this checklist for design reviews, App Store submissions, or stakeholder presentations: + +#### Content-First Design +- [ ] Does UI defer to content? (Not competing for attention) +- [ ] Is branding restrained? (No logo on every screen) +- [ ] Are backgrounds content-appropriate? (Media apps dark, others system) + +#### Platform Consistency +- [ ] Does it feel native to iOS/iPad/Mac? +- [ ] Uses system colors and fonts? +- [ ] Standard gestures work as expected? +- [ ] Navigation patterns familiar? + +#### Accessibility Compliance +- [ ] All contrast ratios meet requirements? +- [ ] All touch targets ≥ 44x44 points? +- [ ] Information conveyed beyond color? +- [ ] VoiceOver labels complete? +- [ ] Dynamic Type supported? + +#### Light & Dark Modes +- [ ] Works in both appearance modes? +- [ ] Colors adapt automatically? +- [ ] No hardcoded color values? +- [ ] Increased Contrast tested? + +#### Localization-Ready +- [ ] No hardcoded strings in images? +- [ ] RTL language support? +- [ ] Text doesn't truncate? +- [ ] Layouts adapt to text size? + +--- + +## Design Review Pressure: Defending HIG Decisions + +### The Problem + +In design reviews, you'll hear: +- "Let's add our logo to every screen for brand consistency" +- "Use light font weights—they look more elegant" +- "Make a custom appearance toggle—some users prefer dark" +- "This screen needs a splash screen for our brand" + +These violate HIG. Here's how to push back professionally. + +### Red Flags — Requests That Violate HIG + +If you hear ANY of these, **reference this skill**: + +- ❌ **"Add logo to navigation bar"** — Wastes space, distracts from content +- ❌ **"Use Ultralight font"** — Legibility issues, fails accessibility +- ❌ **"Custom dark mode toggle"** — Creates more work for users, ignores system preference +- ❌ **"Splash screen for branding"** — Launch screens can't include branding +- ❌ **"Custom brand color for all text"** — May fail contrast requirements + +### How to Push Back Professionally + +#### Step 1: Show the HIG Guidance + +``` +"I want to make this change, but let me show you Apple's guidance: + +[Show the relevant HIG section from this skill or hig-ref] + +Apple explicitly recommends against this because..." +``` + +#### Step 2: Demonstrate the Risk + +**For contrast issues:** +- Show the design at 4.5:1 contrast (passing) +- Show their proposal (failing) +- Explain App Store rejection risk + +**For appearance toggles:** +- Show iOS Settings → Display & Brightness +- Explain users already have this control +- Demonstrate confusion of two separate settings + +#### Step 3: Offer Compromise + +``` +"I understand the brand concern. Here are HIG-compliant alternatives: + +1. Use your brand color as the app's tint color +2. Feature branding in onboarding (not launch screen) +3. Use your accent color for primary actions +4. Include subtle branding in content, not chrome" +``` + +#### Step 4: Document the Decision + +If overruled: + +``` +Slack message to PM + designer: + +"Design review decided to [violate HIG guidance]. + +Important risks to monitor: +- App Store rejection (HIG violations) +- Accessibility issues (users with visual impairments) +- User complaints (departure from platform norms) + +I'm flagging this proactively. If we see issues after launch, +we'll need an expedited follow-up." +``` + +### When to Accept the Design Decision + +Sometimes designers have valid reasons to override HIG. Accept if: + +- [ ] They understand the HIG guidance +- [ ] They're willing to accept rejection/accessibility risks +- [ ] You document the decision in writing +- [ ] They commit to monitoring post-launch feedback + +--- + +## Three Core HIG Principles + +Every design decision should support these principles: + +### 1. Clarity + +**Definition:** Content should be paramount, interface elements should defer to content. + +**In practice:** +- White space is your friend +- Every element has a purpose +- Remove anything that doesn't serve the user +- Users should know what they can do without instructions + +### 2. Consistency + +**Definition:** Use standard UI elements and familiar patterns. + +**In practice:** +- Standard gestures work as expected +- Navigation follows platform conventions +- Colors and fonts use system values +- Familiar components in familiar locations + +### 3. Deference + +**Definition:** UI shouldn't compete with content for attention. + +**In practice:** +- Subtle backgrounds, not bold +- Navigation recedes when not needed +- Content is the hero +- Branding is restrained + +**From HIG:** "Deference makes an app beautiful by ensuring the content stands out while the surrounding visual elements do not compete with it." + +--- + +## Platform-Specific Quick Tips + +### iOS +- Portrait-first design +- One-handed reachability +- Bottom tab bar for primary navigation +- Swipe back gesture + +### iPadOS +- Sidebar-adaptable layouts +- Split view support +- Pointer interactions +- Arbitrary window sizing (iOS 26+) + +### macOS +- Menu bar for commands +- Dense layouts acceptable +- Pointer-first interactions +- Window chrome and controls + +### watchOS +- Glanceable interfaces +- Full-bleed content +- Minimal padding +- Digital Crown interactions + +### tvOS +- Focus-based navigation +- 10-foot viewing distance +- Large touch targets +- Gestural remote + +### visionOS +- Spatial layout +- Glass materials +- Comfortable viewing depth +- Avoid head-anchored content + +--- + +## Resources + +**WWDC**: 356, 2019-808 + +**Docs**: /design/human-interface-guidelines, /design/human-interface-guidelines/color, /design/human-interface-guidelines/dark-mode, /design/human-interface-guidelines/typography + +**Skills**: axiom-hig-ref, axiom-liquid-glass, axiom-liquid-glass-ref, axiom-accessibility-diag + +--- + +**Last Updated**: Based on Apple HIG (2024-2025), WWDC25-356, WWDC19-808 +**Skill Type**: Discipline (Quick decisions, checklists, pressure scenarios) diff --git a/.claude/skills/axiom-hig/agents/openai.yaml b/.claude/skills/axiom-hig/agents/openai.yaml new file mode 100644 index 0000000..b13eeb8 --- /dev/null +++ b/.claude/skills/axiom-hig/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "HIG" + short_description: "Making design decisions, reviewing UI for HIG compliance, choosing colors/backgrounds/typography, or defending design..." diff --git a/.claude/skills/axiom-icloud-drive-ref/.openskills.json b/.claude/skills/axiom-icloud-drive-ref/.openskills.json new file mode 100644 index 0000000..6da4c2f --- /dev/null +++ b/.claude/skills/axiom-icloud-drive-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-icloud-drive-ref", + "installedAt": "2026-04-12T08:06:23.761Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-icloud-drive-ref/SKILL.md b/.claude/skills/axiom-icloud-drive-ref/SKILL.md new file mode 100644 index 0000000..4d609e0 --- /dev/null +++ b/.claude/skills/axiom-icloud-drive-ref/SKILL.md @@ -0,0 +1,496 @@ +--- +name: axiom-icloud-drive-ref +description: Use when implementing 'iCloud Drive', 'ubiquitous container', 'file sync', 'NSFileCoordinator', 'NSFilePresenter', 'isUbiquitousItem', 'NSUbiquitousKeyValueStore', 'ubiquitous file sync' - comprehensive file-based iCloud sync reference +license: MIT +compatibility: iOS 5.0+, iPadOS 13.0+, macOS 10.7+ +metadata: + version: "1.0.0" + last-updated: "2025-12-12" +--- + +# iCloud Drive Reference + +**Purpose**: Comprehensive reference for file-based iCloud sync using ubiquitous containers +**Availability**: iOS 5.0+ (basic), iOS 8.0+ (iCloud Drive), iOS 11.0+ (modern APIs) +**Context**: File-based cloud storage, not database (use CloudKit for structured data) + +## When to Use This Skill + +Use this skill when: +- Implementing document-based iCloud sync +- Syncing user files across devices +- Building document-based apps (like Pages, Numbers) +- Coordinating file access across processes +- Handling iCloud file conflicts +- Using NSUbiquitousKeyValueStore for preferences + +**NOT for**: Structured data with relationships (use `axiom-cloudkit-ref` instead) + +--- + +## Overview + +**iCloud Drive is for FILE-BASED sync**, not structured data. + +**Use when**: +- User creates/edits documents +- Files need to sync like Dropbox +- Document picker integration + +**Don't use when**: +- Need queryable structured data (use CloudKit) +- Need relationships between records (use CloudKit) +- Small key-value preferences (use NSUbiquitousKeyValueStore) + +--- + +## Ubiquitous Containers + +### Getting Ubiquitous Container URL + +```swift +// ✅ CORRECT: Get iCloud container +func getICloudContainerURL() -> URL? { + // nil = use first container in entitlements + return FileManager.default.url( + forUbiquityContainerIdentifier: nil + ) +} + +// ✅ Check if iCloud is available +if let iCloudURL = getICloudContainerURL() { + print("iCloud available: \(iCloudURL)") +} else { + print("iCloud not available (not signed in or no entitlement)") +} +``` + +### Container Structure + +``` +iCloud Container/ +├── Documents/ # User-visible files (Files app) +│ └── MyApp/ # Your app's documents +├── Library/ # Hidden from user +│ ├── Application Support/ +│ └── Caches/ +``` + +### Saving to iCloud Drive + +```swift +// ✅ CORRECT: Save document to iCloud +func saveToICloud(data: Data, filename: String) throws { + guard let iCloudURL = FileManager.default.url( + forUbiquityContainerIdentifier: nil + ) else { + throw iCloudError.notAvailable + } + + let documentsURL = iCloudURL.appendingPathComponent("Documents") + + // Create directory if needed + try FileManager.default.createDirectory( + at: documentsURL, + withIntermediateDirectories: true + ) + + let fileURL = documentsURL.appendingPathComponent(filename) + + // Use file coordination for safe access + let coordinator = NSFileCoordinator() + var error: NSError? + + coordinator.coordinate( + writingItemAt: fileURL, + options: .forReplacing, + error: &error + ) { newURL in + try? data.write(to: newURL) + } + + if let error = error { + throw error + } +} +``` + +--- + +## File Coordination (Critical for Safety) + +**Always use NSFileCoordinator** when accessing iCloud files. This prevents: +- Race conditions with sync +- Data corruption +- Lost updates + +### Reading Files + +```swift +// ✅ CORRECT: Coordinated read +func readICloudFile(url: URL) throws -> Data { + let coordinator = NSFileCoordinator() + var data: Data? + var coordinationError: NSError? + + coordinator.coordinate( + readingItemAt: url, + options: [], + error: &coordinationError + ) { newURL in + data = try? Data(contentsOf: newURL) + } + + if let error = coordinationError { + throw error + } + + guard let data = data else { + throw fileError.readFailed + } + + return data +} +``` + +### Writing Files + +```swift +// ✅ CORRECT: Coordinated write +func writeICloudFile(data: Data, to url: URL) throws { + let coordinator = NSFileCoordinator() + var coordinationError: NSError? + + coordinator.coordinate( + writingItemAt: url, + options: .forReplacing, + error: &coordinationError + ) { newURL in + try? data.write(to: newURL) + } + + if let error = coordinationError { + throw error + } +} +``` + +### Moving Files + +```swift +// ✅ CORRECT: Coordinated move +func moveFile(from sourceURL: URL, to destURL: URL) throws { + let coordinator = NSFileCoordinator() + var coordinationError: NSError? + + coordinator.coordinate( + writingItemAt: sourceURL, + options: .forMoving, + writingItemAt: destURL, + options: .forReplacing, + error: &coordinationError + ) { newSource, newDest in + try? FileManager.default.moveItem(at: newSource, to: newDest) + } + + if let error = coordinationError { + throw error + } +} +``` + +--- + +## URL Resource Values for iCloud + +### Checking iCloud Status + +```swift +// ✅ Check if file is in iCloud +func isInICloud(url: URL) -> Bool { + let values = try? url.resourceValues(forKeys: [.isUbiquitousItemKey]) + return values?.isUbiquitousItem ?? false +} + +// ✅ Check download status +func getDownloadStatus(url: URL) -> String { + let values = try? url.resourceValues(forKeys: [ + .ubiquitousItemDownloadingStatusKey, + .ubiquitousItemIsDownloadingKey, + .ubiquitousItemDownloadingErrorKey + ]) + + if let downloading = values?.ubiquitousItemIsDownloading, downloading { + return "Downloading..." + } + + if let status = values?.ubiquitousItemDownloadingStatus { + switch status { + case .current: + return "Downloaded" + case .notDownloaded: + return "Not downloaded (iCloud only)" + case .downloaded: + return "Downloaded" + @unknown default: + return "Unknown" + } + } + + return "Unknown" +} + +// ✅ Check upload status +func isUploading(url: URL) -> Bool { + let values = try? url.resourceValues(forKeys: [.ubiquitousItemIsUploadingKey]) + return values?.ubiquitousItemIsUploading ?? false +} + +// ✅ Check for conflicts +func hasConflicts(url: URL) -> Bool { + let values = try? url.resourceValues(forKeys: [ + .ubiquitousItemHasUnresolvedConflictsKey + ]) + return values?.ubiquitousItemHasUnresolvedConflicts ?? false +} +``` + +### Downloading Files + +```swift +// ✅ CORRECT: Request download +func downloadFromICloud(url: URL) throws { + try FileManager.default.startDownloadingUbiquitousItem(at: url) +} + +// ✅ Monitor download progress +let query = NSMetadataQuery() +query.predicate = NSPredicate(format: "%K == %@", + NSMetadataItemURLKey, url as NSURL) +query.searchScopes = [NSMetadataQueryUbiquitousDataScope] + +NotificationCenter.default.addObserver( + forName: .NSMetadataQueryDidUpdate, + object: query, + queue: .main +) { notification in + // Check progress + if let item = query.results.first as? NSMetadataItem { + if let percent = item.value(forAttribute: NSMetadataUbiquitousItemPercentDownloadedKey) as? Double { + print("Downloaded: \(percent)%") + } + } +} + +query.start() +``` + +--- + +## Conflict Resolution + +### Detecting Conflicts + +```swift +// ✅ Get conflict versions +func getConflictVersions(for url: URL) -> [NSFileVersion]? { + return NSFileVersion.unresolvedConflictVersionsOfItem(at: url) +} +``` + +### Resolving Conflicts + +```swift +// ✅ CORRECT: Resolve conflicts +func resolveConflicts(at url: URL, keepingVersion: ConflictResolution) throws { + guard let conflicts = NSFileVersion.unresolvedConflictVersionsOfItem(at: url), + !conflicts.isEmpty else { + return // No conflicts + } + + let current = try NSFileVersion.currentVersionOfItem(at: url) + + switch keepingVersion { + case .current: + // Keep current version, discard others + for conflict in conflicts { + conflict.isResolved = true + } + + case .other(let chosenVersion): + // Replace current with chosen conflict version + try chosenVersion.replaceItem(at: url, options: []) + chosenVersion.isResolved = true + + // Mark other conflicts as resolved + for conflict in conflicts where conflict != chosenVersion { + conflict.isResolved = true + } + + case .manual: + // App merges manually, then marks resolved + let mergedData = mergeConflicts(current: current, conflicts: conflicts) + try mergedData.write(to: url) + + for conflict in conflicts { + conflict.isResolved = true + } + } + + // Remove resolved versions + try NSFileVersion.removeOtherVersionsOfItem(at: url) +} + +enum ConflictResolution { + case current + case other(NSFileVersion) + case manual +} +``` + +--- + +## NSUbiquitousKeyValueStore (Preferences Sync) + +**For small preferences only** (<1 MB total, <1024 keys) + +```swift +// ✅ CORRECT: Sync small preferences +let store = NSUbiquitousKeyValueStore.default + +// Set values +store.set(true, forKey: "darkModeEnabled") +store.set(2.0, forKey: "textSizeMultiplier") +store.set(["en", "es"], forKey: "selectedLanguages") + +// Synchronize +store.synchronize() + +// Read values +let darkMode = store.bool(forKey: "darkModeEnabled") +let textSize = store.double(forKey: "textSizeMultiplier") + +// Listen for changes from other devices +NotificationCenter.default.addObserver( + forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, + object: store, + queue: .main +) { notification in + // Update UI with new values + updatePreferences() +} +``` + +**Limitations**: +- Total storage: 1 MB +- Max keys: 1024 +- Max value size: 1 MB +- Use only for preferences, not data + +--- + +## Entitlements + +```xml + +com.apple.developer.icloud-services + + CloudDocuments + + + +com.apple.developer.ubiquity-container-identifiers + + iCloud.com.example.app + + + +com.apple.developer.ubiquity-kvstore-identifier +$(TeamIdentifierPrefix)com.example.app +``` + +--- + +## Common Patterns + +### Pattern 1: Document Picker Integration + +```swift +// ✅ Present iCloud document picker +import UniformTypeIdentifiers + +let picker = UIDocumentPickerViewController( + forOpeningContentTypes: [.pdf, .plainText] +) +picker.delegate = self +picker.allowsMultipleSelection = false + +// Enable iCloud +picker.directoryURL = getICloudContainerURL() + +present(picker, animated: true) +``` + +### Pattern 2: Monitor Directory for Changes + +```swift +// ✅ Monitor iCloud directory +class ICloudMonitor { + let query = NSMetadataQuery() + + func startMonitoring(directory: URL) { + query.predicate = NSPredicate(format: "%K BEGINSWITH %@", + NSMetadataItemPathKey, directory.path) + + query.searchScopes = [NSMetadataQueryUbiquitousDataScope] + + NotificationCenter.default.addObserver( + forName: .NSMetadataQueryDidUpdate, + object: query, + queue: .main + ) { [weak self] _ in + self?.processResults() + } + + query.start() + } + + func processResults() { + for item in query.results { + if let metadataItem = item as? NSMetadataItem, + let url = metadataItem.value(forAttribute: NSMetadataItemURLKey) as? URL { + print("File: \(url.lastPathComponent)") + } + } + } +} +``` + +--- + +## Quick Reference + +| Task | API | Notes | +|------|-----|-------| +| Get iCloud URL | `FileManager.default.url(forUbiquityContainerIdentifier:)` | Returns nil if unavailable | +| Check if in iCloud | `.isUbiquitousItemKey` resource value | Bool | +| Download file | `startDownloadingUbiquitousItem(at:)` | Async, monitor with NSMetadataQuery | +| Check download status | `.ubiquitousItemDownloadingStatusKey` | current/notDownloaded/downloaded | +| Check for conflicts | `.ubiquitousItemHasUnresolvedConflictsKey` | Bool | +| Resolve conflicts | `NSFileVersion.unresolvedConflictVersionsOfItem(at:)` | Manual merge or choose version | +| Sync preferences | `NSUbiquitousKeyValueStore.default` | <1 MB total | +| File coordination | `NSFileCoordinator` | **Always** use for iCloud files | + +--- + +## Related Skills + +- `axiom-storage` — Choose iCloud Drive vs CloudKit +- `axiom-cloudkit-ref` — For structured data sync +- `axiom-cloud-sync-diag` — Debug iCloud sync issues + +--- + +**Last Updated**: 2025-12-12 +**Skill Type**: Reference +**Minimum iOS**: 5.0 (basic), 8.0 (iCloud Drive), 11.0 (modern APIs) diff --git a/.claude/skills/axiom-icloud-drive-ref/agents/openai.yaml b/.claude/skills/axiom-icloud-drive-ref/agents/openai.yaml new file mode 100644 index 0000000..f648689 --- /dev/null +++ b/.claude/skills/axiom-icloud-drive-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "iCloud Drive Reference" + short_description: "Implementing 'iCloud Drive', 'ubiquitous container', 'file sync', 'NSFileCoordinator', 'NSFilePresenter', 'isUbiquito..." diff --git a/.claude/skills/axiom-implement-iap/.openskills.json b/.claude/skills/axiom-implement-iap/.openskills.json new file mode 100644 index 0000000..6edf14a --- /dev/null +++ b/.claude/skills/axiom-implement-iap/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-implement-iap", + "installedAt": "2026-04-12T08:06:23.762Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-implement-iap/SKILL.md b/.claude/skills/axiom-implement-iap/SKILL.md new file mode 100644 index 0000000..9933b85 --- /dev/null +++ b/.claude/skills/axiom-implement-iap/SKILL.md @@ -0,0 +1,172 @@ +--- +name: axiom-implement-iap +description: Use when the user wants to add in-app purchases, implement StoreKit 2, or set up subscriptions. +license: MIT +disable-model-invocation: true +--- +# In-App Purchase Implementation Agent + +You are an expert at implementing production-ready in-app purchases using StoreKit 2. + +## Your Mission + +Implement complete IAP following testing-first workflow: +1. Create StoreKit configuration FIRST +2. Implement centralized StoreManager +3. Add transaction listener and verification +4. Implement purchase flows +5. Add subscription management (if applicable) +6. Implement restore purchases +7. Provide testing instructions + +## Phase 1: Gather Requirements + +Ask the user: +1. **Product types**: Consumables, non-consumables, subscriptions? +2. **Product IDs**: Format `com.company.app.product_name` +3. **Server backend**: For appAccountToken integration? +4. **Subscription details**: Group ID, tiers, trial duration? + +## Phase 2: Create StoreKit Configuration (FIRST!) + +**CRITICAL**: Create `.storekit` file BEFORE any Swift code! + +1. Create via Xcode: File → New → File → StoreKit Configuration File +2. Add products with ID, name, price +3. Configure scheme: Edit Scheme → Run → Options → StoreKit Configuration +4. Test products load before proceeding + +## Phase 3: Implement StoreManager + +Create `StoreManager.swift` with these essential components: + +```swift +@MainActor +final class StoreManager: ObservableObject { + @Published private(set) var products: [Product] = [] + @Published private(set) var purchasedProductIDs: Set = [] + private var transactionListener: Task? + + init(productIDs: [String]) { + // Start transaction listener IMMEDIATELY + transactionListener = listenForTransactions() + Task { await loadProducts(); await updatePurchasedProducts() } + } + + // CRITICAL: Transaction listener handles ALL purchase sources + func listenForTransactions() -> Task { + Task.detached { [weak self] in + for await result in Transaction.updates { + await self?.handleTransaction(result) + } + } + } + + private func handleTransaction(_ result: VerificationResult) async { + guard let transaction = try? result.payloadValue else { return } + if transaction.revocationDate != nil { + // Handle refund + await transaction.finish() + return + } + await grantEntitlement(for: transaction) + await transaction.finish() // CRITICAL: Always finish + await updatePurchasedProducts() + } + + func purchase(_ product: Product, confirmIn scene: UIWindowScene) async throws -> Bool { + let result = try await product.purchase(confirmIn: scene) + switch result { + case .success(let verification): + guard let tx = try? verification.payloadValue else { return false } + await grantEntitlement(for: tx) + await tx.finish() + return true + case .userCancelled, .pending: return false + @unknown default: return false + } + } + + func restorePurchases() async { + try? await AppStore.sync() + await updatePurchasedProducts() + } +} +``` + +**Key Requirements**: +- ✅ Transaction listener (handles ALL purchase sources) +- ✅ Transaction verification +- ✅ Always calls finish() +- ✅ Handles refunds +- ✅ @MainActor for UI state + +## Phase 4: Purchase UI + +**Custom View** or **StoreKit Views** (iOS 17+): +```swift +// Custom +Button(product.displayPrice) { + Task { _ = try await store.purchase(product, confirmIn: scene) } +} + +// StoreKit Views (simpler) +StoreKit.StoreView(ids: productIDs) +SubscriptionStoreView(groupID: "pro_tier") +``` + +## Phase 5: Subscription Management (If Applicable) + +Check subscription status via: +```swift +let statuses = try? await Product.SubscriptionInfo.status(for: groupID) +// Handle: .subscribed, .expired, .inGracePeriod, .inBillingRetryPeriod +``` + +## Phase 6: Restore Purchases (REQUIRED) + +**App Store Requirement**: Non-consumables/subscriptions MUST have restore: +```swift +Button("Restore Purchases") { + Task { await store.restorePurchases() } +} +``` + +## Deliverables + +1. `Products.storekit` - Configuration file +2. `StoreManager.swift` - Centralized IAP manager +3. Purchase UI (custom or StoreKit views) +4. Settings with restore button +5. Testing instructions + +## Implementation Checklist + +- [ ] StoreKit config created and tested +- [ ] StoreManager with transaction listener +- [ ] Purchase flow with verification +- [ ] transaction.finish() always called +- [ ] Entitlements tracked +- [ ] Restore purchases implemented +- [ ] Subscription states handled (if applicable) + +## Critical Pitfalls to Avoid + +1. ❌ Writing code before .storekit file +2. ❌ No Transaction.updates listener +3. ❌ Forgetting transaction.finish() +4. ❌ No restore button (App Store rejection) +5. ❌ Ignoring refunds (revocationDate) + +## Testing Instructions + +1. **Local**: Run with Products.storekit in scheme +2. **Sandbox**: Create sandbox account in App Store Connect +3. **TestFlight**: Upload build, test real flows +4. **Production**: Use promo codes + +## Related + +For detailed patterns: `axiom-in-app-purchases` skill +For API reference: `axiom-storekit-ref` skill +For auditing: `iap-auditor` agent diff --git a/.claude/skills/axiom-implement-iap/agents/openai.yaml b/.claude/skills/axiom-implement-iap/agents/openai.yaml new file mode 100644 index 0000000..d1beae9 --- /dev/null +++ b/.claude/skills/axiom-implement-iap/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Implement IAP" + short_description: "The user wants to add in-app purchases, implement StoreKit 2, or set up subscriptions." diff --git a/.claude/skills/axiom-in-app-purchases/.openskills.json b/.claude/skills/axiom-in-app-purchases/.openskills.json new file mode 100644 index 0000000..3377317 --- /dev/null +++ b/.claude/skills/axiom-in-app-purchases/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-in-app-purchases", + "installedAt": "2026-04-12T08:06:24.266Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-in-app-purchases/SKILL.md b/.claude/skills/axiom-in-app-purchases/SKILL.md new file mode 100644 index 0000000..83ab07c --- /dev/null +++ b/.claude/skills/axiom-in-app-purchases/SKILL.md @@ -0,0 +1,887 @@ +--- +name: axiom-in-app-purchases +description: Use when implementing in-app purchases, StoreKit 2, subscriptions, or transaction handling - testing-first workflow with .storekit configuration, StoreManager architecture, transaction verification, subscription management, and restore purchases for consumables, non-consumables, and auto-renewable subscriptions +license: MIT +metadata: + version: "1.0" +--- + +# StoreKit 2 In-App Purchase Implementation + +**Purpose**: Guide robust, testable in-app purchase implementation +**StoreKit Version**: StoreKit 2 +**iOS Version**: iOS 15+ (iOS 18.4+ for latest features) +**Xcode**: Xcode 13+ (Xcode 16+ recommended) +**Context**: WWDC 2025-241, 2025-249, 2023-10013, 2021-10114 + +## When to Use This Skill + +✅ **Use this skill when**: +- Implementing any in-app purchase functionality (new or existing) +- Adding consumable products (coins, hints, boosts) +- Adding non-consumable products (premium features, level packs) +- Adding auto-renewable subscriptions (monthly/annual plans) +- Debugging purchase failures, missing transactions, or restore issues +- Setting up StoreKit testing configuration +- Implementing subscription status tracking +- Adding promotional offers or introductory offers +- Server-side receipt validation +- Family Sharing support + +❌ **Do NOT use this skill for**: +- StoreKit 1 (legacy API) - this skill focuses on StoreKit 2 +- App Store Connect product configuration (separate documentation) +- Pricing strategy or business model decisions + +--- + +## ⚠️ Already Wrote Code Before Creating .storekit Config? + +If you wrote purchase code before creating `.storekit` configuration, you have three options: + +### Option A: Delete and Start Over (Strongly Recommended) + +Delete all IAP code and follow the testing-first workflow below. This reinforces correct habits and ensures you experience the full benefit of .storekit-first development. + +**Why this is best**: +- Validates that you understand the workflow +- Catches product ID issues you might have missed +- Builds muscle memory for future IAP implementations +- Takes only 15-30 minutes for experienced developers + +### Option B: Create .storekit Config Now (Acceptable with Caution) + +Create the `.storekit` file now with your existing product IDs. Test everything works locally. Document in your PR that you tested in sandbox first. + +**Trade-offs**: +- ✅ Keeps working code +- ✅ Adds local testing capability +- ❌ Misses product ID validation benefit +- ❌ Reinforces testing-after pattern +- ❌ Requires extra vigilance in code review + +**If choosing this path**: Create .storekit immediately, verify locally, and commit a note explaining the approach. + +### Option C: Skip .storekit Entirely (Not Recommended) + +Commit without `.storekit` configuration, test only in sandbox. + +**Why this is problematic**: +- Teammates can't test purchases locally +- No validation of product IDs before runtime +- Harder iteration (requires App Store Connect) +- Missing documentation of product structure + +**Bottom line**: Choose Option A if possible, Option B if pragmatic, never Option C. + +--- + +## Core Philosophy: Testing-First Workflow + +> **Best Practice**: Create and test StoreKit configuration BEFORE writing production purchase code. + +### Why .storekit-First Matters + +The recommended workflow is to create `.storekit` configuration before writing any purchase code. This isn't arbitrary - it provides concrete benefits: + +**Immediate product ID validation**: +- Typos caught in Xcode, not at runtime +- Product configuration visible in project +- No App Store Connect dependency for testing + +**Faster iteration**: +- Test purchases in simulator instantly +- No network requests during development +- Accelerated subscription renewal for testing + +**Team benefits**: +- Anyone can test purchase flows locally +- Product catalog documented in code +- Code review includes purchase testing + +**Common objections addressed**: + +❓ **"I already tested in sandbox"** - Sandbox testing is valuable but comes later. Local testing with .storekit is faster and enables true TDD. + +❓ **"My code works"** - Working code is great! Adding .storekit makes it easier for teammates to verify and maintain. + +❓ **"I've done this before"** - Experience is valuable. The .storekit-first workflow makes experienced developers even more productive. + +❓ **"Time pressure"** - Creating .storekit takes 10-15 minutes. The time saved in iteration pays back immediately. + +### The Recommended Workflow + +``` +StoreKit Config → Local Testing → Production Code → Unit Tests → Sandbox Testing + ↓ ↓ ↓ ↓ ↓ + .storekit Test purchases StoreManager Mock store Integration test +``` + +**Why this order helps**: +1. **StoreKit Config First**: Defines products without App Store Connect dependency +2. **Local Testing**: Validates product IDs and purchase flows immediately +3. **Production Code**: Implements against validated product configuration +4. **Unit Tests**: Verifies business logic with mocked store responses +5. **Sandbox Testing**: Final validation in App Store environment + +**Benefits of following this workflow**: +- Product IDs validated before writing code +- Faster development iteration +- Easier team collaboration +- Better test coverage + +--- + +## Mandatory Checklist + +Before marking IAP implementation complete, **ALL** items must be verified: + +### Phase 1: Testing Foundation +- [ ] Created `.storekit` configuration file with all products +- [ ] Verified each product type renders correctly in StoreKit preview +- [ ] Tested successful purchase flow for each product in Xcode +- [ ] Tested purchase failure scenarios (insufficient funds, cancelled) +- [ ] Tested restore purchases flow +- [ ] For subscriptions: tested renewal, expiration, and upgrade/downgrade + +### Phase 2: Architecture +- [ ] Centralized StoreManager class exists (single source of truth) +- [ ] StoreManager is ObservableObject (SwiftUI) or uses NotificationCenter +- [ ] Transaction observer listens for updates via `Transaction.updates` +- [ ] All transaction verification uses `VerificationResult` +- [ ] All transactions call `.finish()` after entitlement granted +- [ ] Product loading happens at app launch or before displaying store + +### Phase 3: Purchase Flow +- [ ] Purchase uses new `purchase(confirmIn:options:)` with UI context (iOS 18.2+) +- [ ] Purchase handles all `PurchaseResult` cases (success, userCancelled, pending) +- [ ] Purchase verifies transaction signature before granting entitlement +- [ ] Purchase stores transaction receipt/identifier for support +- [ ] appAccountToken set for all purchases (if using server backend) + +### Phase 4: Subscription Management (if applicable) +- [ ] Subscription status tracked via `Product.SubscriptionInfo.Status` +- [ ] Current entitlements checked via `Transaction.currentEntitlements(for:)` +- [ ] Renewal info accessed for expiration, renewal date, offer status +- [ ] Subscription views use ProductView or SubscriptionStoreView +- [ ] Win-back offers implemented for expired subscriptions +- [ ] Grace period and billing retry states handled + +### Phase 5: Restore & Sync +- [ ] Restore purchases implemented (required by App Store Review) +- [ ] Restore uses `Transaction.currentEntitlements` or `Transaction.all` +- [ ] Family Sharing transactions identified (if supported) +- [ ] Server sync implemented (if using backend) +- [ ] Cross-device entitlement sync tested + +### Phase 6: Error Handling +- [ ] Network errors handled gracefully (retries, user messaging) +- [ ] Invalid product IDs detected and logged +- [ ] Purchase failures show user-friendly error messages +- [ ] Transaction verification failures logged and reported +- [ ] Refund notifications handled (via App Store Server Notifications) + +### Phase 7: Testing & Validation +- [ ] Unit tests verify purchase logic with mocked Product/Transaction +- [ ] Unit tests verify subscription status determination +- [ ] Integration tests with StoreKit configuration pass +- [ ] Sandbox testing with real Apple ID completed +- [ ] TestFlight testing completed before production release + +--- + +## Step 1: Create StoreKit Configuration (FIRST!) + +**DO THIS BEFORE WRITING ANY PURCHASE CODE.** + +### Create Configuration File + +1. **Xcode → File → New → File → StoreKit Configuration File** +2. **Save as**: `Products.storekit` (or your app name) +3. **Add to target**: ✅ (include in app bundle for testing) + +### Add Products + +Click "+" and add each product type: + +#### Consumable +``` +Product ID: com.yourapp.coins_100 +Reference Name: 100 Coins +Price: $0.99 +``` + +#### Non-Consumable +``` +Product ID: com.yourapp.premium +Reference Name: Premium Upgrade +Price: $4.99 +``` + +#### Auto-Renewable Subscription +``` +Product ID: com.yourapp.pro_monthly +Reference Name: Pro Monthly +Price: $9.99/month +Subscription Group ID: pro_tier +``` + +### Test Immediately + +1. **Run app in simulator** +2. **Scheme → Edit Scheme → Run → Options** +3. **StoreKit Configuration**: Select `Products.storekit` +4. **Verify**: Products load, purchases complete, transactions appear + +--- + +## Step 2: Implement StoreManager Architecture + +### Required Pattern: Centralized StoreManager + +**All purchase logic must go through a single StoreManager.** No scattered `Product.purchase()` calls throughout app. + +```swift +import StoreKit + +@MainActor +final class StoreManager: ObservableObject { + // Published state for UI + @Published private(set) var products: [Product] = [] + @Published private(set) var purchasedProductIDs: Set = [] + + // Product IDs from StoreKit configuration + private let productIDs = [ + "com.yourapp.coins_100", + "com.yourapp.premium", + "com.yourapp.pro_monthly" + ] + + private var transactionListener: Task? + + init() { + // Start transaction listener immediately + transactionListener = listenForTransactions() + + Task { + await loadProducts() + await updatePurchasedProducts() + } + } + + deinit { + transactionListener?.cancel() + } +} +``` + +**Why @MainActor**: Published properties must update on main thread for UI binding. + +### Load Products (At Launch) + +```swift +extension StoreManager { + func loadProducts() async { + do { + // Load products from App Store + let loadedProducts = try await Product.products(for: productIDs) + + // Update published property on main thread + self.products = loadedProducts + + } catch { + print("Failed to load products: \(error)") + // Show error to user + } + } +} +``` + +**Call from**: `App.init()` or first view's `.task` modifier + +### Listen for Transactions (REQUIRED) + +```swift +extension StoreManager { + func listenForTransactions() -> Task { + Task.detached { [weak self] in + // Listen for ALL transaction updates + for await verificationResult in Transaction.updates { + await self?.handleTransaction(verificationResult) + } + } + } + + @MainActor + private func handleTransaction(_ result: VerificationResult) async { + // Verify transaction signature + guard let transaction = try? result.payloadValue else { + print("Transaction verification failed") + return + } + + // Grant entitlement to user + await grantEntitlement(for: transaction) + + // CRITICAL: Always finish transaction + await transaction.finish() + + // Update purchased products + await updatePurchasedProducts() + } +} +``` + +**Why detached**: Transaction listener runs independently of view lifecycle + +--- + +## Step 3: Implement Purchase Flow + +### Purchase with UI Context (iOS 18.2+) + +```swift +extension StoreManager { + func purchase(_ product: Product, confirmIn scene: UIWindowScene) async throws -> Bool { + // Perform purchase with UI context for payment sheet + let result = try await product.purchase(confirmIn: scene) + + switch result { + case .success(let verificationResult): + // Verify the transaction + guard let transaction = try? verificationResult.payloadValue else { + print("Transaction verification failed") + return false + } + + // Grant entitlement + await grantEntitlement(for: transaction) + + // CRITICAL: Finish transaction + await transaction.finish() + + // Update state + await updatePurchasedProducts() + + return true + + case .userCancelled: + // User tapped "Cancel" in payment sheet + return false + + case .pending: + // Purchase requires action (Ask to Buy, payment issue) + // Will be delivered via Transaction.updates when approved + return false + + @unknown default: + return false + } + } +} +``` + +### SwiftUI Purchase (Using Environment) + +```swift +struct ProductRow: View { + let product: Product + @Environment(\.purchase) private var purchase + + var body: some View { + Button("Buy \(product.displayPrice)") { + Task { + do { + let result = try await purchase(product) + // Handle result + } catch { + print("Purchase failed: \(error)") + } + } + } + } +} +``` + +### Set appAccountToken (If Using Backend) + +```swift +func purchase( + _ product: Product, + confirmIn scene: UIWindowScene, + accountToken: UUID +) async throws -> Bool { + // Purchase with appAccountToken for server-side association + let result = try await product.purchase( + confirmIn: scene, + options: [ + .appAccountToken(accountToken) + ] + ) + + // ... handle result +} +``` + +**When to use**: When your backend needs to associate purchases with user accounts + +--- + +## Step 4: Verify Transactions (MANDATORY) + +### Always Use VerificationResult + +```swift +func handleTransaction(_ result: VerificationResult) async { + switch result { + case .verified(let transaction): + // ✅ Transaction signed by App Store + await grantEntitlement(for: transaction) + await transaction.finish() + + case .unverified(let transaction, let error): + // ❌ Transaction signature invalid + print("Unverified transaction: \(error)") + // DO NOT grant entitlement + // DO finish transaction to clear from queue + await transaction.finish() + } +} +``` + +**Why verify**: Prevents granting entitlements for: +- Fraudulent receipts +- Jailbroken device receipts +- Man-in-the-middle attacks + +### Check Transaction Fields + +```swift +func grantEntitlement(for transaction: Transaction) async { + // Check transaction hasn't been revoked + guard transaction.revocationDate == nil else { + print("Transaction was refunded") + await revokeEntitlement(for: transaction.productID) + return + } + + // Grant based on product type + switch transaction.productType { + case .consumable: + await addConsumable(productID: transaction.productID) + + case .nonConsumable: + await unlockFeature(productID: transaction.productID) + + case .autoRenewable: + await activateSubscription(productID: transaction.productID) + + default: + break + } +} +``` + +--- + +## Step 5: Track Current Entitlements + +### Check What User Owns + +```swift +extension StoreManager { + func updatePurchasedProducts() async { + var purchased: Set = [] + + // Iterate through all current entitlements + for await result in Transaction.currentEntitlements { + guard let transaction = try? result.payloadValue else { + continue + } + + // Only include active entitlements (not revoked) + if transaction.revocationDate == nil { + purchased.insert(transaction.productID) + } + } + + self.purchasedProductIDs = purchased + } +} +``` + +### Check Specific Product + +```swift +func isEntitled(to productID: String) async -> Bool { + // Check current entitlements for specific product + for await result in Transaction.currentEntitlements(for: productID) { + if let transaction = try? result.payloadValue, + transaction.revocationDate == nil { + return true + } + } + + return false +} +``` + +--- + +## Step 6: Implement Subscription Management + +### Track Subscription Status + +```swift +extension StoreManager { + func checkSubscriptionStatus(for groupID: String) async -> Product.SubscriptionInfo.Status? { + // Get subscription statuses for group + guard let result = try? await Product.SubscriptionInfo.status(for: groupID), + let status = result.first else { + return nil + } + + return status.state + } +} +``` + +### Handle Subscription States + +```swift +func updateSubscriptionUI(for status: Product.SubscriptionInfo.Status) { + switch status.state { + case .subscribed: + // User has active subscription + showSubscribedContent() + + case .expired: + // Subscription expired - show win-back offer + showResubscribeOffer() + + case .inGracePeriod: + // Billing issue - show payment update prompt + showUpdatePaymentPrompt() + + case .inBillingRetryPeriod: + // Apple retrying payment - maintain access + showBillingRetryMessage() + + case .revoked: + // Family Sharing access removed + removeAccess() + + @unknown default: + break + } +} +``` + +### Use StoreKit Views (iOS 17+) + +```swift +struct SubscriptionView: View { + var body: some View { + SubscriptionStoreView(groupID: "pro_tier") { + // Marketing content + VStack { + Image("premium-icon") + Text("Unlock all features") + } + } + .subscriptionStoreControlStyle(.prominentPicker) + } +} +``` + +--- + +## Step 7: Implement Restore Purchases (REQUIRED) + +### Restore Flow + +```swift +extension StoreManager { + func restorePurchases() async { + // Sync all transactions from App Store + try? await AppStore.sync() + + // Update current entitlements + await updatePurchasedProducts() + } +} +``` + +### UI Button + +```swift +struct SettingsView: View { + @StateObject private var store = StoreManager() + + var body: some View { + Button("Restore Purchases") { + Task { + await store.restorePurchases() + } + } + } +} +``` + +**App Store Requirement**: Apps with IAP must provide restore functionality for non-consumables and subscriptions. + +--- + +## Step 8: Handle Refunds + +### Listen for Refund Notifications + +```swift +extension StoreManager { + func listenForTransactions() -> Task { + Task.detached { [weak self] in + for await verificationResult in Transaction.updates { + await self?.handleTransaction(verificationResult) + } + } + } + + @MainActor + private func handleTransaction(_ result: VerificationResult) async { + guard let transaction = try? result.payloadValue else { + return + } + + // Check if transaction was refunded + if let revocationDate = transaction.revocationDate { + print("Transaction refunded on \(revocationDate)") + await revokeEntitlement(for: transaction.productID) + } else { + await grantEntitlement(for: transaction) + } + + await transaction.finish() + } +} +``` + +--- + +## Step 9: Unit Testing + +### Mock Store Responses + +```swift +protocol StoreProtocol { + func products(for ids: [String]) async throws -> [Product] + func purchase(_ product: Product) async throws -> PurchaseResult +} + +// Production +final class StoreManager: StoreProtocol { + func products(for ids: [String]) async throws -> [Product] { + try await Product.products(for: ids) + } +} + +// Testing +final class MockStore: StoreProtocol { + var mockProducts: [Product] = [] + var mockPurchaseResult: PurchaseResult? + + func products(for ids: [String]) async throws -> [Product] { + mockProducts + } + + func purchase(_ product: Product) async throws -> PurchaseResult { + mockPurchaseResult ?? .userCancelled + } +} +``` + +### Test Purchase Logic + +```swift +@Test func testSuccessfulPurchase() async { + let mockStore = MockStore() + let manager = StoreManager(store: mockStore) + + // Given: Mock successful purchase + mockStore.mockPurchaseResult = .success(.verified(mockTransaction)) + + // When: Purchase product + let result = await manager.purchase(mockProduct) + + // Then: Entitlement granted + #expect(result == true) + #expect(manager.purchasedProductIDs.contains("com.app.premium")) +} + +@Test func testCancelledPurchase() async { + let mockStore = MockStore() + let manager = StoreManager(store: mockStore) + + // Given: User cancels + mockStore.mockPurchaseResult = .userCancelled + + // When: Purchase product + let result = await manager.purchase(mockProduct) + + // Then: No entitlement granted + #expect(result == false) + #expect(manager.purchasedProductIDs.isEmpty) +} +``` + +--- + +## Common Anti-Patterns (NEVER DO THIS) + +### ❌ No StoreKit Configuration + +```swift +// ❌ WRONG: Writing purchase code without .storekit file +let products = try await Product.products(for: productIDs) +// Can't test this without App Store Connect setup! +``` + +✅ **Correct**: Create `.storekit` file FIRST, test in Xcode, THEN implement. + +### ❌ Code Before .storekit Config + +```swift +// ❌ Less ideal: Write code, test in sandbox, add .storekit later +let products = try await Product.products(for: productIDs) +let result = try await product.purchase(confirmIn: scene) +// "I tested this in sandbox, it works! I'll add .storekit config later." +``` + +✅ **Recommended**: Create `.storekit` config first, then write code. + +**If you're in this situation**: See "Already Wrote Code Before Creating .storekit Config?" section above for your options (A, B, or C). + +**Why .storekit-first is better**: +- Product ID typos caught in Xcode, not at runtime +- Faster iteration without network requests +- Teammates can test locally +- Documents product structure in code + +**Sandbox testing is valuable** - it validates against real App Store infrastructure. But starting with .storekit makes sandbox testing easier because you've already validated product IDs locally. + +### ❌ Scattered Purchase Calls + +```swift +// ❌ WRONG: Purchase calls scattered throughout app +Button("Buy") { + try await product.purchase() // In view 1 +} + +Button("Subscribe") { + try await subscriptionProduct.purchase() // In view 2 +} +``` + +✅ **Correct**: All purchases through centralized StoreManager. + +### ❌ Forgetting to Finish Transactions + +```swift +// ❌ WRONG: Never calling finish() +func handleTransaction(_ transaction: Transaction) { + grantEntitlement(for: transaction) + // Missing: await transaction.finish() +} +``` + +✅ **Correct**: ALWAYS call `transaction.finish()` after granting entitlement. + +### ❌ Not Verifying Transactions + +```swift +// ❌ WRONG: Using unverified transaction +for await transaction in Transaction.all { + grantEntitlement(for: transaction) // Unsafe! +} +``` + +✅ **Correct**: Always check `VerificationResult` before granting. + +### ❌ Ignoring Transaction Listener + +```swift +// ❌ WRONG: Only handling purchases in purchase() method +func purchase() { + let result = try await product.purchase() + // What about pending purchases, family sharing, restore? +} +``` + +✅ **Correct**: Listen to `Transaction.updates` for ALL transaction sources. + +### ❌ Not Implementing Restore + +```swift +// ❌ WRONG: No restore button +// App Store will REJECT your app! +``` + +✅ **Correct**: Provide visible "Restore Purchases" button in settings. + +--- + +## Validation + +Before marking IAP implementation complete, verify: + +### Code Inspection + +Run these searches to verify compliance: + +```bash +# Check StoreKit configuration exists +find . -name "*.storekit" + +# Check transaction.finish() is called +rg "transaction\.finish\(\)" --type swift + +# Check VerificationResult usage +rg "VerificationResult" --type swift + +# Check Transaction.updates listener +rg "Transaction\.updates" --type swift + +# Check restore implementation +rg "AppStore\.sync|Transaction\.all" --type swift +``` + +### Functional Testing + +- [ ] Can purchase each product type in StoreKit configuration +- [ ] Can cancel purchase and state remains consistent +- [ ] Can restore purchases and regain access +- [ ] Subscription renewal/expiration works as expected +- [ ] Refunded transactions revoke access +- [ ] Family Sharing transactions identified (if supported) + +### Sandbox Testing + +- [ ] Real Apple ID sandbox purchases complete +- [ ] TestFlight beta testers confirm purchase flows work +- [ ] Server-side validation works (if using backend) + +### App Store Connect Submission (see **app-store-submission** for full checklist) + +- [ ] **Review screenshot uploaded** for each IAP product (shows purchase UI — review-only, not on App Store) +- [ ] **IAP products attached to this version** (first submission: App Version → In-App Purchases section → Select → checkbox each product) +- [ ] **Terms of Use + Privacy Policy links on purchase screen** (required by DPLA Schedule 2; `SubscriptionStoreView` handles this automatically) +- [ ] Subscription terms explicit: price, period, auto-renewal, cancellation + +--- + +## Resources + +**WWDC**: 2025-241, 2025-249, 2023-10013, 2021-10114 + +**Docs**: /storekit, /appstoreserverapi + +**Skills**: axiom-storekit-ref diff --git a/.claude/skills/axiom-in-app-purchases/agents/openai.yaml b/.claude/skills/axiom-in-app-purchases/agents/openai.yaml new file mode 100644 index 0000000..a735c03 --- /dev/null +++ b/.claude/skills/axiom-in-app-purchases/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "In App Purchases" + short_description: "Implementing in-app purchases, StoreKit 2, subscriptions, or transaction handling" diff --git a/.claude/skills/axiom-ios-accessibility/.openskills.json b/.claude/skills/axiom-ios-accessibility/.openskills.json new file mode 100644 index 0000000..81adacb --- /dev/null +++ b/.claude/skills/axiom-ios-accessibility/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-accessibility", + "installedAt": "2026-04-12T08:05:35.617Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-ios-accessibility/SKILL.md b/.claude/skills/axiom-ios-accessibility/SKILL.md new file mode 100644 index 0000000..98480a3 --- /dev/null +++ b/.claude/skills/axiom-ios-accessibility/SKILL.md @@ -0,0 +1,93 @@ +--- +name: axiom-ios-accessibility +description: Use when fixing or auditing ANY accessibility issue - VoiceOver, Dynamic Type, color contrast, touch targets, WCAG compliance, App Store accessibility review. +license: MIT +--- + +# iOS Accessibility Router + +**You MUST use this skill for ANY accessibility work including VoiceOver, Dynamic Type, color contrast, and WCAG compliance.** + +## When to Use + +Use this router when: +- Fixing VoiceOver issues +- Implementing Dynamic Type +- Checking color contrast +- Ensuring touch target sizes +- Preparing for App Store accessibility review +- WCAG compliance auditing +- Assistive Access support (cognitive disabilities, iOS 17+) + +## Routing Logic + +### Accessibility Issues + +**All accessibility work** → `/skill axiom-accessibility-diag` +- VoiceOver labels and hints +- Dynamic Type scaling +- Color contrast (WCAG) +- Touch target sizes +- Keyboard navigation +- Reduce Motion support +- Assistive Access (cognitive disabilities, iOS 17+) +- Accessibility Inspector usage +- App Store Review preparation + +### Automated Scanning + +**Accessibility audit** → Launch `accessibility-auditor` agent or `/axiom:audit accessibility` (VoiceOver issues, Dynamic Type violations, color contrast failures, WCAG compliance scanning) + +## Decision Tree + +1. ANY accessibility issue → accessibility-diag +2. Want automated accessibility scan? → accessibility-auditor (Agent) + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "I'll add VoiceOver labels when I'm done building" | Accessibility is foundational, not polish. accessibility-diag prevents App Store rejection. | +| "My app doesn't need accessibility" | All apps need accessibility. It's required by App Store guidelines and benefits all users. | +| "Dynamic Type just needs .scaledFont" | Dynamic Type has 7 common violations. accessibility-diag catches them all. | +| "Color contrast looks fine to me" | Visual assessment is unreliable. WCAG ratios require measurement. accessibility-diag validates. | + +## Critical Patterns + +#### Image Accessibility + +- Use `Image(decorative: "photo")` for purely decorative images — automatically hidden from VoiceOver (equivalent to `accessibilityHidden(true)` but semantically clearer) +- Use `accessibilityInputLabels()` for buttons with complex or changing labels — improves Voice Control accuracy by providing alternative labels +- Respect `accessibilityDifferentiateWithoutColor` environment value — when active, provide non-color cues (icons, patterns, labels) alongside color indicators + +#### accessibility-diag Coverage + +- 8 critical accessibility issues (including Assistive Access) +- WCAG compliance levels (A, AA, AAA) +- Assistive Access mode (cognitive disabilities, iOS 17+) +- Accessibility Inspector workflows +- VoiceOver testing checklist +- App Store Review requirements + +## Example Invocations + +User: "My button isn't being read by VoiceOver" +→ Invoke: `/skill axiom-accessibility-diag` + +User: "How do I support Dynamic Type?" +→ Invoke: `/skill axiom-accessibility-diag` + +User: "Check my app for accessibility issues" +→ Invoke: `/skill axiom-accessibility-diag` + +User: "Prepare for App Store accessibility review" +→ Invoke: `/skill axiom-accessibility-diag` + +User: "Scan my app for accessibility issues automatically" +→ Invoke: `accessibility-auditor` agent + +User: "How do I support Assistive Access?" +→ Invoke: `/skill axiom-accessibility-diag` + +User: "My app doesn't show up in Assistive Access" +→ Invoke: `/skill axiom-accessibility-diag` diff --git a/.claude/skills/axiom-ios-ai/.openskills.json b/.claude/skills/axiom-ios-ai/.openskills.json new file mode 100644 index 0000000..fb3316f --- /dev/null +++ b/.claude/skills/axiom-ios-ai/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-ai", + "installedAt": "2026-04-12T08:05:35.618Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-ios-ai/SKILL.md b/.claude/skills/axiom-ios-ai/SKILL.md new file mode 100644 index 0000000..056c484 --- /dev/null +++ b/.claude/skills/axiom-ios-ai/SKILL.md @@ -0,0 +1,135 @@ +--- +name: axiom-ios-ai +description: Use when implementing ANY Apple Intelligence or on-device AI feature. Covers Foundation Models, @Generable, LanguageModelSession, structured output, Tool protocol, iOS 26 AI integration. +license: MIT +--- + +# iOS Apple Intelligence Router + +**You MUST use this skill for ANY Apple Intelligence or Foundation Models work.** + +## When to Use + +Use this router when: +- Implementing Apple Intelligence features +- Using Foundation Models +- Working with LanguageModelSession +- Generating structured output with @Generable +- Debugging AI generation issues +- iOS 26 on-device AI + +## AI Approach Triage + +**First, determine which kind of AI the developer needs:** + +| Developer Intent | Route To | +|-----------------|----------| +| On-device text generation (Apple Intelligence) | **Stay here** → Foundation Models skills | +| Custom ML model deployment (PyTorch, TensorFlow) | **Route to ios-ml** → CoreML conversion, compression | +| Computer vision (image analysis, OCR, segmentation) | **Route to ios-vision** → Vision framework | +| Cloud API integration (OpenAI, etc.) | **Route to ios-networking** → URLSession patterns | +| System AI features (Writing Tools, Genmoji) | No custom code needed — these are system-provided | + +**Key boundary: ios-ai vs ios-ml** +- ios-ai = Apple's Foundation Models framework (LanguageModelSession, @Generable, on-device LLM) +- ios-ml = Custom model deployment (CoreML conversion, quantization, MLTensor, speech-to-text) +- If developer says "run my own model" → ios-ml. If "use Apple Intelligence" → ios-ai. + +## Cross-Domain Routing + +**Foundation Models + concurrency** (session blocking main thread, UI freezes): +- Foundation Models sessions are async — blocking likely means missing `await` or running on @MainActor +- **Fix here first** using async session patterns in foundation-models skill +- If concurrency issue is broader than Foundation Models → **also invoke ios-concurrency** + +**Foundation Models + data** (@Generable decoding errors, structured output issues): +- @Generable output problems are Foundation Models-specific, NOT generic Codable issues +- **Stay here** → foundation-models-diag handles structured output debugging +- If developer also has general Codable/serialization questions → **also invoke ios-data** + +## Routing Logic + +### Foundation Models Work + +**Implementation patterns** → `/skill axiom-foundation-models` +- LanguageModelSession basics +- @Generable structured output +- Tool protocol integration +- Streaming with PartiallyGenerated +- Dynamic schemas +- 26 WWDC code examples + +**API reference** → `/skill axiom-foundation-models-ref` +- Complete API documentation +- All @Generable examples +- Tool protocol patterns +- Streaming generation patterns + +**Diagnostics** → `/skill axiom-foundation-models-diag` +- AI response blocked +- Generation slow +- Guardrail violations +- Context limits exceeded +- Model unavailable + +**Automated scanning** → Launch `foundation-models-auditor` agent or `/axiom:audit foundation-models` (missing availability checks, main thread blocking, manual JSON parsing, session lifecycle issues) + +## Decision Tree + +1. Custom ML model / CoreML / PyTorch conversion? → **Route to ios-ml** (not this router) +2. Computer vision / image analysis / OCR? → **Route to ios-vision** (not this router) +3. Cloud AI API integration? → **Route to ios-networking** (not this router) +4. Implementing Foundation Models / @Generable / Tool protocol? → foundation-models +5. Need API reference / code examples? → foundation-models-ref +6. Debugging AI issues (blocked, slow, guardrails)? → foundation-models-diag +7. Foundation Models + UI freezing? → foundation-models (async patterns) + also invoke ios-concurrency if needed +8. Want automated Foundation Models code scan? → foundation-models-auditor (Agent) + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "Foundation Models is just LanguageModelSession" | Foundation Models has @Generable, Tool protocol, streaming, and guardrails. foundation-models covers all. | +| "I'll figure out the AI patterns as I go" | AI APIs have specific error handling and fallback requirements. foundation-models prevents runtime failures. | +| "I've used LLMs before, this is similar" | Apple's on-device models have unique constraints (guardrails, context limits). foundation-models is Apple-specific. | + +## Critical Patterns + +**foundation-models**: +- LanguageModelSession setup +- @Generable for structured output +- Tool protocol for function calling +- Streaming generation +- Dynamic schema evolution + +**foundation-models-diag**: +- Blocked response handling +- Performance optimization +- Guardrail violations +- Context management + +## Example Invocations + +User: "How do I use Apple Intelligence to generate structured data?" +→ Invoke: `/skill axiom-foundation-models` + +User: "My AI generation is being blocked" +→ Invoke: `/skill axiom-foundation-models-diag` + +User: "Show me @Generable examples" +→ Invoke: `/skill axiom-foundation-models-ref` + +User: "Implement streaming AI generation" +→ Invoke: `/skill axiom-foundation-models` + +User: "I want to add AI to my app" +→ First ask: Apple Intelligence (Foundation Models) or custom ML model? Route accordingly. + +User: "My Foundation Models session is blocking the UI" +→ Invoke: `/skill axiom-foundation-models` (async patterns) + also invoke `ios-concurrency` if needed + +User: "Review my Foundation Models code for issues" +→ Invoke: `foundation-models-auditor` agent + +User: "I want to run my PyTorch model on device" +→ Route to: `ios-ml` router (CoreML conversion, not Foundation Models) diff --git a/.claude/skills/axiom-ios-build/.openskills.json b/.claude/skills/axiom-ios-build/.openskills.json new file mode 100644 index 0000000..bec17fb --- /dev/null +++ b/.claude/skills/axiom-ios-build/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-build", + "installedAt": "2026-04-12T08:05:35.618Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-ios-build/SKILL.md b/.claude/skills/axiom-ios-build/SKILL.md new file mode 100644 index 0000000..c76c49d --- /dev/null +++ b/.claude/skills/axiom-ios-build/SKILL.md @@ -0,0 +1,423 @@ +--- +name: axiom-ios-build +description: Use when ANY iOS build fails, test crashes, Xcode misbehaves, or environment issue occurs before debugging code. Covers build failures, compilation errors, dependency conflicts, simulator problems, environment-first diagnostics. +license: MIT +--- + +# iOS Build & Environment Router + +**You MUST use this skill for ANY build, environment, or Xcode-related issue before debugging application code.** + +## When to Use + +Use this router when you encounter: +- Build failures (`BUILD FAILED`, compilation errors, linker errors) +- Test crashes or hangs +- Simulator issues (won't boot, device errors) +- Xcode misbehavior (stale builds, zombie processes) +- Dependency conflicts (CocoaPods, SPM) +- Build performance issues (slow compilation) +- Environment issues before debugging code + +## Routing Logic + +This router invokes specialized skills based on the specific issue: + +### 1. Environment-First Issues → **xcode-debugging** +**Triggers**: +- `BUILD FAILED` without obvious code cause +- Tests crash in clean project +- Simulator hangs or won't boot +- "No such module" after SPM changes +- Zombie `xcodebuild` processes +- Stale builds (old code still running) +- Clean build differs from incremental build + +**Why xcode-debugging first**: 90% of mysterious issues are environment, not code. Check this BEFORE debugging code. + +**Invoke**: `/skill axiom-xcode-debugging` + +--- + +### 2. Slow Builds → **build-performance** +**Triggers**: +- Compilation takes too long +- Type checking bottlenecks +- Want to optimize build time +- Build Timeline shows slow phases + +**Invoke**: `/skill axiom-build-performance` + +--- + +### 3. SPM Dependency Conflicts → **spm-conflict-resolver** (Agent) +**Triggers**: +- SPM resolution failures +- "No such module" after adding package +- Duplicate symbol linker errors +- Version conflicts between packages +- Swift 6 package compatibility issues +- Package.swift / Package.resolved conflicts + +**Why spm-conflict-resolver**: Specialized agent that analyzes Package.swift and Package.resolved to diagnose and resolve Swift Package Manager conflicts. + +**Invoke**: Launch `spm-conflict-resolver` agent + +--- + +### 4. Security & Privacy Audit → **security-privacy-scanner** (Agent) +**Triggers**: +- App Store submission prep +- Privacy Manifest requirements (iOS 17+) +- Hardcoded credentials in code +- Sensitive data storage concerns +- ATS violations +- Required Reason API declarations + +**Why security-privacy-scanner**: Specialized agent that scans for security vulnerabilities and privacy compliance issues. + +**Invoke**: Launch `security-privacy-scanner` agent or `/axiom:audit security` + +--- + +### 5. iOS 17→18 Modernization → **modernization-helper** (Agent) +**Triggers**: +- Migrate ObservableObject to @Observable +- Update @StateObject to @State +- Adopt modern SwiftUI patterns +- Deprecated API cleanup +- iOS 17+ migration + +**Why modernization-helper**: Specialized agent that scans for legacy patterns and provides migration paths with code examples. + +**Invoke**: Launch `modernization-helper` agent or `/axiom:audit modernization` + +--- + +### 6. Build Failure Auto-Fix → **build-fixer** (Agent) +**Triggers**: +- BUILD FAILED with no clear error details +- Build sometimes succeeds, sometimes fails +- App builds but runs old code +- "Unable to boot simulator" error +- Want automated environment-first diagnostics + +**Why build-fixer**: Autonomous agent that checks zombie processes, Derived Data, SPM cache, and simulator state before investigating code. Saves 30+ minutes on environment issues. + +**Invoke**: Launch `build-fixer` agent or `/axiom:fix-build` + +--- + +### 7. Slow Build Optimization → **build-optimizer** (Agent) +**Triggers**: +- Builds take too long +- Want to identify slow type checking +- Expensive build phase scripts +- Suboptimal build settings +- Want parallelization opportunities + +**Why build-optimizer**: Scans Xcode projects for build performance optimizations — slow type checking, expensive scripts, suboptimal settings — to reduce build times by 30-50%. + +**Invoke**: Launch `build-optimizer` agent or `/axiom:optimize-build` + +--- + +### 8. General Dependency Issues → **build-debugging** +**Triggers**: +- CocoaPods resolution failures +- "Multiple commands produce" errors +- Framework version mismatches +- Non-SPM dependency graph conflicts + +**Invoke**: `/skill axiom-build-debugging` + +--- + +### 9. TestFlight Crash Triage → **testflight-triage** +**Triggers**: +- Beta tester reported a crash +- Crash reports in Xcode Organizer +- Crash logs aren't symbolicated +- TestFlight feedback with screenshots +- App was killed but no crash report + +**Why testflight-triage**: Systematic workflow for investigating TestFlight crashes and reviewing beta feedback. Covers symbolication, crash interpretation, common patterns, and Claude-assisted analysis. + +**Invoke**: `/skill axiom-testflight-triage` + +--- + +### 10. App Store Connect Navigation → **app-store-connect-ref** +**Triggers**: +- How to find crashes in App Store Connect +- ASC metrics dashboard navigation +- Understanding crash-free users percentage +- Comparing crash rates between versions +- Exporting crash data from ASC +- App Store Connect API for crash data + +**Why app-store-connect-ref**: Reference for navigating ASC crash analysis, metrics dashboards, and data export workflows. + +**Invoke**: `/skill axiom-app-store-connect-ref` + +--- + +### 11. Crash Log Analysis → **crash-analyzer** (Agent) +**Triggers**: +- User has .ips or .crash file to analyze +- User pasted crash report text +- Need to parse crash log programmatically +- Identify crash pattern from exception type +- Check symbolication status + +**Why crash-analyzer**: Autonomous agent that parses crash reports, identifies patterns (null pointer, Swift runtime, watchdog, jetsam), and generates actionable analysis. + +**Invoke**: Launch `crash-analyzer` agent or `/axiom:analyze-crash` + +--- + +### 12. MetricKit API Reference → **metrickit-ref** +**Triggers**: +- MetricKit setup and subscription +- MXMetricPayload parsing (CPU, memory, launches, hitches) +- MXDiagnosticPayload parsing (crashes, hangs, disk writes) +- MXCallStackTree decoding and symbolication +- Field crash/hang collection +- Background exit metrics + +**Why metrickit-ref**: Complete MetricKit API reference with setup patterns, payload parsing, and integration with crash reporting systems. + +**Invoke**: `/skill axiom-metrickit-ref` + +--- + +### 13. Hang Diagnostics → **hang-diagnostics** +**Triggers**: +- App hangs or freezes +- Main thread blocked for >1 second +- UI unresponsive to touches +- Xcode Organizer shows hang diagnostics +- MXHangDiagnostic from MetricKit +- Watchdog terminations (app killed during launch/background transition) + +**Why hang-diagnostics**: Systematic diagnosis of hangs with decision tree for busy vs blocked main thread, tool selection (Time Profiler, System Trace), and 8 common hang patterns with fixes. + +**Invoke**: `/skill axiom-hang-diagnostics` + +--- + +### 14. Live Debugging → **axiom-lldb** +**Triggers**: +- Need to reproduce a crash interactively +- Want to set breakpoints and inspect state +- Crash report analyzed, now need live investigation +- Need to attach debugger to running app + +**Why axiom-lldb**: Crash reports tell you WHAT crashed. LLDB tells you WHY. + +**Invoke**: `/skill axiom-lldb` + +--- + +### 16. Runtime Console Capture → **xclog-ref** +**Triggers**: +- Need to see what the app is logging at runtime +- App crashes but no crash report (need console output) +- Silent failures (network, data, auth) with no UI feedback +- Want to capture print()/os_log() output from simulator +- Need structured log output for analysis +- "What is the app printing?" + +**Why xclog-ref**: Xcode's debug console isn't accessible externally. xclog combines simctl stdout/stderr with `log stream` JSON to capture everything print(), NSLog(), os_log(), and Logger emit — with structured fields (level, subsystem, category) for automated analysis. + +**Invoke**: `/skill axiom-xclog-ref` or `/axiom:console` + +--- + +### 15. Code Signing Issues → **code-signing** +**Triggers**: +- "No signing certificate found" +- "Provisioning profile doesn't include signing certificate" +- errSecInternalComponent in CI +- ITMS-90035 Invalid Signature on upload +- Ambiguous identity / multiple certificates +- Entitlement mismatch or missing capability +- Setting up CI/CD code signing (GitHub Actions, fastlane match) +- Certificate expired or revoked + +**Why code-signing**: Code signing errors are NEVER code bugs — they are 100% configuration (certificates, profiles, entitlements, keychains). Diagnosing with CLI tools takes 5 minutes vs hours of guessing. + +**Invoke**: `/skill axiom-code-signing` (workflows) or `/skill axiom-code-signing-diag` (troubleshooting) + +--- + +## Decision Tree + +1. Mysterious/intermittent/clean build fails? → xcode-debugging (environment-first) +2. SPM dependency conflict? → spm-conflict-resolver (Agent) +3. CocoaPods/other dependency conflict? → build-debugging +4. Slow build time? → build-performance +5. Security/privacy/App Store prep? → security-privacy-scanner (Agent) +6. Want automated build fix (environment-first diagnostics)? → build-fixer (Agent) +7. Want build time optimization scan? → build-optimizer (Agent) +8. Modernization/deprecated APIs? → modernization-helper (Agent) +9. TestFlight crash/feedback? → testflight-triage +10. Navigating App Store Connect? → app-store-connect-ref +11. Have a crash log (.ips/.crash)? → crash-analyzer (Agent) +12. MetricKit setup/parsing? → metrickit-ref +13. App hang/freeze/watchdog? → hang-diagnostics +14. Need to reproduce crash interactively / inspect runtime state? → axiom-lldb +15. Code signing error (certificate, profile, entitlement, Keychain)? → code-signing / code-signing-diag +16. Need to see runtime console output (print/os_log)? → xclog-ref or `/axiom:console` + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "I know how to fix this linker error" | Linker errors have 4+ root causes. xcode-debugging diagnoses all in 2 min. | +| "Let me just clean the build folder" | Clean builds mask the real issue. xcode-debugging finds the root cause. | +| "It's just an SPM issue, I'll fix Package.swift" | SPM conflicts cascade. spm-conflict-resolver analyzes the full dependency graph. | +| "The simulator is just slow today" | Simulator issues indicate environment corruption. xcode-debugging checks systematically. | +| "I'll skip environment checks, it compiles locally" | Environment-first saves 30+ min. Every time. | +| "I'll read the crash report more carefully instead of reproducing" | Crash reports show WHAT crashed, not WHY. Reproducing in LLDB with breakpoints reveals the actual state. axiom-lldb has the workflow. | +| "I know my certificate is fine, let me check the code" | Code signing errors are NEVER code bugs. 100% configuration. code-signing diagnoses with CLI in 5 min. | +| "I can't see what the app is logging without Xcode" | xclog captures print() + os_log from the simulator. Structured JSON output with level, subsystem, category. `/axiom:console` or `/skill axiom-xclog-ref`. | + +## When NOT to Use (Conflict Resolution) + +**Do NOT use ios-build for these — use the correct router instead:** + +| Error Type | Correct Router | Why NOT ios-build | +|------------|----------------|-------------------| +| Swift 6 concurrency errors | **ios-concurrency** | Code error, not environment | +| SwiftData migration errors | **ios-data** | Schema issue, not build environment | +| "Sending 'self' risks data race" | **ios-concurrency** | Language error, not Xcode issue | +| Type mismatch / compilation errors | Fix the code | These are code bugs | + +**ios-build is for environment mysteries**, not code errors: +- ✅ "No such module" when code is correct +- ✅ Simulator won't boot +- ✅ Clean build fails, incremental works +- ✅ Zombie xcodebuild processes +- ❌ Swift concurrency warnings/errors +- ❌ Database migration failures +- ❌ Type checking errors in valid code + +## Example Invocations + +User: "My build failed with a linker error" +→ Invoke: `/skill axiom-xcode-debugging` (environment-first diagnostic) + +User: "Builds are taking 10 minutes" +→ Invoke: `/skill axiom-build-performance` + +User: "SPM won't resolve dependencies" +→ Invoke: `spm-conflict-resolver` agent + +User: "Two packages require different versions of the same dependency" +→ Invoke: `spm-conflict-resolver` agent + +User: "Duplicate symbol linker error" +→ Invoke: `spm-conflict-resolver` agent + +User: "I need to prepare for App Store security review" +→ Invoke: `security-privacy-scanner` agent + +User: "Do I need a Privacy Manifest?" +→ Invoke: `security-privacy-scanner` agent + +User: "Are there hardcoded credentials in my code?" +→ Invoke: `security-privacy-scanner` agent + +User: "How do I migrate from ObservableObject to @Observable?" +→ Invoke: `modernization-helper` agent + +User: "Update my code to use modern SwiftUI patterns" +→ Invoke: `modernization-helper` agent + +User: "Should I still use @StateObject?" +→ Invoke: `modernization-helper` agent + +User: "A beta tester said my app crashed" +→ Invoke: `/skill axiom-testflight-triage` + +User: "I see crashes in App Store Connect but don't know how to investigate" +→ Invoke: `/skill axiom-testflight-triage` + +User: "My crash logs aren't symbolicated" +→ Invoke: `/skill axiom-testflight-triage` + +User: "I need to review TestFlight feedback" +→ Invoke: `/skill axiom-testflight-triage` + +User: "How do I find crashes in App Store Connect?" +→ Invoke: `/skill axiom-app-store-connect-ref` + +User: "Where's the crash-free users metric in ASC?" +→ Invoke: `/skill axiom-app-store-connect-ref` + +User: "How do I export crash data from App Store Connect?" +→ Invoke: `/skill axiom-app-store-connect-ref` + +User: "Analyze this crash log" [pastes .ips content] +→ Invoke: `crash-analyzer` agent or `/axiom:analyze-crash` + +User: "Parse this .ips file: ~/Library/Logs/DiagnosticReports/MyApp.ips" +→ Invoke: `crash-analyzer` agent or `/axiom:analyze-crash` + +User: "Why did my app crash? Here's the report..." +→ Invoke: `crash-analyzer` agent or `/axiom:analyze-crash` + +User: "How do I set up MetricKit to collect crash data?" +→ Invoke: `/skill axiom-metrickit-ref` + +User: "How do I parse MXDiagnosticPayload?" +→ Invoke: `/skill axiom-metrickit-ref` + +User: "What's in MXCallStackTree and how do I decode it?" +→ Invoke: `/skill axiom-metrickit-ref` + +User: "My app hangs sometimes" +→ Invoke: `/skill axiom-hang-diagnostics` + +User: "The main thread is blocked and UI is unresponsive" +→ Invoke: `/skill axiom-hang-diagnostics` + +User: "Xcode Organizer shows hang diagnostics for my app" +→ Invoke: `/skill axiom-hang-diagnostics` + +User: "My app was killed by watchdog during launch" +→ Invoke: `/skill axiom-hang-diagnostics` + +User: "I have a crash report and need to reproduce it in the debugger" +→ Invoke: `/skill axiom-lldb` + +User: "How do I set breakpoints to catch this crash?" +→ Invoke: `/skill axiom-lldb` + +User: "My build is failing with BUILD FAILED but no error details" +→ Invoke: `build-fixer` agent or `/axiom:fix-build` + +User: "Build sometimes succeeds, sometimes fails" +→ Invoke: `build-fixer` agent or `/axiom:fix-build` + +User: "How can I speed up my Xcode build times?" +→ Invoke: `build-optimizer` agent or `/axiom:optimize-build` + +User: "No signing certificate found when I try to build" +→ Invoke: `/skill axiom-code-signing-diag` + +User: "errSecInternalComponent in my GitHub Actions CI" +→ Invoke: `/skill axiom-code-signing-diag` + +User: "How do I set up code signing for GitHub Actions?" +→ Invoke: `/skill axiom-code-signing` + +User: "What is my app printing to the console?" +→ Invoke: `/skill axiom-xclog-ref` or `/axiom:console` + +User: "I need to see the simulator console output" +→ Invoke: `/skill axiom-xclog-ref` or `/axiom:console` + +User: "The app fails silently, no error in the UI" +→ Invoke: `/skill axiom-xclog-ref` or `/axiom:console` diff --git a/.claude/skills/axiom-ios-concurrency/.openskills.json b/.claude/skills/axiom-ios-concurrency/.openskills.json new file mode 100644 index 0000000..e8e9037 --- /dev/null +++ b/.claude/skills/axiom-ios-concurrency/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-concurrency", + "installedAt": "2026-04-12T08:05:35.619Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-ios-concurrency/SKILL.md b/.claude/skills/axiom-ios-concurrency/SKILL.md new file mode 100644 index 0000000..e1d1c3f --- /dev/null +++ b/.claude/skills/axiom-ios-concurrency/SKILL.md @@ -0,0 +1,209 @@ +--- +name: axiom-ios-concurrency +description: Use when writing ANY code with async, actors, threads, or seeing ANY concurrency error. Covers Swift 6 concurrency, @MainActor, Sendable, data races, async/await patterns, performance optimization. +license: MIT +--- + +# iOS Concurrency Router + +**You MUST use this skill for ANY concurrency, async/await, threading, or Swift 6 concurrency work.** + +## When to Use + +Use this router when: +- Writing async/await code +- Seeing concurrency errors (data races, actor isolation) +- Working with @MainActor +- Dealing with Sendable conformance +- Optimizing Swift performance +- Migrating to Swift 6 concurrency +- **App freezes during loading** (likely main thread blocking) + +## Conflict Resolution + +**ios-concurrency vs ios-performance**: When app freezes or feels slow: +1. **Try ios-concurrency FIRST** — Main thread blocking is the #1 cause of UI freezes. Check for synchronous work on @MainActor before profiling. +2. **Only use ios-performance** if concurrency fixes don't help — Profile after ruling out obvious blocking. + +**ios-concurrency vs ios-build**: When seeing Swift 6 concurrency errors: +- **Use ios-concurrency, NOT ios-build** — Concurrency errors are CODE issues, not environment issues +- ios-build is for "No such module", simulator issues, build failures unrelated to Swift language errors + +**ios-concurrency vs ios-data**: When concurrency errors involve Core Data or SwiftData: +- Core Data threading (NSManagedObjectContext thread confinement, performBackgroundTask) → **use ios-data first** — Core Data has its own threading model distinct from Swift concurrency +- SwiftData + @MainActor ModelContext → **use ios-concurrency** — This is Swift concurrency isolation +- General "background saves losing data" → **use ios-data first** — Framework-specific threading rules take priority + +**Rationale**: A 2-second freeze during data loading is almost always `await` on main thread or missing background dispatch. Domain knowledge solves this faster than Time Profiler. Core Data threading violations need Core Data-specific fixes, not generic concurrency patterns. + +## Routing Logic + +### Swift Concurrency Issues + +**Swift 6 concurrency patterns** → `/skill axiom-swift-concurrency` +- async/await patterns +- @MainActor usage +- Actor isolation +- Sendable conformance +- Data race prevention +- Swift 6 migration + +**Swift concurrency API reference** → `/skill axiom-swift-concurrency-ref` +- Actor definition, reentrancy, global actors +- Sendable patterns, @unchecked Sendable +- Task/TaskGroup/cancellation API +- AsyncStream, continuations +- DispatchQueue → actor migration + +**Swift performance** → `/skill axiom-swift-performance` +- Value vs reference types +- Copy-on-write optimization +- ARC overhead +- Generic specialization +- Collection performance + +**Synchronous actor access** → `/skill axiom-assume-isolated` +- MainActor.assumeIsolated +- @preconcurrency protocol conformances +- Legacy delegate callbacks +- Testing MainActor code synchronously + +**Thread-safe primitives** → `/skill axiom-synchronization` +- Mutex (iOS 18+) +- OSAllocatedUnfairLock (iOS 16+) +- Atomic types +- Lock vs actor decision + +**Parameter ownership** → `/skill axiom-ownership-conventions` +- borrowing/consuming modifiers +- Noncopyable types (~Copyable) +- ARC traffic reduction +- consume operator + +**Concurrency profiling** → `/skill axiom-concurrency-profiling` +- Swift Concurrency Instruments template +- Actor contention diagnosis +- Thread pool exhaustion +- Task visualization + +**Combine reactive patterns** → `/skill axiom-combine-patterns` +- Publisher/Subscriber lifecycle, AnyCancellable +- Combine vs async/await decision +- @Published + ObservableObject +- Operator patterns, bridging + +### Automated Scanning + +**Concurrency audit** → Launch `concurrency-auditor` agent or `/axiom:audit concurrency` (5-phase semantic audit: maps isolation architecture, detects 8 anti-patterns, reasons about missing concurrency patterns, correlates compound risks, scores Swift 6.3 readiness) + +## Decision Tree + +1. Data races / actor isolation / @MainActor / Sendable? → swift-concurrency +1a. Need specific API syntax (actor definition, TaskGroup, AsyncStream, continuation)? → swift-concurrency-ref +2. Writing async/await code? → swift-concurrency +3. Swift 6 migration? → swift-concurrency +4. assumeIsolated / @preconcurrency? → assume-isolated +5. Mutex / lock / synchronization? → synchronization +6. borrowing / consuming / ~Copyable? → ownership-conventions +7. Profile async performance / actor contention? → concurrency-profiling +8. Value type / ARC / generic optimization? → swift-performance +9. Want automated concurrency scan? → concurrency-auditor (Agent) +10. Combine / @Published / AnyCancellable / reactive streams? → combine-patterns + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "Just add @MainActor and it'll work" | @MainActor has isolation inheritance rules. swift-concurrency covers all patterns. | +| "I'll use nonisolated(unsafe) to silence the warning" | Silencing warnings hides data races. swift-concurrency shows the safe pattern. | +| "It's just one async call" | Even single async calls have cancellation and isolation implications. swift-concurrency covers them. | +| "I know how actors work" | Actor reentrancy and isolation rules changed in Swift 6.2. swift-concurrency is current. | +| "I'll fix the Sendable warnings later" | Sendable violations cause runtime crashes. swift-concurrency fixes them correctly now. | +| "Combine is dead, just use async/await" | Combine has no deprecation notice. Rewriting working pipelines wastes time and introduces bugs. combine-patterns covers incremental migration. | + +## Critical Patterns + +**Swift 6 Concurrency** (swift-concurrency): +- Progressive journey: single-threaded → async → concurrent → actors +- @concurrent attribute for forced background execution +- Isolated conformances +- Main actor mode for approachable concurrency +- 11 copy-paste patterns + +**Swift Performance** (swift-performance): +- ~Copyable for non-copyable types +- Copy-on-write (COW) patterns +- Value vs reference type decisions +- ARC overhead reduction +- Generic specialization + +## Example Invocations + +User: "I'm getting 'data race' errors in Swift 6" +→ Invoke: `/skill axiom-swift-concurrency` + +User: "How do I use @MainActor correctly?" +→ Invoke: `/skill axiom-swift-concurrency` + +User: "My app is slow due to unnecessary copying" +→ Invoke: `/skill axiom-swift-performance` + +User: "Should I use async/await for this network call?" +→ Invoke: `/skill axiom-swift-concurrency` + +User: "How do I use assumeIsolated?" +→ Invoke: `/skill axiom-assume-isolated` + +User: "My delegate callback runs on main thread, how do I access MainActor state?" +→ Invoke: `/skill axiom-assume-isolated` + +User: "Should I use Mutex or actor?" +→ Invoke: `/skill axiom-synchronization` + +User: "What's the difference between os_unfair_lock and OSAllocatedUnfairLock?" +→ Invoke: `/skill axiom-synchronization` + +User: "What does borrowing do in Swift?" +→ Invoke: `/skill axiom-ownership-conventions` + +User: "How do I use ~Copyable types?" +→ Invoke: `/skill axiom-ownership-conventions` + +User: "My async code is slow, how do I profile it?" +→ Invoke: `/skill axiom-concurrency-profiling` + +User: "I think I have actor contention, how do I diagnose it?" +→ Invoke: `/skill axiom-concurrency-profiling` + +User: "My Core Data saves lose data from background tasks" +→ Route to: `ios-data` router (Core Data threading is framework-specific) + +User: "How do I create a TaskGroup?" +→ Invoke: `/skill axiom-swift-concurrency-ref` + +User: "What's the AsyncStream API?" +→ Invoke: `/skill axiom-swift-concurrency-ref` + +User: "How do I create a custom global actor?" +→ Invoke: `/skill axiom-swift-concurrency-ref` + +User: "How do I convert a completion handler to async?" +→ Invoke: `/skill axiom-swift-concurrency-ref` + +User: "What are the actor reentrancy rules?" +→ Invoke: `/skill axiom-swift-concurrency-ref` + +User: "My Combine pipeline silently stopped producing values" +→ Invoke: `/skill axiom-combine-patterns` + +User: "Should I use Combine or async/await for this data flow?" +→ Invoke: `/skill axiom-combine-patterns` + +User: "How do I bridge a Combine publisher into async/await code?" +→ Invoke: `/skill axiom-combine-patterns` + +User: "AnyCancellable is leaking memory" +→ Invoke: `/skill axiom-combine-patterns` + +User: "Check my code for Swift 6 concurrency issues" +→ Invoke: `concurrency-auditor` agent diff --git a/.claude/skills/axiom-ios-data/.openskills.json b/.claude/skills/axiom-ios-data/.openskills.json new file mode 100644 index 0000000..6542c37 --- /dev/null +++ b/.claude/skills/axiom-ios-data/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-data", + "installedAt": "2026-04-12T08:05:35.620Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-ios-data/SKILL.md b/.claude/skills/axiom-ios-data/SKILL.md new file mode 100644 index 0000000..106914b --- /dev/null +++ b/.claude/skills/axiom-ios-data/SKILL.md @@ -0,0 +1,186 @@ +--- +name: axiom-ios-data +description: Use when working with ANY data persistence, database, axiom-storage, CloudKit, migration, or serialization. Covers SwiftData, Core Data, GRDB, SQLite, CloudKit sync, file storage, Codable, migrations. +license: MIT +--- + +# iOS Data & Persistence Router + +**You MUST use this skill for ANY data persistence, database, axiom-storage, CloudKit, or serialization work.** + +## When to Use + +Use this router when working with: +- Databases (SwiftData, Core Data, GRDB, SQLiteData) +- Schema migrations +- CloudKit sync +- File storage (iCloud Drive, local storage) +- Data serialization (Codable, JSON) +- Storage strategy decisions +- Keychain / secure credential storage +- Encryption, signing, key management (CryptoKit) + +## Routing Logic + +### SwiftData + +**Working with SwiftData** → `/skill axiom-swiftdata` +**Schema migration** → `/skill axiom-swiftdata-migration` +**Migration issues** → `/skill axiom-swiftdata-migration-diag` +**Migrating from Realm** → `/skill axiom-realm-migration-ref` +**SwiftData vs SQLiteData** → `/skill axiom-sqlitedata-migration` + +### Other Databases + +**GRDB queries** → `/skill axiom-grdb` +**SQLiteData** → `/skill axiom-sqlitedata` +**Advanced SQLiteData** → `/skill axiom-sqlitedata-ref` +**Core Data patterns** → `/skill axiom-core-data` +**Core Data issues** → `/skill axiom-core-data-diag` + +### Migrations + +**Database migration safety** → `/skill axiom-database-migration` (critical - prevents data loss) + +### Serialization + +**Codable issues** → `/skill axiom-codable` + +### Cloud Storage + +**Cloud sync patterns** → `/skill axiom-cloud-sync` +**CloudKit** → `/skill axiom-cloudkit-ref` +**iCloud Drive** → `/skill axiom-icloud-drive-ref` +**Cloud sync errors** → `/skill axiom-cloud-sync-diag` + +### Keychain & Encryption + +**Keychain / secure credential storage** → `/skill axiom-keychain` +**Keychain errors** → `/skill axiom-keychain-diag` +**Keychain API reference** → `/skill axiom-keychain-ref` +**Encryption / signing / key management** → `/skill axiom-cryptokit` +**CryptoKit API reference** → `/skill axiom-cryptokit-ref` + +### File Storage + +**Storage strategy** → `/skill axiom-storage` +**Storage issues** → `/skill axiom-storage-diag` +**Storage management** → `/skill axiom-storage-management-ref` +**File protection** → `/skill axiom-file-protection-ref` + +### tvOS Storage + +**tvOS data persistence** → `/skill axiom-tvos` (CRITICAL: no persistent local storage on tvOS) +**tvOS + CloudKit** → `/skill axiom-sqlitedata` (recommended: SyncEngine as persistent store) + +### Automated Scanning + +**Core Data audit** → Launch `core-data-auditor` agent or `/axiom:audit core-data` (migration risks, thread-confinement, N+1 queries, production data loss) +**Codable audit** → Launch `codable-auditor` agent or `/axiom:audit codable` (try? swallowing errors, JSONSerialization, date handling) +**iCloud audit** → Launch `icloud-auditor` agent or `/axiom:audit icloud` (entitlement checks, file coordination, CloudKit anti-patterns) +**Storage audit** → Launch `storage-auditor` agent or `/axiom:audit storage` (wrong file locations, missing backup exclusions, data loss risks) +**Database schema audit** → Launch `database-schema-auditor` agent or `/axiom:audit database-schema` (unsafe ALTER TABLE, DROP operations, missing idempotency, foreign key misuse) +**SwiftData audit** → Launch `swiftdata-auditor` agent or `/axiom:audit swiftdata` (struct models, missing VersionedSchema, relationship defaults, background context misuse, N+1 patterns) + +## Decision Tree + +1. SwiftData? → swiftdata, swiftdata-migration +2. Core Data? → core-data, core-data-diag +3. GRDB? → grdb +4. SQLiteData? → sqlitedata, sqlitedata-ref +5. ANY schema migration? → database-migration (ALWAYS — prevents data loss) +6. Realm migration? → realm-migration-ref +7. SwiftData vs SQLiteData? → sqlitedata-migration +8. Cloud sync architecture? → cloud-sync +9. CloudKit? → cloudkit-ref +10. iCloud Drive? → icloud-drive-ref +11. Cloud sync errors? → cloud-sync-diag +12. Codable/JSON serialization? → codable +13. File storage strategy? → storage, storage-diag, storage-management-ref +14. File protection? → file-protection-ref +15. Keychain / storing tokens, passwords, secrets securely? → keychain, keychain-diag, keychain-ref +16. SecItem errors (errSecDuplicateItem, errSecItemNotFound, errSecInteractionNotAllowed)? → keychain-diag +17. Encryption, signing, Secure Enclave, CryptoKit? → cryptokit, cryptokit-ref +18. Quantum-secure cryptography, HPKE, ML-KEM? → cryptokit +19. Want Core Data safety scan? → core-data-auditor (Agent) +16. Want Codable anti-pattern scan? → codable-auditor (Agent) +17. Want iCloud sync audit? → icloud-auditor (Agent) +18. Want storage location audit? → storage-auditor (Agent) +19. Want database schema/migration safety scan? → database-schema-auditor (Agent) +20. Want SwiftData code audit? → swiftdata-auditor (Agent) +21. tvOS data persistence? → axiom-tvos (CRITICAL: no persistent local storage) + axiom-sqlitedata (CloudKit SyncEngine) + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "Just adding a column, no migration needed" | Schema changes without migration crash users. database-migration prevents data loss. | +| "I'll handle the migration manually" | Manual migrations miss edge cases. database-migration covers rollback and testing. | +| "Simple query, I don't need the skill" | Query patterns prevent N+1 and thread-safety issues. The skill has copy-paste solutions. | +| "CloudKit sync is straightforward" | CloudKit has 15+ failure modes. cloud-sync-diag diagnoses them systematically. | +| "I know Codable well enough" | Codable has silent data loss traps (try? swallows errors). codable skill prevents production bugs. | +| "I'll use local storage on tvOS" | tvOS has NO persistent local storage. System deletes Caches at any time. axiom-tvos explains the iCloud-first pattern. | +| "UserDefaults is fine for this token" | UserDefaults is unencrypted, backed up to iCloud, and visible to MDM profiles. One audit catches it. keychain stores tokens securely. | +| "I'll encrypt it myself with CommonCrypto" | CryptoKit replaced CommonCrypto's buffer-management nightmares with one-line APIs. cryptokit prevents misuse. | + +## Critical Pattern: Migrations + +**ALWAYS invoke `/skill axiom-database-migration` when adding/modifying database columns.** + +This prevents: +- "FOREIGN KEY constraint failed" errors +- "no such column" crashes +- Data loss from unsafe migrations + +## Example Invocations + +User: "I need to add a column to my SwiftData model" +→ Invoke: `/skill axiom-database-migration` (critical - prevents data loss) + +User: "How do I query SwiftData with complex filters?" +→ Invoke: `/skill axiom-swiftdata` + +User: "CloudKit sync isn't working" +→ Invoke: `/skill axiom-cloud-sync-diag` + +User: "Should I use SwiftData or SQLiteData?" +→ Invoke: `/skill axiom-sqlitedata-migration` + +User: "Check my Core Data code for safety issues" +→ Invoke: `core-data-auditor` agent + +User: "Scan for Codable anti-patterns before release" +→ Invoke: `codable-auditor` agent + +User: "Audit my iCloud sync implementation" +→ Invoke: `icloud-auditor` agent + +User: "Check if my files are stored in the right locations" +→ Invoke: `storage-auditor` agent + +User: "Audit my database migrations for safety" +→ Invoke: `database-schema-auditor` agent + +User: "Check my SwiftData models for issues" +→ Invoke: `swiftdata-auditor` agent + +User: "How do I persist data on tvOS?" +→ Invoke: `/skill axiom-tvos` + `/skill axiom-sqlitedata` + +User: "My tvOS app loses data between launches" +→ Invoke: `/skill axiom-tvos` + +User: "How do I store an auth token securely?" +→ Invoke: `/skill axiom-keychain` + +User: "errSecDuplicateItem but I checked and the item doesn't exist" +→ Invoke: `/skill axiom-keychain-diag` + +User: "How do I encrypt data with AES in Swift?" +→ Invoke: `/skill axiom-cryptokit` + +User: "I need to sign data with the Secure Enclave" +→ Invoke: `/skill axiom-cryptokit` + +User: "What's ML-KEM and should I use it?" +→ Invoke: `/skill axiom-cryptokit` diff --git a/.claude/skills/axiom-ios-games/.openskills.json b/.claude/skills/axiom-ios-games/.openskills.json new file mode 100644 index 0000000..6fbb364 --- /dev/null +++ b/.claude/skills/axiom-ios-games/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-games", + "installedAt": "2026-04-12T08:05:35.620Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-ios-games/SKILL.md b/.claude/skills/axiom-ios-games/SKILL.md new file mode 100644 index 0000000..1fd7993 --- /dev/null +++ b/.claude/skills/axiom-ios-games/SKILL.md @@ -0,0 +1,288 @@ +--- +name: axiom-ios-games +description: Use when building ANY 2D or 3D game, game prototype, or interactive simulation with SpriteKit, SceneKit, or RealityKit. Covers scene graphs, ECS architecture, physics, actions, game loops, rendering, SwiftUI integration, SceneKit migration. +license: MIT +--- + +# iOS Games Router + +**You MUST use this skill for ANY game development, SpriteKit, SceneKit, RealityKit, or interactive simulation work.** + +## When to Use + +Use this router when: +- Building a new SpriteKit game or prototype (2D) +- Building a 3D game with SceneKit or RealityKit +- Implementing physics (collisions, contacts, forces, joints) +- Setting up game architecture (scenes, layers, cameras, ECS) +- Debugging SpriteKit, SceneKit, or RealityKit issues +- Optimizing game performance (draw calls, node counts, entity counts, batching) +- Managing game loop, delta time, or pause handling +- Implementing touch/input handling in a game context +- Integrating SpriteKit or RealityKit with SwiftUI +- Working with particle effects, texture atlases, or 3D models +- Looking up SpriteKit, SceneKit, or RealityKit API details +- Migrating from SceneKit to RealityKit +- Building AR games with RealityKit + +## Routing Logic + +### SpriteKit (2D) + +**Architecture, patterns, and best practices** → `/skill axiom-spritekit` +- Scene graph model, coordinate systems, anchor points +- Physics engine: bitmask discipline, contact detection, body types +- Actions system: sequencing, grouping, named actions, timing +- Input handling: touches, coordinate conversion +- Performance: draw calls, batching, object pooling, SKShapeNode trap +- Game loop: frame cycle, delta time, pause handling +- Scene transitions and data passing +- SwiftUI integration (SpriteView, UIViewRepresentable) +- Metal integration (SKRenderer) +- Anti-patterns and code review checklist +- Pressure scenarios with push-back templates + +**API reference and lookup** → `/skill axiom-spritekit-ref` +- All 16 node types with properties and performance notes +- SKPhysicsBody creation methods and properties +- Complete SKAction catalog (movement, rotation, scaling, fading, composition, physics) +- Texture and atlas management +- SKConstraint types and SKRange +- SKView configuration and scale modes +- SKEmitterNode properties and presets +- SKRenderer setup and SKShader syntax + +**Troubleshooting and diagnostics** → `/skill axiom-spritekit-diag` +- Physics contacts not firing (6-branch decision tree) +- Objects tunneling through walls (5-branch) +- Poor frame rate (4 top branches, 12 leaves) +- Touches not registering (6-branch) +- Memory spikes and crashes (5-branch) +- Coordinate confusion (5-branch) +- Scene transition crashes (5-branch) + +**Automated scanning** → Launch `spritekit-auditor` agent or `/axiom:audit spritekit` (physics bitmasks, draw call waste, node accumulation, action memory leaks, coordinate confusion, touch handling, missing object pooling, missing debug overlays) + +### SceneKit (3D — Deprecated) + +**SceneKit is soft-deprecated as of iOS 26.** Use for maintenance of existing code only. New 3D projects should use RealityKit. + +**Maintenance and migration planning** → `/skill axiom-scenekit` +- Scene graph architecture, coordinate system, transforms +- Rendering: SCNView, SceneView (deprecated), SCNViewRepresentable +- Geometry, PBR materials, shader modifiers +- Lighting, animation (SCNAction, SCNTransaction, CAAnimation bridge) +- Physics: bodies, collision categories, contact delegate +- Asset pipeline: Model I/O, USD/DAE/SCN formats +- ARKit integration (legacy ARSCNView) +- Migration decision tree (when to migrate vs maintain) +- Anti-patterns and pressure scenarios + +**API reference and migration mapping** → `/skill axiom-scenekit-ref` +- Complete SceneKit → RealityKit concept mapping table +- Scene graph API: SCNScene, SCNNode, SCNGeometry +- Materials: lighting models, PBR properties, shader modifiers +- Lighting: all light types with properties +- Camera: SCNCamera properties +- Physics: body types, shapes, joints +- Animation: SCNAction catalog, timing functions +- Constraints: all constraint types + +### RealityKit (3D — Modern) + +**For non-game 3D content display (product viewers, AR try-on, spatial computing), the ios-graphics router also routes to these RealityKit skills.** + +**Architecture, ECS, and best practices** → `/skill axiom-realitykit` +- Entity-Component-System mental model and paradigm shift +- Entity hierarchy, transforms, world-space queries +- Built-in and custom components, component lifecycle +- System protocol, update ordering, event handling +- SwiftUI integration: RealityView, Model3D, attachments +- AR on iOS: AnchorEntity types, SpatialTrackingSession +- Interaction: ManipulationComponent, gestures, hit testing +- Materials: SimpleMaterial, PBR, Unlit, Occlusion, ShaderGraph, Custom +- Physics: collision shapes, groups/filters, events, forces +- Animation: transform, USD playback, playback control +- Audio: spatial, ambient, channel +- Performance: instancing, component churn, shape optimization +- Multiplayer: synchronization, ownership +- Anti-patterns and code review checklist +- Pressure scenarios + +**API reference and lookup** → `/skill axiom-realitykit-ref` +- Entity API: properties, hierarchy, subclasses +- Complete component catalog with all properties +- MeshResource generators +- ShapeResource types and performance +- System protocol and EntityQuery +- Scene events catalog +- RealityView API: initializers, content, gestures +- Model3D API +- Material system: all types with full property listings +- Animation timing functions and playback control +- Audio components and playback +- RealityRenderer (Metal integration) + +**Troubleshooting and diagnostics** → `/skill axiom-realitykit-diag` +- Entity not visible (8-branch decision tree) +- Anchor not tracking (6-branch) +- Gesture not responding (6-branch) +- Performance problems (7-branch) +- Material looks wrong (6-branch) +- Physics not working (6-branch) +- Multiplayer sync issues (5-branch) + +## Decision Tree + +1. Building/designing a 2D SpriteKit game? → axiom-spritekit +2. How to use a specific SpriteKit API? → axiom-spritekit-ref +3. SpriteKit broken or performing badly? → axiom-spritekit-diag +4. Maintaining existing SceneKit code? → axiom-scenekit +5. SceneKit API reference or migration mapping? → axiom-scenekit-ref +6. Building new 3D game or experience? → axiom-realitykit +7. How to use a specific RealityKit API? → axiom-realitykit-ref +8. RealityKit entity not visible, gestures broken, performance? → axiom-realitykit-diag +9. Migrating SceneKit to RealityKit? → axiom-scenekit (migration tree) + axiom-scenekit-ref (mapping table) +10. Building AR game? → axiom-realitykit +11. Physics contacts not working (SpriteKit)? → axiom-spritekit-diag (Symptom 1) +12. Frame rate dropping (SpriteKit)? → axiom-spritekit-diag (Symptom 3) +13. Coordinate/position confusion (SpriteKit)? → axiom-spritekit-diag (Symptom 6) +14. Need the complete action list? → axiom-spritekit-ref (Part 3) +15. Physics body setup reference? → axiom-spritekit-ref (Part 2) +16. Entity not visible (RealityKit)? → axiom-realitykit-diag (Symptom 1) +17. Gesture not responding (RealityKit)? → axiom-realitykit-diag (Symptom 3) +18. Want automated SpriteKit code scan? → spritekit-auditor (Agent) + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "SpriteKit is simple, I don't need a skill" | Physics bitmasks default to 0xFFFFFFFF and cause phantom collisions. The bitmask checklist catches this in 2 min. | +| "I'll just use SKShapeNode, it's quick" | Each SKShapeNode is a separate draw call. 50 of them = 50 draw calls. axiom-spritekit has the pre-render-to-texture pattern. | +| "I can figure out the coordinate system" | SpriteKit uses bottom-left origin (opposite of UIKit). Anchor points add another layer. axiom-spritekit-diag Symptom 6 resolves in 5 min. | +| "Physics is straightforward" | Three different bitmask properties, modification rules inside callbacks, and tunneling edge cases. axiom-spritekit Section 3 covers all gotchas. | +| "The performance is fine on my device" | Performance varies dramatically across devices. axiom-spritekit Section 6 has the debug overlay checklist. | +| "SceneKit is fine for our new project" | SceneKit is soft-deprecated iOS 26. No new features, only security patches. axiom-scenekit has the migration decision tree. | +| "I'll learn RealityKit later" | Every line of SceneKit is migration debt. axiom-scenekit-ref has the concept mapping table so the transition is concrete, not abstract. | +| "ECS is overkill for a simple 3D app" | You're already using ECS — Entity + ModelComponent. axiom-realitykit shows how to scale from simple to complex. | +| "I don't need collision shapes for taps" | RealityKit gestures require CollisionComponent. axiom-realitykit-diag diagnoses this in 2 min vs 30 min guessing. | +| "I'll just use a Timer for game updates" | Timer-based updates miss frames and aren't synchronized with rendering. axiom-realitykit has the System pattern. | + +## Critical Patterns + +**axiom-spritekit**: +- PhysicsCategory struct with explicit bitmasks (default `0xFFFFFFFF` causes phantom collisions) +- Camera node pattern for viewport + HUD separation +- SKShapeNode pre-render-to-texture conversion +- `[weak self]` in all `SKAction.run` closures +- Delta time with spiral-of-death clamping + +**axiom-spritekit-ref**: +- Complete node type table (16 types with batching behavior) +- Physics body creation methods (circle cheapest, texture most expensive) +- Full action catalog with composition patterns +- SKView debug overlays and scale mode matrix + +**axiom-spritekit-diag**: +- 5-step bitmask checklist (2 min vs 30-120 min guessing) +- Debug overlays as mandatory first diagnostic step +- Tunneling prevention flowchart +- Memory growth diagnosis via `showsNodeCount` trending + +**axiom-scenekit**: +- Migration decision tree (new project → RealityKit, existing → maintain or migrate) +- USDZ asset conversion before migration (`xcrun scntool`) +- SceneView deprecation and SCNViewRepresentable replacement +- Pressure scenarios for "just use SceneKit" rationalization + +**axiom-scenekit-ref**: +- Complete SceneKit → RealityKit concept mapping table +- All material lighting models and properties +- Full constraint catalog + +**axiom-realitykit**: +- ECS mental shift table (scene graph thinking → ECS thinking) +- Custom component registration (`registerComponent()`) +- Read-modify-write pattern for component updates +- CollisionComponent required for all interaction +- System-based updates instead of timers + +**axiom-realitykit-ref**: +- Complete component catalog with all properties +- MeshResource generators and ShapeResource types +- Scene events catalog +- Material system with all PBR properties + +**axiom-realitykit-diag**: +- Entity visibility checklist (8 branches, 2-5 min vs 30-60 min) +- Gesture debugging (CollisionComponent first check) +- Performance diagnosis (entity count, resource sharing, component churn) +- Physics constraint: entities must share an anchor + +## Example Invocations + +User: "I'm building a SpriteKit game" +→ Invoke: `/skill axiom-spritekit` + +User: "My physics contacts aren't firing" +→ Invoke: `/skill axiom-spritekit-diag` + +User: "How do I create a physics body from a texture?" +→ Invoke: `/skill axiom-spritekit-ref` + +User: "Frame rate is dropping in my game" +→ Invoke: `/skill axiom-spritekit-diag` + +User: "How do I set up SpriteKit with SwiftUI?" +→ Invoke: `/skill axiom-spritekit` + +User: "What action types are available?" +→ Invoke: `/skill axiom-spritekit-ref` + +User: "Objects pass through walls" +→ Invoke: `/skill axiom-spritekit-diag` + +User: "I need to build a 3D game" +→ Invoke: `/skill axiom-realitykit` + +User: "How do I add a 3D model to my SwiftUI app?" +→ Invoke: `/skill axiom-realitykit` + +User: "My RealityKit entity isn't showing up" +→ Invoke: `/skill axiom-realitykit-diag` + +User: "How do I set up physics in RealityKit?" +→ Invoke: `/skill axiom-realitykit-ref` + +User: "I'm migrating from SceneKit to RealityKit" +→ Invoke: `/skill axiom-scenekit` + `/skill axiom-scenekit-ref` + +User: "What's the RealityKit equivalent of SCNNode?" +→ Invoke: `/skill axiom-scenekit-ref` + +User: "Should I use SceneKit for my new 3D project?" +→ Invoke: `/skill axiom-scenekit` + +User: "Tap gestures don't work on my RealityKit entity" +→ Invoke: `/skill axiom-realitykit-diag` + +User: "How do I set up ECS in RealityKit?" +→ Invoke: `/skill axiom-realitykit` + +User: "My AR content isn't tracking" +→ Invoke: `/skill axiom-realitykit-diag` + +User: "What materials are available in RealityKit?" +→ Invoke: `/skill axiom-realitykit-ref` + +User: "How do I animate entities in RealityKit?" +→ Invoke: `/skill axiom-realitykit-ref` + +User: "Memory keeps growing during gameplay" +→ Invoke: `/skill axiom-spritekit-diag` + +User: "What particle emitter settings should I use for fire?" +→ Invoke: `/skill axiom-spritekit-ref` + +User: "Can you scan my SpriteKit code for common issues?" +→ Invoke: `spritekit-auditor` agent diff --git a/.claude/skills/axiom-ios-graphics/.openskills.json b/.claude/skills/axiom-ios-graphics/.openskills.json new file mode 100644 index 0000000..81d5408 --- /dev/null +++ b/.claude/skills/axiom-ios-graphics/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-graphics", + "installedAt": "2026-04-12T08:05:35.621Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-ios-graphics/SKILL.md b/.claude/skills/axiom-ios-graphics/SKILL.md new file mode 100644 index 0000000..971573b --- /dev/null +++ b/.claude/skills/axiom-ios-graphics/SKILL.md @@ -0,0 +1,196 @@ +--- +name: axiom-ios-graphics +description: Use when working with ANY GPU rendering, Metal, OpenGL migration, shaders, 3D content, RealityKit, AR, or display performance. Covers Metal migration, shader conversion, RealityKit ECS, RealityView, variable refresh rate, ProMotion. +license: MIT +--- + +# iOS Graphics Router + +**You MUST use this skill for ANY GPU rendering, graphics programming, 3D content display, or display performance work.** + +## When to Use + +Use this router when: +- Porting OpenGL/OpenGL ES code to Metal +- Porting DirectX code to Metal +- Converting GLSL/HLSL shaders to Metal Shading Language +- Setting up MTKView or CAMetalLayer +- Debugging GPU rendering issues (black screen, wrong colors, crashes) +- Evaluating translation layers (MetalANGLE, MoltenVK) +- Optimizing GPU performance or fixing thermal throttling +- App stuck at 60fps on ProMotion device +- Configuring CADisplayLink or render loops +- Variable refresh rate display issues +- Displaying 3D content in a non-game SwiftUI app +- Building AR experiences with RealityKit +- Using RealityView or Model3D in SwiftUI +- Spatial computing or visionOS 3D content + +## Routing Logic + +### Metal Migration + +**Strategy decisions** → `/skill axiom-metal-migration` +- Translation layer vs native rewrite decision +- Project assessment and migration planning +- Anti-patterns and common mistakes +- Pressure scenarios for deadline resistance + +**API reference & conversion** → `/skill axiom-metal-migration-ref` +- GLSL → MSL shader conversion tables +- HLSL → MSL shader conversion tables +- GL/D3D API → Metal API equivalents +- MTKView setup, render pipelines, compute shaders +- Complete WWDC code examples + +**Diagnostics** → `/skill axiom-metal-migration-diag` +- Black screen after porting +- Shader compilation errors +- Wrong colors or coordinate systems +- Performance regressions +- Time-cost analysis per diagnostic path + +### Display Performance + +**Frame rate & render loops** → `/skill axiom-display-performance` +- App stuck at 60fps on ProMotion (120Hz) device +- MTKView or CADisplayLink configuration +- Variable refresh rate optimization +- System caps (Low Power Mode, Limit Frame Rate, Thermal, Adaptive Power) +- Frame budget math (8.33ms for 120Hz) +- Measuring actual vs reported frame rate + +### RealityKit (Non-Game 3D Content) + +For 3D content in non-game SwiftUI apps, AR experiences, and spatial computing, use the RealityKit skills. **For game-specific RealityKit patterns, use the ios-games router instead.** + +**Architecture, ECS, and best practices** → `/skill axiom-realitykit` +- Entity-Component-System architecture +- SwiftUI integration: RealityView, Model3D, attachments +- AR on iOS: AnchorEntity types, SpatialTrackingSession +- Materials, physics, interaction +- Performance optimization + +**API reference** → `/skill axiom-realitykit-ref` +- Complete component catalog +- RealityView and Model3D API +- Material system (PBR, Unlit, Occlusion, Custom) +- RealityRenderer (Metal integration) + +**Troubleshooting** → `/skill axiom-realitykit-diag` +- Entity not visible, anchor not tracking +- Gesture not responding, performance issues +- Material problems, physics issues + +## Decision Tree + +1. Translation layer vs native rewrite? → metal-migration +2. Porting / converting code to Metal? → metal-migration +3. API reference / shader conversion tables? → metal-migration-ref +4. MTKView / render pipeline setup? → metal-migration-ref +5. Something broken after porting (black screen, wrong colors)? → metal-migration-diag +6. Stuck at 60fps on ProMotion device? → display-performance +7. CADisplayLink / variable refresh rate? → display-performance +8. Frame rate not as expected? → display-performance +9. Display a 3D model in SwiftUI? → axiom-realitykit +10. Build an AR experience? → axiom-realitykit +11. RealityView or Model3D setup? → axiom-realitykit-ref +12. 3D content not visible or not tracking? → axiom-realitykit-diag +13. Custom Metal rendering of RealityKit content? → axiom-realitykit-ref (RealityRenderer) +14. Building a 3D game? → Use ios-games router instead + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "I'll just translate the shaders line by line" | GLSL→MSL has type, coordinate, and precision differences. metal-migration-ref has conversion tables. | +| "MetalANGLE will handle everything" | Translation layers have significant limitations for production. metal-migration evaluates the trade-offs. | +| "It's just a black screen, probably a simple bug" | Black screen has 6 distinct causes. metal-migration-diag diagnoses in 5 min vs 30+ min. | +| "My app runs at 60fps, that's fine" | ProMotion devices support 120Hz. display-performance configures the correct frame rate. | +| "I'll just use SceneKit for the 3D model" | SceneKit is soft-deprecated. RealityView and Model3D are the modern path. axiom-realitykit covers SwiftUI integration. | +| "I don't need ECS for one 3D model" | Model3D shows one model with zero ECS. RealityView scales to complex scenes. axiom-realitykit shows both paths. | + +## Critical Patterns + +**metal-migration**: +- Translation layer (MetalANGLE) for quick demos +- Native Metal rewrite for production +- State management differences (GL stateful → Metal explicit) +- Coordinate system gotchas (Y-flip, NDC differences) + +**metal-migration-ref**: +- Complete shader type mappings +- API equivalent tables +- MTKView vs CAMetalLayer decision +- Render pipeline setup patterns + +**metal-migration-diag**: +- GPU Frame Capture workflow (2-5 min vs 30+ min guessing) +- Shader debugger for variable inspection +- Metal validation layer for API misuse +- Performance regression diagnosis + +**display-performance**: +- MTKView defaults to 60fps (must set preferredFramesPerSecond = 120) +- CADisplayLink preferredFrameRateRange for explicit rate control +- System caps: Low Power Mode, Limit Frame Rate, Thermal, Adaptive Power (iOS 26) +- 8.33ms frame budget for 120Hz +- UIScreen.maximumFramesPerSecond lies; CADisplayLink tells truth + +**axiom-realitykit** (non-game 3D): +- RealityView make/update closure pattern +- Model3D for simple model display +- AR anchoring with AnchorEntity +- Material selection (SimpleMaterial, PBR, Occlusion) + +**axiom-realitykit-ref** (API): +- RealityRenderer for custom Metal rendering of RealityKit content +- Complete material property reference +- RealityView gesture integration + +## Example Invocations + +User: "Should I use MetalANGLE or rewrite in native Metal?" +→ Invoke: `/skill axiom-metal-migration` + +User: "I'm porting projectM from OpenGL ES to iOS" +→ Invoke: `/skill axiom-metal-migration` + +User: "How do I convert this GLSL shader to Metal?" +→ Invoke: `/skill axiom-metal-migration-ref` + +User: "Setting up MTKView for the first time" +→ Invoke: `/skill axiom-metal-migration-ref` + +User: "My ported app shows a black screen" +→ Invoke: `/skill axiom-metal-migration-diag` + +User: "Performance is worse after porting to Metal" +→ Invoke: `/skill axiom-metal-migration-diag` + +User: "My app is stuck at 60fps on iPhone Pro" +→ Invoke: `/skill axiom-display-performance` + +User: "How do I configure CADisplayLink for 120Hz?" +→ Invoke: `/skill axiom-display-performance` + +User: "ProMotion not working in my Metal app" +→ Invoke: `/skill axiom-display-performance` + +User: "How do I show a 3D model in my SwiftUI app?" +→ Invoke: `/skill axiom-realitykit` + +User: "I need to display a USDZ model" +→ Invoke: `/skill axiom-realitykit` + +User: "How do I set up RealityView?" +→ Invoke: `/skill axiom-realitykit-ref` + +User: "My 3D model isn't showing in RealityView" +→ Invoke: `/skill axiom-realitykit-diag` + +User: "How do I use RealityRenderer with Metal?" +→ Invoke: `/skill axiom-realitykit-ref` + +User: "I need AR in my app" +→ Invoke: `/skill axiom-realitykit` diff --git a/.claude/skills/axiom-ios-integration/.openskills.json b/.claude/skills/axiom-ios-integration/.openskills.json new file mode 100644 index 0000000..7c425c7 --- /dev/null +++ b/.claude/skills/axiom-ios-integration/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-integration", + "installedAt": "2026-04-12T08:05:35.622Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-ios-integration/SKILL.md b/.claude/skills/axiom-ios-integration/SKILL.md new file mode 100644 index 0000000..e7c9878 --- /dev/null +++ b/.claude/skills/axiom-ios-integration/SKILL.md @@ -0,0 +1,434 @@ +--- +name: axiom-ios-integration +description: Use when integrating ANY iOS system feature - Siri, Shortcuts, widgets, IAP, camera, photo library, audio, ShazamKit, haptics, localization, privacy, alarms, calendar, reminders, contacts. Covers App Intents, WidgetKit, StoreKit, AVFoundation, ShazamKit, Core Haptics, Spotlight, EventKit, Contacts. +license: MIT +--- + +# iOS System Integration Router + +**You MUST use this skill for ANY iOS system integration including Siri, Shortcuts, widgets, in-app purchases, camera, photo library, audio, axiom-haptics, and more.** + +## When to Use + +Use this router for: +- Siri & Shortcuts (App Intents) +- Apple Intelligence integration +- Widgets & Live Activities +- In-app purchases (StoreKit) +- Camera capture (AVCaptureSession) +- Photo library & pickers (PHPicker, PhotosPicker) +- Audio, haptics, & audio recognition (ShazamKit) +- Localization +- Privacy & permissions +- Spotlight search +- App discoverability +- Alarms (AlarmKit) +- Background processing (BGTaskScheduler) +- Location services (Core Location) +- Maps & MapKit (Map, MKMapView, annotations, search, directions) +- Passkeys & authentication (ASAuthorizationController, WebAuthn) +- App integrity & fraud prevention (App Attest, DeviceCheck) +- Calendar events & reminders (EventKit, EventKitUI) +- Contacts & contact pickers (Contacts, ContactsUI, ContactProvider) + +## Cross-Domain Routing + +When integration issues overlap with other domains: + +**Widget + data sync issues** (widget not showing updated data): +- Widget timeline not refreshing → **stay in ios-integration** (extensions-widgets) +- SwiftData/Core Data not shared with extension → **also invoke ios-data** — App Groups and shared containers are data-layer concerns +- Background refresh timing → **also invoke ios-concurrency** if async patterns are involved + +**Live Activity + push notification issues**: +- ActivityKit push token setup, Live Activity not updating → **stay in ios-integration** (extensions-widgets) +- Push notification delivery failures, APNs errors → **also invoke ios-networking** (networking-diag) +- Entitlements/certificates misconfigured → **also invoke ios-build** (xcode-debugging) + +**Camera + permissions + privacy**: +- Camera code issues → **stay in ios-integration** (camera-capture) +- Privacy manifest or Info.plist issues → **stay in ios-integration** (privacy-ux) +- Build/entitlement errors → **also invoke ios-build** + +**MapKit + location issues** (user location not showing on map): +- Map display, annotations, search → **stay in ios-integration** (mapkit) +- Location authorization, monitoring, background location → **also invoke ios-performance** or **ios-integration** (core-location) +- Map performance with many annotations → **also invoke ios-performance** if profiling needed + +**Push notification + Live Activity issues** (push not updating Live Activity): +- Push transport, APNs headers, token management → **stay in ios-integration** (push-notifications, push-notifications-diag) +- ActivityKit UI, attributes, Dynamic Island → **also invoke ios-integration** (extensions-widgets) +- Background execution timing → **also invoke ios-concurrency** if async patterns are involved + +**Push notification + background processing** (silent push not triggering background work): +- Push payload and delivery → **stay in ios-integration** (push-notifications-diag) +- Background execution, BGTaskScheduler → **also invoke ios-integration** (background-processing) + +**Calendar + data sync issues** (events not syncing, stale calendar data): +- EventKit store changes, EKEventStoreChanged → **stay in ios-integration** (eventkit) +- Shared data with widget via App Groups → **also invoke ios-data** for shared container patterns +- Background refresh for calendar sync → **also invoke ios-integration** (background-processing) + +**Contacts + privacy issues** (contact access denied, limited access confusion): +- Contact permission model, Contact Access Button → **stay in ios-integration** (contacts) +- Privacy manifest or Info.plist for contacts → **stay in ios-integration** (privacy-ux) +- Contact Provider extension architecture → **also invoke ios-build** if extension target issues + +**ShazamKit + microphone permissions** (no match results, permission denied): +- Microphone NSMicrophoneUsageDescription / Info.plist → **stay in ios-integration** (privacy-ux) +- ShazamKit App Service not enabled → **stay in ios-integration** (shazamkit) + +**ShazamKit + AVFoundation** (custom SHSession with AVAudioEngine buffers): +- Audio engine setup, buffer formats, session configuration → **stay in ios-integration** (shazamkit) +- AVAudioEngine pipeline, audio session category, format conversion → **also invoke avfoundation-ref** + +**ShazamKit + MusicKit** (play matched song via Apple Music): +- Match result with appleMusicID/appleMusicURL → **stay in ios-integration** (shazamkit) +- MusicKit playback, ApplicationMusicPlayer → **also invoke ios-integration** (now-playing-musickit) + +## Routing Logic + +### Apple Intelligence & Siri + +**App Intents** → `/skill axiom-app-intents-ref` +**App Shortcuts** → `/skill axiom-app-shortcuts-ref` +**App discoverability** → `/skill axiom-app-discoverability` +**Core Spotlight** → `/skill axiom-core-spotlight-ref` + +### Widgets & Extensions + +**Widgets/Live Activities** → `/skill axiom-extensions-widgets` +**Widget reference** → `/skill axiom-extensions-widgets-ref` + +### In-App Purchases + +**IAP implementation** → `/skill axiom-in-app-purchases` +**StoreKit 2 reference** → `/skill axiom-storekit-ref` +**IAP audit** → Launch `iap-auditor` agent (missing transaction.finish(), weak receipt validation, missing restore, subscription tracking) +**IAP full implementation** → Launch `iap-implementation` agent (StoreKit config, StoreManager, transaction handling, restore purchases) + +### Camera & Photos + +**Camera capture implementation** → `/skill axiom-camera-capture` +**Camera API reference** → `/skill axiom-camera-capture-ref` +**Camera debugging** → `/skill axiom-camera-capture-diag` +**Camera audit** → Launch `camera-auditor` agent or `/axiom:audit camera` (deprecated APIs, missing interruption handlers, threading violations, permission anti-patterns) +**Photo pickers & library** → `/skill axiom-photo-library` +**Photo library API reference** → `/skill axiom-photo-library-ref` + +### Audio, Haptics & Audio Recognition + +**Audio (AVFoundation)** → `/skill axiom-avfoundation-ref` +**Haptics** → `/skill axiom-haptics` +**Now Playing** → `/skill axiom-now-playing` +**CarPlay Now Playing** → `/skill axiom-now-playing-carplay` +**MusicKit integration** → `/skill axiom-now-playing-musickit` + +**ShazamKit implementation** → `/skill axiom-shazamkit` +- Song recognition (Shazam catalog), custom audio matching +- SHManagedSession (modern) vs SHSession (legacy) decision +- Custom catalogs, signature generation, Shazam CLI +- Library management (SHLibrary) + +**ShazamKit API reference** → `/skill axiom-shazamkit-ref` +- SHManagedSession, SHSession, SHCustomCatalog, SHSignatureGenerator +- SHMediaItem, SHMatchedMediaItem, SHLibrary, SHError +- Complete property keys and error codes + +### Localization & Privacy + +**Localization** → `/skill axiom-localization` +**Privacy UX** → `/skill axiom-privacy-ux` + +### Authentication & Credentials + +**Passkeys / WebAuthn sign-in** → `/skill axiom-passkeys` + +### App Integrity & Fraud Prevention + +**App Attest / DeviceCheck** → `/skill axiom-app-attest` + +### Alarms + +**AlarmKit (iOS 26+)** → `/skill axiom-alarmkit-ref` +- Alarm scheduling and authorization +- Live Activity integration +- SwiftUI alarm management views + +### Calendar & Reminders (EventKit) + +**EventKit implementation** → `/skill axiom-eventkit` +- Permission model (no access, write-only, full access) +- Event creation patterns (EventKitUI vs direct EventKit) +- Reminder patterns (DateComponents, EKSource selection) +- Store lifecycle (singleton, change notifications) +- Migration from pre-iOS 17 APIs + +**EventKit API reference** → `/skill axiom-eventkit-ref` +- EKEventStore, EKEvent, EKReminder, EKAlarm, EKRecurrenceRule +- EventKitUI view controllers +- Siri Event Suggestions (INReservation donation) +- Virtual conference extensions +- Location-based reminders + +### Contacts + +**Contacts implementation** → `/skill axiom-contacts` +- Permission model (limited vs full access) +- Contact Access Button (iOS 18+) +- Picker vs store access decisions +- CNContactStore patterns +- Contact Provider extensions + +**Contacts API reference** → `/skill axiom-contacts-ref` +- CNContactStore, CNMutableContact, CNSaveRequest +- CNContactFormatter, CNContactVCardSerialization +- CNContactPickerViewController, ContactAccessButton +- ContactProvider framework +- Change history (CNChangeHistoryFetchRequest) + +### Background Processing + +**BGTaskScheduler implementation** → `/skill axiom-background-processing` +**Background task debugging** → `/skill axiom-background-processing-diag` +**Background task API reference** → `/skill axiom-background-processing-ref` + +### Push Notifications + +**Push notification implementation** → `/skill axiom-push-notifications` +**Push notification API reference** → `/skill axiom-push-notifications-ref` +**Push notification debugging** → `/skill axiom-push-notifications-diag` + +### Location Services + +**Implementation patterns** → `/skill axiom-core-location` +**API reference** → `/skill axiom-core-location-ref` +**Debugging location issues** → `/skill axiom-core-location-diag` + +### Maps & MapKit + +**MapKit implementation patterns** → `/skill axiom-mapkit` +- SwiftUI Map vs MKMapView decision +- Annotation strategies by count +- Search and directions +- 8 anti-patterns + +**MapKit API reference** → `/skill axiom-mapkit-ref` +- SwiftUI Map API +- MKMapView delegates +- MKLocalSearch, MKDirections, Look Around +- Platform availability matrix + +**MapKit troubleshooting** → `/skill axiom-mapkit-diag` +- Annotations not appearing +- Region jumping / infinite loops +- Clustering issues +- Search failures + +## Decision Tree + +1. App Intents / Siri / Apple Intelligence? → app-intents-ref +2. App Shortcuts? → app-shortcuts-ref +3. App discoverability / Spotlight? → app-discoverability, core-spotlight-ref +4. Widgets / Live Activities? → extensions-widgets, extensions-widgets-ref +5. In-app purchases / StoreKit? → in-app-purchases, storekit-ref +6. Want IAP audit (missing finish, receipt validation)? → iap-auditor (Agent) +7. Want full IAP implementation? → iap-implementation (Agent) +8. Camera capture? → camera-capture (patterns), camera-capture-diag (debugging), camera-capture-ref (API) +9. Want camera code audit? → camera-auditor (Agent) +10. Photo pickers / library? → photo-library (patterns), photo-library-ref (API) +11. Audio / AVFoundation? → avfoundation-ref +12. Now Playing? → now-playing, now-playing-carplay, now-playing-musickit +13. Haptics? → haptics +14. Audio recognition / ShazamKit / song identification? → shazamkit (patterns), shazamkit-ref (API) +15. Custom audio catalogs / second-screen sync / audio matching? → shazamkit (patterns), shazamkit-ref (API) +16. Localization? → localization +17. Privacy / permissions? → privacy-ux +18. Background processing? → background-processing (patterns), background-processing-diag (debugging), background-processing-ref (API) +19. Push notification implementation, APNs, or remote notification handling? → push-notifications (patterns), push-notifications-ref (API), push-notifications-diag (debugging) +20. Need APNs payload format, headers, or JWT auth details? → push-notifications-ref +21. Push notifications not arriving, token issues, or delivery failures? → push-notifications-diag +22. Location services? → core-location (patterns), core-location-diag (debugging), core-location-ref (API) +23. Maps / MapKit / annotations / directions? → mapkit (patterns), mapkit-ref (API), mapkit-diag (debugging) +24. Alarms / AlarmKit? → alarmkit-ref +25. Passkeys / WebAuthn / replacing passwords / ASAuthorizationController? → passkeys +26. App Attest / DeviceCheck / fraud prevention / app integrity? → app-attest +27. Calendar events / EventKit / EventKitUI / add to calendar? → eventkit (patterns), eventkit-ref (API) +28. Reminders / EKReminder / reminder lists? → eventkit (patterns), eventkit-ref (API) +29. Siri Event Suggestions / INReservation? → eventkit-ref (API, Part 9) +30. Virtual conference extension / EKVirtualConferenceProvider? → eventkit-ref (API, Part 8) +31. Contacts / contact picker / CNContactStore? → contacts (patterns), contacts-ref (API) +32. Contact Access Button / limited access / iOS 18 contacts? → contacts (patterns), contacts-ref (API) +33. Contact Provider extension / expose contacts to system? → contacts-ref (API, Part 10) + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "App Intents are just a protocol conformance" | App Intents have parameter validation, entity queries, and background execution. app-intents-ref covers all. | +| "Widgets are simple, I've done them before" | Widgets have timeline, interactivity, and Live Activity patterns that evolve yearly. extensions-widgets is current. | +| "I'll add haptics with a simple API call" | Haptic design has patterns for each interaction type. haptics skill matches HIG guidelines. | +| "Localization is just String Catalogs" | Xcode 26 has type-safe localization, generated symbols, and #bundle macro. localization skill is current. | +| "Camera capture is just AVCaptureSession setup" | Camera has interruption handlers, rotation, and threading requirements. camera-capture covers all. | +| "I'll just use MKMapView, I know it already" | SwiftUI Map is 10x less code for standard map features. mapkit has the decision tree. | +| "MapKit search doesn't work, I'll use Google Maps SDK" | MapKit search needs region bias and resultTypes configuration. mapkit-diag fixes this in 5 minutes. | +| "Alarm scheduling is just UNNotificationRequest" | AlarmKit (iOS 26+) has dedicated alarm UI, authorization, and Live Activity integration. alarmkit-ref covers the framework. | +| "Push notifications are just a payload and a token" | Token lifecycle, Focus interruption levels, service extension gotchas, and sandbox/production mismatch cause 80% of push bugs. push-notifications covers all. | +| "Our users aren't ready for passkeys" | Apple, Google, and Microsoft ship passkeys across all platforms. Users don't need to understand crypto — they just tap. passkeys covers the migration path. | +| "App Attest is overkill for our app" | Any app with server-side value (premium content, virtual currency, user accounts) is a fraud target. app-attest has a gradual rollout strategy. | +| "Just request full Calendar access" | Most apps only need to add events — EventKitUI does that with zero permissions. eventkit has the access tier decision tree. | +| "ShazamKit is just SHSession + a delegate" | iOS 17+ has SHManagedSession which eliminates all AVAudioEngine boilerplate. shazamkit has the era decision tree. | +| "I'll use CNContactStore directly for contact picking" | CNContactPickerViewController needs no authorization and shows all contacts. contacts has the access level decision tree. | +| "Contacts access is simple, just request and fetch" | iOS 18 limited access means your app may only see a subset. ContactAccessButton handles this gracefully. contacts covers the full model. | + +## Example Invocations + +User: "How do I add Siri support for my app?" +→ Invoke: `/skill axiom-app-intents-ref` + +User: "My widget isn't updating" +→ Invoke: `/skill axiom-extensions-widgets` + +User: "My widget isn't showing updated SwiftData content" +→ Invoke: `/skill axiom-extensions-widgets` + also invoke `ios-data` router for App Group/shared container setup + +User: "My Live Activity isn't updating and I'm getting push notification errors" +→ Invoke: `/skill axiom-extensions-widgets` for ActivityKit + also invoke `ios-networking` router for push delivery + +User: "Implement in-app purchases with StoreKit 2" +→ Invoke: `/skill axiom-in-app-purchases` + +User: "How do I localize my app strings?" +→ Invoke: `/skill axiom-localization` + +User: "Implement haptic feedback for button taps" +→ Invoke: `/skill axiom-haptics` + +User: "How do I set up a camera preview?" +→ Invoke: `/skill axiom-camera-capture` + +User: "Camera freezes when I get a phone call" +→ Invoke: `/skill axiom-camera-capture-diag` + +User: "What is RotationCoordinator?" +→ Invoke: `/skill axiom-camera-capture-ref` + +User: "How do I let users pick photos in SwiftUI?" +→ Invoke: `/skill axiom-photo-library` + +User: "User can't see their photos after granting access" +→ Invoke: `/skill axiom-photo-library` + +User: "How do I save a photo to the camera roll?" +→ Invoke: `/skill axiom-photo-library` + +User: "My background task never runs" +→ Invoke: `/skill axiom-background-processing-diag` + +User: "How do I implement BGTaskScheduler?" +→ Invoke: `/skill axiom-background-processing` + +User: "What's the difference between BGAppRefreshTask and BGProcessingTask?" +→ Invoke: `/skill axiom-background-processing-ref` + +User: "How do I implement geofencing?" +→ Invoke: `/skill axiom-core-location` + +User: "Location updates not working in background" +→ Invoke: `/skill axiom-core-location-diag` + +User: "What is CLServiceSession?" +→ Invoke: `/skill axiom-core-location-ref` + +User: "Review my in-app purchase implementation" +→ Invoke: `iap-auditor` agent + +User: "Implement in-app purchases for my app" +→ Invoke: `iap-implementation` agent + +User: "Check my camera code for issues" +→ Invoke: `camera-auditor` agent + +User: "How do I add a map to my SwiftUI app?" +→ Invoke: `/skill axiom-mapkit` + +User: "My annotations aren't showing on the map" +→ Invoke: `/skill axiom-mapkit-diag` + +User: "How do I implement search with autocomplete on a map?" +→ Invoke: `/skill axiom-mapkit-ref` + +User: "My map region keeps jumping when I scroll" +→ Invoke: `/skill axiom-mapkit-diag` + +User: "How do I add directions between two points?" +→ Invoke: `/skill axiom-mapkit-ref` + +User: "How do I schedule alarms in iOS 26?" +→ Invoke: `/skill axiom-alarmkit-ref` + +User: "How do I integrate AlarmKit with Live Activities?" +→ Invoke: `/skill axiom-alarmkit-ref` + +User: "How do I implement push notifications?" +→ Invoke: `/skill axiom-push-notifications` + +User: "What APNs headers do I need?" +→ Invoke: `/skill axiom-push-notifications-ref` + +User: "Push notifications work in dev but not production" +→ Invoke: `/skill axiom-push-notifications-diag` + +User: "My Live Activity isn't updating via push" +→ Invoke: `/skill axiom-push-notifications-diag` + `/skill axiom-extensions-widgets` + +User: "Should I use FCM or direct APNs?" +→ Invoke: `/skill axiom-push-notifications` + +User: "How do I use pushTokenUpdates for Live Activities?" +→ Invoke: `/skill axiom-extensions-widgets` (ActivityKit API owns push token observation) + +User: "How do I test push notifications without a real server?" +→ Invoke: `/skill axiom-push-notifications-ref` (command-line testing section) + +User: "How do I implement passkey sign-in?" +→ Invoke: `/skill axiom-passkeys` + +User: "How do I replace passwords with passkeys?" +→ Invoke: `/skill axiom-passkeys` + +User: "How do I verify my app hasn't been tampered with?" +→ Invoke: `/skill axiom-app-attest` + +User: "How do I prevent promotional fraud?" +→ Invoke: `/skill axiom-app-attest` + +User: "How do I add an event to the user's calendar?" +→ Invoke: `/skill axiom-eventkit` + +User: "What's the difference between write-only and full Calendar access?" +→ Invoke: `/skill axiom-eventkit` + +User: "How do I create reminders programmatically?" +→ Invoke: `/skill axiom-eventkit` + +User: "What is EKEventEditViewController?" +→ Invoke: `/skill axiom-eventkit-ref` + +User: "How do I implement Siri Event Suggestions?" +→ Invoke: `/skill axiom-eventkit-ref` + +User: "How do I let users pick a contact?" +→ Invoke: `/skill axiom-contacts` + +User: "What is the Contact Access Button?" +→ Invoke: `/skill axiom-contacts` + +User: "How do I search and fetch contacts?" +→ Invoke: `/skill axiom-contacts-ref` + +User: "How do I build a Contact Provider extension?" +→ Invoke: `/skill axiom-contacts-ref` + +User: "How do I detect contact changes for sync?" +→ Invoke: `/skill axiom-contacts-ref` diff --git a/.claude/skills/axiom-ios-ml/.openskills.json b/.claude/skills/axiom-ios-ml/.openskills.json new file mode 100644 index 0000000..430e506 --- /dev/null +++ b/.claude/skills/axiom-ios-ml/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-ml", + "installedAt": "2026-04-12T08:05:35.624Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-ios-ml/SKILL.md b/.claude/skills/axiom-ios-ml/SKILL.md new file mode 100644 index 0000000..f7dcd1c --- /dev/null +++ b/.claude/skills/axiom-ios-ml/SKILL.md @@ -0,0 +1,136 @@ +--- +name: axiom-ios-ml +description: Use when deploying ANY machine learning model on-device, converting models to CoreML, compressing models, or implementing speech-to-text. Covers CoreML conversion, MLTensor, model compression (quantization/palettization/pruning), stateful models, KV-cache, multi-function models, async prediction, SpeechAnalyzer, SpeechTranscriber. +license: MIT +--- + +# iOS Machine Learning Router + +**You MUST use this skill for ANY on-device machine learning or speech-to-text work.** + +## When to Use + +Use this router when: +- Converting PyTorch/TensorFlow models to CoreML +- Deploying ML models on-device +- Compressing models (quantization, palettization, pruning) +- Working with large language models (LLMs) +- Implementing KV-cache for transformers +- Using MLTensor for model stitching +- Building speech-to-text features +- Transcribing audio (live or recorded) + +## Boundary with ios-ai + +**ios-ml vs ios-ai — know the difference:** + +| Developer Intent | Router | +|-----------------|--------| +| "Use Apple Intelligence / Foundation Models" | **ios-ai** — Apple's on-device LLM | +| "Run my own ML model on device" | **ios-ml** — CoreML conversion + deployment | +| "Add text generation with @Generable" | **ios-ai** — Foundation Models structured output | +| "Deploy a custom LLM with KV-cache" | **ios-ml** — Custom model optimization | +| "Use Vision framework for image analysis" | **ios-vision** — Not ML deployment | +| "Use pre-trained Apple NLP models" | **ios-ai** — Apple's models, not custom | + +**Rule of thumb**: If the developer is converting/compressing/deploying their own model → ios-ml. If they're using Apple's built-in AI → ios-ai. If they're doing computer vision → ios-vision. + +## Routing Logic + +### CoreML Work + +**Implementation patterns** → `/skill coreml` +- Model conversion workflow +- MLTensor for model stitching +- Stateful models with KV-cache +- Multi-function models (adapters/LoRA) +- Async prediction patterns +- Compute unit selection + +**API reference** → `/skill coreml-ref` +- CoreML Tools Python API +- MLModel lifecycle +- MLTensor operations +- MLComputeDevice availability +- State management APIs +- Performance reports + +**Diagnostics** → `/skill coreml-diag` +- Model won't load +- Slow inference +- Memory issues +- Compression accuracy loss +- Compute unit problems + +### Speech Work + +**Implementation patterns** → `/skill speech` +- SpeechAnalyzer setup (iOS 26+) +- SpeechTranscriber configuration +- Live transcription +- File transcription +- Volatile vs finalized results +- Model asset management + +## Decision Tree + +1. Implementing / converting ML models? → coreml +2. CoreML API reference? → coreml-ref +3. Debugging ML issues (load, inference, compression)? → coreml-diag +4. Speech-to-text / transcription? → speech + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "CoreML is just load and predict" | CoreML has compression, stateful models, compute unit selection, and async prediction. coreml covers all. | +| "My model is small, no optimization needed" | Even small models benefit from compute unit selection and async prediction. coreml has the patterns. | +| "I'll just use SFSpeechRecognizer" | iOS 26 has SpeechAnalyzer with better accuracy and offline support. speech skill covers the modern API. | + +## Critical Patterns + +**coreml**: +- Model conversion (PyTorch → CoreML) +- Compression (palettization, quantization, pruning) +- Stateful KV-cache for LLMs +- Multi-function models for adapters +- MLTensor for pipeline stitching +- Async concurrent prediction + +**coreml-diag**: +- Load failures and caching +- Inference performance issues +- Memory pressure from models +- Accuracy degradation from compression + +**speech**: +- SpeechAnalyzer + SpeechTranscriber setup +- AssetInventory model management +- Live transcription with volatile results +- Audio format conversion + +## Example Invocations + +User: "How do I convert a PyTorch model to CoreML?" +→ Invoke: `/skill coreml` + +User: "Compress my model to fit on iPhone" +→ Invoke: `/skill coreml` + +User: "Implement KV-cache for my language model" +→ Invoke: `/skill coreml` + +User: "Model loads slowly on first launch" +→ Invoke: `/skill coreml-diag` + +User: "My compressed model has bad accuracy" +→ Invoke: `/skill coreml-diag` + +User: "Add live transcription to my app" +→ Invoke: `/skill speech` + +User: "Transcribe audio files with SpeechAnalyzer" +→ Invoke: `/skill speech` + +User: "What's MLTensor and how do I use it?" +→ Invoke: `/skill coreml-ref` diff --git a/.claude/skills/axiom-ios-ml/coreml-diag/SKILL.md b/.claude/skills/axiom-ios-ml/coreml-diag/SKILL.md new file mode 100644 index 0000000..d2bdcbf --- /dev/null +++ b/.claude/skills/axiom-ios-ml/coreml-diag/SKILL.md @@ -0,0 +1,473 @@ +--- +name: coreml-diag +description: CoreML diagnostics - model load failures, slow inference, memory issues, compression accuracy loss, compute unit problems, conversion errors. +license: MIT +version: 1.0.0 +--- + +# CoreML Diagnostics + +## Quick Reference + +| Symptom | First Check | Pattern | +|---------|-------------|---------| +| Model won't load | Deployment target | 1a-1c | +| Slow first load | Cache miss | 2a | +| Slow inference | Compute units | 2b-2c | +| High memory | Concurrent predictions | 3a-3b | +| Bad accuracy after compression | Granularity | 4a-4c | +| Conversion fails | Operation support | 5a-5b | + +## Decision Tree + +``` +CoreML issue +├─ Load failure? +│ ├─ "Unsupported model version" → 1a +│ ├─ "Failed to create compute plan" → 1b +│ └─ Other load error → 1c +├─ Performance issue? +│ ├─ First load slow, subsequent fast? → 2a +│ ├─ All predictions slow? → 2b +│ └─ Slow only on specific device? → 2c +├─ Memory issue? +│ ├─ Memory grows during predictions? → 3a +│ └─ Out of memory on load? → 3b +├─ Accuracy degraded? +│ ├─ After palettization? → 4a +│ ├─ After quantization? → 4b +│ └─ After pruning? → 4c +└─ Conversion issue? + ├─ Operation not supported? → 5a + └─ Wrong output? → 5b +``` + +--- + +## Pattern 1a - "Unsupported model version" + +**Symptom**: Model fails to load with version error. + +**Cause**: Model compiled for newer OS than device supports. + +**Diagnosis**: +```python +# Check model's minimum deployment target +import coremltools as ct +model = ct.models.MLModel("Model.mlpackage") +print(model.get_spec().specificationVersion) +``` + +| Spec Version | Minimum iOS | +|--------------|-------------| +| 4 | iOS 13 | +| 5 | iOS 14 | +| 6 | iOS 15 | +| 7 | iOS 16 | +| 8 | iOS 17 | +| 9 | iOS 18 | + +**Fix**: Re-convert with lower deployment target: +```python +mlmodel = ct.convert( + traced, + minimum_deployment_target=ct.target.iOS16 # Lower target +) +``` + +**Tradeoff**: Loses newer optimizations (SDPA fusion, per-block quantization, MLTensor). + +--- + +## Pattern 1b - "Failed to create compute plan" + +**Symptom**: Model loads on some devices but not others. + +**Cause**: Unsupported operations for target compute unit. + +**Diagnosis**: +1. Open model in Xcode +2. Create Performance Report +3. Check "Unsupported" operations +4. Hover for hints + +**Fix**: +```swift +// Force CPU-only to bypass unsupported GPU/NE operations +let config = MLModelConfiguration() +config.computeUnits = .cpuOnly +let model = try MLModel(contentsOf: url, configuration: config) +``` + +**Better fix**: Update model precision or operations during conversion: +```python +# Float16 often better supported +mlmodel = ct.convert(traced, compute_precision=ct.precision.FLOAT16) +``` + +--- + +## Pattern 1c - General Load Failures + +**Symptom**: Model fails to load with unclear error. + +**Checklist**: +1. Check file exists and is readable +2. Check compiled vs source model (runtime needs `.mlmodelc`) +3. Check available disk space (cache needs room) +4. Check model isn't corrupted (re-convert) + +```swift +// Debug logging +let config = MLModelConfiguration() +config.parameters = [.reporter: { print($0) }] // iOS 17+ +``` + +--- + +## Pattern 2a - Slow First Load (Cache Miss) + +**Symptom**: First prediction after install/update is slow, subsequent are fast. + +**Cause**: Device specialization not cached. + +**Diagnosis**: +1. Profile with Core ML Instrument +2. Look at Load event label: + - "prepare and cache" = cache miss (slow) + - "cached" = cache hit (fast) + +**Why cache misses**: +- First launch after install +- System update invalidated cache +- Low disk space cleared cache +- Model file was modified + +**Mitigation**: +```swift +// Warm cache in background at app launch +Task.detached(priority: .background) { + _ = try? await MLModel.load(contentsOf: modelURL) +} +``` + +**Note**: Cache is tied to (model path + configuration + device). Different configs = different cache entries. + +--- + +## Pattern 2b - All Predictions Slow + +**Symptom**: Predictions consistently slow, not just first one. + +**Diagnosis**: +1. Create Xcode Performance Report +2. Check compute unit distribution +3. Look for high-cost operations + +**Common causes**: + +| Cause | Fix | +|-------|-----| +| Running on CPU when GPU/NE available | Check `computeUnits` config | +| Model too large for Neural Engine | Compress model | +| Frequent CPU↔GPU↔NE transfers | Adjust segmentation | +| Dynamic shapes recompiling | Use fixed/enumerated shapes | + +**Profile compute unit usage**: +```swift +let plan = try await MLComputePlan.load(contentsOf: modelURL) +for op in plan.modelStructure.operations { + let info = plan.computeDeviceInfo(for: op) + print("\(op.name): \(info.preferredDevice)") +} +``` + +--- + +## Pattern 2c - Slow on Specific Device + +**Symptom**: Fast on Mac, slow on iPhone (or vice versa). + +**Cause**: Different hardware characteristics. + +**Diagnosis**: +```swift +// Check available compute +let devices = MLModel.availableComputeDevices +print(devices) // Different per device +``` + +**Common issues**: + +| Scenario | Cause | Fix | +|----------|-------|-----| +| Fast on M-series Mac, slow on iPhone | Model optimized for GPU | Use palettization (Neural Engine) | +| Fast on iPhone, slow on Intel Mac | No Neural Engine | Use quantization (GPU) | +| Slow on older devices | Less compute power | Use more aggressive compression | + +**Recommendation**: Profile on target devices, not just development Mac. + +--- + +## Pattern 3a - Memory Grows During Predictions + +**Symptom**: Memory increases with each prediction, doesn't release. + +**Cause**: Input/output buffers accumulating from concurrent predictions. + +**Diagnosis**: +``` +Instruments → Allocations + Core ML template +Look for: Many concurrent prediction intervals +Check: MLMultiArray allocations growing +``` + +**Fix**: Limit concurrent predictions: +```swift +actor PredictionLimiter { + private let maxConcurrent = 2 + private var inFlight = 0 + + func predict(_ model: MLModel, input: MLFeatureProvider) async throws -> MLFeatureProvider { + while inFlight >= maxConcurrent { + await Task.yield() + } + inFlight += 1 + defer { inFlight -= 1 } + return try await model.prediction(from: input) + } +} +``` + +--- + +## Pattern 3b - Out of Memory on Load + +**Symptom**: App crashes or model fails to load on memory-constrained devices. + +**Cause**: Model too large for device memory. + +**Diagnosis**: +```bash +# Check model size +ls -lh Model.mlpackage/Data/com.apple.CoreML/weights/ +``` + +**Fix options**: + +| Approach | Compression | Memory Impact | +|----------|-------------|---------------| +| 8-bit palettization | 2x smaller | 2x less memory | +| 4-bit palettization | 4x smaller | 4x less memory | +| Pruning (50%) | ~2x smaller | ~2x less memory | + +**Note**: Compressed weights are decompressed just-in-time (iOS 17+), so smaller on-disk = smaller in memory. + +--- + +## Pattern 4a - Bad Accuracy After Palettization + +**Symptom**: Model output degraded after palettization. + +**Diagnosis**: +1. What bit depth? (2-bit most likely to fail) +2. What granularity? (per-tensor loses more than per-grouped-channel) + +**Fix progression**: + +```python +# Step 1: Try grouped channels (iOS 18+) +config = OpPalettizerConfig( + nbits=4, + granularity="per_grouped_channel", + group_size=16 +) + +# Step 2: If still bad, try more bits +config = OpPalettizerConfig(nbits=6, ...) + +# Step 3: If still need 4-bit, use calibration +from coremltools.optimize.torch.palettization import DKMPalettizer +# ... training-time compression +``` + +**Key insight**: 4-bit per-tensor has only 16 clusters for entire weight matrix. Grouped channels = 16 clusters per 16 channels = much better granularity. + +--- + +## Pattern 4b - Bad Accuracy After Quantization + +**Symptom**: Model output degraded after INT8/INT4 quantization. + +**Diagnosis**: +1. What bit depth? +2. What granularity? + +**Fix progression**: + +```python +# Step 1: Use per-block (iOS 18+) +config = OpLinearQuantizerConfig( + dtype="int4", + granularity="per_block", + block_size=32 +) + +# Step 2: Use calibration data +from coremltools.optimize.torch.quantization import LayerwiseCompressor +compressor = LayerwiseCompressor(model, config) +quantized = compressor.compress(calibration_loader) +``` + +**Note**: INT4 quantization works best on Mac GPU. For Neural Engine, prefer palettization. + +--- + +## Pattern 4c - Bad Accuracy After Pruning + +**Symptom**: Model output degraded after weight pruning. + +**Diagnosis**: +1. What sparsity level? +2. Post-training or training-time? + +**Thresholds** (model-dependent): +- 0-30% sparsity: Usually safe +- 30-50% sparsity: May need calibration +- 50%+ sparsity: Usually needs training-time + +**Fix**: +```python +# Use calibration-based pruning +from coremltools.optimize.torch.pruning import LayerwiseCompressor + +config = MagnitudePrunerConfig( + target_sparsity=0.4, + n_samples=128 +) +compressor = LayerwiseCompressor(model, config) +sparse = compressor.compress(calibration_loader) +``` + +--- + +## Pattern 5a - Operation Not Supported + +**Symptom**: Conversion fails with unsupported operation error. + +**Diagnosis**: +``` +Error: "Op 'custom_op' is not supported for conversion" +``` + +**Options**: + +1. **Check if op is in coremltools**: May need newer version +```bash +pip install --upgrade coremltools +``` + +2. **Use composite ops**: Split into supported primitives +```python +# Instead of custom_op(x) +# Use: supported_op1(supported_op2(x)) +``` + +3. **Register custom op**: Advanced, requires MIL programming +```python +from coremltools.converters.mil import Builder as mb + +@mb.register_torch_op +def custom_op(context, node): + # Map to MIL operations + ... +``` + +--- + +## Pattern 5b - Conversion Succeeds but Wrong Output + +**Symptom**: Model converts but predictions differ from PyTorch. + +**Diagnosis checklist**: + +1. **Input normalization**: Ensure preprocessing matches +```python +# PyTorch often uses ImageNet normalization +# CoreML may need explicit preprocessing +``` + +2. **Shape ordering**: PyTorch (NCHW) vs CoreML (NHWC for some ops) +```python +# Check shapes in conversion +ct.convert(..., inputs=[ct.ImageType(shape=(1, 3, 224, 224))]) +``` + +3. **Precision differences**: Float16 may differ from Float32 +```python +# Force Float32 to match PyTorch +ct.convert(..., compute_precision=ct.precision.FLOAT32) +``` + +4. **Random ops**: Dropout, random initialization differ +```python +# Ensure eval mode +model.eval() +``` + +**Debug**: +```python +# Compare outputs layer by layer +import numpy as np + +torch_output = model(input).detach().numpy() +coreml_output = mlmodel.predict({"input": input.numpy()})["output"] + +print(f"Max diff: {np.max(np.abs(torch_output - coreml_output))}") +``` + +--- + +## Pressure Scenario - "Model works on simulator but not device" + +**Wrong approach**: Assume simulator bug, ignore. + +**Right approach**: +1. Check model spec version vs device iOS version (Pattern 1a) +2. Check compute unit availability (Pattern 2c) +3. Profile on actual device, not simulator +4. Simulator uses host Mac's GPU/CPU, not device Neural Engine + +--- + +## Pressure Scenario - "Ship now, optimize later" + +**Wrong approach**: Compress to smallest possible size without testing. + +**Right approach**: +1. Ship Float16 baseline first +2. Profile on target devices +3. Apply compression incrementally with accuracy testing +4. Document compression settings for future optimization + +--- + +## Diagnostic Checklist + +When CoreML isn't working: + +- [ ] Check deployment target matches device iOS +- [ ] Check model file is compiled (.mlmodelc) +- [ ] Profile load: cached vs uncached +- [ ] Profile prediction: which compute units +- [ ] Check memory: concurrent predictions limited +- [ ] For compression issues: try higher granularity +- [ ] For conversion issues: check op support, precision + +## Resources + +**WWDC**: 2023-10047, 2023-10049, 2024-10159, 2024-10161 + +**Docs**: /coreml, /coreml/mlmodel + +**Skills**: coreml, coreml-ref diff --git a/.claude/skills/axiom-ios-ml/coreml-ref/SKILL.md b/.claude/skills/axiom-ios-ml/coreml-ref/SKILL.md new file mode 100644 index 0000000..e60d344 --- /dev/null +++ b/.claude/skills/axiom-ios-ml/coreml-ref/SKILL.md @@ -0,0 +1,467 @@ +--- +name: coreml-ref +description: CoreML API reference - MLModel lifecycle, MLTensor operations, coremltools conversion, compression APIs, state management, compute device availability, performance profiling. +license: MIT +version: 1.0.0 +--- + +# CoreML API Reference + +## Part 1 - Model Lifecycle + +### MLModel Loading + +```swift +// Synchronous load (blocks thread) +let model = try MLModel(contentsOf: compiledModelURL) + +// Async load (preferred) +let model = try await MLModel.load(contentsOf: compiledModelURL) + +// With configuration +let config = MLModelConfiguration() +config.computeUnits = .all // .cpuOnly, .cpuAndGPU, .cpuAndNeuralEngine +let model = try await MLModel.load(contentsOf: url, configuration: config) +``` + +### Model Asset Types + +| Type | Extension | Purpose | +|------|-----------|---------| +| Source | `.mlmodel`, `.mlpackage` | Development, editing | +| Compiled | `.mlmodelc` | Runtime execution | + +**Note**: Xcode compiles source models automatically. At runtime, use compiled models. + +### Caching Behavior + +First load triggers device specialization (can be slow). Subsequent loads use cache. + +``` +Load flow: + ├─ Check cache for (model path + configuration + device) + │ ├─ Found → Cached load (fast) + │ └─ Not found → Device specialization + │ ├─ Parse model + │ ├─ Optimize operations + │ ├─ Segment for compute units + │ ├─ Compile for each unit + │ └─ Cache result +``` + +Cache invalidated by: system updates, low disk space, model modification. + +### Multi-Function Models + +```swift +// Load specific function +let config = MLModelConfiguration() +config.functionName = "sticker" // Function name from model + +let model = try MLModel(contentsOf: url, configuration: config) +``` + +--- + +## Part 2 - Compute Availability + +### MLComputeDevice (iOS 17+) + +```swift +// Check available compute devices +let devices = MLModel.availableComputeDevices + +// Check for Neural Engine +let hasNeuralEngine = devices.contains { device in + if case .neuralEngine = device { return true } + return false +} + +// Check for specific GPU +for device in devices { + switch device { + case .cpu: + print("CPU available") + case .gpu(let gpu): + print("GPU: \(gpu.name)") + case .neuralEngine(let ne): + print("Neural Engine: \(ne.totalCoreCount) cores") + @unknown default: + break + } +} +``` + +### MLModelConfiguration.ComputeUnits + +| Value | Behavior | +|-------|----------| +| `.all` | Best performance (default) | +| `.cpuOnly` | CPU only | +| `.cpuAndGPU` | Exclude Neural Engine | +| `.cpuAndNeuralEngine` | Exclude GPU | + +--- + +## Part 3 - Prediction APIs + +### Synchronous Prediction + +```swift +// Single prediction (NOT thread-safe) +let output = try model.prediction(from: input) + +// Batch prediction +let outputs = try model.predictions(from: batch) +``` + +### Async Prediction (iOS 17+) + +```swift +// Single prediction (thread-safe, supports concurrency) +let output = try await model.prediction(from: input) + +// With cancellation +let output = try await withTaskCancellationHandler { + try await model.prediction(from: input) +} onCancel: { + // Prediction will be cancelled +} +``` + +### State-Based Prediction + +```swift +// Create state from model +let state = model.makeState() + +// Prediction with state (state updated in-place) +let output = try model.prediction(from: input, using: state) + +// Async with state +let output = try await model.prediction(from: input, using: state) +``` + +--- + +## Part 4 - MLTensor (iOS 18+) + +### Creating Tensors + +```swift +import CoreML + +// From MLShapedArray +let shapedArray = MLShapedArray(scalars: [1, 2, 3, 4], shape: [2, 2]) +let tensor = MLTensor(shapedArray) + +// From nested collections +let tensor = MLTensor([[1.0, 2.0], [3.0, 4.0]]) + +// Zeros/ones +let zeros = MLTensor(zeros: [3, 3], scalarType: Float.self) +``` + +### Math Operations + +```swift +// Element-wise +let sum = tensor1 + tensor2 +let product = tensor1 * tensor2 +let scaled = tensor * 2.0 + +// Reductions +let mean = tensor.mean() +let sum = tensor.sum() +let max = tensor.max() + +// Comparison +let mask = tensor .> mean // Boolean mask + +// Softmax +let probs = tensor.softmax() +``` + +### Indexing and Reshaping + +```swift +// Slicing (Python-like syntax) +let row = tensor[0] // First row +let col = tensor[.all, 0] // First column +let slice = tensor[0..<2, 1..<3] + +// Reshaping +let reshaped = tensor.reshaped(to: [4]) +let expanded = tensor.expandingShape(at: 0) +``` + +### Materialization + +**Critical**: Tensor operations are async. Must materialize to access data. + +```swift +// Materialize to MLShapedArray (blocks until complete) +let array = await tensor.shapedArray(of: Float.self) + +// Access scalars +let values = array.scalars +``` + +--- + +## Part 5 - Core ML Tools (Python) + +### Basic Conversion + +```python +import coremltools as ct +import torch + +# Trace PyTorch model +model.eval() +traced = torch.jit.trace(model, example_input) + +# Convert +mlmodel = ct.convert( + traced, + inputs=[ct.TensorType(shape=example_input.shape)], + outputs=[ct.TensorType(name="output")], + minimum_deployment_target=ct.target.iOS18 +) + +mlmodel.save("Model.mlpackage") +``` + +### Dynamic Shapes + +```python +# Fixed shape +ct.TensorType(shape=(1, 3, 224, 224)) + +# Range dimension +ct.TensorType(shape=(1, ct.RangeDim(1, 2048))) + +# Enumerated shapes +ct.TensorType(shape=ct.EnumeratedShapes(shapes=[(1, 256), (1, 512), (1, 1024)])) +``` + +### State Types + +```python +# For stateful models (KV-cache) +states = [ + ct.StateType( + name="keyCache", + wrapped_type=ct.TensorType(shape=(1, 32, 2048, 128)) + ), + ct.StateType( + name="valueCache", + wrapped_type=ct.TensorType(shape=(1, 32, 2048, 128)) + ) +] + +mlmodel = ct.convert(traced, inputs=inputs, states=states, ...) +``` + +--- + +## Part 6 - Compression APIs (coremltools.optimize) + +### Post-Training Palettization + +```python +from coremltools.optimize.coreml import ( + OpPalettizerConfig, + OptimizationConfig, + palettize_weights +) + +# Per-tensor (iOS 17+) +config = OpPalettizerConfig(mode="kmeans", nbits=4) + +# Per-grouped-channel (iOS 18+, better accuracy) +config = OpPalettizerConfig( + mode="kmeans", + nbits=4, + granularity="per_grouped_channel", + group_size=16 +) + +opt_config = OptimizationConfig(global_config=config) +compressed = palettize_weights(model, opt_config) +``` + +### Post-Training Quantization + +```python +from coremltools.optimize.coreml import ( + OpLinearQuantizerConfig, + OptimizationConfig, + linear_quantize_weights +) + +# INT8 per-channel (iOS 17+) +config = OpLinearQuantizerConfig(mode="linear", dtype="int8") + +# INT4 per-block (iOS 18+) +config = OpLinearQuantizerConfig( + mode="linear", + dtype="int4", + granularity="per_block", + block_size=32 +) + +opt_config = OptimizationConfig(global_config=config) +compressed = linear_quantize_weights(model, opt_config) +``` + +### Post-Training Pruning + +```python +from coremltools.optimize.coreml import ( + OpMagnitudePrunerConfig, + OptimizationConfig, + prune_weights +) + +config = OpMagnitudePrunerConfig(target_sparsity=0.5) +opt_config = OptimizationConfig(global_config=config) +sparse = prune_weights(model, opt_config) +``` + +### Training-Time Palettization (PyTorch) + +```python +from coremltools.optimize.torch.palettization import ( + DKMPalettizerConfig, + DKMPalettizer +) + +config = DKMPalettizerConfig(global_config={"n_bits": 4}) +palettizer = DKMPalettizer(model, config) + +# Prepare (inserts palettization layers) +prepared = palettizer.prepare() + +# Training loop +for epoch in range(epochs): + train_one_epoch(prepared, data_loader) + palettizer.step() + +# Finalize +final = palettizer.finalize() +``` + +### Calibration-Based Compression + +```python +from coremltools.optimize.torch.pruning import ( + MagnitudePrunerConfig, + LayerwiseCompressor +) + +config = MagnitudePrunerConfig( + target_sparsity=0.4, + n_samples=128 +) + +compressor = LayerwiseCompressor(model, config) +compressed = compressor.compress(calibration_loader) +``` + +--- + +## Part 7 - Multi-Function Models + +### Merging Models + +```python +from coremltools.models import MultiFunctionDescriptor +from coremltools.models.utils import save_multifunction + +# Create descriptor +desc = MultiFunctionDescriptor() +desc.add_function("function_a", "model_a.mlpackage") +desc.add_function("function_b", "model_b.mlpackage") + +# Merge (deduplicates shared weights) +save_multifunction(desc, "merged.mlpackage") +``` + +### Inspecting Functions (Xcode) + +Open model in Xcode → Predictions tab → Functions listed above inputs. + +--- + +## Part 8 - Performance Profiling + +### MLComputePlan (iOS 18+) + +```swift +let plan = try await MLComputePlan.load(contentsOf: modelURL) + +// Inspect operations +for op in plan.modelStructure.operations { + let info = plan.computeDeviceInfo(for: op) + print("Op: \(op.name)") + print(" Preferred: \(info.preferredDevice)") + print(" Estimated cost: \(info.estimatedCost)") +} +``` + +### Xcode Performance Reports + +1. Open model in Xcode +2. Select Performance tab +3. Click + to create report +4. Select device and compute units +5. Click "Run Test" + +**New in iOS 18**: Shows estimated time per operation, compute device support hints. + +### Core ML Instrument + +``` +Instruments → Core ML template + ├─ Load events: "cached" vs "prepare and cache" + ├─ Prediction intervals + ├─ Compute unit usage + └─ Neural Engine activity +``` + +--- + +## Part 9 - Deployment Targets + +| Target | Key Features | +|--------|--------------| +| iOS 16 | Weight compression (palettization, quantization, pruning) | +| iOS 17 | Async prediction, MLComputeDevice, activation quantization | +| iOS 18 | MLTensor, State, SDPA fusion, per-block quantization, multi-function | + +**Recommendation**: Always set `minimum_deployment_target=ct.target.iOS18` for best optimizations. + +--- + +## Part 10 - Conversion Pass Pipelines + +```python +# Default pipeline +mlmodel = ct.convert(traced, ...) + +# With palettization support +mlmodel = ct.convert( + traced, + pass_pipeline=ct.PassPipeline.DEFAULT_PALETTIZATION, + ... +) +``` + +## Resources + +**WWDC**: 2023-10047, 2023-10049, 2024-10159, 2024-10161 + +**Docs**: /coreml, /coreml/mlmodel, /coreml/mltensor, /documentation/coremltools + +**Skills**: coreml, coreml-diag diff --git a/.claude/skills/axiom-ios-ml/coreml/SKILL.md b/.claude/skills/axiom-ios-ml/coreml/SKILL.md new file mode 100644 index 0000000..aa3d002 --- /dev/null +++ b/.claude/skills/axiom-ios-ml/coreml/SKILL.md @@ -0,0 +1,468 @@ +--- +name: coreml +description: Use when deploying custom ML models on-device, converting PyTorch models, compressing models, implementing LLM inference, or optimizing CoreML performance. Covers model conversion, compression, stateful models, KV-cache, multi-function models, MLTensor. +license: MIT +version: 1.0.0 +--- + +# CoreML On-Device Machine Learning + +## Overview + +CoreML enables on-device machine learning inference across all Apple platforms. It abstracts hardware details while leveraging Apple Silicon's CPU, GPU, and Neural Engine for high-performance, private, and efficient execution. + +**Key principle**: Start with the simplest approach, then optimize based on profiling. Don't over-engineer compression or caching until you have real performance data. + +## Decision Tree - CoreML vs Foundation Models + +``` +Need on-device ML? + ├─ Text generation (LLM)? + │ ├─ Simple prompts, structured output? → Foundation Models (ios-ai skill) + │ └─ Custom model, fine-tuned, specific architecture? → CoreML + ├─ Custom trained model? + │ └─ Yes → CoreML + ├─ Image/audio/sensor processing? + │ └─ Yes → CoreML + └─ Apple's built-in intelligence? + └─ Yes → Foundation Models (ios-ai skill) +``` + +## Red Flags + +Use this skill when you see: +- "Convert PyTorch model to CoreML" +- "Model too large for device" +- "Slow inference performance" +- "LLM on-device" +- "KV-cache" or "stateful model" +- "Model compression" or "quantization" +- MLModel, MLTensor, or coremltools in context + +## Pattern 1 - Basic Model Conversion + +The standard PyTorch → CoreML workflow. + +```python +import coremltools as ct +import torch + +# Trace the model +model.eval() +traced_model = torch.jit.trace(model, example_input) + +# Convert to CoreML +mlmodel = ct.convert( + traced_model, + inputs=[ct.TensorType(shape=example_input.shape)], + minimum_deployment_target=ct.target.iOS18 +) + +# Save +mlmodel.save("MyModel.mlpackage") +``` + +**Critical**: Always set `minimum_deployment_target` to enable latest optimizations. + +## Pattern 2 - Model Compression (Post-Training) + +Three techniques, each with different tradeoffs: + +### Palettization (Best for Neural Engine) + +Clusters weights into lookup tables. Use per-grouped-channel for better accuracy. + +```python +from coremltools.optimize.coreml import ( + OpPalettizerConfig, + OptimizationConfig, + palettize_weights +) + +# 4-bit with grouped channels (iOS 18+) +op_config = OpPalettizerConfig( + mode="kmeans", + nbits=4, + granularity="per_grouped_channel", + group_size=16 +) + +config = OptimizationConfig(global_config=op_config) +compressed_model = palettize_weights(model, config) +``` + +| Bits | Compression | Accuracy Impact | +|------|-------------|-----------------| +| 8-bit | 2x | Minimal | +| 6-bit | 2.7x | Low | +| 4-bit | 4x | Moderate (use grouped channels) | +| 2-bit | 8x | High (requires training-time) | + +### Quantization (Best for GPU on Mac) + +Linear mapping to INT8/INT4. Use per-block for better accuracy. + +```python +from coremltools.optimize.coreml import ( + OpLinearQuantizerConfig, + OptimizationConfig, + linear_quantize_weights +) + +# INT4 per-block quantization (iOS 18+) +op_config = OpLinearQuantizerConfig( + mode="linear", + dtype="int4", + granularity="per_block", + block_size=32 +) + +config = OptimizationConfig(global_config=op_config) +compressed_model = linear_quantize_weights(model, config) +``` + +### Pruning (Combine with other techniques) + +Sets weights to zero for sparse representation. Can combine with palettization. + +```python +from coremltools.optimize.coreml import ( + OpMagnitudePrunerConfig, + OptimizationConfig, + prune_weights +) + +op_config = OpMagnitudePrunerConfig( + target_sparsity=0.4 # 40% zeros +) + +config = OptimizationConfig(global_config=op_config) +sparse_model = prune_weights(model, config) +``` + +## Pattern 3 - Training-Time Compression + +When post-training compression loses too much accuracy, fine-tune with compression. + +```python +from coremltools.optimize.torch.palettization import ( + DKMPalettizerConfig, + DKMPalettizer +) + +# Configure 4-bit palettization +config = DKMPalettizerConfig(global_config={"n_bits": 4}) + +# Prepare model +palettizer = DKMPalettizer(model, config) +prepared_model = palettizer.prepare() + +# Fine-tune (your training loop) +for epoch in range(num_epochs): + train_epoch(prepared_model, data_loader) + palettizer.step() + +# Finalize +final_model = palettizer.finalize() +``` + +**Tradeoff**: Better accuracy than post-training, but requires training data and time. + +## Pattern 4 - Calibration-Based Compression (iOS 18+) + +Middle ground: uses calibration data without full training. + +```python +from coremltools.optimize.torch.pruning import ( + MagnitudePrunerConfig, + LayerwiseCompressor +) + +# Configure +config = MagnitudePrunerConfig( + target_sparsity=0.4, + n_samples=128 # Calibration samples +) + +# Create pruner +pruner = LayerwiseCompressor(model, config) + +# Calibrate +sparse_model = pruner.compress(calibration_data_loader) +``` + +## Pattern 5 - Stateful Models (KV-Cache for LLMs) + +For transformer models, use state to avoid recomputing key/value vectors. + +### PyTorch Model with State + +```python +class StatefulLLM(nn.Module): + def __init__(self): + super().__init__() + # Register state buffers + self.register_buffer("keyCache", torch.zeros(batch, heads, seq_len, dim)) + self.register_buffer("valueCache", torch.zeros(batch, heads, seq_len, dim)) + + def forward(self, input_ids, causal_mask): + # Update caches in-place during forward + # ... attention with KV-cache ... + return logits +``` + +### Conversion with State + +```python +import coremltools as ct + +mlmodel = ct.convert( + traced_model, + inputs=[ + ct.TensorType(name="input_ids", shape=(1, ct.RangeDim(1, 2048))), + ct.TensorType(name="causal_mask", shape=(1, 1, ct.RangeDim(1, 2048), ct.RangeDim(1, 2048))) + ], + states=[ + ct.StateType(name="keyCache", ...), + ct.StateType(name="valueCache", ...) + ], + minimum_deployment_target=ct.target.iOS18 +) +``` + +### Using State at Runtime + +```swift +// Create state from model +let state = model.makeState() + +// Run prediction with state (updated in-place) +let output = try model.prediction(from: input, using: state) +``` + +**Performance**: 1.6x speedup on Mistral-7B (M3 Max) compared to manual KV-cache I/O. + +## Pattern 6 - Multi-Function Models (Adapters/LoRA) + +Deploy multiple adapters in a single model, sharing base weights. + +```python +from coremltools.models import MultiFunctionDescriptor +from coremltools.models.utils import save_multifunction + +# Convert individual models +sticker_model = ct.convert(sticker_adapter_model, ...) +storybook_model = ct.convert(storybook_adapter_model, ...) + +# Save individually +sticker_model.save("sticker.mlpackage") +storybook_model.save("storybook.mlpackage") + +# Merge with shared weights +desc = MultiFunctionDescriptor() +desc.add_function("sticker", "sticker.mlpackage") +desc.add_function("storybook", "storybook.mlpackage") + +save_multifunction(desc, "MultiAdapter.mlpackage") +``` + +### Loading Specific Function + +```swift +let config = MLModelConfiguration() +config.functionName = "sticker" // or "storybook" + +let model = try MLModel(contentsOf: modelURL, configuration: config) +``` + +## Pattern 7 - MLTensor for Pipeline Stitching (iOS 18+) + +Simplifies computation between models (decoding, post-processing). + +```swift +import CoreML + +// Create tensors +let scores = MLTensor(shape: [1, vocab_size], scalars: logits) + +// Operations (executed asynchronously on Apple Silicon) +let topK = scores.topK(k: 10) +let probs = (topK.values / temperature).softmax() + +// Sample from distribution +let sampled = probs.multinomial(numSamples: 1) + +// Materialize to access data (blocks until complete) +let shapedArray = await sampled.shapedArray(of: Int32.self) +``` + +**Key insight**: MLTensor operations are async. Call `shapedArray()` to materialize results. + +## Pattern 8 - Async Prediction for Concurrency + +Thread-safe concurrent predictions for throughput. + +```swift +class ImageProcessor { + let model: MLModel + + func processImages(_ images: [CGImage]) async throws -> [Output] { + try await withThrowingTaskGroup(of: Output.self) { group in + for image in images { + group.addTask { + // Check cancellation before expensive work + try Task.checkCancellation() + + let input = try self.prepareInput(image) + // Async prediction - thread safe! + return try await self.model.prediction(from: input) + } + } + + return try await group.reduce(into: []) { $0.append($1) } + } + } +} +``` + +**Warning**: Limit concurrent predictions to avoid memory pressure from multiple input/output buffers. + +```swift +// Limit concurrency +let semaphore = AsyncSemaphore(value: 2) + +for image in images { + group.addTask { + await semaphore.wait() + defer { semaphore.signal() } + return try await process(image) + } +} +``` + +## Anti-Patterns + +### Don't - Load models on main thread at launch + +```swift +// BAD - blocks UI +class AppDelegate { + let model = try! MLModel(contentsOf: url) // Blocks! +} + +// GOOD - lazy async loading +class ModelManager { + private var model: MLModel? + + func getModel() async throws -> MLModel { + if let model { return model } + model = try await Task.detached { + try MLModel(contentsOf: url) + }.value + return model! + } +} +``` + +### Don't - Reload model for each prediction + +```swift +// BAD - reloads every time +func predict(_ input: Input) throws -> Output { + let model = try MLModel(contentsOf: url) // Expensive! + return try model.prediction(from: input) +} + +// GOOD - keep model loaded +class Predictor { + private let model: MLModel + + func predict(_ input: Input) throws -> Output { + try model.prediction(from: input) + } +} +``` + +### Don't - Compress without profiling first + +```swift +// BAD - blind compression +let compressed = palettize_weights(model, 2bit_config) // May break accuracy! + +// GOOD - profile, then compress iteratively +// 1. Profile Float16 baseline +// 2. Try 8-bit → check accuracy +// 3. Try 6-bit → check accuracy +// 4. Try 4-bit with grouped channels → check accuracy +// 5. Only use 2-bit with training-time compression +``` + +### Don't - Ignore deployment target + +```python +# BAD - misses optimizations +mlmodel = ct.convert(traced_model, inputs=[...]) + +# GOOD - enables SDPA fusion, per-block quantization, etc. +mlmodel = ct.convert( + traced_model, + inputs=[...], + minimum_deployment_target=ct.target.iOS18 +) +``` + +## Pressure Scenarios + +### Scenario 1 - "Model is 5GB, need it under 2GB for iPhone" + +**Wrong approach**: Jump straight to 2-bit palettization. + +**Right approach**: +1. Start with 8-bit palettization → check accuracy +2. Try 6-bit → check accuracy +3. Try 4-bit with `per_grouped_channel` → check accuracy +4. If still too large, use calibration-based compression +5. If still losing accuracy, use training-time compression + +### Scenario 2 - "LLM inference is too slow" + +**Wrong approach**: Try different compute units randomly. + +**Right approach**: +1. Profile with Core ML Instrument +2. Check if load is cached (look for "cached" vs "prepare and cache") +3. Enable stateful KV-cache +4. Check SDPA optimization is enabled (iOS 18+ deployment target) +5. Consider INT4 quantization for GPU on Mac + +### Scenario 3 - "Need multiple LoRA adapters in one app" + +**Wrong approach**: Ship separate models for each adapter. + +**Right approach**: +1. Convert each adapter model separately +2. Use `MultiFunctionDescriptor` to merge with shared base +3. Load specific function via `config.functionName` +4. Weights are deduplicated automatically + +## Checklist + +Before deploying a CoreML model: + +- [ ] Set `minimum_deployment_target` to latest supported iOS +- [ ] Profile baseline Float16 performance +- [ ] Check if model load is cached +- [ ] Consider compression only if size/performance requires it +- [ ] Test accuracy after each compression step +- [ ] Use async prediction for concurrent workloads +- [ ] Limit concurrent predictions to manage memory +- [ ] Use state for transformer KV-cache +- [ ] Use multi-function for adapter variants + +## Resources + +**WWDC**: 2023-10047, 2023-10049, 2024-10159, 2024-10161 + +**Docs**: /coreml, /coreml/mlmodel, /coreml/mltensor + +**Skills**: coreml-ref, coreml-diag, axiom-ios-ai (Foundation Models) diff --git a/.claude/skills/axiom-ios-ml/speech/SKILL.md b/.claude/skills/axiom-ios-ml/speech/SKILL.md new file mode 100644 index 0000000..2882bcd --- /dev/null +++ b/.claude/skills/axiom-ios-ml/speech/SKILL.md @@ -0,0 +1,496 @@ +--- +name: speech +description: Use when implementing speech-to-text, live transcription, or audio transcription. Covers SpeechAnalyzer (iOS 26+), SpeechTranscriber, volatile/finalized results, AssetInventory model management, audio format handling. +license: MIT +version: 1.0.0 +--- + +# Speech-to-Text with SpeechAnalyzer + +## Overview + +SpeechAnalyzer is Apple's new speech-to-text API introduced in iOS 26. It powers Notes, Voice Memos, Journal, and Call Summarization. The on-device model is faster, more accurate, and better for long-form/distant audio than SFSpeechRecognizer. + +**Key principle**: SpeechAnalyzer is modular—add transcription modules to an analysis session. Results stream asynchronously using Swift's AsyncSequence. + +## Decision Tree - SpeechAnalyzer vs SFSpeechRecognizer + +``` +Need speech-to-text? + ├─ iOS 26+ only? + │ └─ Yes → SpeechAnalyzer (preferred) + ├─ Need iOS 10-25 support? + │ └─ Yes → SFSpeechRecognizer (or DictationTranscriber) + ├─ Long-form audio (meetings, lectures)? + │ └─ Yes → SpeechAnalyzer + ├─ Distant audio (across room)? + │ └─ Yes → SpeechAnalyzer + └─ Short dictation commands? + └─ Either works +``` + +**SpeechAnalyzer advantages**: +- Better for long-form and conversational audio +- Works well with distant speakers (meetings) +- On-device, private +- Model managed by system (no app size increase) +- Powers Notes, Voice Memos, Journal + +**DictationTranscriber** (iOS 26+): Same languages as SFSpeechRecognizer, but doesn't require user to enable Siri/dictation in Settings. + +## Red Flags + +Use this skill when you see: +- "Live transcription" +- "Transcribe audio" +- "Speech-to-text" +- "SpeechAnalyzer" or "SpeechTranscriber" +- "Volatile results" +- Building Notes-like or Voice Memos-like features + +## Pattern 1 - File Transcription (Simplest) + +Transcribe an audio file to text in one function. + +```swift +import Speech + +func transcribe(file: URL, locale: Locale) async throws -> AttributedString { + // Set up transcriber + let transcriber = SpeechTranscriber( + locale: locale, + preset: .offlineTranscription + ) + + // Collect results asynchronously + async let transcriptionFuture = try transcriber.results + .reduce(AttributedString()) { str, result in + str + result.text + } + + // Set up analyzer with transcriber module + let analyzer = SpeechAnalyzer(modules: [transcriber]) + + // Analyze the file + if let lastSample = try await analyzer.analyzeSequence(from: file) { + try await analyzer.finalizeAndFinish(through: lastSample) + } else { + await analyzer.cancelAndFinishNow() + } + + return try await transcriptionFuture +} +``` + +**Key points**: +- `analyzeSequence(from:)` reads file and feeds audio to analyzer +- `finalizeAndFinish(through:)` ensures all results are finalized +- Results are `AttributedString` with timing metadata + +## Pattern 2 - Live Transcription Setup + +For real-time transcription from microphone. + +### Step 1 - Configure SpeechTranscriber + +```swift +import Speech + +class TranscriptionManager: ObservableObject { + private var transcriber: SpeechTranscriber? + private var analyzer: SpeechAnalyzer? + private var analyzerFormat: AudioFormatDescription? + private var inputBuilder: AsyncStream.Continuation? + + @Published var finalizedTranscript = AttributedString() + @Published var volatileTranscript = AttributedString() + + func setUp() async throws { + // Create transcriber with options + transcriber = SpeechTranscriber( + locale: Locale.current, + transcriptionOptions: [], + reportingOptions: [.volatileResults], // Enable real-time updates + attributeOptions: [.audioTimeRange] // Include timing + ) + + guard let transcriber else { throw TranscriptionError.setupFailed } + + // Create analyzer with transcriber module + analyzer = SpeechAnalyzer(modules: [transcriber]) + + // Get required audio format + analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat( + compatibleWith: [transcriber] + ) + + // Ensure model is available + try await ensureModel(for: transcriber) + + // Create input stream + let (stream, builder) = AsyncStream.makeStream() + inputBuilder = builder + + // Start analyzer + try await analyzer?.start(inputSequence: stream) + } +} +``` + +### Step 2 - Ensure Model Availability + +```swift +func ensureModel(for transcriber: SpeechTranscriber) async throws { + let locale = Locale.current + + // Check if language is supported + let supported = await SpeechTranscriber.supportedLocales + guard supported.contains(where: { + $0.identifier(.bcp47) == locale.identifier(.bcp47) + }) else { + throw TranscriptionError.localeNotSupported + } + + // Check if model is installed + let installed = await SpeechTranscriber.installedLocales + if installed.contains(where: { + $0.identifier(.bcp47) == locale.identifier(.bcp47) + }) { + return // Already installed + } + + // Download model + if let downloader = try await AssetInventory.assetInstallationRequest( + supporting: [transcriber] + ) { + // Track progress if needed + let progress = downloader.progress + try await downloader.downloadAndInstall() + } +} +``` + +**Note**: Models are stored in system storage, not app storage. Limited number of languages can be allocated at once. + +### Step 3 - Handle Results + +```swift +func startResultHandling() { + Task { + guard let transcriber else { return } + + do { + for try await result in transcriber.results { + let text = result.text + + if result.isFinal { + // Finalized result - won't change + finalizedTranscript += text + volatileTranscript = AttributedString() + + // Access timing info + for run in text.runs { + if let timeRange = run.audioTimeRange { + print("Time: \(timeRange)") + } + } + } else { + // Volatile result - will be replaced + volatileTranscript = text + } + } + } catch { + print("Transcription failed: \(error)") + } + } +} +``` + +## Pattern 3 - Audio Recording and Streaming + +Connect AVAudioEngine to SpeechAnalyzer. + +```swift +import AVFoundation + +class AudioRecorder { + private let audioEngine = AVAudioEngine() + private var outputContinuation: AsyncStream.Continuation? + private let transcriptionManager: TranscriptionManager + + func startRecording() async throws { + // Request permission + guard await AVAudioApplication.requestRecordPermission() else { + throw RecordingError.permissionDenied + } + + // Configure audio session (iOS) + #if os(iOS) + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .spokenAudio) + try session.setActive(true, options: .notifyOthersOnDeactivation) + #endif + + // Set up transcriber + try await transcriptionManager.setUp() + transcriptionManager.startResultHandling() + + // Stream audio to transcriber + for await buffer in try audioStream() { + try await transcriptionManager.streamAudio(buffer) + } + } + + private func audioStream() throws -> AsyncStream { + let inputNode = audioEngine.inputNode + let format = inputNode.outputFormat(forBus: 0) + + inputNode.installTap( + onBus: 0, + bufferSize: 4096, + format: format + ) { [weak self] buffer, time in + self?.outputContinuation?.yield(buffer) + } + + audioEngine.prepare() + try audioEngine.start() + + return AsyncStream { continuation in + outputContinuation = continuation + } + } +} +``` + +### Stream Audio with Format Conversion + +```swift +extension TranscriptionManager { + private var converter: AVAudioConverter? + + func streamAudio(_ buffer: AVAudioPCMBuffer) async throws { + guard let inputBuilder, let analyzerFormat else { + throw TranscriptionError.notSetUp + } + + // Convert to analyzer's required format + let converted = try convertBuffer(buffer, to: analyzerFormat) + + // Send to analyzer + let input = AnalyzerInput(buffer: converted) + inputBuilder.yield(input) + } + + private func convertBuffer( + _ buffer: AVAudioPCMBuffer, + to format: AudioFormatDescription + ) throws -> AVAudioPCMBuffer { + // Lazy initialize converter + if converter == nil { + let sourceFormat = buffer.format + let destFormat = AVAudioFormat(cmAudioFormatDescription: format)! + converter = AVAudioConverter(from: sourceFormat, to: destFormat) + } + + guard let converter else { + throw TranscriptionError.conversionFailed + } + + let outputBuffer = AVAudioPCMBuffer( + pcmFormat: converter.outputFormat, + frameCapacity: buffer.frameLength + )! + + try converter.convert(to: outputBuffer, from: buffer) + return outputBuffer + } +} +``` + +## Pattern 4 - Stopping Transcription + +Properly finalize to get remaining volatile results as finalized. + +```swift +func stopRecording() async { + // Stop audio + audioEngine.stop() + audioEngine.inputNode.removeTap(onBus: 0) + outputContinuation?.finish() + + // Finalize transcription (converts remaining volatile to final) + try? await analyzer?.finalizeAndFinishThroughEndOfInput() + + // Cancel any pending tasks + recognizerTask?.cancel() +} +``` + +**Critical**: Always call `finalizeAndFinishThroughEndOfInput()` to ensure volatile results are finalized. + +## Pattern 5 - Model Asset Management + +### Check Supported Languages + +```swift +// Languages the API supports +let supported = await SpeechTranscriber.supportedLocales + +// Languages currently installed on device +let installed = await SpeechTranscriber.installedLocales +``` + +### Deallocate Languages + +Limited number of languages can be allocated. Deallocate unused ones. + +```swift +func deallocateLanguages() async { + let allocated = await AssetInventory.allocatedLocales + for locale in allocated { + await AssetInventory.deallocate(locale: locale) + } +} +``` + +## Pattern 6 - Displaying Results with Timing + +Highlight text during audio playback using timing metadata. + +```swift +struct TranscriptView: View { + let transcript: AttributedString + @Binding var playbackTime: CMTime + + var body: some View { + Text(highlightedTranscript) + } + + var highlightedTranscript: AttributedString { + var result = transcript + + for (range, run) in transcript.runs { + guard let timeRange = run.audioTimeRange else { continue } + + let isActive = timeRange.containsTime(playbackTime) + if isActive { + result[range].backgroundColor = .yellow + } + } + + return result + } +} +``` + +## Anti-Patterns + +### Don't - Forget to finalize + +```swift +// BAD - volatile results lost +func stopRecording() { + audioEngine.stop() + // Missing finalize! +} + +// GOOD - volatile results become finalized +func stopRecording() async { + audioEngine.stop() + try? await analyzer?.finalizeAndFinishThroughEndOfInput() +} +``` + +### Don't - Ignore format conversion + +```swift +// BAD - format mismatch may fail silently +inputBuilder.yield(AnalyzerInput(buffer: rawBuffer)) + +// GOOD - convert to analyzer's format +let format = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriber]) +let converted = try convertBuffer(rawBuffer, to: format) +inputBuilder.yield(AnalyzerInput(buffer: converted)) +``` + +### Don't - Skip model availability check + +```swift +// BAD - may crash if model not installed +let transcriber = SpeechTranscriber(locale: locale, ...) +// Start using immediately + +// GOOD - ensure model is ready +let transcriber = SpeechTranscriber(locale: locale, ...) +try await ensureModel(for: transcriber) +// Now safe to use +``` + +## Presets Reference + +| Preset | Use Case | +|--------|----------| +| `.offlineTranscription` | File transcription, no real-time feedback needed | +| `.progressiveLiveTranscription` | Live transcription with volatile updates | + +## Options Reference + +### TranscriptionOptions +- Default: None (standard transcription) + +### ReportingOptions +- `.volatileResults`: Enable real-time approximate results + +### AttributeOptions +- `.audioTimeRange`: Include CMTimeRange for each text segment + +## Platform Availability + +| Platform | SpeechTranscriber | DictationTranscriber | +|----------|-------------------|---------------------| +| iOS 26+ | Yes | Yes | +| macOS Tahoe+ | Yes | Yes | +| watchOS 26+ | No | Yes | +| tvOS 26+ | TBD | TBD | + +**Hardware requirements**: Varies by device. Use `supportedLocales` to check. + +## Integration with Apple Intelligence + +Combine with Foundation Models for summarization: + +```swift +import FoundationModels + +func generateTitle(for transcript: String) async throws -> String { + let session = LanguageModelSession() + let prompt = "Generate a short, clever title for this story: \(transcript)" + let response = try await session.respond(to: prompt) + return response.content +} +``` + +See `axiom-ios-ai` skill for Foundation Models details. + +## Checklist + +Before shipping speech-to-text: + +- [ ] Check locale support with `supportedLocales` +- [ ] Ensure model with `AssetInventory.assetInstallationRequest` +- [ ] Handle download progress for user feedback +- [ ] Convert audio to `bestAvailableAudioFormat` +- [ ] Enable `.volatileResults` for live transcription +- [ ] Call `finalizeAndFinishThroughEndOfInput()` on stop +- [ ] Handle timing with `.audioTimeRange` if needed +- [ ] Clear volatile results when finalized result arrives +- [ ] Request microphone permission before recording + +## Resources + +**WWDC**: 2025-277 + +**Docs**: /speech, /speech/speechanalyzer, /speech/speechtranscriber + +**Skills**: coreml (on-device ML), axiom-ios-ai (Foundation Models) diff --git a/.claude/skills/axiom-ios-networking/.openskills.json b/.claude/skills/axiom-ios-networking/.openskills.json new file mode 100644 index 0000000..d60f4b4 --- /dev/null +++ b/.claude/skills/axiom-ios-networking/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-networking", + "installedAt": "2026-04-12T08:05:35.624Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-ios-networking/SKILL.md b/.claude/skills/axiom-ios-networking/SKILL.md new file mode 100644 index 0000000..208bafd --- /dev/null +++ b/.claude/skills/axiom-ios-networking/SKILL.md @@ -0,0 +1,141 @@ +--- +name: axiom-ios-networking +description: Use when implementing or debugging ANY network connection, API call, or socket. Covers URLSession, Network.framework, NetworkConnection, deprecated APIs, connection diagnostics, structured concurrency networking. +license: MIT +--- + +# iOS Networking Router + +**You MUST use this skill for ANY networking work including HTTP requests, WebSockets, TCP connections, or network debugging.** + +## When to Use + +Use this router when: +- Implementing network requests (URLSession) +- Using Network.framework or NetworkConnection +- Debugging connection failures +- Migrating from deprecated networking APIs +- Network performance issues + +## Pressure Resistance + +**When user has invested significant time in custom implementation:** + +Do NOT capitulate to sunk cost pressure. The correct approach is: + +1. **Diagnose first** — Understand what's actually failing before recommending changes +2. **Recommend correctly** — If standard APIs (URLSession, Network.framework) would solve the problem, say so professionally +3. **Respect but don't enable** — Acknowledge their work while providing honest technical guidance + +**Example pressure scenario:** +> "I spent 2 days on custom networking. Just help me fix it, don't tell me to use URLSession." + +**Correct response:** +> "Let me diagnose the cellular failure first. [After diagnosis] The issue is [X]. URLSession handles this automatically via [Y]. I recommend migrating the affected code path — it's 30 minutes vs continued debugging. Your existing work on [Z] can be preserved." + +**Why this matters:** Users often can't see that migration is faster than continued debugging. Honest guidance serves them better than false comfort. + +## Routing Logic + +### Network Implementation + +**Networking patterns** → `/skill axiom-networking` +- URLSession with structured concurrency +- Network.framework migration +- Modern networking patterns +- Deprecated API migration + +**Network.framework reference** → `/skill axiom-network-framework-ref` +**Legacy iOS 12-25 patterns** → `/skill axiom-networking-legacy` +**Migration guides** → `/skill axiom-networking-migration` +- NWConnection (iOS 12-25) +- NetworkConnection (iOS 26+) +- TCP connections +- TLV framing +- Wi-Fi Aware + +### App Store Compliance + +**ATS / HTTP security** → `/skill axiom-networking-diag` +- App Transport Security (ATS) configuration +- HTTP → HTTPS migration +- App Store rejection for insecure connections +- NSAllowsArbitraryLoads exceptions + +**Deprecated API rejection** → Launch `networking-auditor` agent +- UIWebView → WKWebView migration +- SCNetworkReachability → NWPathMonitor +- CFSocket → Network.framework + +### Network Debugging + +**Connection issues** → `/skill axiom-networking-diag` +- Connection timeouts +- TLS handshake failures +- Data not arriving +- Connection drops +- VPN/proxy problems + +### Automated Scanning + +**Networking audit** → Launch `networking-auditor` agent or `/axiom:audit networking` (deprecated APIs like SCNetworkReachability, CFSocket, NSStream; anti-patterns like reachability checks, hardcoded IPs, missing error handling) + +## Decision Tree + +1. URLSession with structured concurrency? → networking +2. Network.framework / NetworkConnection (iOS 26+)? → network-framework-ref +3. NWConnection (iOS 12-25)? → networking-legacy +4. Migrating from sockets/URLSession? → networking-migration +5. Connection issues / debugging? → networking-diag +6. ATS / HTTP / App Store rejection for networking? → networking-diag + networking-auditor +7. UIWebView or deprecated API rejection? → networking-auditor (Agent) +8. Want deprecated API / anti-pattern scan? → networking-auditor (Agent) + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "URLSession is simple, I don't need a skill" | URLSession with structured concurrency has async/cancellation patterns. networking skill covers them. | +| "I'll debug the connection timeout myself" | Connection failures have 8 causes (DNS, TLS, proxy, cellular). networking-diag diagnoses systematically. | +| "I just need a basic HTTP request" | Even basic requests need error handling, retry, and cancellation patterns. networking has them. | +| "My custom networking layer works fine" | Custom layers miss cellular/proxy edge cases. Standard APIs handle them automatically. | + +## Critical Patterns + +**Networking** (networking): +- URLSession with structured concurrency +- Socket migration to Network.framework +- Deprecated API replacement + +**Network Framework Reference** (network-framework-ref): +- NWConnection for iOS 12-25 +- NetworkConnection for iOS 26+ +- Connection lifecycle management + +**Networking Diagnostics** (networking-diag): +- Connection timeout diagnosis +- TLS debugging +- Network stack inspection + +## Example Invocations + +User: "My API request is failing with a timeout" +→ Invoke: `/skill axiom-networking-diag` + +User: "How do I use URLSession with async/await?" +→ Invoke: `/skill axiom-networking` + +User: "I need to implement a TCP connection" +→ Invoke: `/skill axiom-network-framework-ref` + +User: "Should I use NWConnection or NetworkConnection?" +→ Invoke: `/skill axiom-network-framework-ref` + +User: "My app was rejected for using HTTP connections" +→ Invoke: `/skill axiom-networking-diag` (ATS compliance) + +User: "App Store says I'm using UIWebView" +→ Invoke: `networking-auditor` agent (deprecated API scan) + +User: "Check my networking code for deprecated APIs" +→ Invoke: `networking-auditor` agent diff --git a/.claude/skills/axiom-ios-performance/.openskills.json b/.claude/skills/axiom-ios-performance/.openskills.json new file mode 100644 index 0000000..075e2e3 --- /dev/null +++ b/.claude/skills/axiom-ios-performance/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-performance", + "installedAt": "2026-04-12T08:05:35.625Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-ios-performance/SKILL.md b/.claude/skills/axiom-ios-performance/SKILL.md new file mode 100644 index 0000000..a53eab5 --- /dev/null +++ b/.claude/skills/axiom-ios-performance/SKILL.md @@ -0,0 +1,311 @@ +--- +name: axiom-ios-performance +description: Use when app feels slow, memory grows, battery drains, or diagnosing ANY performance issue. Covers memory leaks, profiling, Instruments workflows, retain cycles, performance optimization. +license: MIT +--- + +# iOS Performance Router + +**You MUST use this skill for ANY performance issue including memory leaks, slow execution, battery drain, or profiling.** + +## When to Use + +Use this router when: +- App feels slow or laggy +- Memory usage grows over time +- Battery drains quickly +- Device gets hot during use +- High energy usage in Battery Settings +- Diagnosing performance with Instruments +- Memory leaks or retain cycles +- App crashes with memory warnings + +## Routing Logic + +### Memory Issues + +**Memory leaks (Swift)** → `/skill axiom-memory-debugging` +- Systematic leak diagnosis +- 5 common leak patterns +- Instruments workflows +- deinit not called + +**Memory leak scan** → Launch `memory-auditor` agent or `/axiom:audit memory` (5-phase semantic audit: maps resource ownership, detects 6 leak patterns, reasons about missing cleanup, correlates compound risks, scores lifecycle health) + +**Memory leaks (Objective-C blocks)** → `/skill axiom-objc-block-retain-cycles` +- Block retain cycles +- Weak-strong pattern +- Network callback leaks + +### Performance Profiling + +**Performance profiling (GUI)** → `/skill axiom-performance-profiling` +- Time Profiler (CPU) +- Allocations (memory growth) +- Core Data profiling (N+1 queries) +- Decision trees for tool selection + +**Automated profiling (CLI)** → `/skill axiom-xctrace-ref` +- Headless xctrace profiling +- CI/CD integration patterns +- Command-line trace recording +- Programmatic trace analysis + +**Run automated profile** → Use `performance-profiler` agent or `/axiom:profile` +- Records trace via xctrace +- Exports and analyzes data +- Reports findings with severity + +### Hang/Freeze Issues + +**App hangs or freezes** → `/skill axiom-hang-diagnostics` +- UI unresponsive for >1 second +- Main thread blocked (busy or waiting) +- Decision tree: busy vs blocked diagnosis +- Time Profiler vs System Trace selection +- 8 common hang patterns with fixes +- Watchdog terminations + +### Energy Issues + +**Battery drain, high energy** → `/skill axiom-energy` +- Power Profiler workflow +- Subsystem diagnosis (CPU/GPU/Network/Location/Display) +- Anti-pattern fixes +- Background execution optimization + +**Symptom-based diagnosis** → `/skill axiom-energy-diag` +- "App at top of Battery Settings" +- "Device gets hot" +- "Background battery drain" +- Time-cost analysis for each path + +**API reference with code** → `/skill axiom-energy-ref` +- Complete WWDC code examples +- Timer, network, location efficiency +- BGContinuedProcessingTask (iOS 26) +- MetricKit setup + +**Energy scan** → Launch `energy-auditor` agent or `/axiom:audit energy` (8 anti-patterns: timer abuse, polling, continuous location, animation leaks, background mode misuse, network inefficiency, GPU waste, disk I/O) + +### Timer Safety + +**Timer crash patterns (DispatchSourceTimer)** → `/skill axiom-timer-patterns` +- 4 crash scenarios causing EXC_BAD_INSTRUCTION +- RunLoop mode gotcha (Timer stops during scroll) +- SafeDispatchTimer wrapper +- Timer vs DispatchSourceTimer decision + +**Timer API reference** → `/skill axiom-timer-patterns-ref` +- Timer, DispatchSourceTimer, Combine, AsyncTimerSequence APIs +- Lifecycle diagrams +- Platform availability + +### Swift Performance + +**Swift performance optimization** → `/skill axiom-swift-performance` +- Value vs reference types, copy-on-write +- ARC overhead, generic specialization +- Collection performance + +**Swift performance scan** → Launch `swift-performance-analyzer` agent or `/axiom:audit swift-performance` (unnecessary copies, ARC overhead, unspecialized generics, collection inefficiencies, actor isolation costs, memory layout) + +**Modern Swift idioms** → `/skill axiom-swift-modern` +- Outdated API patterns (Date(), CGFloat, DateFormatter) +- Foundation modernization (URL.documentsDirectory, FormatStyle) +- Claude-specific hallucination corrections + +### MetricKit Integration + +**MetricKit API reference** → `/skill axiom-metrickit-ref` +- MXMetricPayload parsing +- MXDiagnosticPayload (crashes, hangs) +- Field performance data collection +- Integration with crash reporting + +### Runtime Console Capture + +**Capture simulator console output** → `/skill axiom-xclog-ref` or `/axiom:console` +- Capture print(), os_log(), Logger output from simulator +- Structured JSON with level, subsystem, category +- Bounded collection with `--timeout` and `--max-lines` +- Filter by subsystem or regex + +### Runtime State Inspection + +**LLDB interactive debugging** → `/skill axiom-lldb` +- Set breakpoints, inspect variables at runtime +- Crash reproduction from crash logs +- Thread state analysis for hangs +- Swift value inspection (po vs v) + +**LLDB command reference** → `/skill axiom-lldb-ref` +- Complete command syntax +- Breakpoint recipes +- Expression evaluation patterns + +## Decision Tree + +1. Memory climbing + UI stutter/jank? → memory-debugging FIRST (memory pressure causes GC pauses that drop frames), then performance-profiling if memory is fixed but stutter remains +2. Memory leak (Swift)? → memory-debugging +3. Memory leak (Objective-C blocks)? → objc-block-retain-cycles +4. App hang/freeze — is UI completely unresponsive (can't tap, no feedback)? + - YES → hang-diagnostics (busy vs blocked diagnosis) + - NO, just slow → performance-profiling (Time Profiler) + - First launch only? → Also check for synchronous I/O or lazy initialization in hang-diagnostics +5. Slowdown when multiple async operations complete at once? → Cross-route to `axiom-ios-concurrency` (callback contention, not profiling) +6. Battery drain (know the symptom)? → energy-diag +7. Battery drain (need API reference)? → energy-ref +8. Battery drain (general)? → energy +9. MetricKit setup/parsing? → metrickit-ref +10. Profile with GUI (Instruments)? → performance-profiling +11. Profile with CLI (xctrace)? → xctrace-ref +12. Run automated profile now? → performance-profiler agent +13. General slow/lag? → performance-profiling +14. Want proactive memory leak scan? → memory-auditor (Agent) +15. Want energy anti-pattern scan? → energy-auditor (Agent) +16. Want Swift performance audit (ARC, generics, collections)? → swift-performance-analyzer (Agent) +17. Need to inspect variable/thread state at runtime? → axiom-lldb +18. Need exact LLDB command syntax? → axiom-lldb-ref +19. Timer stops during scrolling? → timer-patterns (RunLoop mode) +20. EXC_BAD_INSTRUCTION crash with DispatchSourceTimer? → timer-patterns (4 crash patterns) +21. Choosing between Timer, DispatchSourceTimer, Combine timer, async timer? → timer-patterns +22. Need timer API syntax/lifecycle? → timer-patterns-ref +23. Code review for outdated Swift patterns? → swift-modern +24. Claude generating legacy APIs (DateFormatter, CGFloat, DispatchQueue)? → swift-modern +25. Need to see runtime console output before profiling? → xclog-ref or `/axiom:console` + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "I know it's a memory leak, let me find it" | Memory leaks have 6 patterns. memory-debugging diagnoses the right one in 15 min vs 2 hours. | +| "I'll just run Time Profiler" | Wrong Instruments template wastes time. performance-profiling selects the right tool first. | +| "Battery drain is probably the network layer" | Energy issues span 8 subsystems. energy skill diagnoses the actual cause. | +| "App feels slow, I'll optimize later" | Performance issues compound. Profiling now saves exponentially more time later. | +| "It's just a UI freeze, probably a slow API call" | Freezes have busy vs blocked causes. hang-diagnostics has a decision tree for both. | +| "Memory is climbing AND scrolling stutters — two separate bugs" | Memory pressure causes GC pauses that drop frames. Fix the leak first, then re-check scroll performance. | +| "It only freezes on first launch, must be loading something" | First-launch hangs have 3 patterns: synchronous I/O, lazy initialization, main thread contention. hang-diagnostics diagnoses which. | +| "UI locks up when network requests finish — that's slow" | Multiple callbacks completing at once = main thread contention = concurrency issue. Cross-route to ios-concurrency. | +| "I'll just add print statements to debug this" | Print-debug cycles cost 3-5 min each (build + run + reproduce). An LLDB breakpoint costs 30 seconds. axiom-lldb has the commands. | +| "I can't see what the app is logging" | xclog captures print() + os_log from the simulator with structured JSON. `/axiom:console` or `/skill axiom-xclog-ref`. | +| "I'll just use Timer.scheduledTimer, it's simpler" | Timer stops during scrolling (`.default` mode), retains its target (leak). timer-patterns has the decision tree. | +| "DispatchSourceTimer crashed but it's intermittent, let's ship" | DispatchSourceTimer has 4 crash patterns that are ALL deterministic. timer-patterns diagnoses which one. | +| "Claude already knows modern Swift" | Claude defaults to pre-5.5 patterns (Date(), CGFloat, filter().count). swift-modern has the correction table. | + +## Critical Patterns + +**Memory Debugging** (memory-debugging): +- 6 leak patterns: timers, observers, closures, delegates, view callbacks, PhotoKit +- Instruments workflows +- Leak vs caching distinction + +**Performance Profiling** (performance-profiling): +- Time Profiler for CPU bottlenecks +- Allocations for memory growth +- Core Data SQL logging for N+1 queries +- Self Time vs Total Time + +**Energy Optimization** (energy): +- Power Profiler subsystem diagnosis +- 8 anti-patterns: timers, polling, location, animations, background, network, GPU, disk +- Audit checklists by subsystem +- Pressure scenarios for deadline resistance + +## Example Invocations + +User: "My app's memory usage keeps growing" +→ Invoke: `/skill axiom-memory-debugging` + +User: "I have a memory leak but deinit isn't being called" +→ Invoke: `/skill axiom-memory-debugging` + +User: "My app feels slow, where do I start?" +→ Invoke: `/skill axiom-performance-profiling` + +User: "My Objective-C block callback is leaking" +→ Invoke: `/skill axiom-objc-block-retain-cycles` + +User: "My app drains battery quickly" +→ Invoke: `/skill axiom-energy` + +User: "Users say the device gets hot when using my app" +→ Invoke: `/skill axiom-energy-diag` + +User: "What's the best way to implement location tracking efficiently?" +→ Invoke: `/skill axiom-energy-ref` + +User: "Profile my app's CPU usage" +→ Use: `performance-profiler` agent (or `/axiom:profile`) + +User: "How do I run xctrace from the command line?" +→ Invoke: `/skill axiom-xctrace-ref` + +User: "I need headless profiling for CI/CD" +→ Invoke: `/skill axiom-xctrace-ref` + +User: "My app hangs sometimes" +→ Invoke: `/skill axiom-hang-diagnostics` + +User: "The UI freezes and becomes unresponsive" +→ Invoke: `/skill axiom-hang-diagnostics` + +User: "Main thread is blocked, how do I diagnose?" +→ Invoke: `/skill axiom-hang-diagnostics` + +User: "How do I set up MetricKit?" +→ Invoke: `/skill axiom-metrickit-ref` + +User: "How do I parse MXMetricPayload?" +→ Invoke: `/skill axiom-metrickit-ref` + +User: "Scan my code for memory leaks" +→ Invoke: `memory-auditor` agent + +User: "Check my app for battery drain issues" +→ Invoke: `energy-auditor` agent + +User: "Audit my Swift code for performance anti-patterns" +→ Invoke: `swift-performance-analyzer` agent + +User: "How do I inspect this variable in the debugger?" +→ Invoke: `/skill axiom-lldb` + +User: "What's the LLDB command for conditional breakpoints?" +→ Invoke: `/skill axiom-lldb-ref` + +User: "I need to reproduce this crash in the debugger" +→ Invoke: `/skill axiom-lldb` + +User: "My list scrolls slowly and memory keeps growing" +→ Invoke: `/skill axiom-memory-debugging` first, then `/skill axiom-performance-profiling` if stutter remains + +User: "App freezes for a few seconds on first launch then works fine" +→ Invoke: `/skill axiom-hang-diagnostics` + +User: "UI locks up when multiple API calls return at the same time" +→ Cross-route: `/skill axiom-ios-concurrency` (callback contention) + +User: "My timer stops when the user scrolls" +→ Invoke: `/skill axiom-timer-patterns` + +User: "EXC_BAD_INSTRUCTION crash in my timer code" +→ Invoke: `/skill axiom-timer-patterns` + +User: "Should I use Timer or DispatchSourceTimer?" +→ Invoke: `/skill axiom-timer-patterns` + +User: "How do I create an AsyncTimerSequence?" +→ Invoke: `/skill axiom-timer-patterns-ref` + +User: "Review my Swift code for outdated patterns" +→ Invoke: `/skill axiom-swift-modern` + +User: "Is there a more modern way to do this?" +→ Invoke: `/skill axiom-swift-modern` + +User: "What is the app logging? I need to see console output" +→ Invoke: `/skill axiom-xclog-ref` or `/axiom:console` + +User: "Capture the simulator logs while I reproduce this bug" +→ Invoke: `/skill axiom-xclog-ref` or `/axiom:console` diff --git a/.claude/skills/axiom-ios-testing/.openskills.json b/.claude/skills/axiom-ios-testing/.openskills.json new file mode 100644 index 0000000..bdf6bee --- /dev/null +++ b/.claude/skills/axiom-ios-testing/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-testing", + "installedAt": "2026-04-12T08:05:35.626Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-ios-testing/SKILL.md b/.claude/skills/axiom-ios-testing/SKILL.md new file mode 100644 index 0000000..cf76aa5 --- /dev/null +++ b/.claude/skills/axiom-ios-testing/SKILL.md @@ -0,0 +1,299 @@ +--- +name: axiom-ios-testing +description: Use when writing ANY test, debugging flaky tests, making tests faster, or asking about Swift Testing vs XCTest. Covers unit tests, UI tests, fast tests without simulator, async testing, test architecture. +license: MIT +--- + +# iOS Testing Router + +**You MUST use this skill for ANY testing-related question, including writing tests, debugging test failures, making tests faster, or choosing between testing approaches.** + +## When to Use + +Use this router when you encounter: +- Writing new unit tests or UI tests +- Swift Testing framework (@Test, #expect, @Suite) +- XCTest or XCUITest questions +- Making tests run faster (without simulator) +- Flaky tests (pass sometimes, fail sometimes) +- Testing async code reliably +- Migrating from XCTest to Swift Testing +- Test architecture decisions +- Condition-based waiting patterns + +## Routing Logic + +This router invokes specialized skills based on the specific testing need: + +### 1. Unit Tests / Fast Tests → **swift-testing** + +**Triggers**: +- Writing new unit tests +- Swift Testing framework (`@Test`, `#expect`, `#require`, `@Suite`) +- Making tests run without simulator +- Testing async code reliably +- `withMainSerialExecutor`, `TestClock` +- Migrating from XCTest +- Parameterized tests +- Tags and traits +- Host Application: None configuration +- Swift Package tests (`swift test`) +- Tests take >5 seconds for pure logic +- Want TDD with fast feedback loop + +**Why swift-testing**: Modern Swift Testing framework with parallel execution, better async support, and the ability to run without launching simulator. + +**Invoke**: Read the `axiom-swift-testing` skill + +--- + +### 2. UI Tests / XCUITest → **ui-testing** + +**Triggers**: +- Recording UI Automation (Xcode 26) +- XCUIApplication, XCUIElement +- Flaky UI tests +- Tests pass locally, fail in CI +- `sleep()` or arbitrary timeouts +- Condition-based waiting +- Cross-device testing +- Accessibility-first testing + +**Why ui-testing**: XCUITest requires simulator and has unique patterns for reliability. + +**Invoke**: Read the `axiom-ui-testing` skill + +--- + +### 3. Flaky Tests / Race Conditions → **test-failure-analyzer** (Agent) + +**Triggers**: +- Tests fail randomly in CI +- Tests pass locally but fail in CI +- Flaky tests (pass sometimes, fail sometimes) +- Race conditions in Swift Testing +- Missing `await confirmation` for async callbacks +- Missing `@MainActor` on UI tests +- Shared mutable state in `@Suite` +- Tests pass individually, fail when run together + +**Why test-failure-analyzer**: Specialized agent that scans for patterns causing intermittent failures in Swift Testing. + +**Invoke**: Launch `test-failure-analyzer` agent + +--- + +### 4. Async Testing Patterns → **testing-async** + +**Triggers**: +- Testing async/await functions +- `confirmation` for callbacks (Swift Testing) +- `expectedCount` for multiple callbacks +- Testing MainActor code with `@MainActor @Test` +- Migrating XCTestExpectation → confirmation +- Parallel test execution concerns +- Test timeout configuration + +**Why testing-async**: Dedicated patterns for async code in Swift Testing framework. + +**Invoke**: Read the `axiom-testing-async` skill + +--- + +### 5. Test Crashes / Environment Issues → **xcode-debugging** + +**Triggers**: +- Tests crash before assertions run +- Simulator won't boot for tests +- Tests hang indefinitely +- "Unable to boot simulator" errors +- Clean test run differs from incremental + +**Why xcode-debugging**: Test failures from environment issues, not test logic. + +**Invoke**: Read the `axiom-xcode-debugging` skill + +--- + +### 6. Running XCUITests from Command Line → **test-runner** (Agent) + +**Triggers**: +- Run tests with xcodebuild +- Parse xcresult bundles +- Export failure screenshots/videos +- Code coverage reports +- CI/CD test execution + +**Why test-runner**: Specialized agent for command-line test execution with xcresulttool parsing. + +**Invoke**: Launch `test-runner` agent + +--- + +### 7. Closed-Loop Test Debugging → **test-debugger** (Agent) + +**Triggers**: +- Fix failing tests automatically +- Debug persistent test failures +- Run → analyze → fix → verify cycle +- Need to iterate until tests pass +- Analyze failure screenshots + +**Why test-debugger**: Automated cycle of running tests, analyzing failures, suggesting fixes, and re-running. + +**Invoke**: Launch `test-debugger` agent + +--- + +### 8. Recording UI Automation (Xcode 26) → **ui-recording** + +**Triggers**: +- Record user interactions in Xcode +- Test plans for multi-config replay +- Video review of test runs +- Xcode 26 recording workflow +- Enhancing recorded test code + +**Why ui-recording**: Focused guide for Xcode 26's Record/Replay/Review workflow. + +**Invoke**: Read the `axiom-ui-recording` skill + +--- + +### 9. Test Quality Audit → **testing-auditor** (Agent) + +**Triggers**: +- Want to audit test quality +- Find flaky test patterns (sleep calls, shared mutable state) +- Speed up test execution +- Migrate from XCTest to Swift Testing +- Check tests for Swift 6 concurrency issues + +**Why testing-auditor**: Maps test coverage shape against production code, detects flaky patterns and speed issues, identifies untested critical paths (auth, payments, persistence), and scores overall test health. + +**Invoke**: Launch `testing-auditor` agent or `/axiom:audit testing` + +--- + +### 10. UI Automation Without XCUITest → **simulator-tester** + **axe-ref** + +**Triggers**: +- Automate app without test target +- AXe CLI usage (tap, swipe, type) +- describe-ui for accessibility tree +- Quick automation outside XCUITest +- Scripted simulator interactions + +**Why simulator-tester + axe-ref**: AXe provides accessibility-based UI automation when XCUITest isn't available. + +**Invoke**: Launch `simulator-tester` agent (uses axiom-axe-ref) + +--- + +## Decision Tree + +1. Writing unit tests / Swift Testing? → swift-testing +2. Writing UI tests / XCUITest? → ui-testing +3. Testing async/await code? → testing-async +4. Flaky tests / race conditions (XCUITest)? → ui-testing +5. Flaky tests / race conditions (Swift Testing)? → test-failure-analyzer (Agent) +6. Tests crash / environment wrong? → xcode-debugging (via ios-build) +7. Tests are slow / want swift test? → swift-testing (Strategy 1: Package extraction) +8. Run tests from CLI / parse results? → test-runner (Agent) +9. Fix failing tests automatically? → test-debugger (Agent) +10. Want test quality audit (flaky patterns, migration)? → testing-auditor (Agent) +11. Record UI interactions (Xcode 26)? → ui-recording +12. Automate without XCUITest / AXe CLI? → simulator-tester + axe-ref + +## Swift Testing vs XCTest Quick Guide + +| Need | Use | +|------|-----| +| Unit tests (logic, models) | Swift Testing | +| UI tests (tap, swipe, assert screens) | XCUITest (XCTest) | +| Tests without simulator | Swift Testing + Package/Framework | +| Parameterized tests | Swift Testing | +| Performance measurements | XCTest (XCTMetric) | +| Objective-C tests | XCTest | + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "Simple test question, I don't need the skill" | Proper patterns prevent test debt. swift-testing has copy-paste solutions. | +| "I know XCTest well enough" | Swift Testing is significantly better for unit tests. swift-testing covers migration. | +| "Tests are slow but it's fine" | Fast tests enable TDD. swift-testing shows how to run without simulator. | +| "I'll fix the flaky test with a sleep()" | sleep() makes tests slower AND flakier. ui-testing has condition-based waiting patterns. | +| "I'll add tests later" | Tests written after implementation miss edge cases. swift-testing makes writing tests first easy. | + +## Example Invocations + +User: "How do I write a unit test in Swift?" +→ Invoke: axiom-swift-testing + +User: "My UI tests are flaky in CI" +→ Check codebase: XCUIApplication/XCUIElement patterns? → ui-testing +→ Check codebase: @Test/#expect patterns? → test-failure-analyzer + +User: "Tests fail randomly, pass sometimes fail sometimes" +→ Invoke: test-failure-analyzer (Agent) + +User: "Tests pass locally but fail in CI" +→ Invoke: test-failure-analyzer (Agent) + +User: "How do I test async code without flakiness?" +→ Invoke: testing-async + +User: "How do I test callback-based APIs with Swift Testing?" +→ Invoke: testing-async + +User: "What's the Swift Testing equivalent of XCTestExpectation?" +→ Invoke: testing-async + +User: "How do I use confirmation with expectedCount?" +→ Invoke: testing-async + +User: "I want my tests to run faster" +→ Invoke: axiom-swift-testing (Strategy 1: Package extraction) + +User: "My unit tests take 25 seconds to run" +→ Invoke: axiom-swift-testing (Strategy 1: Package extraction) + +User: "How do I use swift test instead of xcodebuild test?" +→ Invoke: axiom-swift-testing (Fast Tests section) + +User: "Should I use Swift Testing or XCTest?" +→ Invoke: axiom-swift-testing (Migration section) + this decision tree + +User: "Tests crash before any assertions" +→ Invoke: axiom-xcode-debugging + +User: "Run my tests and show me what failed" +→ Invoke: test-runner (Agent) + +User: "Help me fix these failing tests" +→ Invoke: test-debugger (Agent) + +User: "Parse the xcresult from my last test run" +→ Invoke: test-runner (Agent) + +User: "Export failure screenshots from my tests" +→ Invoke: test-runner (Agent) + +User: "How do I record UI automation in Xcode 26?" +→ Invoke: axiom-ui-recording + +User: "How do I use test plans for multi-language testing?" +→ Invoke: axiom-ui-recording + +User: "Can I automate my app without writing XCUITests?" +→ Invoke: simulator-tester (Agent) + axiom-axe-ref + +User: "How do I tap a button using AXe?" +→ Invoke: axiom-axe-ref (via simulator-tester) + +User: "Audit my tests for quality issues" +→ Invoke: `testing-auditor` agent + +User: "Should I migrate to Swift Testing?" +→ Invoke: `testing-auditor` agent diff --git a/.claude/skills/axiom-ios-ui/.openskills.json b/.claude/skills/axiom-ios-ui/.openskills.json new file mode 100644 index 0000000..005a58d --- /dev/null +++ b/.claude/skills/axiom-ios-ui/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-ui", + "installedAt": "2026-04-12T08:05:35.626Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-ios-ui/SKILL.md b/.claude/skills/axiom-ios-ui/SKILL.md new file mode 100644 index 0000000..4333041 --- /dev/null +++ b/.claude/skills/axiom-ios-ui/SKILL.md @@ -0,0 +1,249 @@ +--- +name: axiom-ios-ui +description: Use when building, fixing, or improving ANY iOS UI including SwiftUI, UIKit, layout, navigation, animations, design guidelines. Covers view updates, layout bugs, navigation issues, performance, architecture, Apple design compliance. +license: MIT +--- + +# iOS UI Router + +**You MUST use this skill for ANY iOS UI work including SwiftUI, UIKit, layout, navigation, animations, and design.** + +## When to Use + +Use this router when working with: +- SwiftUI views, state, bindings +- UIKit views and constraints +- Layout issues (Auto Layout, SwiftUI layout) +- Navigation (NavigationStack, deep linking) +- Animations and transitions +- Liquid Glass design (iOS 26+) +- Apple Human Interface Guidelines +- UI architecture and patterns +- Accessibility UI issues + +## Conflict Resolution + +**ios-ui vs ios-performance**: When UI is slow (e.g., "SwiftUI List slow"): +1. **Try ios-ui FIRST** — Domain-specific fixes (LazyVStack, view identity, @State optimization) often solve UI performance in 5 minutes +2. **Only use ios-performance** if domain fixes don't help — Profiling takes longer and may confirm what domain knowledge already knows + +**Rationale**: Jumping to Instruments wastes time when the fix is a known SwiftUI pattern. Profile AFTER trying domain fixes, not before. + +## Routing Logic + +### SwiftUI Issues + +**View not updating** → `/skill axiom-swiftui-debugging` +**Navigation issues** → `/skill axiom-swiftui-nav` +**Performance/lag** → `/skill axiom-swiftui-performance` +**Layout problems** → `/skill axiom-swiftui-layout` +**Stacks/grids/outlines** → `/skill axiom-swiftui-containers-ref` +**Animation issues** → `/skill axiom-swiftui-animation-ref` +**Gesture conflicts** → `/skill axiom-swiftui-gestures` +**Drag/drop, copy/paste, sharing** → `/skill axiom-transferable-ref` +**Architecture/testability** → `/skill axiom-swiftui-architecture` +**App-level composition** → `/skill axiom-app-composition` +**Search implementation** → `/skill axiom-swiftui-search-ref` +**iOS 26 features** → `/skill axiom-swiftui-26-ref` +**UIKit bridging (Representable, HostingController)** → `/skill axiom-uikit-bridging` + +### UIKit Issues + +**Auto Layout conflicts** → `/skill axiom-auto-layout-debugging` +**Animation timing issues** → `/skill axiom-uikit-animation-debugging` +**SwiftUI embedding (HostingController, HostingConfiguration)** → `/skill axiom-uikit-bridging` + +### Design & Guidelines + +**Liquid Glass adoption** → `/skill axiom-liquid-glass` +**SF Symbols (effects, rendering, custom)** → `/skill axiom-sf-symbols` +**Design decisions** → `/skill axiom-hig` +**Typography** → `/skill axiom-typography-ref` +**TextKit/rich text** → `/skill axiom-textkit-ref` + +### tvOS + +**Focus Engine, remote input, TVUIKit, text input** → `/skill axiom-tvos` + +### Accessibility + +**VoiceOver, Dynamic Type** → `/skill axiom-accessibility-diag` + +### Testing + +**UI test flakiness** → `/skill axiom-ui-testing` + +### Automated Scanning + +**Architecture audit** → Launch `swiftui-architecture-auditor` agent (separation of concerns, logic in views, testability) +**Performance scan** → Launch `swiftui-performance-analyzer` agent or `/axiom:audit swiftui-performance` (expensive view body ops, unnecessary updates) +**Navigation audit** → Launch `swiftui-nav-auditor` agent or `/axiom:audit swiftui-nav` (deep link gaps, state restoration, wrong containers) +**Layout audit** → Launch `swiftui-layout-auditor` agent or `/axiom:audit swiftui-layout` (GeometryReader misuse, missing adaptivity, hardcoded breakpoints, identity loss) +**UX flow audit** → Launch `ux-flow-auditor` agent or `/axiom:audit ux-flow` (dead ends, dismiss traps, buried CTAs, missing empty/loading/error states) +**Liquid Glass scan** → Launch `liquid-glass-auditor` agent or `/axiom:audit liquid-glass` (adoption opportunities, toolbar improvements) +**TextKit scan** → Launch `textkit-auditor` agent or `/axiom:audit textkit` (TextKit 1 fallbacks, deprecated glyph APIs, Writing Tools) + +## Decision Tree + +```dot +digraph ios_ui { + start [label="UI issue" shape=ellipse]; + is_tvos [label="tvOS?" shape=diamond]; + is_swiftui [label="SwiftUI?" shape=diamond]; + is_uikit [label="UIKit?" shape=diamond]; + is_design [label="Design/guidelines?" shape=diamond]; + + start -> is_tvos; + is_tvos -> "axiom-tvos" [label="focus, remote, TVUIKit, text input, storage"]; + is_tvos -> is_swiftui [label="no"]; + is_swiftui -> swiftui_type [label="yes"]; + is_swiftui -> is_uikit [label="no"]; + is_uikit -> uikit_type [label="yes"]; + is_uikit -> is_design [label="no"]; + is_design -> design_type [label="yes"]; + is_design -> "accessibility-diag" [label="accessibility"]; + + swiftui_type [label="What's wrong?" shape=diamond]; + swiftui_type -> "swiftui-debugging" [label="view not updating"]; + swiftui_type -> "swiftui-nav" [label="navigation"]; + swiftui_type -> "swiftui-performance" [label="slow/lag"]; + swiftui_type -> "swiftui-layout" [label="adaptive layout"]; + swiftui_type -> "swiftui-containers-ref" [label="stacks/grids/outlines"]; + swiftui_type -> "swiftui-architecture" [label="feature architecture"]; + swiftui_type -> "app-composition" [label="app-level (root, auth, scenes)"]; + swiftui_type -> "swiftui-animation-ref" [label="animations"]; + swiftui_type -> "swiftui-gestures" [label="gestures"]; + swiftui_type -> "transferable-ref" [label="drag/drop, sharing, copy/paste"]; + swiftui_type -> "swiftui-search-ref" [label="search"]; + swiftui_type -> "swiftui-26-ref" [label="iOS 26 features"]; + swiftui_type -> "uikit-bridging" [label="UIKit interop"]; + swiftui_type -> "ux-flow-audit" [label="UX dead ends, dismiss traps"]; + + uikit_type [label="UIKit issue?" shape=diamond]; + uikit_type -> "auto-layout-debugging" [label="Auto Layout"]; + uikit_type -> "uikit-animation-debugging" [label="animations"]; + uikit_type -> "uikit-bridging" [label="SwiftUI embedding"]; + uikit_type -> "ux-flow-audit" [label="UX dead ends, dismiss traps"]; + + design_type [label="Design topic?" shape=diamond]; + design_type -> "liquid-glass" [label="Liquid Glass"]; + design_type -> "sf-symbols" [label="SF Symbols"]; + design_type -> "hig" [label="HIG compliance"]; + design_type -> "typography-ref" [label="typography"]; + design_type -> "textkit-ref" [label="TextKit/rich text"]; +} +``` + +**Automated scanning agents:** +- Want architecture audit (separation of concerns, testability)? → swiftui-architecture-auditor (Agent) +- Want SwiftUI performance scan (view body ops, unnecessary updates)? → swiftui-performance-analyzer (Agent) +- Want navigation audit (deep links, state restoration)? → swiftui-nav-auditor (Agent) +- Want layout audit (GeometryReader, adaptivity, hardcoded sizes)? → swiftui-layout-auditor (Agent) +- Want UX flow audit (dead ends, dismiss traps, missing states)? → ux-flow-auditor (Agent) +- Want Liquid Glass adoption scan? → liquid-glass-auditor (Agent) +- Want TextKit scan (Writing Tools, deprecated APIs)? → textkit-auditor (Agent) + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "Simple SwiftUI layout, no need for the layout skill" | SwiftUI layout has 12 gotchas. swiftui-layout covers all of them. | +| "I know how NavigationStack works" | Navigation has state restoration, deep linking, and identity traps. swiftui-nav prevents 2-hour debugging. | +| "It's just a view not updating, I'll debug it" | View update failures have 4 root causes. swiftui-debugging diagnoses in 5 min. | +| "I'll just add .animation() and fix later" | Animation issues compound. swiftui-animation-ref has the correct patterns. | +| "This UI is simple, no architecture needed" | Even small features benefit from separation. swiftui-architecture prevents refactoring debt. | +| "UX issues are just polish, we'll fix later" | Dead ends and dismiss traps cause 1-star reviews. ux-flow-audit catches them in minutes. | +| "I know how .searchable works" | Search has 6 gotchas (navigation container, isSearching level, suggestion completion). swiftui-search-ref covers all of them. | +| "I know SF Symbols, it's just Image(systemName:)" | 4 rendering modes, 12+ effects, 3 Draw playback modes, custom symbol authoring. sf-symbols has decision trees for all of them. | +| "Drag and drop is just .draggable and .dropDestination" | UTType declarations, representation ordering, file lifecycle, cross-app transfer gotchas. transferable-ref covers all of them. | +| "I'll just wrap this UIView real quick" | UIViewRepresentable has lifecycle, coordinator, sizing, and memory gotchas. uikit-bridging prevents 1-2 hour debugging sessions. | +| "tvOS is just iOS on a TV" | tvOS has no persistent storage, no WebView, a dual focus system, and two remote generations. axiom-tvos covers all the traps. | + +## Example Invocations + +User: "My SwiftUI view isn't updating when I change the model" +→ Invoke: `/skill axiom-swiftui-debugging` + +User: "How do I implement Liquid Glass in my toolbar?" +→ Invoke: `/skill axiom-liquid-glass` + +User: "NavigationStack is popping unexpectedly" +→ Invoke: `/skill axiom-swiftui-nav` + +User: "Should I use MVVM for this SwiftUI app?" +→ Invoke: `/skill axiom-swiftui-architecture` + +User: "How do I switch between login and main screens?" +→ Invoke: `/skill axiom-app-composition` + +User: "Where should auth state live in my app?" +→ Invoke: `/skill axiom-app-composition` + +User: "How do I create a grid layout with LazyVGrid?" +→ Invoke: `/skill axiom-swiftui-containers-ref` + +User: "What's the difference between VStack and LazyVStack?" +→ Invoke: `/skill axiom-swiftui-containers-ref` + +User: "How do I display hierarchical data with OutlineGroup?" +→ Invoke: `/skill axiom-swiftui-containers-ref` + +User: "How do I add search to my SwiftUI list?" +→ Invoke: `/skill axiom-swiftui-search-ref` + +User: "My search suggestions aren't working" +→ Invoke: `/skill axiom-swiftui-search-ref` + +User: "How do I animate an SF Symbol when tapped?" +→ Invoke: `/skill axiom-sf-symbols` + +User: "My SF Symbol Draw animation isn't working on my custom symbol" +→ Invoke: `/skill axiom-sf-symbols` + +User: "Which rendering mode should I use for my toolbar icons?" +→ Invoke: `/skill axiom-sf-symbols` + +User: "How do I make my model draggable in SwiftUI?" +→ Invoke: `/skill axiom-transferable-ref` + +User: "How do I add ShareLink with a custom preview?" +→ Invoke: `/skill axiom-transferable-ref` + +User: "How do I wrap a UIKit view in SwiftUI?" +→ Invoke: `/skill axiom-uikit-bridging` + +User: "How do I embed SwiftUI in my UIKit app?" +→ Invoke: `/skill axiom-uikit-bridging` + +User: "My UIViewRepresentable isn't updating correctly" +→ Invoke: `/skill axiom-uikit-bridging` + +User: "How do I use UIHostingConfiguration for collection view cells?" +→ Invoke: `/skill axiom-uikit-bridging` + +User: "I'm building a tvOS app and focus navigation isn't working" +→ Invoke: `/skill axiom-tvos` + +User: "How do I handle text input on tvOS?" +→ Invoke: `/skill axiom-tvos` + +User: "Check my app for UX dead ends and dismiss traps" +→ Invoke: `ux-flow-auditor` agent + +User: "Check my SwiftUI architecture for separation of concerns" +→ Invoke: `swiftui-architecture-auditor` agent + +User: "Scan my SwiftUI views for performance issues" +→ Invoke: `swiftui-performance-analyzer` agent + +User: "Audit my navigation for deep link gaps" +→ Invoke: `swiftui-nav-auditor` agent + +User: "Check my layouts for iPad and multitasking issues" +→ Invoke: `swiftui-layout-auditor` agent + +User: "Check my app for Liquid Glass adoption opportunities" +→ Invoke: `liquid-glass-auditor` agent + +User: "Why isn't Writing Tools appearing in my text view?" +→ Invoke: `textkit-auditor` agent diff --git a/.claude/skills/axiom-ios-vision/.openskills.json b/.claude/skills/axiom-ios-vision/.openskills.json new file mode 100644 index 0000000..67f8740 --- /dev/null +++ b/.claude/skills/axiom-ios-vision/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-vision", + "installedAt": "2026-04-12T08:05:35.627Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-ios-vision/SKILL.md b/.claude/skills/axiom-ios-vision/SKILL.md new file mode 100644 index 0000000..16c625e --- /dev/null +++ b/.claude/skills/axiom-ios-vision/SKILL.md @@ -0,0 +1,151 @@ +--- +name: axiom-ios-vision +description: Use when implementing ANY computer vision feature - image analysis, object detection, pose detection, person segmentation, subject lifting, hand/body pose tracking. +license: MIT +--- + +# iOS Computer Vision Router + +**You MUST use this skill for ANY computer vision work using the Vision framework.** + +## When to Use + +Use this router when: +- Analyzing images or video +- Detecting objects, faces, or people +- Tracking hand or body pose +- Segmenting people or subjects +- Lifting subjects from backgrounds +- Recognizing text in images (OCR) +- Detecting barcodes or QR codes +- Scanning documents +- Using VisionKit or DataScannerViewController +- Integrating with Visual Intelligence (iOS 26+ system camera feature) + +## Routing Logic + +### Vision Work + +**Implementation patterns** → `/skill axiom-vision` +- Subject segmentation (VisionKit) +- Hand pose detection (21 landmarks) +- Body pose detection (2D/3D) +- Person segmentation +- Face detection +- Isolating objects while excluding hands +- Text recognition (VNRecognizeTextRequest) +- Barcode/QR detection (VNDetectBarcodesRequest) +- Document scanning (VNDocumentCameraViewController) +- Live scanning (DataScannerViewController) +- Structured document extraction (RecognizeDocumentsRequest, iOS 26+) + +**API reference** → `/skill axiom-vision-ref` +- Complete Vision framework API +- VNDetectHumanHandPoseRequest +- VNDetectHumanBodyPoseRequest +- VNGenerateForegroundInstanceMaskRequest +- VNRecognizeTextRequest (fast/accurate modes) +- VNDetectBarcodesRequest (symbologies) +- DataScannerViewController delegates +- RecognizeDocumentsRequest (iOS 26+) +- Coordinate conversion patterns + +**Visual Intelligence integration** → `/skill axiom-vision-ref` (see Visual Intelligence Integration section) +- Making app content discoverable to Visual Intelligence camera +- `IntentValueQuery` and `SemanticContentDescriptor` +- Deep linking from Visual Intelligence results + +**Diagnostics** → `/skill axiom-vision-diag` +- Subject not detected +- Hand pose missing landmarks +- Low confidence observations +- Performance issues +- Coordinate conversion bugs +- Text not recognized or wrong characters +- Barcodes not detected +- DataScanner showing blank or no items +- Document edges not detected + +## Decision Tree + +1. Implementing (pose, segmentation, OCR, barcodes, documents, live scanning)? → vision +2. Visual Intelligence system integration (camera feature, iOS 26+)? → vision-ref (Visual Intelligence section) +3. Need API reference / code examples? → vision-ref +4. Debugging issues (detection failures, confidence, coordinates)? → vision-diag + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "Vision framework is just a request/handler pattern" | Vision has coordinate conversion, confidence thresholds, and performance gotchas. vision covers them. | +| "I'll handle text recognition without the skill" | VNRecognizeTextRequest has fast/accurate modes and language-specific settings. vision has the patterns. | +| "Subject segmentation is straightforward" | Instance masks have HDR compositing and hand-exclusion patterns. vision covers complex scenarios. | +| "Visual Intelligence is just the camera API" | Visual Intelligence is a system-level feature requiring IntentValueQuery and SemanticContentDescriptor. vision-ref has the integration section. | + +## Critical Patterns + +**vision**: +- Subject segmentation with VisionKit +- Hand pose detection (21 landmarks) +- Body pose detection (2D/3D, up to 4 people) +- Isolating objects while excluding hands +- CoreImage HDR compositing +- Text recognition (fast vs accurate modes) +- Barcode detection (symbology selection) +- Document scanning with perspective correction +- Live scanning with DataScannerViewController +- Structured document extraction (iOS 26+) + +**vision-diag**: +- Subject detection failures +- Landmark tracking issues +- Performance optimization +- Observation confidence thresholds +- Text recognition failures (language, contrast) +- Barcode detection issues (symbology, distance) +- DataScanner troubleshooting +- Document edge detection problems + +## Example Invocations + +User: "How do I detect hand pose in an image?" +→ Invoke: `/skill axiom-vision` + +User: "Isolate a subject but exclude the user's hands" +→ Invoke: `/skill axiom-vision` + +User: "How do I read text from an image?" +→ Invoke: `/skill axiom-vision` + +User: "Scan QR codes with the camera" +→ Invoke: `/skill axiom-vision` + +User: "How do I implement document scanning?" +→ Invoke: `/skill axiom-vision` + +User: "Use DataScannerViewController for live text" +→ Invoke: `/skill axiom-vision` + +User: "Subject detection isn't working" +→ Invoke: `/skill axiom-vision-diag` + +User: "Text recognition returns wrong characters" +→ Invoke: `/skill axiom-vision-diag` + +User: "Barcode not being detected" +→ Invoke: `/skill axiom-vision-diag` + +User: "Show me VNDetectHumanBodyPoseRequest examples" +→ Invoke: `/skill axiom-vision-ref` + +User: "What symbologies does VNDetectBarcodesRequest support?" +→ Invoke: `/skill axiom-vision-ref` + +User: "RecognizeDocumentsRequest API reference" +→ Invoke: `/skill axiom-vision-ref` + +User: "How do I make my app work with Visual Intelligence?" +→ Invoke: `/skill axiom-vision-ref` + +User: "How do users discover my app content through the camera?" +→ Invoke: `/skill axiom-vision-ref` diff --git a/.claude/skills/axiom-keychain-diag/.openskills.json b/.claude/skills/axiom-keychain-diag/.openskills.json new file mode 100644 index 0000000..549646c --- /dev/null +++ b/.claude/skills/axiom-keychain-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-keychain-diag", + "installedAt": "2026-04-12T08:06:25.387Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-keychain-diag/SKILL.md b/.claude/skills/axiom-keychain-diag/SKILL.md new file mode 100644 index 0000000..e632988 --- /dev/null +++ b/.claude/skills/axiom-keychain-diag/SKILL.md @@ -0,0 +1,358 @@ +--- +name: axiom-keychain-diag +description: Use when SecItem calls fail — errSecDuplicateItem from unexpected uniqueness, errSecItemNotFound despite item existing, errSecInteractionNotAllowed in background, keychain items disappearing after app update, access group entitlement errors, or Mac keychain shim issues. Covers systematic error diagnosis with decision trees. +license: MIT +--- + +# Keychain Diagnostics + +Systematic troubleshooting for Security framework failures: uniqueness constraint violations, query mismatches, data protection timing, access group entitlements, disappearing items after updates, and Mac shim behavior differences. + +## Overview + +**Core Principle**: When keychain operations fail, the problem is usually: +1. **Uniqueness constraint mismatch** (errSecDuplicateItem) — 25% +2. **Query attribute confusion** (errSecItemNotFound) — 25% +3. **Data protection / background timing** (errSecInteractionNotAllowed) — 20% +4. **Access group / entitlement mismatch** (errSecMissingEntitlement) — 15% +5. **Mac shim behavior differences** — 10% +6. **Lost items after app update** (entitlement or App ID prefix change) — 5% + +**Always dump existing items and compare attributes BEFORE changing keychain code.** + +## Red Flags + +Symptoms that indicate keychain-specific issues: + +| Symptom | Likely Cause | +|---------|--------------| +| errSecDuplicateItem when query returned not found | Non-unique attributes in add query — uniqueness is per-class + primary key attributes, not per your full query | +| errSecItemNotFound but item was just added | Wrong `kSecClass`, erroneous attribute narrowing query, or access group mismatch | +| errSecInteractionNotAllowed in background | `kSecAttrAccessibleWhenUnlocked` (default) + device locked + background execution | +| errSecMissingEntitlement | Access group not listed in keychain-access-groups entitlement | +| errSecNoSuchAttr | Attribute not supported for item class (e.g. `kSecAttrApplicationTag` on `kSecClassGenericPassword`) | +| errSecAuthFailed on Mac | File-based keychain locked or timed out | +| Items gone after app update | Access group or entitlement changed between versions | +| Items gone after team change | App ID prefix changed — items keyed to old prefix are inaccessible | +| SecItemDelete deleted everything | `kSecMatchLimit` is irrelevant for delete — it deletes ALL matching items | +| Keychain works in simulator, fails on device | Simulator does not enforce data protection — device does | + +## Anti-Rationalization + +| Rationalization | Why It Fails | Time Cost | +|----------------|--------------|-----------| +| "The wrapper handles it" | Wrappers hide uniqueness constraints. When errSecDuplicateItem happens, you can't debug what you can't see. You end up reading the wrapper source. | 30+ min unwrapping the wrapper | +| "I'll just delete and re-add" | Loses item metadata, breaks iCloud Keychain sync state, and if the delete query is broader than intended, silently deletes other items too. | 1-2 hours debugging missing credentials | +| "UserDefaults is fine for this one token" | UserDefaults is unencrypted, backed up to iCloud, visible to MDM profiles, and readable via device backup extraction. One security audit catches it. | Hours migrating to keychain after rejection | +| "errSecItemNotFound means it's not there" | It means your query didn't match. The item may exist with different attributes than you're searching for. Dump all items to check. | 30-60 min rewriting add logic when the item already exists | +| "I'll fix the keychain code after launch" | Keychain bugs are silent data loss. Users lose credentials after an update, can't log in, and have no recovery path. You find out from 1-star reviews. | Days of emergency patches + user trust damage | + +## Mandatory First Steps + +Before changing keychain code, run these diagnostics: + +### Step 1: Dump All Items of the Relevant Class + +```swift +let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + kSecReturnRef as String: true +] +var result: AnyObject? +let status = SecItemCopyMatching(query as CFDictionary, &result) +if status == errSecSuccess, let items = result as? [[String: Any]] { + for item in items { + print(item) + } +} +``` + +This reveals every item of that class your app can see — including ones you forgot about. + +### Step 2: Compare Attributes Against Your Query + +Check each attribute in your add/update/search query against the dump output. Common mismatches: +- `kSecAttrAccount` vs `kSecAttrService` — which one are you using for the key? +- `kSecAttrAccessGroup` — are you specifying one that differs from the default? +- Extra attributes narrowing the search (e.g. `kSecAttrLabel` you set on add but omit on search) + +### Step 3: Check Accessibility Class vs Device Lock State + +```swift +// In your dump, look for: +// kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked (default — fails when locked) +// kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock (survives background) +``` + +If the app accesses keychain in background (push notification handlers, background fetch), `WhenUnlocked` will fail on a locked device. + +### Step 4: Verify Access Group Entitlements + +```bash +codesign -d --entitlements - /path/to/YourApp.app 2>&1 | grep keychain-access-groups +``` + +The access group in your query must appear in this list. The default group is `$(AppIdentifierPrefix)$(CFBundleIdentifier)`. + +## Decision Trees + +### Tree 1: errSecDuplicateItem + +```dot +digraph tree1 { + "errSecDuplicateItem?" [shape=diamond]; + "Dump all items (Step 1)" [shape=box]; + "Item with same primary keys exists?" [shape=diamond]; + "Same kSecAttrAccount + kSecAttrService?" [shape=diamond]; + + "Use SecItemUpdate" [shape=box, label="Use SecItemUpdate instead.\nQuery with primary key attrs only.\nPass new values in attributesToUpdate."]; + "Query-before-add" [shape=box, label="Search first, update if found:\nSecItemCopyMatching → exists?\n yes → SecItemUpdate\n no → SecItemAdd"]; + "Different account/service" [shape=box, label="Your add query matches an existing\nitem on primary key attributes.\nkSecClassGenericPassword uniqueness:\n kSecAttrAccount + kSecAttrService\n + kSecAttrAccessGroup"]; + "Check access group" [shape=box, label="Item exists in a different access\ngroup. Your search missed it but\nadd sees it. Specify kSecAttrAccessGroup\nexplicitly in both operations."]; + + "errSecDuplicateItem?" -> "Dump all items (Step 1)"; + "Dump all items (Step 1)" -> "Item with same primary keys exists?" [label="inspect"]; + "Item with same primary keys exists?" -> "Same kSecAttrAccount + kSecAttrService?" [label="yes"]; + "Item with same primary keys exists?" -> "Check access group" [label="no visible match"]; + "Same kSecAttrAccount + kSecAttrService?" -> "Use SecItemUpdate" [label="yes, want to overwrite"]; + "Same kSecAttrAccount + kSecAttrService?" -> "Different account/service" [label="no, different values"]; + "Use SecItemUpdate" -> "Query-before-add" [label="prevent future duplicates"]; +} +``` + +**Uniqueness constraints by class**: + +| Class | Primary Key Attributes | +|-------|----------------------| +| kSecClassGenericPassword | kSecAttrAccount + kSecAttrService + kSecAttrAccessGroup | +| kSecClassInternetPassword | kSecAttrAccount + kSecAttrSecurityDomain + kSecAttrServer + kSecAttrProtocol + kSecAttrAuthenticationType + kSecAttrPort + kSecAttrPath | +| kSecClassCertificate | kSecAttrCertificateType + kSecAttrIssuer + kSecAttrSerialNumber | +| kSecClassKey | kSecAttrKeyClass + kSecAttrKeyType + kSecAttrApplicationLabel + kSecAttrApplicationTag + kSecAttrEffectiveKeySize | + +### Tree 2: errSecItemNotFound + +```dot +digraph tree2 { + "errSecItemNotFound?" [shape=diamond]; + "Dump all items (Step 1)" [shape=box]; + "Any items returned?" [shape=diamond]; + "Correct kSecClass?" [shape=diamond]; + "Erroneous attribute?" [shape=diamond]; + + "Class mismatch" [shape=box, label="Wrong kSecClass in query.\nGenericPassword vs InternetPassword\nis the most common confusion.\nKeys use kSecClassKey."]; + "Narrow query" [shape=box, label="Erroneous attribute narrows\nquery to match nothing.\nRemove attributes one at a time\nuntil item is found.\nCommon: kSecAttrLabel, kSecAttrType"]; + "Access group" [shape=box, label="Item exists in different\naccess group than query.\nCheck kSecAttrAccessGroup\nor omit it to use default."]; + "Data protection" [shape=box, label="Item exists but device is locked\nand item has WhenUnlocked accessibility.\nSee Tree 3."]; + "Not added yet" [shape=box, label="Item was never successfully added.\nCheck return value of SecItemAdd\n— was it errSecSuccess?"]; + + "errSecItemNotFound?" -> "Dump all items (Step 1)"; + "Dump all items (Step 1)" -> "Any items returned?" [label="check"]; + "Any items returned?" -> "Not added yet" [label="no items at all"]; + "Any items returned?" -> "Correct kSecClass?" [label="yes, items exist"]; + "Correct kSecClass?" -> "Class mismatch" [label="no"]; + "Correct kSecClass?" -> "Erroneous attribute?" [label="yes"]; + "Erroneous attribute?" -> "Narrow query" [label="yes, extra attrs"]; + "Erroneous attribute?" -> "Access group" [label="no, attrs match"]; + "Access group" -> "Data protection" [label="access group matches too"]; +} +``` + +### Tree 3: errSecInteractionNotAllowed + +```dot +digraph tree3 { + "errSecInteractionNotAllowed?" [shape=diamond]; + "Background execution?" [shape=diamond]; + "Device locked?" [shape=diamond]; + "Check accessibility" [shape=diamond]; + + "Change accessibility" [shape=box, label="Migrate item to\nkSecAttrAccessibleAfterFirstUnlock\nor AfterFirstUnlockThisDeviceOnly.\nRequires delete + re-add."]; + "Timing issue" [shape=box, label="App launched in background\nbefore first unlock after reboot.\nDefer keychain access until\nUIApplication.protectedDataDidBecomeAvailable"]; + "Delete trap" [shape=octagon, label="DANGER: Do NOT delete and re-add\njust to change accessibility.\nIf device is locked, the delete\nwill succeed but the add will FAIL\n— you lose the credential."]; + "Not data protection" [shape=box, label="On Mac: file-based keychain\nmay be locked. Check\nsecurity unlock-keychain.\nOr keychain requires user\ninteraction (SecAccessControl)."]; + "Check SecAccessControl" [shape=box, label="If using biometric protection\n(SecAccessControlCreateWithFlags),\nbackground access is impossible.\nStore a separate non-biometric\ncopy for background use."]; + + "errSecInteractionNotAllowed?" -> "Background execution?" [label="check context"]; + "Background execution?" -> "Device locked?" [label="yes"]; + "Background execution?" -> "Not data protection" [label="no, foreground"]; + "Device locked?" -> "Check accessibility" [label="yes"]; + "Device locked?" -> "Check SecAccessControl" [label="no, unlocked but still fails"]; + "Check accessibility" -> "Timing issue" [label="WhenUnlocked + after reboot"]; + "Check accessibility" -> "Change accessibility" [label="WhenUnlocked + normal lock"]; + "Change accessibility" -> "Delete trap" [label="WARNING"]; +} +``` + +### Tree 4: errSecMissingEntitlement + +```dot +digraph tree4 { + "errSecMissingEntitlement?" [shape=diamond]; + "Using explicit access group?" [shape=diamond]; + "Check entitlements (Step 4)" [shape=box]; + "Group in entitlements?" [shape=diamond]; + + "Add to entitlements" [shape=box, label="Xcode > Target >\nSigning & Capabilities >\nKeychain Sharing >\nAdd access group"]; + "Prefix mismatch" [shape=box, label="Access group must use\nApp ID prefix (Team ID or\nApp ID prefix from portal).\n$(AppIdentifierPrefix)com.your.group\nNOT just com.your.group"]; + "Shared group config" [shape=box, label="For shared keychain between apps:\n1. Same Team ID\n2. Same access group string\n3. Both apps list group in\n Keychain Sharing capability"]; + "Default group" [shape=box, label="If not specifying access group,\ndefault is AppIdentifierPrefix +\nbundle ID. Verify your app's\nprefix hasn't changed."]; + + "errSecMissingEntitlement?" -> "Using explicit access group?" [label="check query"]; + "Using explicit access group?" -> "Check entitlements (Step 4)" [label="yes"]; + "Using explicit access group?" -> "Default group" [label="no"]; + "Check entitlements (Step 4)" -> "Group in entitlements?" [label="inspect"]; + "Group in entitlements?" -> "Prefix mismatch" [label="no, group missing"]; + "Group in entitlements?" -> "Shared group config" [label="yes but still fails"]; + "Prefix mismatch" -> "Add to entitlements" [label="fix"]; +} +``` + +### Tree 5: Lost Keychain Items After App Update + +```dot +digraph tree5 { + "Items gone after update?" [shape=diamond]; + "Access group changed?" [shape=diamond]; + "App ID prefix changed?" [shape=diamond]; + "Entitlements file changed?" [shape=diamond]; + + "Restore access group" [shape=box, label="Add the OLD access group back\nto Keychain Sharing entitlement.\nItems are keyed to the group\nthey were created with."]; + "Prefix migration" [shape=box, label="App ID prefix change means\nnew items are under new prefix.\nOld items are under old prefix.\nAdd both prefixes to entitlements\nor migrate items at first launch."]; + "Entitlement restore" [shape=box, label="If Keychain Sharing was removed,\nthe default access group changed.\nRe-add Keychain Sharing with\nthe original group name."]; + "Query change" [shape=box, label="Check if the query attributes\nchanged between versions.\nDump items (Step 1) to verify\nitems still exist under old attrs."]; + + "Items gone after update?" -> "Access group changed?" [label="check entitlements diff"]; + "Access group changed?" -> "Restore access group" [label="yes"]; + "Access group changed?" -> "App ID prefix changed?" [label="no"]; + "App ID prefix changed?" -> "Prefix migration" [label="yes, team transfer"]; + "App ID prefix changed?" -> "Entitlements file changed?" [label="no"]; + "Entitlements file changed?" -> "Entitlement restore" [label="yes"]; + "Entitlements file changed?" -> "Query change" [label="no, entitlements identical"]; +} +``` + +### Tree 6: Mac-Specific Issues + +```dot +digraph tree6 { + "Mac keychain issue?" [shape=diamond]; + "Catalyst or native?" [shape=diamond]; + "File-based keychain?" [shape=diamond]; + + "Shim behavior" [shape=box, label="Mac Catalyst uses iOS-style\ndata-protection keychain by default.\nkSecUseDataProtectionKeychain = true\nis automatic on Catalyst.\nFile-based keychain quirks don't apply."]; + "Native Mac" [shape=box, label="Native macOS apps default to\nfile-based keychain unless you set\nkSecUseDataProtectionKeychain = true.\nFile-based has different:\n- kSecMatchLimit defaults\n- Locking behavior\n- Access control prompts"]; + "Match limit" [shape=box, label="File-based keychain default:\nkSecMatchLimit = kSecMatchLimitAll\nData-protection keychain default:\nkSecMatchLimit = kSecMatchLimitOne\nAlways set explicitly."]; + "Lock timeout" [shape=box, label="File-based keychain locks after\ntimeout (default: sleep + 5 min idle).\nerrSecAuthFailed = locked keychain.\nsecurity unlock-keychain to test."]; + "Use data protection" [shape=box, label="For cross-platform code,\nset kSecUseDataProtectionKeychain = true\non macOS. This gives iOS-identical\nbehavior on macOS 10.15+."]; + + "Mac keychain issue?" -> "Catalyst or native?" [label="check target"]; + "Catalyst or native?" -> "Shim behavior" [label="Catalyst"]; + "Catalyst or native?" -> "File-based keychain?" [label="native macOS"]; + "File-based keychain?" -> "Match limit" [label="unexpected result count"]; + "File-based keychain?" -> "Lock timeout" [label="errSecAuthFailed"]; + "File-based keychain?" -> "Use data protection" [label="want iOS-identical behavior"]; + "Shim behavior" -> "Native Mac" [label="opted out of shim"]; +} +``` + +### Tree 7: errSecNoSuchAttr + +```dot +digraph tree7 { + "errSecNoSuchAttr?" [shape=diamond]; + "Check attr vs class" [shape=box, label="Not all attributes work\nwith all item classes.\nDump item to see which\nattributes it actually has."]; + "Common mistakes" [shape=diamond]; + + "Tag on password" [shape=box, label="kSecAttrApplicationTag is for\nkSecClassKey only.\nFor passwords, use\nkSecAttrAccount or kSecAttrService."]; + "Label mismatch" [shape=box, label="kSecAttrLabel behavior differs:\n- Passwords: free-form string\n- Keys: computed from key data\n- Certs: computed from subject\nSetting it may be silently ignored."]; + "Description on key" [shape=box, label="kSecAttrDescription is for\nkSecClassGenericPassword and\nkSecClassInternetPassword only.\nNot available on keys or certs."]; + + "errSecNoSuchAttr?" -> "Check attr vs class" [label="first"]; + "Check attr vs class" -> "Common mistakes" [label="identify"]; + "Common mistakes" -> "Tag on password" [label="kSecAttrApplicationTag + password"]; + "Common mistakes" -> "Label mismatch" [label="kSecAttrLabel unexpected behavior"]; + "Common mistakes" -> "Description on key" [label="kSecAttrDescription + key/cert"]; +} +``` + +## Quick Reference Table + +| Symptom | Check | Fix | +|---------|-------|-----| +| errSecDuplicateItem | Dump items (Step 1), compare primary key attrs | Use SecItemUpdate or query-before-add pattern | +| errSecItemNotFound | Dump items, verify kSecClass + attributes match | Remove erroneous attributes, fix class | +| errSecInteractionNotAllowed in background | Check kSecAttrAccessible value | Migrate to AfterFirstUnlock (delete + re-add while unlocked) | +| errSecInteractionNotAllowed after reboot | Check if first unlock happened | Defer access until protectedDataDidBecomeAvailable | +| errSecMissingEntitlement | `codesign -d --entitlements -` for access groups | Add group to Keychain Sharing capability | +| errSecNoSuchAttr | Check attribute compatibility with item class | Use correct attribute for the class | +| errSecAuthFailed on Mac | Check if file-based keychain is locked | `security unlock-keychain` or use data-protection keychain | +| Items gone after update | Diff entitlements between versions | Restore old access group, migrate items | +| Items gone after team change | Check App ID prefix change | Add both prefixes to entitlements | +| Delete removed too many items | Review delete query specificity | Always specify all primary key attrs in delete query | +| Works in simulator, fails on device | Check accessibility class | Simulator ignores data protection — test on device | +| Inconsistent Mac vs iOS behavior | Check kSecUseDataProtectionKeychain | Set to true for consistent cross-platform behavior | +| Query returns wrong item | Check kSecMatchLimit | Always set explicitly — defaults differ by keychain type | +| Biometric item fails in background | Check SecAccessControl flags | Store separate non-biometric copy for background | +| SecItemAdd returns errSecSuccess but search fails | Check if access groups differ between add and search | Specify kSecAttrAccessGroup explicitly in both | + +## Pressure Scenarios + +### Scenario 1: "Users can't log in after the update — just clear and re-store the token" + +**Context**: Version 2.1 shipped with a Keychain Sharing entitlement change. Users updating from 2.0 lose their auth tokens. Support tickets are flooding in. + +**Pressure**: "Just delete the old item and store a new one on first launch." + +**Reality**: The old item is inaccessible because the access group changed — SecItemDelete can't find it either. The "delete and re-add" approach silently does nothing. Meanwhile, the real fix is restoring the old access group in entitlements so existing items are readable again, then migrating to the new group. + +**Correct action**: Add the old access group back to the Keychain Sharing entitlement. On first launch, read from old group, write to new group, delete from old group. Ship as 2.1.1. + +**Push-back template**: "The delete won't work either — the old items are under the old access group that we can no longer read. We need to add the old access group back to our entitlements so we can read and migrate those items. This is a 30-minute fix, not a redesign." + +### Scenario 2: "errSecInteractionNotAllowed in push handler — just change to AfterFirstUnlock" + +**Context**: Background push notification handler reads an auth token from keychain to call an API. Fails with errSecInteractionNotAllowed when device is locked. + +**Pressure**: "Just change the accessibility to AfterFirstUnlock. Quick fix." + +**Reality**: Changing accessibility requires deleting the old item and adding a new one with the new accessibility class. If you do this in the push handler while the device is locked, the delete succeeds (it doesn't read data) but the add fails (AfterFirstUnlock still requires first unlock, and if the device just rebooted, first unlock hasn't happened). You just deleted the user's credential. + +**Correct action**: Change accessibility in foreground code (app launch, `protectedDataDidBecomeAvailable`). Never migrate keychain items in background execution paths. + +**Push-back template**: "We can't change accessibility in the push handler — the delete works but the re-add can fail if the device rebooted without unlocking. We need to migrate in the foreground on next app launch, and handle the push handler failure gracefully until then." + +### Scenario 3: "The keychain wrapper handles all this — just use it" + +**Context**: Team uses a third-party keychain wrapper (KeychainAccess, Valet, etc.). errSecDuplicateItem keeps happening despite the wrapper's "upsert" method. + +**Pressure**: "The wrapper documentation says it handles duplicates. Must be a bug in the wrapper." + +**Reality**: The wrapper's upsert does query-then-add or query-then-update. But if your query attributes don't match the uniqueness constraints of the item class, the search returns not-found while the add hits the existing item's primary key. The wrapper can't fix a query that uses the wrong attributes. You need to understand what makes items unique and ensure your wrapper configuration matches. + +**Correct action**: Dump all items (Step 1) to see what exists. Compare the wrapper's query attributes against the item class uniqueness constraints table. Fix the wrapper configuration to query on primary key attributes. + +**Push-back template**: "The wrapper works correctly — it's our configuration that doesn't match the keychain's uniqueness constraints. Let me dump the existing items and compare against our query. This is a 10-minute diagnosis." + +## Checklist + +Before declaring a keychain issue fixed: + +- [ ] Dumped all items of relevant class — understand what exists +- [ ] Verified kSecClass matches the item type (GenericPassword vs InternetPassword vs Key) +- [ ] Checked primary key attributes for uniqueness constraints +- [ ] Confirmed kSecAttrAccessible suits the execution context (foreground vs background) +- [ ] Verified access group in entitlements matches query +- [ ] Tested on device (not just simulator — simulator ignores data protection) +- [ ] Tested after device reboot + lock for background scenarios +- [ ] If migrating accessibility: migration runs in foreground only, never background +- [ ] If sharing between apps: both apps have same access group in Keychain Sharing + +## Resources + +**Docs**: /security/keychain_services, /security/keychain_services/keychain_items, /security/errSecDuplicateItem, /security/errSecItemNotFound, /security/errSecInteractionNotAllowed + +**Reference**: Quinn "The Eskimo" — SecItem Pitfalls and Best Practices (Apple Developer Forums), Keychain Items Fundamentals (Apple TN3137) + +**Skills**: axiom-keychain, axiom-keychain-ref diff --git a/.claude/skills/axiom-keychain-diag/agents/openai.yaml b/.claude/skills/axiom-keychain-diag/agents/openai.yaml new file mode 100644 index 0000000..91e5d9f --- /dev/null +++ b/.claude/skills/axiom-keychain-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Keychain Diagnostics" + short_description: "SecItem calls fail — errSecDuplicateItem from unexpected uniqueness, errSecItemNotFound despite item existing, errSec..." diff --git a/.claude/skills/axiom-keychain-ref/.openskills.json b/.claude/skills/axiom-keychain-ref/.openskills.json new file mode 100644 index 0000000..ea488db --- /dev/null +++ b/.claude/skills/axiom-keychain-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-keychain-ref", + "installedAt": "2026-04-12T08:06:25.915Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-keychain-ref/SKILL.md b/.claude/skills/axiom-keychain-ref/SKILL.md new file mode 100644 index 0000000..fd93a2c --- /dev/null +++ b/.claude/skills/axiom-keychain-ref/SKILL.md @@ -0,0 +1,585 @@ +--- +name: axiom-keychain-ref +description: Use when needing SecItem function signatures, keychain attribute constants, item class uniqueness constraints, accessibility level details, SecAccessControlCreateFlags, kSecReturn behavior per class, LAContext keychain integration, or OSStatus error codes. Covers complete keychain API surface. +license: MIT +--- + +# Keychain Services API Reference + +Comprehensive API reference for iOS/macOS Keychain Services: SecItem CRUD functions, item class attributes, uniqueness constraints, accessibility levels, access control flags, biometric integration, and error codes. + +## Quick Reference + +```swift +// Add a generic password +let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.example.app", + kSecAttrAccount as String: "user@example.com", + kSecValueData as String: "secret".data(using: .utf8)! +] +let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + +// Read a generic password +let readQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.example.app", + kSecAttrAccount as String: "user@example.com", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne +] +var result: AnyObject? +let readStatus = SecItemCopyMatching(readQuery as CFDictionary, &result) +let data = result as? Data + +// Update a generic password +let updateQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.example.app", + kSecAttrAccount as String: "user@example.com" +] +let updateAttributes: [String: Any] = [ + kSecValueData as String: "newSecret".data(using: .utf8)! +] +let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary) + +// Delete a generic password +let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.example.app", + kSecAttrAccount as String: "user@example.com" +] +let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) +``` + +--- + +## SecItem Functions + +### SecItemAdd + +```swift +func SecItemAdd(_ attributes: CFDictionary, _ result: UnsafeMutablePointer?) -> OSStatus +``` + +**`attributes` dictionary accepts**: Item class + item attributes + value properties + return type properties. + +Does NOT accept search properties (`kSecMatch*`). Providing `kSecMatchLimit` in an add query is an error. + +**`result`**: Pass `nil` if you don't need the added item back. Pass a pointer to receive the item in the format specified by `kSecReturn*` keys. Pass `nil` in most cases — requesting the result back forces an extra read. + +### SecItemCopyMatching + +```swift +func SecItemCopyMatching(_ query: CFDictionary, _ result: UnsafeMutablePointer?) -> OSStatus +``` + +**`query` dictionary accepts**: Item class + item attributes + search properties + return type properties. + +Does NOT accept value properties (`kSecValueData`) as search criteria. + +**`result`**: The type depends on which `kSecReturn*` keys are set: +- `kSecReturnData` alone → `CFData` +- `kSecReturnAttributes` alone → `CFDictionary` +- `kSecReturnRef` alone → `SecKey` / `SecCertificate` / `SecIdentity` +- `kSecReturnPersistentRef` alone → `CFData` (persistent reference) +- Multiple `kSecReturn*` keys → `CFDictionary` containing requested types +- `kSecMatchLimit = kSecMatchLimitAll` → `CFArray` of the above + +### SecItemUpdate + +```swift +func SecItemUpdate(_ query: CFDictionary, _ attributesToUpdate: CFDictionary) -> OSStatus +``` + +**`query` dictionary accepts**: Item class + item attributes + search properties. Used to find items to update. + +**`attributesToUpdate` dictionary accepts**: Item attributes + value properties. These are applied to matched items. Does NOT accept item class or search properties. + +Updating `kSecValueData` replaces the stored secret. Updating attributes (e.g., `kSecAttrLabel`) changes metadata without touching the secret. + +### SecItemDelete + +```swift +func SecItemDelete(_ query: CFDictionary) -> OSStatus +``` + +**`query` dictionary accepts**: Item class + item attributes + search properties. + +On macOS, deletes ALL matching items by default (implicit `kSecMatchLimitAll`). On iOS, also deletes all matches. There is no confirmation — deletion is immediate. + +--- + +## Item Classes + +### kSecClassGenericPassword + +General-purpose secret storage. The most commonly used class. + +| Attribute | Key | Type | +|-----------|-----|------| +| Service | `kSecAttrService` | `CFString` | +| Account | `kSecAttrAccount` | `CFString` | +| Access Group | `kSecAttrAccessGroup` | `CFString` | +| Accessible | `kSecAttrAccessible` | `CFString` (constant) | +| Synchronizable | `kSecAttrSynchronizable` | `CFBoolean` | +| Label | `kSecAttrLabel` | `CFString` | +| Description | `kSecAttrDescription` | `CFString` | +| Comment | `kSecAttrComment` | `CFString` | +| Generic | `kSecAttrGeneric` | `CFData` | +| Creator | `kSecAttrCreator` | `CFNumber` (FourCharCode) | +| Type | `kSecAttrType` | `CFNumber` (FourCharCode) | +| Creation Date | `kSecAttrCreationDate` | `CFDate` (read-only) | +| Modification Date | `kSecAttrModificationDate` | `CFDate` (read-only) | + +### kSecClassInternetPassword + +URL-associated credentials. Rarely needed — most apps use generic passwords. + +| Attribute | Key | Type | +|-----------|-----|------| +| Server | `kSecAttrServer` | `CFString` | +| Protocol | `kSecAttrProtocol` | `CFString` (constant) | +| Port | `kSecAttrPort` | `CFNumber` | +| Path | `kSecAttrPath` | `CFString` | +| Account | `kSecAttrAccount` | `CFString` | +| Authentication Type | `kSecAttrAuthenticationType` | `CFString` (constant) | +| Security Domain | `kSecAttrSecurityDomain` | `CFString` | +| Accessible | `kSecAttrAccessible` | `CFString` (constant) | +| Access Group | `kSecAttrAccessGroup` | `CFString` | +| Synchronizable | `kSecAttrSynchronizable` | `CFBoolean` | +| Label | `kSecAttrLabel` | `CFString` | +| Comment | `kSecAttrComment` | `CFString` | +| Creator | `kSecAttrCreator` | `CFNumber` (FourCharCode) | +| Type | `kSecAttrType` | `CFNumber` (FourCharCode) | + +### kSecClassCertificate + +X.509 certificates. Typically managed by the system, not app code. + +| Attribute | Key | Type | +|-----------|-----|------| +| Subject | `kSecAttrSubject` | `CFData` (read-only) | +| Issuer | `kSecAttrIssuer` | `CFData` (read-only) | +| Serial Number | `kSecAttrSerialNumber` | `CFData` (read-only) | +| Subject Key ID | `kSecAttrSubjectKeyID` | `CFData` (read-only) | +| Public Key Hash | `kSecAttrPublicKeyHash` | `CFData` (read-only) | +| Certificate Type | `kSecAttrCertificateType` | `CFNumber` | +| Certificate Encoding | `kSecAttrCertificateEncoding` | `CFNumber` | +| Label | `kSecAttrLabel` | `CFString` | +| Access Group | `kSecAttrAccessGroup` | `CFString` | +| Synchronizable | `kSecAttrSynchronizable` | `CFBoolean` | + +### kSecClassKey + +Cryptographic keys (RSA, EC, AES). Used for encryption, signing, key agreement. + +| Attribute | Key | Type | +|-----------|-----|------| +| Key Class | `kSecAttrKeyClass` | `CFString` (constant) | +| Application Label | `kSecAttrApplicationLabel` | `CFData` | +| Application Tag | `kSecAttrApplicationTag` | `CFData` | +| Key Type | `kSecAttrKeyType` | `CFString` (constant) | +| Key Size in Bits | `kSecAttrKeySizeInBits` | `CFNumber` | +| Effective Key Size | `kSecAttrEffectiveKeySize` | `CFNumber` | +| Permanent | `kSecAttrIsPermanent` | `CFBoolean` | +| Sensitive | `kSecAttrIsSensitive` | `CFBoolean` | +| Extractable | `kSecAttrIsExtractable` | `CFBoolean` | +| Label | `kSecAttrLabel` | `CFString` | +| Access Group | `kSecAttrAccessGroup` | `CFString` | +| Synchronizable | `kSecAttrSynchronizable` | `CFBoolean` | +| Token ID | `kSecAttrTokenID` | `CFString` | + +### kSecClassIdentity + +A digital identity is a certificate paired with its private key. Not a distinct storage class — the system synthesizes it from a matching certificate and key. You cannot add a `kSecClassIdentity` item directly; add the certificate and key separately. Queries return an identity when both halves share the same `kSecAttrPublicKeyHash`. + +See Quinn "The Eskimo!"'s technote: "SecItem: Pitfalls and Best Practices" (forums/thread/724013) — digital identities are a virtual join, not a stored item. + +--- + +## Uniqueness Constraints Per Class + +Each keychain item is uniquely identified by a subset of its attributes. Adding a second item with the same primary key returns `errSecDuplicateItem` (-25299). Use `SecItemUpdate` to modify existing items. + +| Class | Primary Key Attributes | +|-------|----------------------| +| Generic Password | `kSecAttrService` + `kSecAttrAccount` + `kSecAttrAccessGroup` + `kSecAttrSynchronizable` | +| Internet Password | `kSecAttrServer` + `kSecAttrPort` + `kSecAttrProtocol` + `kSecAttrAuthenticationType` + `kSecAttrPath` + `kSecAttrAccount` + `kSecAttrAccessGroup` + `kSecAttrSynchronizable` | +| Certificate | `kSecAttrCertificateType` + `kSecAttrIssuer` + `kSecAttrSerialNumber` + `kSecAttrAccessGroup` + `kSecAttrSynchronizable` | +| Key | `kSecAttrApplicationLabel` + `kSecAttrApplicationTag` + `kSecAttrKeyType` + `kSecAttrKeySizeInBits` + `kSecAttrEffectiveKeySize` + `kSecAttrKeyClass` + `kSecAttrAccessGroup` + `kSecAttrSynchronizable` | +| Identity | N/A (virtual join of certificate + key) | + +**Consequence**: If you store tokens for multiple users under the same `kSecAttrService` without unique `kSecAttrAccount` values, `SecItemAdd` returns `errSecDuplicateItem` for the second user. + +--- + +## Attribute Constants Reference + +### Identity Attributes + +| Constant | Type | Used By | +|----------|------|---------| +| `kSecAttrService` | `CFString` | GenericPassword | +| `kSecAttrAccount` | `CFString` | GenericPassword, InternetPassword | +| `kSecAttrServer` | `CFString` | InternetPassword | +| `kSecAttrLabel` | `CFString` | All classes | +| `kSecAttrDescription` | `CFString` | GenericPassword, InternetPassword | +| `kSecAttrComment` | `CFString` | GenericPassword, InternetPassword | +| `kSecAttrGeneric` | `CFData` | GenericPassword | + +### Security Attributes + +| Constant | Type | Used By | +|----------|------|---------| +| `kSecAttrAccessible` | `CFString` (constant) | All classes | +| `kSecAttrAccessControl` | `SecAccessControl` | All classes | +| `kSecAttrAccessGroup` | `CFString` | All classes | +| `kSecAttrSynchronizable` | `CFBoolean` | All classes | + +`kSecAttrAccessible` and `kSecAttrAccessControl` are mutually exclusive. Setting both is an error — `kSecAttrAccessControl` includes an accessibility level in its creation. + +### Token Attributes + +| Constant | Type | Purpose | +|----------|------|---------| +| `kSecAttrTokenID` | `CFString` | Bind key to hardware token | +| `kSecAttrTokenIDSecureEnclave` | `CFString` (value) | Secure Enclave — EC keys only (256-bit) | + +### Key Metadata Attributes + +| Constant | Type | Values | +|----------|------|--------| +| `kSecAttrKeyType` | `CFString` | `kSecAttrKeyTypeRSA`, `kSecAttrKeyTypeECSECPrimeRandom` | +| `kSecAttrKeySizeInBits` | `CFNumber` | 256 (EC), 2048/4096 (RSA) | +| `kSecAttrKeyClass` | `CFString` | `kSecAttrKeyClassPublic`, `kSecAttrKeyClassPrivate`, `kSecAttrKeyClassSymmetric` | +| `kSecAttrApplicationTag` | `CFData` | App-defined tag for key lookup | +| `kSecAttrApplicationLabel` | `CFData` | SHA-1 hash of public key (auto-generated) | + +--- + +## Search Properties + +Used in `SecItemCopyMatching`, `SecItemUpdate` (query parameter), and `SecItemDelete` queries. + +| Constant | Type | Purpose | +|----------|------|---------| +| `kSecMatchLimit` | `CFString` or `CFNumber` | Max results — `kSecMatchLimitOne`, `kSecMatchLimitAll`, or `CFNumber` for explicit integer limits (e.g., limit to 5 results) | +| `kSecMatchCaseInsensitive` | `CFBoolean` | Case-insensitive string attribute matching | + +### kSecMatchLimit Defaults + +The default depends on context and is a common source of bugs: + +| Function | Default | Behavior | +|----------|---------|----------| +| `SecItemCopyMatching` | `kSecMatchLimitOne` | Returns first match | +| `SecItemDelete` | All matches | Deletes every matching item | + +Always set `kSecMatchLimit` explicitly in `SecItemCopyMatching` to make intent clear. For `SecItemDelete`, omitting `kSecMatchLimit` deletes all matches — this is by design, not a bug. + +--- + +## Return Type Properties + +Control what `SecItemCopyMatching` and `SecItemAdd` return. Set in the query dictionary. + +| Constant | Returns | Result Type | +|----------|---------|-------------| +| `kSecReturnData` | The secret (password bytes, key data) | `CFData` | +| `kSecReturnAttributes` | Item metadata dictionary | `CFDictionary` | +| `kSecReturnRef` | Keychain object reference | `SecKey`, `SecCertificate`, or `SecIdentity` | +| `kSecReturnPersistentRef` | Persistent reference (survives app relaunch) | `CFData` | + +### Return Type Behavior Per Class + +| Class | `kSecReturnData` | `kSecReturnRef` | +|-------|-------------------|-----------------| +| Generic Password | Password bytes | N/A (no ref type) | +| Internet Password | Password bytes | N/A (no ref type) | +| Certificate | DER-encoded certificate data | `SecCertificate` | +| Key | Key data (if extractable) | `SecKey` | +| Identity | N/A | `SecIdentity` | + +### Multiple Return Types + +When multiple `kSecReturn*` keys are `true`, the result is a `CFDictionary` with keys: +- `kSecValueData` → the data +- `kSecValueRef` → the ref +- `kSecValuePersistentRef` → the persistent ref +- Plus all attribute keys if `kSecReturnAttributes` is `true` + +When `kSecMatchLimitAll` is set, the result is a `CFArray` of the above. + +--- + +## Value Type Properties + +Used to provide or extract values in add, query, and update dictionaries. + +| Constant | Type | Purpose | +|----------|------|---------| +| `kSecValueData` | `CFData` | The secret (password, key material) | +| `kSecValueRef` | `SecKey` / `SecCertificate` / `SecIdentity` | Keychain object reference | +| `kSecValuePersistentRef` | `CFData` | Persistent reference to an item | + +### Behavior Per Operation + +| Property | SecItemAdd | SecItemCopyMatching | SecItemUpdate | +|----------|------------|---------------------|---------------| +| `kSecValueData` | Sets the secret | Not valid as search criteria | Replaces the secret | +| `kSecValueRef` | Adds the referenced object | Finds by reference | Not valid | +| `kSecValuePersistentRef` | Not valid | Finds by persistent ref | Not valid | + +--- + +## Accessibility Constants + +Controls when keychain items are readable. Set via `kSecAttrAccessible`. + +| Constant | Available When | Survives Backup | Syncs via iCloud | +|----------|---------------|-----------------|------------------| +| `kSecAttrAccessibleWhenUnlocked` | Device unlocked | Yes | Yes (default) | +| `kSecAttrAccessibleAfterFirstUnlock` | After first unlock until reboot | Yes | Yes | +| `kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly` | Device unlocked + passcode set | No | No | +| `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` | Device unlocked | No | No | +| `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | After first unlock until reboot | No | No | + +**Default**: `kSecAttrAccessibleWhenUnlocked` for new items. + +**`ThisDeviceOnly` variants**: Item is not included in encrypted backups and does not sync via iCloud Keychain. Use for device-bound secrets (biometric-gated tokens, Secure Enclave keys). + +**`WhenPasscodeSetThisDeviceOnly`**: Item is deleted if the user removes their passcode. Use for secrets that must not survive passcode removal. + +**`AfterFirstUnlock`**: Available in the background after the user unlocks once post-reboot. Required for background fetch, push notification handlers, and background URLSession completions. + +**Deprecated** (do not use): `kSecAttrAccessibleAlways`, `kSecAttrAccessibleAlwaysThisDeviceOnly`. + +--- + +## SecAccessControlCreateFlags + +Fine-grained access control for keychain items. Created with `SecAccessControlCreateWithFlags` and set via `kSecAttrAccessControl`. + +### All Flags + +| Flag | Purpose | +|------|---------| +| `.userPresence` | Any biometric OR device passcode | +| `.biometryAny` | Any enrolled biometric (survives new enrollment) | +| `.biometryCurrentSet` | Current biometric set only (invalidated if biometrics change) | +| `.devicePasscode` | Device passcode required | +| `.privateKeyUsage` | Required for Secure Enclave key signing operations | +| `.applicationPassword` | App-provided password (in addition to other factors) | +| `.watch` | Paired Apple Watch can satisfy authentication | +| `.or` | Combine flags with logical OR (any one satisfies) | +| `.and` | Combine flags with logical AND (all must satisfy) | + +### Creating Access Control + +```swift +var error: Unmanaged? +guard let accessControl = SecAccessControlCreateWithFlags( + kCFAllocatorDefault, + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + [.biometryCurrentSet, .or, .devicePasscode], + &error +) else { + let nsError = error!.takeRetainedValue() as Error + fatalError("Failed to create access control: \(nsError)") +} + +let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.example.app", + kSecAttrAccount as String: "auth-token", + kSecAttrAccessControl as String: accessControl, + kSecValueData as String: tokenData +] +let status = SecItemAdd(query as CFDictionary, nil) +``` + +### Flag Combinations + +| Combination | Meaning | +|-------------|---------| +| `[.biometryAny]` | Any enrolled fingerprint/face | +| `[.biometryCurrentSet]` | Current fingerprint/face set (re-enroll invalidates) | +| `[.biometryCurrentSet, .or, .devicePasscode]` | Biometric OR passcode fallback | +| `[.biometryCurrentSet, .and, .applicationPassword]` | Biometric AND app password | +| `[.privateKeyUsage]` | Secure Enclave key operations (sign, decrypt) | +| `[.biometryAny, .or, .watch]` | Biometric OR paired Watch | + +**`.biometryAny` vs `.biometryCurrentSet`**: Use `.biometryCurrentSet` for high-security items (banking tokens). If the user enrolls a new fingerprint, the item becomes inaccessible — your app must re-authenticate and re-store. Use `.biometryAny` for convenience items where new enrollment should not invalidate access. + +--- + +## LocalAuthentication Integration + +### LAContext with Keychain + +Pre-evaluate biometrics with `LAContext`, then pass the context to the keychain query to avoid a second biometric prompt. + +```swift +import LocalAuthentication + +let context = LAContext() +context.localizedReason = "Access your credentials" + +var authError: NSError? +guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &authError) else { + // Biometrics unavailable — handle error or fall back to passcode + return +} + +context.evaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + localizedReason: "Authenticate to access credentials" +) { success, error in + guard success else { return } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.example.app", + kSecAttrAccount as String: "auth-token", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseAuthenticationContext as String: context + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) +} +``` + +### LAContext Keychain Keys + +| Key | Type | Purpose | +|-----|------|---------| +| `kSecUseAuthenticationContext` | `LAContext` | Reuse authenticated context (avoids double prompt) | +| `kSecUseAuthenticationUI` | `CFString` | Control UI behavior: `kSecUseAuthenticationUIAllow` (default), `kSecUseAuthenticationUIFail`, `kSecUseAuthenticationUISkip` | + +**`kSecUseAuthenticationUIFail`**: Returns `errSecInteractionNotAllowed` instead of showing the biometric prompt. Use to check if an item exists without triggering UI. + +### LAPolicy Types + +| Policy | Requires | +|--------|----------| +| `.deviceOwnerAuthenticationWithBiometrics` | Face ID or Touch ID only | +| `.deviceOwnerAuthentication` | Biometrics or passcode fallback | + +### BiometryType Detection + +```swift +let context = LAContext() +var error: NSError? +context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + +switch context.biometryType { +case .faceID: // Face ID available +case .touchID: // Touch ID available +case .opticID: // Optic ID available (visionOS) +case .none: // No biometric hardware +@unknown default: break +} +``` + +### LAError Codes + +| Error | Code | Cause | +|-------|------|-------| +| `.authenticationFailed` | -1 | User failed authentication | +| `.userCancel` | -2 | User tapped Cancel | +| `.userFallback` | -3 | User tapped "Enter Password" | +| `.systemCancel` | -4 | System cancelled (app backgrounded) | +| `.passcodeNotSet` | -5 | No passcode configured | +| `.biometryNotAvailable` | -6 | Hardware unavailable or restricted | +| `.biometryNotEnrolled` | -7 | No biometrics enrolled | +| `.biometryLockout` | -8 | Too many failed attempts | + +--- + +## OSStatus Error Codes + +Common keychain `OSStatus` values and their root causes. + +| Error | Code | Description | Common Cause | +|-------|------|-------------|--------------| +| `errSecSuccess` | 0 | Operation succeeded | — | +| `errSecDuplicateItem` | -25299 | Item already exists | Adding with same primary key — use `SecItemUpdate` instead | +| `errSecItemNotFound` | -25300 | No matching item | Wrong query attributes or item never stored | +| `errSecInteractionNotAllowed` | -25308 | UI prompt blocked | Item requires auth but device locked, or `kSecUseAuthenticationUIFail` set | +| `errSecAuthFailed` | -25293 | Authentication failed | Wrong password, failed biometric, or ACL denied | +| `errSecMissingEntitlement` | -34018 | Missing keychain entitlement | App lacks `keychain-access-groups` entitlement — common in unit test targets | +| `errSecNoSuchAttr` | -25303 | Attribute not found | Querying an attribute not valid for the item class | +| `errSecParam` | -50 | Invalid parameter | Malformed query dictionary — check for type mismatches (e.g., String where Data expected) | +| `errSecAllocate` | -108 | Memory allocation failed | System resource exhaustion | +| `errSecDecode` | -26275 | Unable to decode data | Corrupted item or encoding mismatch | +| `errSecNotAvailable` | -25291 | Keychain not available | No keychain database (rare — Simulator reset or corrupted install) | + +### Interpreting OSStatus in Swift + +```swift +let status = SecItemAdd(query as CFDictionary, nil) +if status != errSecSuccess { + let message = SecCopyErrorMessageString(status, nil) as? String ?? "Unknown error" + print("Keychain error \(status): \(message)") +} +``` + +### -34018 on Test Targets + +Unit test runners (XCTest) often lack the `keychain-access-groups` entitlement. Workarounds: +1. Add a Host Application to the test target (Xcode → Test Target → General → Host Application) +2. Use an in-memory mock for unit tests, real keychain for integration tests only + +--- + +## Keychain Sharing + +### Access Groups + +Items are isolated per app by default. To share between apps or extensions: + +1. Enable "Keychain Sharing" capability in Xcode +2. Add shared access group identifiers +3. Set `kSecAttrAccessGroup` when adding items + +```swift +let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.example.shared", + kSecAttrAccount as String: "shared-token", + kSecAttrAccessGroup as String: "TEAMID.com.example.shared", + kSecValueData as String: tokenData +] +``` + +The access group format is `$(TeamIdentifierPrefix)$(GroupIdentifier)`. Items without an explicit access group default to the app's first access group in its entitlements. + +### iCloud Keychain Sync + +Set `kSecAttrSynchronizable` to `true` to sync via iCloud Keychain: + +```swift +let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.example.app", + kSecAttrAccount as String: "sync-token", + kSecAttrSynchronizable as String: true, + kSecValueData as String: tokenData +] +``` + +Synchronizable items cannot use `ThisDeviceOnly` accessibility levels or `SecAccessControl`. They must use `kSecAttrAccessibleWhenUnlocked` or `kSecAttrAccessibleAfterFirstUnlock`. + +When querying, `kSecAttrSynchronizable` defaults to `kSecAttrSynchronizableAny` (returns both local and synced items). Set explicitly to `true` or `false` to filter. + +--- + +## Resources + +**WWDC**: 2013-709, 2014-711, 2020-10147 + +**Docs**: /security/keychain_services, /localauthentication, /security/secaccesscontrolcreateflags, /security/secitemadd(_:_:) + +**Skills**: axiom-code-signing-ref, axiom-app-attest diff --git a/.claude/skills/axiom-keychain-ref/agents/openai.yaml b/.claude/skills/axiom-keychain-ref/agents/openai.yaml new file mode 100644 index 0000000..708b39c --- /dev/null +++ b/.claude/skills/axiom-keychain-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Keychain Reference" + short_description: "Needing SecItem function signatures, keychain attribute constants, item class uniqueness constraints, accessibility l..." diff --git a/.claude/skills/axiom-keychain/.openskills.json b/.claude/skills/axiom-keychain/.openskills.json new file mode 100644 index 0000000..ddac75e --- /dev/null +++ b/.claude/skills/axiom-keychain/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-keychain", + "installedAt": "2026-04-12T08:06:24.824Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-keychain/SKILL.md b/.claude/skills/axiom-keychain/SKILL.md new file mode 100644 index 0000000..deab801 --- /dev/null +++ b/.claude/skills/axiom-keychain/SKILL.md @@ -0,0 +1,544 @@ +--- +name: axiom-keychain +description: Use when storing credentials, tokens, or secrets securely, debugging SecItem errors (errSecDuplicateItem, errSecItemNotFound, errSecInteractionNotAllowed), managing keychain access groups, or choosing accessibility classes. Covers SecItem API mental model, uniqueness constraints, data protection, biometric access control, sharing between apps, and Mac keychain differences. +license: MIT +--- + +# Keychain Services + +Secure credential storage, SecItem API mental model, uniqueness constraints, data protection classes, biometric access control, keychain sharing, and Mac keychain differences for iOS/macOS apps. + +## When to Use This Skill + +Use when you need to: +- ☑ Store tokens, passwords, API keys, or cryptographic keys securely +- ☑ Debug errSecDuplicateItem, errSecItemNotFound, or errSecInteractionNotAllowed +- ☑ Choose the right kSecAttrAccessible level for your use case +- ☑ Add biometric or passcode protection to a keychain item +- ☑ Share credentials between apps or app extensions via access groups +- ☑ Migrate from UserDefaults/@AppStorage to keychain for sensitive data +- ☑ Understand why SecItemCopyMatching returns something different than expected +- ☑ Store or retrieve keychain items from a background context + +## Example Prompts + +"How do I store an auth token in the keychain?" +"errSecDuplicateItem when saving to keychain but the item doesn't exist" +"My keychain read fails in background refresh" +"How do I add Face ID protection to a keychain item?" +"Share keychain items between my app and widget extension" +"What's the difference between kSecAttrAccessibleAfterFirstUnlock and WhenUnlocked?" +"SecItemCopyMatching returns errSecItemNotFound but I just saved it" +"How do I use the keychain on macOS with Catalyst?" +"My keychain wrapper is returning nil — how do I debug this?" +"errSecInteractionNotAllowed in background task" + +## Red Flags + +Signs you're making this harder than it needs to be: + +- ❌ Using a keychain wrapper without understanding SecItem — Wrappers hide the 4-function model and introduce their own bugs. Quinn "The Eskimo!" explicitly warns: most wrapper issues stem from the wrapper, not the keychain. Learn the model first. +- ❌ Storing tokens in UserDefaults or @AppStorage — These are plist files readable by anyone with device access (or a backup). Credentials belong in the keychain. No exceptions. +- ❌ Force-unwrapping SecItemCopyMatching results — The return type depends on which kSecReturn* flags you passed. A mismatch gives you a surprising type, not your data. +- ❌ Not specifying kSecAttrAccessible — Defaults to kSecAttrAccessibleWhenUnlocked, which fails in background execution. If your app does background refresh, push processing, or VoIP, this will bite you. +- ❌ Catching errSecDuplicateItem by deleting then re-adding — This creates a race condition and destroys metadata (creation date, access control). Use SecItemUpdate instead. +- ❌ Using kSecMatchLimitAll without understanding delete semantics — SecItemDelete has no kSecMatchLimit; it deletes ALL matching items. One careless query can wipe credentials. +- ❌ Ignoring errSecDuplicateItem — "It worked last time" means you have a query/uniqueness mismatch. This is the #1 keychain bug. + +## The 4-Function Model + +Quinn's mental model: the keychain is a database with per-class tables. The four SecItem functions map directly to SQL. + +| SecItem Function | SQL Equivalent | Purpose | +|---|---|---| +| SecItemAdd | INSERT | Create a new item | +| SecItemCopyMatching | SELECT | Read one or more items | +| SecItemUpdate | UPDATE | Modify an existing item | +| SecItemDelete | DELETE | Remove items | + +### Per-Class Tables + +Each item class is a separate table with different columns (attributes): + +| Class | kSecClass Value | Typical Use | +|---|---|---| +| Generic password | kSecClassGenericPassword | App credentials, tokens, secrets | +| Internet password | kSecClassInternetPassword | URL-associated credentials | +| Certificate | kSecClassCertificate | X.509 certificates | +| Key | kSecClassKey | Cryptographic keys | +| Identity | kSecClassIdentity | Certificate + private key pair | + +For most iOS apps, you only need `kSecClassGenericPassword`. Internet passwords are for credentials tied to a specific server/protocol/port. Certificates and keys are for custom cryptographic operations. + +## Uniqueness Constraints + +This is where most keychain bugs originate. Each class has a **primary key** — a combination of attributes that must be unique. + +### Generic Password Primary Key + +``` +kSecAttrService + kSecAttrAccount + kSecAttrAccessGroup +``` + +### Internet Password Primary Key + +``` +kSecAttrServer + kSecAttrAccount + kSecAttrPort + + kSecAttrProtocol + kSecAttrAuthenticationType + kSecAttrSecurityDomain + + kSecAttrAccessGroup +``` + +See axiom-keychain-ref for the full attribute breakdown per class. + +### The errSecDuplicateItem Trap + +You call SecItemCopyMatching with a query and get errSecItemNotFound. You call SecItemAdd with what you think is the same item. You get errSecDuplicateItem. How? + +**The query attributes don't match the uniqueness attributes.** Your copy query might search by `kSecAttrService` alone, but uniqueness is `service + account + accessGroup`. An item exists with the same service but a different account — your query misses it, but your add hits the uniqueness constraint on the combination. + +```swift +// This query finds nothing (searching by service + label, but label isn't a primary key) +let copyQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.app.auth", + kSecAttrLabel as String: "auth-token", + kSecReturnData as String: true +] +// Result: errSecItemNotFound (no item has this label) + +// This add fails — an item with service "com.app.auth" + account "user-token" +// already exists. The add hits the primary key (service + account + accessGroup). +let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.app.auth", + kSecAttrAccount as String: "user-token", + kSecValueData as String: tokenData +] +// Result: errSecDuplicateItem +``` + +**Fix**: Always specify ALL primary key attributes in every query. For generic passwords, always include `kSecAttrService` AND `kSecAttrAccount`. + +### The Safe Add-or-Update Pattern + +```swift +func saveToKeychain(service: String, account: String, data: Data) -> OSStatus { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + + let attributes: [String: Any] = [ + kSecValueData as String: data + ] + + // Try update first + var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + + if status == errSecItemNotFound { + // Item doesn't exist — add it + var addQuery = query + addQuery[kSecValueData as String] = data + status = SecItemAdd(addQuery as CFDictionary, nil) + } + + return status +} +``` + +This is safer than delete-then-add because it preserves item metadata and avoids race conditions. + +## Parameter Block Anatomy + +Every SecItem function takes a dictionary. That dictionary contains properties from 5 groups, but which groups are valid depends on which function you're calling. + +### The 5 Property Groups + +| Group | Prefix/Pattern | Purpose | +|---|---|---| +| Class | kSecClass | Which table (generic password, key, etc.) | +| Attributes | kSecAttr* | Column values (service, account, label) | +| Search | kSecMatch* | Query modifiers (limit, case sensitivity) | +| Return type | kSecReturn* | What shape the result takes | +| Value type | kSecValue* | The actual data/reference | + +### Which Groups Apply to Which Function + +```dot +digraph param_blocks { + rankdir=LR; + + "SecItemAdd" [shape=box]; + "SecItemCopyMatching" [shape=box]; + "SecItemUpdate\n(query dict)" [shape=box]; + "SecItemUpdate\n(attributes dict)" [shape=box]; + "SecItemDelete" [shape=box]; + + "Class" [shape=ellipse]; + "Attributes" [shape=ellipse]; + "Search" [shape=ellipse]; + "Return type" [shape=ellipse]; + "Value type" [shape=ellipse]; + + "SecItemAdd" -> "Class"; + "SecItemAdd" -> "Attributes"; + "SecItemAdd" -> "Return type"; + "SecItemAdd" -> "Value type"; + + "SecItemCopyMatching" -> "Class"; + "SecItemCopyMatching" -> "Attributes"; + "SecItemCopyMatching" -> "Search"; + "SecItemCopyMatching" -> "Return type"; + + "SecItemUpdate\n(query dict)" -> "Class"; + "SecItemUpdate\n(query dict)" -> "Attributes"; + "SecItemUpdate\n(query dict)" -> "Search"; + + "SecItemUpdate\n(attributes dict)" -> "Attributes"; + "SecItemUpdate\n(attributes dict)" -> "Value type"; + + "SecItemDelete" -> "Class"; + "SecItemDelete" -> "Attributes"; + "SecItemDelete" -> "Search"; +} +``` + +### kSecMatchLimit Defaults + +This is a documented pitfall from Quinn's thread. The default value of `kSecMatchLimit` depends on context: + +| Context | Default kSecMatchLimit | Behavior | +|---|---|---| +| SecItemCopyMatching with kSecReturnData | kSecMatchLimitOne | Returns one item | +| SecItemCopyMatching with kSecReturnAttributes | kSecMatchLimitOne | Returns one item | +| SecItemCopyMatching with kSecReturnRef | kSecMatchLimitOne | Returns one item | +| SecItemDelete | No limit concept | Deletes ALL matches | + +SecItemDelete has no `kSecMatchLimit` parameter. It deletes every item matching your query. If your query is broad (only `kSecClass` and `kSecAttrService`), you will delete every item for that service across all accounts. + +### Return Type Determines Output Type + +| kSecReturn* Flags | Output Type | +|---|---| +| kSecReturnData only | CFData (the raw secret) | +| kSecReturnAttributes only | CFDictionary (item metadata) | +| kSecReturnRef only | SecKey / SecCertificate / SecIdentity | +| kSecReturnData + kSecReturnAttributes | CFDictionary (metadata + kSecValueData key) | +| kSecReturnPersistentRef | CFData (persistent reference, survives keychain resets) | + +Force-casting the result to the wrong type is a common crash source. Always match your cast to your return flags. + +## Accessibility and Data Protection + +`kSecAttrAccessible` controls when the keychain item's decryption key is available. This maps directly to the device's data protection classes. + +| Level | Available When | Use Case | +|---|---|---| +| kSecAttrAccessibleWhenUnlocked | Device unlocked | Default. UI-driven credentials | +| kSecAttrAccessibleWhenUnlockedThisDeviceOnly | Device unlocked, no backup | High-security tokens | +| kSecAttrAccessibleAfterFirstUnlock | After first unlock until reboot | Background refresh, push processing | +| kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | After first unlock, no backup | Background + high security | +| kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly | Passcode set + unlocked | Biometric-gated items | + +### The Background Execution Trap + +This is the single most common keychain failure in production. Your app works perfectly during normal use but fails with `errSecInteractionNotAllowed` (-25308) during background refresh. + +**The dangerous pattern**: + +```swift +// Background task tries to read a token +let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.app.auth", + kSecAttrAccount as String: "refresh-token", + kSecReturnData as String: true + // Item was stored with default WhenUnlocked accessibility (set at add time) +] +var result: AnyObject? +let status = SecItemCopyMatching(query as CFDictionary, &result) +// status == errSecInteractionNotAllowed when device is locked + +// Developer's instinct: "the token is corrupted, delete and re-auth" +if status != errSecSuccess { + SecItemDelete(query as CFDictionary) // DELETES THE CREDENTIAL +} +``` + +From Quinn: "This is a lesson that, once learnt, is never forgotten!" + +The item is fine. The device is locked, so the decryption key isn't available. The developer's error handler destroys a perfectly valid credential because it misinterprets the error. + +**The fix**: Store tokens needed in background with `kSecAttrAccessibleAfterFirstUnlock`: + +```swift +let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.app.auth", + kSecAttrAccount as String: "refresh-token", + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + kSecValueData as String: tokenData +] +``` + +And never delete on `errSecInteractionNotAllowed` — it means "try again when the device is unlocked", not "this item is broken". + +## Access Control and Biometrics + +`SecAccessControl` gates individual keychain items behind biometric authentication or device passcode. + +### Creating a Biometric-Gated Item + +```swift +var error: Unmanaged? +guard let accessControl = SecAccessControlCreateWithFlags( + nil, + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + .biometryCurrentSet, + &error +) else { + // Handle error — usually means device has no passcode + return +} + +let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.app.auth", + kSecAttrAccount as String: "biometric-secret", + kSecAttrAccessControl as String: accessControl, + kSecValueData as String: secretData +] + +let status = SecItemAdd(query as CFDictionary, nil) +``` + +### Access Control Flag Selection + +```dot +digraph acl_decision { + "What gates access?" [shape=diamond]; + "Enrollment change\nmatters?" [shape=diamond]; + "Biometric or\npasscode fallback?" [shape=diamond]; + + ".biometryCurrentSet" [shape=box, label=".biometryCurrentSet\nInvalidates if fingerprints/face change"]; + ".biometryAny" [shape=box, label=".biometryAny\nSurvives enrollment changes"]; + ".userPresence" [shape=box, label=".userPresence\nBiometry with passcode fallback"]; + ".devicePasscode" [shape=box, label=".devicePasscode\nPasscode only, no biometry"]; + + "What gates access?" -> "Enrollment change\nmatters?" [label="biometry"]; + "What gates access?" -> ".devicePasscode" [label="passcode only"]; + "What gates access?" -> "Biometric or\npasscode fallback?" [label="either"]; + "Enrollment change\nmatters?" -> ".biometryCurrentSet" [label="yes, re-auth\non change"]; + "Enrollment change\nmatters?" -> ".biometryAny" [label="no, any enrolled\nbiometry"]; + "Biometric or\npasscode fallback?" -> ".userPresence" [label="user convenience"]; +} +``` + +| Flag | Behavior | When to Use | +|---|---|---| +| .biometryCurrentSet | Invalidated if biometry enrollment changes | Banking, medical — re-auth on new fingerprint/face | +| .biometryAny | Survives enrollment changes | Convenience auth, app lock | +| .userPresence | Biometry with passcode fallback | Most common choice — works even if biometry fails | +| .devicePasscode | Passcode only, no biometry prompt | Accessibility-first or biometry-averse users | + +### LAContext for Reuse Duration + +By default, each keychain read prompts for biometric auth. To allow reuse within a time window: + +```swift +let context = LAContext() +context.touchIDAuthenticationAllowableReuseDuration = 10 // seconds + +let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.app.auth", + kSecAttrAccount as String: "biometric-secret", + kSecUseAuthenticationContext as String: context, + kSecReturnData as String: true +] +``` + +After one successful biometric auth, subsequent reads within 10 seconds skip the prompt. Maximum reuse duration is `LATouchIDAuthenticationMaximumAllowableReuseDuration` (5 minutes on current hardware). + +## Sharing and Access Groups + +### Keychain Access Groups vs App Groups + +| Feature | Keychain Access Groups | App Groups | +|---|---|---| +| Entitlement | keychain-access-groups | com.apple.security.application-groups | +| Prefix | Team ID (auto-added) | No prefix (you control full string) | +| Scope | Keychain items only | Files, UserDefaults, AND keychain | +| Setup | Signing & Capabilities → Keychain Sharing | Signing & Capabilities → App Groups | + +### How Access Groups Work + +Every keychain item belongs to exactly one access group. If you don't specify `kSecAttrAccessGroup`, the item goes into your app's default access group (`TEAMID.your.bundle.id`). + +To share between apps or extensions: + +1. Add the same keychain access group to both targets (Signing & Capabilities → Keychain Sharing) +2. Specify the group when writing: + +```swift +let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.app.shared-auth", + kSecAttrAccount as String: "user-token", + kSecAttrAccessGroup as String: "TEAMID.com.app.shared", + kSecValueData as String: tokenData +] +``` + +### The Team ID Prefix Trap + +Keychain access groups get the **Team ID** automatically prepended by the system. You write `com.app.shared` in the entitlement, the system stores it as `ABCD1234.com.app.shared`. + +But `kSecAttrAccessGroup` in your code must include the prefix: `"ABCD1234.com.app.shared"`. + +App Groups do NOT get this prefix. If you're using an App Group for keychain sharing (via `kSecAttrAccessGroup` with a `group.` prefix), use the full string as-is. + +### The App ID Prefix Change Trap + +From Quinn's pitfalls: if an app changes its App ID prefix (Team ID), it loses access to all existing keychain items. This happens when: +- Transferring an app between developer accounts +- Certain legacy App ID configurations + +There is no recovery. The items are orphaned. Plan for this in migration logic — detect the condition and prompt the user to re-authenticate. + +## Mac Keychain Differences + +macOS has two keychain systems. Understanding the difference prevents "it works on iOS but fails on Mac" bugs. + +### File-Based Keychain (Legacy) + +The traditional macOS keychain (`~/Library/Keychains/login.keychain-db`): +- User-visible in Keychain Access.app +- Supports ACLs (access control lists) for per-app access +- Items can be shared across all apps by default +- No data protection classes + +### Data Protection Keychain (Modern) + +Aligned with iOS keychain behavior (from TN3137): +- Not visible in Keychain Access.app (prior to macOS 15) +- Uses data protection classes (kSecAttrAccessible) +- Items scoped to access groups (same as iOS) +- Required for Catalyst and SwiftUI multiplatform apps + +### Always Use Data Protection Keychain + +```swift +let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.app.auth", + kSecAttrAccount as String: "token", + kSecUseDataProtectionKeychain as String: true, // Critical for Mac + kSecValueData as String: tokenData +] +``` + +Set `kSecUseDataProtectionKeychain: true` for ALL keychain operations on macOS. This gives you consistent behavior across iOS, macOS, Catalyst, and Mac Catalyst. + +Without this flag on macOS, items go into the file-based keychain where: +- Access control behaves differently (ACLs instead of data protection) +- Sharing semantics change (any app can read by default) +- Items appear in Keychain Access.app (user confusion) +- Migration between file-based and data-protection keychains is one-way and manual + +## Anti-Rationalization + +| Rationalization | Why It Fails | Time Cost | +|---|---|---| +| "UserDefaults is fine for tokens, it's a private app" | Backup extraction, device access tools, and MDM profiles all read UserDefaults. The keychain is encrypted at the hardware level. | 4-8 hours for a security incident response | +| "I'll wrap it in a KeychainHelper class to simplify things" | Wrappers abstract the 4-function model, hiding uniqueness and parameter block semantics. When they break, you debug the wrapper AND the keychain. | 2-4 hours debugging a wrapper bug vs 30 min learning SecItem | +| "Delete then re-add is simpler than update" | Destroys creation date, access control settings, and persistent references. Creates a TOCTOU race if another process reads between delete and add. | 1-2 hours debugging intermittent failures | +| "WhenUnlocked is secure enough" | Correct for UI-driven reads. Fatal for background refresh, push processing, silent notifications. errSecInteractionNotAllowed at 3 AM with no user to unlock. | 2-6 hours diagnosing a background failure that only reproduces on locked devices | +| "I tested it on Simulator, it works" | Simulator has no Secure Enclave, no data protection enforcement, and different keychain behavior. Biometric items always succeed. Accessibility constraints are not enforced. | 4-8 hours debugging device-only failures | +| "errSecInteractionNotAllowed means the token is corrupted" | It means the device is locked. The token is fine. Deleting it destroys a valid credential and forces re-authentication. | 15-30 min user complaint cycle per incident | +| "Access groups are too complicated, I'll use App Groups for everything" | App Groups add keychain sharing as a side effect, but the semantics differ from keychain access groups. Team ID prefix behavior is different. Mixing both creates invisible access scope bugs. | 2-4 hours debugging cross-target access failures | + +## Pressure Scenarios + +### Scenario 1: "Just store it in UserDefaults for now" + +**Context**: Sprint deadline, new feature requires storing an API token. Keychain code looks complicated. + +**Pressure**: "UserDefaults is faster to implement. We'll move it to keychain in the security sprint." + +**Reality**: Moving from UserDefaults to keychain later requires migration code (read from UserDefaults, write to keychain, delete from UserDefaults, handle partial migration). That migration itself becomes a security surface — the token exists in both locations during the transition. The "security sprint" never happens because there's always a higher-priority feature. Meanwhile, the token is in plaintext in the app's plist, included in every iCloud backup. + +**Correct action**: Use the safe add-or-update pattern from this skill. It's 15 lines of code. The SecItem call itself is not meaningfully harder than UserDefaults — the perceived complexity comes from not understanding the model. + +**Push-back template**: "The keychain call is 15 lines, same as UserDefaults. Doing it in UserDefaults now means writing migration code later — which is actually harder. Let me write it correctly the first time." + +### Scenario 2: "Delete the keychain item when we get an error" + +**Context**: Background refresh fails intermittently with errSecInteractionNotAllowed. The quick fix: delete the "corrupted" token and force re-login. + +**Pressure**: "Users are complaining about stale data. Just clear the token and re-auth on next launch." + +**Reality**: The token is not corrupted. The device is locked and `kSecAttrAccessibleWhenUnlocked` prevents access. Deleting it forces every user to re-authenticate after any background failure — which is every night when their phone locks. The actual fix is changing the accessibility level to `kSecAttrAccessibleAfterFirstUnlock`, which takes one line. + +**Correct action**: Change the item's accessibility to `kSecAttrAccessibleAfterFirstUnlock`. For existing items already stored with the wrong level, add a one-time migration on app launch (read, delete, re-add with correct accessibility). + +**Push-back template**: "The token isn't corrupted — the device is locked. Deleting it forces every user to re-login every morning. The fix is one line: change the accessibility class to AfterFirstUnlock. I can also add a migration for existing users." + +### Scenario 3: "Use this keychain wrapper library, everyone uses it" + +**Context**: Team is integrating a popular open-source keychain wrapper to "simplify" keychain access. + +**Pressure**: "It has 5,000 GitHub stars and handles all the edge cases." + +**Reality**: Quinn documents this pattern explicitly: most keychain issues reported on Apple Developer Forums trace back to wrapper libraries, not the keychain itself. Wrappers often hardcode kSecMatchLimit, assume return types, conflate accessibility with access control, or use delete-then-add instead of update. When the wrapper breaks, you debug both the wrapper's abstraction AND the underlying SecItem call. You also inherit the wrapper's opinion about access groups, accessibility, and error handling — which may not match your requirements. + +**Correct action**: Write a thin extension or function specific to your app's needs. The safe add-or-update pattern, a read function, and a delete function cover 95% of use cases in under 50 lines. + +**Push-back template**: "Most keychain bugs on Apple's forums come from wrapper libraries, not the keychain itself. Our needs are 3 functions — save, read, delete. That's 50 lines of code we fully understand, versus a dependency that makes its own decisions about access groups and error handling." + +## Checklist + +Before shipping keychain code: + +**Model**: +- [ ] Using SecItem functions directly (not a wrapper you don't understand) +- [ ] All queries include ALL primary key attributes (service + account for generic passwords) +- [ ] Using update-then-add pattern, not delete-then-add +- [ ] kSecMatchLimit explicitly set where needed (never relying on defaults) + +**Data Protection**: +- [ ] kSecAttrAccessible set explicitly on every add operation +- [ ] Background-accessed items use AfterFirstUnlock (not WhenUnlocked) +- [ ] errSecInteractionNotAllowed handled without deleting the item +- [ ] Tested on a locked physical device (not just Simulator) + +**Access Control**: +- [ ] Biometric flag matches security requirement (currentSet vs any) +- [ ] LAContext reuse duration is appropriate (not excessively long) +- [ ] Graceful fallback if biometry is unavailable or fails + +**Sharing**: +- [ ] Keychain access group entitlement matches code (including Team ID prefix) +- [ ] All sharing targets have the same access group in their entitlements +- [ ] Access group specified explicitly in queries (not relying on default) + +**Mac Compatibility** (if multiplatform): +- [ ] kSecUseDataProtectionKeychain set to true on all macOS operations +- [ ] Tested on macOS (not just iOS) + +**Error Handling**: +- [ ] Every SecItem call checks the OSStatus return value +- [ ] errSecDuplicateItem handled (not swallowed or logged-and-ignored) +- [ ] errSecItemNotFound handled as a normal case (not an error in read flows) +- [ ] Error context includes service, account, and operation attempted + +## Resources + +**WWDC**: 2019-709 + +**Docs**: /security/keychain_services, /security/certificate_key_and_trust_services, /technotes/tn3137-on-mac-keychains + +**Apple Forums**: thread/724023 (SecItem Fundamentals), thread/724013 (SecItem Pitfalls) + +**Skills**: axiom-keychain-diag, axiom-keychain-ref, axiom-cryptokit, axiom-code-signing, axiom-app-attest diff --git a/.claude/skills/axiom-keychain/agents/openai.yaml b/.claude/skills/axiom-keychain/agents/openai.yaml new file mode 100644 index 0000000..dde261f --- /dev/null +++ b/.claude/skills/axiom-keychain/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Keychain" + short_description: "Storing credentials, tokens, or secrets securely, debugging SecItem errors (errSecDuplicateItem, errSecItemNotFound, ..." diff --git a/.claude/skills/axiom-liquid-glass-ref/.openskills.json b/.claude/skills/axiom-liquid-glass-ref/.openskills.json new file mode 100644 index 0000000..e834d4e --- /dev/null +++ b/.claude/skills/axiom-liquid-glass-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-liquid-glass-ref", + "installedAt": "2026-04-12T08:06:26.822Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-liquid-glass-ref/SKILL.md b/.claude/skills/axiom-liquid-glass-ref/SKILL.md new file mode 100644 index 0000000..2229d35 --- /dev/null +++ b/.claude/skills/axiom-liquid-glass-ref/SKILL.md @@ -0,0 +1,649 @@ +--- +name: axiom-liquid-glass-ref +description: Use when planning comprehensive Liquid Glass adoption across an app, auditing existing interfaces for Liquid Glass compatibility, implementing app icon updates, or understanding platform-specific Liquid Glass behavior - comprehensive reference guide covering all aspects of Liquid Glass adoption from WWDC 2025 +license: MIT +compatibility: iOS/iPadOS 26+, macOS Tahoe+, tvOS, watchOS, visionOS 3+ +metadata: + version: "1.0.0" + last-updated: "2025-12-01" +--- + +# Liquid Glass Adoption — Reference Guide + +## When to Use This Skill + +Use when: +- Planning comprehensive Liquid Glass adoption across your entire app +- Auditing existing interfaces for Liquid Glass compatibility +- Implementing app icon updates with Icon Composer +- Understanding platform-specific Liquid Glass behavior (iOS, iPadOS, macOS, tvOS, watchOS) +- Migrating from previous materials (blur effects, custom translucency) +- Ensuring accessibility compliance with Liquid Glass interfaces +- Reviewing search, navigation, or organizational component updates + +#### Related Skills +- Use `axiom-liquid-glass` for implementing the Liquid Glass material itself and design review pressure scenarios +- Use `axiom-swiftui-performance` for profiling Liquid Glass rendering performance +- Use `axiom-accessibility-diag` for accessibility testing + +--- + +## Overview + +Adopting Liquid Glass doesn't mean reinventing your app from the ground up. Start by building your app in the latest version of Xcode to see the changes. If your app uses standard components from SwiftUI, UIKit, or AppKit, your interface picks up the latest look and feel automatically on the latest platform releases. + +#### Key Adoption Strategy +1. Build with latest Xcode SDKs +2. Run on latest platform releases +3. Review changes using this reference +4. Adopt best practices incrementally + +--- + +## Visual Refresh + +### What Changes Automatically + +#### Standard Components Get Liquid Glass +- Navigation bars, tab bars, toolbars +- Sheets, popovers, action sheets +- Buttons, sliders, toggles, and controls +- Sidebars, split views, menus + +#### How It Works +- Liquid Glass combines optical properties of glass with fluidity +- Forms distinct functional layer for controls and navigation +- Adapts in response to overlap, focus state, and environment +- Helps bring focus to underlying content + +### Leverage System Frameworks + +#### ✅ DO: Use Standard Components + +Standard components from SwiftUI, UIKit, and AppKit automatically adopt Liquid Glass with minimal code changes. + +```swift +// ✅ Standard components get Liquid Glass automatically +NavigationView { + List(items) { item in + Text(item.name) + } + .toolbar { + ToolbarItem { + Button("Add") { } + } + } +} +// Recompile with Xcode 26 → Liquid Glass applied +``` + +#### ❌ DON'T: Override with Custom Backgrounds + +```swift +// ❌ Custom backgrounds interfere with Liquid Glass +NavigationView { } + .background(Color.blue.opacity(0.5)) // Breaks Liquid Glass effects + .toolbar { + ToolbarItem { } + .background(LinearGradient(...)) // Overlays system effects + } +``` + +#### What to Audit +- Split views +- Tab bars +- Toolbars +- Navigation bars +- Any component with custom background/appearance + +**Solution** Remove custom effects and let the system determine background appearance. + +### Test with Accessibility Settings + +Liquid Glass adapts to: Reduce Transparency (frostier), Increase Contrast (black/white borders), Reduce Motion (no elastic animations). Verify legibility maintained under each setting and that custom elements provide fallback experiences. For detailed accessibility testing workflows, see `axiom-liquid-glass` discipline skill. + +```swift +app.launchArguments += ["-UIAccessibilityIsReduceTransparencyEnabled", "1", + "-UIAccessibilityButtonShapesEnabled", "1", "-UIAccessibilityIsReduceMotionEnabled", "1"] +``` + +### Avoid Overusing Liquid Glass + +Liquid Glass brings attention to underlying content. Overusing it on multiple custom controls distracts from content. Apply `.glassEffect()` only to important functional elements (navigation, primary actions) — not content cards, list rows, or decorative elements. + +```swift +// ✅ Content layer: no glass. Navigation layer: glass on functional buttons only. +ZStack { + ScrollView { ForEach(articles) { ArticleCard($0) } } + VStack { + Spacer() + HStack { + Button("Filter") { }.glassEffect() + Spacer() + Button("Sort") { }.glassEffect() + }.padding() + } +} +``` + +--- + +## App Icons + +App icons now take on a design that's dynamic and expressive. Updates to the icon grid result in standardized iconography that's visually consistent across devices. App icons contain layers that dynamically respond to lighting and visual effects. + +### Platform Support + +Layered icons: iOS/iPadOS 26+, macOS Tahoe+, watchOS (circular mask). Appearance variants: default (light), dark, clear, tinted (Home Screen personalization). + +### Design Principles + +Design clean, simplified layers with solid fills and semi-transparent overlays. Let the system handle effects (reflection, refraction, shadow, blur, masking). Do NOT bake in pre-applied blur, manual shadows, hardcoded highlights, or fixed masking. + +### Design Using Layers + +Three layers: foreground (primary elements), middle (supporting), background (foundation). Export each layer as PNG or SVG at @1x/@2x/@3x with transparency preserved. + +### Icon Composer + +Included in Xcode 26+ (also standalone from developer.apple.com/design/resources). Drag and drop layers, add optional background, adjust attributes (opacity, position, scale), preview with system effects and all appearance variants, export directly to asset catalog. + +### Preview Against Updated Grids + +Grids: iOS/iPadOS/macOS use rounded rectangle mask; watchOS uses circular mask. Download from developer.apple.com/design/resources. Keep elements centered to avoid clipping, test at all sizes, verify all appearance variants look intentional. + +--- + +## Controls + +Controls have refreshed look across platforms and come to life during interaction. Knobs transform into Liquid Glass during interaction, buttons fluidly morph into menus/popovers. Hardware shape informs curvature of controls (rounder forms nestle into corners). + +### Updated Appearance + +Bordered buttons default to capsule shape (mini/small/medium on macOS retain rounded-rectangle). Knobs transform into glass during interaction; buttons morph into menus/popovers. New `controlSize(.extraLarge)` option; heights slightly taller on macOS. Use `controlSize(.small)` for backward-compatible high-density layouts. Standard controls adopt automatically — remove hard-coded `.frame()` dimensions. + +### Review Updated Controls + +Audit sliders, toggles, buttons, steppers, pickers, segmented controls, and progress indicators. Verify appearance matches interface, spacing looks natural, controls aren't cropped, and interaction feedback is responsive. + +### Color in Controls + +Use system colors (`.tint(.blue)`, `.accentColor`) — they adapt to light/dark contexts automatically. Avoid hard-coded RGB values (`Color(red:green:blue:)`) which may not adapt. Test in both modes and verify WCAG AA contrast ratios. + +### Check for Crowding or Overlapping + +Liquid Glass elements need breathing room. Use default `HStack` spacing (not `spacing: 4`) for glass buttons. Overcrowding or layering glass-on-glass creates visual noise. Use `GlassEffectContainer` when multiple glass elements must be close together. + +### Optimize for Legibility with Scroll Edge Effects + +Use `.scrollEdgeEffectStyle(.hard, for: .top)` to obscure content scrolling beneath controls. System bars (toolbars, navigation bars, tab bars) adopt this automatically; custom bars need it explicitly. + +### Align Control Shapes with Containers + +Use `containerRelativeShape()` to align control curvature with containers — creates concentric visual continuity from controls to sheets to windows to display. + +### New Button Styles + +Use built-in styles instead of custom glass effects: `.borderedProminent` (primary, with `.tint()`), `.bordered` (secondary), `.plain` + `.glassEffect()` (tertiary/custom). Each adapts to Liquid Glass automatically. + +--- + +## Navigation + +Liquid Glass applies to topmost layer where you define navigation. Key navigation elements like tab bars and sidebars float in this Liquid Glass layer to help people focus on underlying content. + +### Clear Navigation Hierarchy + +Maintain two distinct layers: **Navigation** (tab bar, sidebar, toolbar — Liquid Glass) floats above **Content** (articles, photos, data — no glass). Do NOT apply `.glassEffect()` to content items like list rows — glass on the content layer blurs the boundary and competes with navigation. + +### Tab Bar Adapting to Sidebar + +Use `.tabViewStyle(.sidebarAdaptable)` (iOS 26) to let the tab bar adapt to sidebar on iPad/macOS while remaining a tab bar on iPhone. Transitions fluidly with adaptive window sizes. + +```swift +TabView { + ContentView().tabItem { Label("Home", systemImage: "house") } + SearchView().tabItem { Label("Search", systemImage: "magnifyingglass") } +} +.tabViewStyle(.sidebarAdaptable) +``` + +### Split Views for Sidebar + Inspector Layouts + +Use `NavigationSplitView` with sidebar, content, and detail columns. Liquid Glass applies automatically to sidebars and inspectors. iOS adapts column visibility; iPadOS/macOS shows all columns on large screens. + +```swift +NavigationSplitView { + List(folders, selection: $selectedFolder) { Label($0.name, systemImage: $0.icon) } + .navigationTitle("Folders") +} content: { + List(items, selection: $selectedItem) { ItemRow($0) } +} detail: { + InspectorView(item: selectedItem) +} +``` + +### Check Content Safe Areas + +Verify content peeks through appropriately beneath sidebars/inspectors. Use `.safeAreaInset(edge:)` when content needs to account for sidebar/inspector space. + +#### Padding with Edge-to-Edge Glass + +When glass extends edge-to-edge via `.ignoresSafeArea()`, use `.safeAreaPadding()` (not `.padding()`) on the content layer to respect device safe areas (notch, Dynamic Island, home indicator): + +```swift +// ❌ .padding(.horizontal, 20) — doesn't account for safe areas +// ✅ .safeAreaPadding(.horizontal, 20) — 20pt beyond safe areas +``` + +Applies to: full-screen sheets with materials, edge-to-edge toolbars, floating panels, custom glass navigation bars. Requires iOS 17+. See `axiom-swiftui-layout-ref` for full `.safeAreaPadding()` vs `.padding()` guidance. + +Verify: content visible beneath sidebar/inspector, not cropped, peek-through looks intentional, properly inset from notch/Dynamic Island/home indicator. + +### Background Extension Effect + +Mirrors and blurs content under sidebar/inspector for an immersive edge-to-edge feel, without actually scrolling content there. Best for hero images, photo galleries, and media-rich split views. + +```swift +NavigationSplitView { + SidebarView() +} detail: { + DetailView() + .backgroundExtensionEffect() +} +``` + +### Automatically Minimize Tab Bar (iOS) + +Tab bars can recede when scrolling via `.tabBarMinimizeBehavior()` (iOS 26). Options: `.onScrollDown` (recommended for reading/media apps), `.onScrollUp`, `.automatic`, `.never`. Tab bar expands when scrolling in opposite direction. + +--- + +## Menus and Toolbars + +Menus have refreshed look across platforms. They adopt Liquid Glass, and menu items for common actions use icons to help people quickly scan and identify actions. iPadOS now has menu bar for faster access to common commands. + +### Cross-Platform Menu Consistency + +Menus now have consistent layout across iOS and macOS — icons on leading edge, same API (`Label` or standard control initializers) produces the same visual result on both platforms. + +### Menu Icons for Standard Actions + +#### Automatic Icon Adoption + +```swift +// ✅ Standard selectors get icons automatically +Menu("Actions") { + Button(action: cut) { + Text("Cut") + } + Button(action: copy) { + Text("Copy") + } + Button(action: paste) { + Text("Paste") + } +} +// System uses selector to determine icon +// cut() → scissors icon +// copy() → documents icon +// paste() → clipboard icon +``` + +#### Standard Selectors +- `cut()` → ✂️ scissors +- `copy()` → 📄 documents +- `paste()` → 📋 clipboard +- `delete()` → 🗑️ trash +- `share()` → ↗️ share arrow +- Many more... + +#### Custom Actions +```swift +// ✅ Provide icon for custom actions +Button { + customAction() +} label: { + Label("Custom Action", systemImage: "star.fill") +} +``` + +### Match Top Menu Actions to Swipe Actions + +#### For consistency and predictability + +```swift +// ✅ Swipe actions match contextual menu +List(emails) { email in + EmailRow(email) + .swipeActions(edge: .leading) { + Button("Archive", systemImage: "archivebox") { + archive(email) + } + } + .swipeActions(edge: .trailing) { + Button("Delete", systemImage: "trash", role: .destructive) { + delete(email) + } + } + .contextMenu { + // ✅ Same actions appear at top + Button("Archive", systemImage: "archivebox") { + archive(email) + } + Button("Delete", systemImage: "trash", role: .destructive) { + delete(email) + } + + Divider() + + // Additional actions below + Button("Mark Unread") { } + } +} +``` + +**Why** Users expect swipe actions and menu actions to match. Consistency builds trust and predictability. + +### Toolbar Grouping, Spacers, and Morphing + +See `axiom-swiftui-26-ref` for complete toolbar API coverage: `ToolbarSpacer`, `ToolbarItemGroup` visual grouping, `.sharedBackgroundVisibility(.hidden)`, toolbar morphing, `DefaultToolbarItem`, user-customizable toolbars, monochrome icon rendering, backward-compatible toolbar labels, and floating glass buttons. + +**Liquid Glass-specific toolbar guidance:** +- Pick one style (icons OR text) per toolbar background group — mixing creates inconsistent visual weight under glass +- Use `.tint()` only to convey meaning (call to action, next step), not for decoration — monochrome reduces visual noise under Liquid Glass + +### Provide Accessibility Labels for Icons + +All icon-only buttons need `.accessibilityLabel("Action Name")` for VoiceOver and Voice Control users. Use `Label("Share", systemImage: "square.and.arrow.up")` to get automatic accessibility support. + +### Audit Toolbar Customizations + +Verify custom spacers, items, and visibility work with Liquid Glass backgrounds. Common issue: conditionally hiding content inside `ToolbarItem` creates empty pills — move the `if` outside to hide the entire `ToolbarItem` instead. + +--- + +## Windows and Modals + +Windows adopt rounder corners to fit controls and navigation elements. iPadOS apps show window controls and support continuous window resizing. Sheets and action sheets adopt Liquid Glass with increased corner radius. + +### Arbitrary Window Sizes (iPadOS) + +iPadOS 26 windows resize continuously (no preset size transitions). Use `.windowResizability(.contentSize)` and flexible layouts. Remove hard-coded size assumptions and test at various window sizes. + +### Split Views for Fluid Column Resizing + +Use `NavigationSplitView(columnVisibility:)` for automatic content reflow during continuous window resizing — avoids manual layout calculations and custom animation code. + +### Use Layout Guides and Safe Areas + +Use `.safeAreaInset(edge:)` so content automatically adjusts around window controls, title bars, and chrome. + +### Sheets: Increased Corner Radius + +Sheets have increased corner radius; half sheets are inset from edge (content peeks through) and become more opaque when transitioning to full height. Check that content isn't cropped by rounder corners and that background peek-through looks intentional. + +### Remove presentationBackground + +Remove `.presentationBackground()` from sheets — the system applies Liquid Glass sheet material automatically. Custom backgrounds interfere with the new material. + +### Audit Sheet/Popover Backgrounds + +Remove custom `VisualEffectView`/`UIBlurEffect` backgrounds from popovers and sheets. The system applies Liquid Glass automatically — no background modifier needed. + +### Action Sheets: Inline Appearance + +Action sheets now originate from the source element (not bottom edge) and allow interaction with other parts of the interface. Use `.confirmationDialog()` attached to the triggering button — the system positions the sheet automatically. + +--- + +## Organization and Layout + +Lists, tables, and forms have larger row height and padding to give content room to breathe. Sections have increased corner radius to match curvature of controls. + +### Larger Row Height and Padding + +Lists, tables, forms, and sections all have increased height, padding, spacing, and corner radius. Standard components adopt automatically. Remove hard-coded `.frame(height:)` and `.padding(.vertical:)` — let the system determine row height and padding. + +### Section Header Capitalization + +iOS 26 no longer uppercases section headers — they render exactly as provided. Update to title-style capitalization: `Section(header: Text("User Settings"))` not `"user settings"`. + +### Adopt Forms for Platform-Optimized Layouts + +Use `.formStyle(.grouped)` for automatic row height, padding, spacing, and section corner radius that matches controls across platforms. + +--- + +## Search + +Platform conventions for search location and behavior optimize experience for each device. Review search field design conventions to provide engaging search experience. + +### Keyboard Layout When Activating Search + +#### What Changed (iOS) + +When a person taps search field to give it focus, it slides upwards as keyboard appears. + +#### Testing +- Tap search field +- Verify smooth upward slide +- Keyboard appears without covering search field +- Consistent with system search experiences (Spotlight, Safari) + +#### No Code Changes Required +```swift +// ✅ Existing searchable modifier adopts new behavior +List(items) { item in + Text(item.name) +} +.searchable(text: $searchText) +``` + +### Semantic Search Tabs + +For Tab API patterns including `.tabRole(.search)`, see swiftui-nav-ref skill Section 5 (Tab Navigation Integration). + +--- + +## Platform Considerations + +Liquid Glass can have distinct appearance and behavior across platforms, contexts, and input methods. Test across devices to understand material appearance. + +### watchOS and tvOS + +| Platform | Adoption | Key Requirement | +|----------|----------|-----------------| +| watchOS | Automatic on latest release, even without latest SDK | Use standard toolbar APIs and `.buttonStyle(.bordered)` from watchOS 10 | +| tvOS | Focus-based — glass appears when controls gain focus (Apple TV 4K 2nd gen+) | Use `.focusable()` on standard controls; for custom controls, apply `.glassEffect()` with `@FocusState`-driven opacity | + +### glassBackgroundEffect() + +For custom views that need to reflect content behind them (not just apply glass material on top), use `.glassBackgroundEffect()`. This creates a glass-like background that shows through underlying content, distinct from `.glassEffect()` which applies glass as an overlay material. + +```swift +// Custom floating panel with glass background reflecting content behind it +struct FloatingPanel: View { + var body: some View { + VStack { + Text("Panel Content") + // ... + } + .padding() + .glassBackgroundEffect() // Reflects content beneath, not on top + } +} +``` + +**`.glassEffect()` vs `.glassBackgroundEffect()`**: Use `.glassEffect()` for controls and navigation elements (buttons, toolbars). Use `.glassBackgroundEffect()` for content containers that should show through to underlying layers (panels, cards that need depth). + +### ScrollView + Glass Interaction + +When Liquid Glass elements overlay scrollable content, handle clipping and visibility carefully: + +```swift +ZStack { + ScrollView { + LazyVStack { + ForEach(items) { item in + ItemRow(item) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + Color.clear.frame(height: 80) // Space for floating glass controls + } + } + + VStack { + Spacer() + HStack { + Button("Action") { } + .glassEffect() + } + .padding() + } +} +``` + +**Common issue**: Glass elements can clip or lose their effect at scroll view bounds. Use `.clipped()` on the scroll content (not the glass element) and ensure glass elements are outside the scroll view's hierarchy, not inside it. + +### UIBlurEffect Migration Mapping + +| Legacy (Pre-iOS 26) | Liquid Glass Equivalent | +|---------------------|------------------------| +| `UIBlurEffect(style: .systemMaterial)` | `.glassEffect()` (standard) | +| `UIBlurEffect(style: .systemUltraThinMaterial)` | `.glassEffect(.clear)` (with conditions) | +| `UIBlurEffect(style: .systemChromeMaterial)` | System toolbar/navigation glass (automatic) | +| `UIVisualEffectView` with blur | Remove entirely — use `.glassEffect()` on SwiftUI view | +| `.background(.thinMaterial)` | `.glassEffect()` or keep material (adapts automatically) | +| `.background(.ultraThinMaterial)` | `.glassBackgroundEffect()` for content containers | +| Custom `NSVisualEffectView` (macOS) | `.glassEffect()` or system components | + +**Migration steps**: (1) Remove `UIVisualEffectView`/`NSVisualEffectView` wrappers, (2) Replace with `.glassEffect()` on the SwiftUI view, (3) Test with Reduce Transparency to verify fallback, (4) Profile performance — glass effects use GPU compositing. + +### Combining Custom Liquid Glass Effects + +Wrap multiple `.glassEffect()` views in `GlassEffectContainer { }` to optimize rendering, enable fluid morphing between glass shapes, and reduce compositor overhead. Use for nearby glass elements, morphing animations, and performance-critical interfaces. + +### Performance Testing + +Profile scrolling, animations, memory, and CPU with Instruments (Time Profiler, SwiftUI, Allocations, Core Animation). See `axiom-swiftui-performance` for SwiftUI Instrument workflows and `axiom-performance-profiling` for Instruments decision trees. + +### Backward Compatibility + +Add `UIDesignRequiresCompatibility = true` to Info.plist to ship with iOS 26 SDK while maintaining iOS 18 appearance (Liquid Glass disabled, previous blur/material styles used). Migration strategy: ship with key enabled, audit changes in separate build, update incrementally, remove key when ready. + +--- + +## Quick Reference: API Checklist + +### Core Liquid Glass APIs +- [ ] `glassEffect()` - Apply Liquid Glass material +- [ ] `glassEffect(.clear)` - Clear variant (requires 3 conditions) +- [ ] `glassEffect(in: Shape)` - Custom shape +- [ ] `glassBackgroundEffect()` - For custom views reflecting content + +### Scroll Edge Effects +- [ ] `scrollEdgeEffectStyle(_:for:)` - Maintain legibility where glass meets scrolling content +- [ ] `.hard` style for pinned accessory views +- [ ] `.soft` style for gradual fade + +### Controls and Shapes +- [ ] `containerRelativeShape()` - Align control shapes with containers +- [ ] `.borderedProminent` button style +- [ ] `.bordered` button style +- [ ] System colors with `.tint()` for adaptation + +### Navigation +- [ ] `.tabViewStyle(.sidebarAdaptable)` - Tab bar adapts to sidebar +- [ ] `.tabBarMinimizeBehavior(_:)` - Minimize on scroll +- [ ] `.tabRole(.search)` - Semantic search tabs +- [ ] `NavigationSplitView` for sidebar + inspector layouts + +### Toolbars and Menus +- [ ] `ToolbarSpacer(.fixed)` - Separate toolbar groups +- [ ] Standard selectors for automatic menu icons +- [ ] Match contextual menu actions to swipe actions + +### Organization and Layout +- [ ] `.formStyle(.grouped)` - Platform-optimized form layouts +- [ ] Title-style capitalization for section headers +- [ ] Respect automatic row height and padding + +### Performance +- [ ] `GlassEffectContainer` - Combine multiple glass effects +- [ ] Profile with Instruments +- [ ] Test with accessibility settings + +### Backward Compatibility +- [ ] `UIDesignRequiresCompatibility` in Info.plist (if needed) + +--- + +## Audit Checklist + +Use this checklist when auditing app for Liquid Glass adoption. 30 highest-impact items grouped by category: + +### Build and Test +- [ ] Built with Xcode 26 SDK and run on latest platform releases +- [ ] Tested with Reduce Transparency, Increase Contrast, and Reduce Motion +- [ ] Performance profiled with Instruments (scrolling, animations, memory) + +### Remove Custom Overrides +- [ ] Custom backgrounds removed from navigation bars, toolbars, tab bars +- [ ] `presentationBackground` removed from sheets and popovers +- [ ] Hard-coded control heights and row heights removed +- [ ] Custom blur/material backgrounds removed from sheets and popovers + +### Icons and App Icon +- [ ] App icon uses foreground/middle/background layers, composed in Icon Composer +- [ ] All appearance variants tested (light/dark/clear/tinted) +- [ ] Accessibility labels provided for all toolbar/menu icons + +### Controls +- [ ] New capsule button shapes reviewed; `controlSize(.small)` for high-density layouts +- [ ] System colors used (not hard-coded RGB); `.borderedProminent`/`.bordered` adopted +- [ ] Controls have adequate spacing (no crowding glass-on-glass) +- [ ] Scroll edge effects applied where glass meets scrolling content + +### Navigation and Layout +- [ ] Clear hierarchy: navigation layer (glass) vs content layer (no glass) +- [ ] Tab bar adapts to sidebar where appropriate (`.sidebarAdaptable`) +- [ ] Content safe areas checked; `.safeAreaPadding()` for edge-to-edge glass +- [ ] Background extension effect considered for split views +- [ ] Section headers updated to title-style capitalization +- [ ] `.formStyle(.grouped)` adopted for forms + +### Menus and Toolbars +- [ ] Standard selectors used for automatic menu icons +- [ ] Swipe actions match contextual menu actions +- [ ] Toolbar items grouped logically (see `axiom-swiftui-26-ref`) + +### Windows and Modals +- [ ] Arbitrary window sizes supported (iPadOS); flexible layouts used +- [ ] Sheet content checked around increased corner radius + +### Platform +- [ ] watchOS: Standard toolbar APIs and button styles adopted +- [ ] tvOS: Standard focus APIs for Liquid Glass on focus +- [ ] `GlassEffectContainer` used for multiple nearby glass effects +- [ ] `UIDesignRequiresCompatibility` key considered if needed + +--- + +## Resources + +**WWDC**: 2025-219, 2025-323 (Build a SwiftUI app with the new design) + +**Docs**: /TechnologyOverviews/liquid-glass, /TechnologyOverviews/adopting-liquid-glass, /design/Human-Interface-Guidelines/materials + +**Sample Code**: /SwiftUI/Landmarks-Building-an-app-with-Liquid-Glass + +**Skills**: axiom-liquid-glass, axiom-swiftui-performance, axiom-swiftui-debugging, axiom-accessibility-diag + +--- + +**Last Updated**: 2025-12-01 +**Minimum Platform**: iOS/iPadOS 26, macOS Tahoe, tvOS, watchOS, visionOS 3 +**Xcode Version**: Xcode 26+ +**Skill Type**: Reference (comprehensive adoption guide) diff --git a/.claude/skills/axiom-liquid-glass-ref/agents/openai.yaml b/.claude/skills/axiom-liquid-glass-ref/agents/openai.yaml new file mode 100644 index 0000000..e158f54 --- /dev/null +++ b/.claude/skills/axiom-liquid-glass-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Liquid Glass Reference" + short_description: "Planning comprehensive Liquid Glass adoption across an app, auditing existing interfaces for Liquid Glass compatibili..." diff --git a/.claude/skills/axiom-liquid-glass/.openskills.json b/.claude/skills/axiom-liquid-glass/.openskills.json new file mode 100644 index 0000000..0796111 --- /dev/null +++ b/.claude/skills/axiom-liquid-glass/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-liquid-glass", + "installedAt": "2026-04-12T08:06:26.426Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-liquid-glass/SKILL.md b/.claude/skills/axiom-liquid-glass/SKILL.md new file mode 100644 index 0000000..760adeb --- /dev/null +++ b/.claude/skills/axiom-liquid-glass/SKILL.md @@ -0,0 +1,609 @@ +--- +name: axiom-liquid-glass +description: Use when implementing Liquid Glass effects, reviewing UI for Liquid Glass adoption, debugging visual artifacts, optimizing performance, or requesting expert review of Liquid Glass implementation - provides comprehensive design principles, API patterns, and troubleshooting guidance from WWDC 2025. Includes design review pressure handling and professional push-back frameworks +license: MIT +compatibility: iOS 26+, iPadOS 26+, macOS Tahoe+, axiom-visionOS 3+ +metadata: + version: "1.2.0" + last-updated: "Added new iOS 26 APIs and backward compatibility guidance" +--- + +# Liquid Glass — Apple's New Material Design System + +## When to Use This Skill + +Use when: +- Implementing Liquid Glass effects in your app +- Reviewing existing UI for Liquid Glass adoption opportunities +- Debugging visual artifacts with Liquid Glass materials +- Optimizing Liquid Glass performance +- **Requesting expert review of Liquid Glass implementation** +- Understanding when to use Regular vs Clear variants +- Troubleshooting tinting, legibility, or adaptive behavior issues + +#### Related Skills +- Use `axiom-liquid-glass-ref` for comprehensive app-wide adoption guidance (app icons, controls, navigation, menus, windows, platform considerations) + +## Example Prompts + +- "How is Liquid Glass different from blur effects? Should I adopt it?" +- "My lensing effect looks like a regular blur. What am I missing?" +- "Liquid Glass looks odd on iPad vs iPhone. How do I adjust?" +- "How do I ensure text contrast on top of Liquid Glass?" +- "What are the expert criteria for reviewing a Liquid Glass implementation?" + +--- + +## What is Liquid Glass? + +Liquid Glass is Apple's next-generation material design system introduced at WWDC 2025. It represents a significant evolution from previous materials (Aqua, iOS 7 blurs, Dynamic Island) by creating a new digital meta-material that: + +- **Dynamically bends and shapes light** (lensing) rather than scattering it +- **Moves organically** like a lightweight liquid, responding to touch and app dynamism +- **Adapts automatically** to size, environment, content, and light/dark modes +- **Unifies design language** across all Apple platforms (iOS, iPadOS, macOS, axiom-visionOS) + +**Core Philosophy**: Liquid Glass complements the evolution of rounded, immersive screens with rounded, floating forms that feel natural to touch interaction while letting content shine through. + +--- + +## Visual Properties + +### 1. Lensing (Primary Visual Characteristic) + +Liquid Glass defines itself through **lensing** — warping and bending light to communicate presence, motion, and form. Elements materialize in/out by modulating light bending (not fading). Controls feel ultra-lightweight yet visually distinguishable. + +### 2. Motion & Fluidity + +- Responds to interaction by flexing with light +- Gel-like flexibility communicates transient, malleable nature +- Elements lift into Liquid Glass on interaction (controls) +- Dynamic morphing between app states as a singular floating plane + +### 3. Adaptive Behavior + +Liquid Glass **continuously adapts** without fixed light/dark appearance: +- Shadows intensify when text scrolls underneath; tint shifts for legibility +- Small elements (navbars) independently flip light/dark; large elements (menus, sidebars) don't flip but adapt depth +- Ambient environment subtly spills onto surface + +--- + +## Implementation Guide + +### Basic API Usage + +#### SwiftUI: `glassEffect` Modifier + +```swift +// Basic usage - applies glass within capsule shape +Text("Hello") + .glassEffect() + +// Custom shape +Text("Hello") + .glassEffect(in: RoundedRectangle(cornerRadius: 12)) + +// Interactive elements (iOS - for controls/containers) +Button("Tap Me") { + // action +} +.glassEffect() +.interactive() // Add for custom controls on iOS +``` + +**Automatic Adoption**: Simply recompiling with Xcode 26 brings Liquid Glass to standard controls automatically. + +### Variants: Regular vs Clear + +**CRITICAL DECISION**: Never mix Regular and Clear in the same interface. + +#### Regular Variant (Default — 95% of Cases) + +Most versatile. Full adaptive effects, automatic legibility, works in any size over any content. Use for navigation bars, tab bars, toolbars, buttons, menus, sidebars. + +#### Clear Variant (Special Cases Only) + +Permanently more transparent, no adaptive behaviors. **Requires dimming layer** for legibility. + +**Use ONLY when ALL three conditions are met**: +1. Element is over **media-rich content** +2. Content layer won't be negatively affected by **dimming layer** +3. Content above glass is **bold and bright** + +Using Clear without meeting all three conditions results in poor legibility. See `axiom-liquid-glass-ref` for implementation examples. + +--- + +## Layered System Architecture + +Liquid Glass is composed of four layers working together: + +1. **Highlights** — Light sources produce highlights responding to geometry; some respond to device motion +2. **Shadows** — Content-aware: stronger over text, weaker over light backgrounds +3. **Internal Glow** — Material illuminates from within on interaction; spreads to nearby glass elements +4. **Adaptive Tinting** — Multiple layers adapt together to maintain hierarchy; all built-in automatically + +--- + +## Scroll Edge Effects + +Scroll edge effects dissolve content into background as it scrolls, lifting glass above moving content. Use `.scrollEdgeEffect(.hard)` when pinned accessory views exist (e.g., column headers) for extra visual separation. See `axiom-liquid-glass-ref` for full API details. + +--- + +## Tinting & Color + +Liquid Glass introduces **adaptive tinting** — selecting a color generates tones mapped to content brightness underneath, inspired by colored glass in reality. Compatible with all glass behaviors (morphing, adaptation, interaction). + +### Tinting Rules + +```swift +// ✅ Tint primary actions only +Button("View Bag") { }.tint(.red).glassEffect() + +// ❌ Don't tint everything — when everything is tinted, nothing stands out +VStack { + Button("Action 1").tint(.blue).glassEffect() + Button("Action 2").tint(.green).glassEffect() // No hierarchy +} + +// ❌ Solid fills break Liquid Glass character +Button("Action") { }.background(.red) // Opaque, wrong + +// ✅ Use .tint() instead of solid fills +Button("Action") { }.tint(.red).glassEffect() // Grounded in environment +``` + +Reserve tinting for primary UI actions. Use color in the content layer for overall app color scheme. + +--- + +## Legibility & Contrast + +SwiftUI automatically uses **vibrant text and tint colors** within glass effects — no manual adjustment needed. Small elements (navbars, tabbars) flip light/dark for discernibility. Large elements (menus, sidebars) adapt but don't flip (too distracting for large surface area). Symbols/glyphs mirror glass behavior and maximize contrast automatically. + +Use custom tint colors selectively for distinct functional purpose (e.g., `.tint(.orange)` on a single toolbar button for emphasis). + +--- + +## Accessibility + +Liquid Glass offers several accessibility features that modify material **without sacrificing its magic**: + +### Reduced Transparency +- Makes Liquid Glass frostier +- Obscures more content behind it +- Applied automatically when system setting enabled + +### Increased Contrast +- Makes elements predominantly black or white +- Highlights with contrasting border +- Applied automatically when system setting enabled + +### Reduced Motion +- Decreases intensity of effects +- Disables elastic properties +- Applied automatically when system setting enabled + +**Developer Action Required**: None - all features available automatically when using Liquid Glass. + +--- + +## Performance Considerations + +### View Hierarchy Impact + +**Concern**: Liquid Glass rendering cost in complex view hierarchies + +**Guidance**: +- Regular variant optimized for performance +- Larger elements (menus, sidebars) use more pronounced effects but managed by system +- Avoid excessive nesting of glass elements + +**Optimization**: +```swift +// ❌ Avoid deep nesting +ZStack { + GlassContainer1() + .glassEffect() + ZStack { + GlassContainer2() + .glassEffect() + // More nesting... + } +} + +// ✅ Flatten hierarchy +VStack { + GlassContainer1() + .glassEffect() + + GlassContainer2() + .glassEffect() +} +``` + +### Rendering Costs + +**Adaptive behaviors have computational cost**: +- Light/dark switching +- Shadow adjustments +- Tint calculations +- Lensing effects + +**System handles optimization**, but be mindful: +- Don't animate Liquid Glass elements unnecessarily +- Use Clear variant sparingly (requires dimming layer computation) +- Profile with Instruments if experiencing performance issues + +--- + +## Testing Liquid Glass + +Test across these configurations: +- Light/dark modes +- Reduced Transparency enabled +- Increased Contrast enabled +- Reduced Motion enabled +- Dynamic Type (larger text sizes) +- Content scrolling (verify scroll edge effects) +- Right-to-left languages + +See `axiom-ui-testing` for comprehensive UI testing patterns including visual regression and accessibility testing. + +--- + +## Design Review Pressure: Defending Your Implementation + +### The Problem + +Under design review pressure, you'll face requests to: +- "Use Clear variant everywhere — Regular is too opaque" +- "Glass on all controls for visual cohesion" +- "More transparency to let content shine through" + +These sound reasonable. **But they violate the framework.** Your job: defend using evidence, not opinion. + +### Red Flags — Designer Requests That Violate Skill Guidelines + +If you hear ANY of these, **STOP and reference the skill**: + +- ❌ **"Use Clear everywhere"** – Clear requires three specific conditions, not design preference +- ❌ **"Glass looks better than fills"** – Correct layer (navigation vs content) trumps aesthetics +- ❌ **"Users won't notice the difference"** – Clear variant fails legibility tests in low-contrast scenarios +- ❌ **"Stack glass on glass for consistency"** – Explicitly prohibited; use fills instead +- ❌ **"Apply glass to Lists for sophistication"** – Lists are content layer; causes visual confusion + +### How to Push Back Professionally + +#### Step 1: Show the Framework + +``` +"I want to make this change, but let me show you Apple's guidance on Clear variant. +It requires THREE conditions: + +1. Media-rich content background +2. Dimming layer for legibility +3. Bold, bright controls on top + +Let me show which screens meet all three..." +``` + +#### Step 2: Demonstrate the Risk + +Open the app on a device. Show: +- Clear variant in low-contrast scenario (unreadable) +- Regular variant in same scenario (legible) + +#### Step 3: Offer Compromise + +``` +"Clear can work beautifully in these 6 hero sections where all three conditions apply. +Regular handles everything else with automatic legibility. Best of both worlds." +``` + +#### Step 4: Document the Decision + +If overruled (designer insists on Clear everywhere): + +``` +Slack message to PM + designer: + +"Design review decided to use Clear variant across all controls. +Important: Clear variant requires legibility testing in low-contrast scenarios +(bright sunlight, dark content). If we see accessibility issues after launch, +we'll need an expedited follow-up. I'm flagging this proactively." +``` + +#### Why this works +- You're not questioning their taste (you like Clear too) +- You're raising accessibility/legibility risk +- You're offering a solution that preserves their vision in hero sections +- You're documenting the decision (protects you post-launch) + +### Real-World Example: App Store Launch Blocker (36-Hour Deadline) + +#### Scenario +- 36 hours to launch +- Chief designer says: "Clear variant everywhere" +- Client watching the review meeting +- You already implemented Regular per the skill + +#### What to do + +```swift +// In the meeting, demo side-by-side: + +// Regular variant (current implementation) +NavigationBar() + .glassEffect() // Automatic legibility + +// Clear variant (requested) +NavigationBar() + .glassEffect(.clear) // Requires dimming layer below + +// Show the three-condition checklist +// Demonstrate which screens pass/fail +// Offer: Clear in hero sections, Regular elsewhere +``` + +#### Result +- 30-minute compromise demo +- 90 minutes to implement changes +- Launch on schedule with optimal legibility +- No post-launch accessibility complaints + +### When to Accept the Design Decision (Even If You Disagree) + +Sometimes designers have valid reasons to override the skill. Accept if: + +- [ ] They understand the three-condition framework +- [ ] They're willing to accept legibility risks +- [ ] You document the decision in writing +- [ ] They commit to monitoring post-launch feedback + +#### Document in Slack + +``` +"Design review decided to use Clear variant [in these locations]. +We understand this requires: +- All three conditions met: [list them] +- Potential legibility issues in low-contrast scenarios +- Accessibility testing across brightness levels + +Monitoring plan: +- Gather user feedback first 48 hours +- Run accessibility audit +- Have fallback to Regular variant ready for push if needed" +``` + +This protects both of you and shows you're not blocking - just de-risking. + +--- + +## Expert Review Checklist + +When reviewing Liquid Glass implementation (your code or others'), check: + +### 1. Material Appropriateness +- [ ] Is Liquid Glass used only on navigation layer (not content)? +- [ ] Are standard controls getting glass automatically via Xcode 26 recompile? +- [ ] Is glass avoided on glass situations? + +### 2. Variant Selection +- [ ] Is Regular variant used for most cases? +- [ ] If Clear variant used, do all three conditions apply? + - [ ] Over media-rich content? + - [ ] Dimming layer acceptable? + - [ ] Content above is bold and bright? +- [ ] Are Regular and Clear never mixed in same interface? + +### 3. Legibility & Contrast +- [ ] Are primary actions selectively tinted (not everything)? +- [ ] Is color used in content layer for overall app color scheme? +- [ ] Are solid fills avoided on glass elements? +- [ ] Do elements maintain legibility on various backgrounds? + +### 4. Layering & Hierarchy +- [ ] Are content intersections avoided in steady states? +- [ ] Are elements on top of glass using fills/transparency (not glass)? +- [ ] Is visual hierarchy clear (navigation layer vs content layer)? + +### 5. Scroll Edge Effects +- [ ] Are scroll edge effects applied where Liquid Glass meets scrolling content? +- [ ] Is hard style used for pinned accessory views? + +### 6. Accessibility +- [ ] Does implementation work with Reduced Transparency? +- [ ] Does implementation work with Increased Contrast? +- [ ] Does implementation work with Reduced Motion? +- [ ] Are interactive elements hittable in all configurations? + +### 7. Performance +- [ ] Is view hierarchy reasonably flat? +- [ ] Are glass elements animated only when necessary? +- [ ] Is Clear variant used sparingly? + +--- + +## Common Mistakes & Solutions + +### Glass Placement Errors + +```swift +// ❌ Glass on content layer — competes with navigation +List(landmarks) { landmark in + LandmarkRow(landmark).glassEffect() +} + +// ✅ Glass on navigation layer only +.toolbar { + ToolbarItem { Button("Add") { }.glassEffect() } +} + +// ❌ Clear without dimming — poor legibility +ZStack { + VideoPlayer(player: player) + PlayButton().glassEffect(.clear) +} + +// ✅ Clear with dimming layer +ZStack { + VideoPlayer(player: player) + .overlay(.black.opacity(0.4)) + PlayButton().glassEffect(.clear) +} +``` + +### Over-Tinting + +Tint primary action only. When everything is tinted, nothing stands out. + +### Static Material Expectations + +Don't hardcode shadows or fixed opacity. Embrace adaptive behavior — test across light/dark modes and backgrounds. + +--- + +## Troubleshooting + +### Visual Artifacts + +**Issue**: Glass appears too transparent or invisible + +**Check**: +1. Are you using Clear variant? (Switch to Regular if inappropriate) +2. Is background content extremely light or dark? (Glass adapts - this may be correct behavior) +3. Is Reduced Transparency enabled? (Check accessibility settings) + +**Issue**: Glass appears opaque or has harsh edges + +**Check**: +1. Are you using solid fills on glass? (Remove, use tinting) +2. Is Increased Contrast enabled? (Expected behavior) +3. Is custom shape too complex? (Simplify geometry) + +### Dark Mode Issues + +**Issue**: Glass doesn't flip to dark style on dark backgrounds + +**Check**: +1. Is element large (menu, sidebar)? (Large elements don't flip - by design) +2. Is background actually dark? (Use Color Picker to verify) +3. Are you overriding appearance? (Remove `.preferredColorScheme()` if unintended) + +**Issue**: Content on glass not legible in dark mode + +**Fix**: +```swift +// Let SwiftUI handle contrast automatically +Text("Label") + .foregroundStyle(.primary) // ✅ Adapts automatically + +// Don't hardcode colors +Text("Label") + .foregroundColor(.black) // ❌ Won't adapt to dark mode +``` + +### Performance Issues + +**Issue**: Scrolling feels janky with Liquid Glass + +**Debug**: +1. Profile with Instruments (see `axiom-swiftui-performance` skill) +2. Check for excessive view body updates +3. Simplify view hierarchy under glass +4. Verify not applying glass to content layer (major performance hit) + +**Issue**: Animations stuttering + +**Check**: +1. Are you animating glass shape changes? (Expensive) +2. Profile with SwiftUI Instrument for long view updates +3. Consider reducing glass usage if critical path + +--- + +## Migration from Previous Materials + +### From UIBlurEffect / NSVisualEffectView + +**Before** (UIKit): +```swift +let blurEffect = UIBlurEffect(style: .systemMaterial) +let blurView = UIVisualEffectView(effect: blurEffect) +view.addSubview(blurView) +``` + +**After** (SwiftUI with Liquid Glass): +```swift +ZStack { + // Content +} +.glassEffect() +``` + +**Benefits**: Automatic adaptation (no manual style switching), built-in interaction feedback, platform-appropriate appearance, accessibility features included. + +### From Custom Materials + +1. **Try Liquid Glass first** — may provide desired effect automatically +2. **Evaluate Regular vs Clear** — Clear may match custom transparency needs +3. **Test across configurations** — Liquid Glass adapts automatically + +**When to keep custom materials**: Specific artistic effect not achievable with Liquid Glass, backward compatibility with iOS < 26 required, or non-standard UI paradigm incompatible with Liquid Glass principles. + +### UIKit + SwiftUI Interop + +When migrating incrementally, glass effects apply per-framework: +- SwiftUI views get `.glassEffect()` / `.glassBackgroundEffect()` +- UIKit views use the UIKit Liquid Glass APIs (see `axiom-liquid-glass-ref` for migration mapping) +- Hosted SwiftUI views inside `UIHostingController` get glass effects independently + +See `axiom-liquid-glass-ref` for complete UIBlurEffect migration mapping table. + +--- + +## Backward Compatibility + +### UIDesignRequiresCompatibility Key (iOS 26) + +To ship with latest SDKs while maintaining previous appearance: + +```xml +UIDesignRequiresCompatibility + +``` + +**Effect**: App built with iOS 26 SDK, appearance matches iOS 18 and earlier, Liquid Glass effects disabled, previous blur/material styles used. + +**When to use**: Need time to audit interface changes, gradual adoption strategy, or maintain exact appearance temporarily. + +**Migration strategy**: +1. Ship with `UIDesignRequiresCompatibility` enabled +2. Audit interface changes in separate build +3. Update interface incrementally +4. Remove key when ready for Liquid Glass + +--- + +## API Reference + +For complete API reference including `glassEffect()`, `glassBackgroundEffect()`, toolbar modifiers, scroll edge effects, navigation/search APIs, controls/layout, `GlassEffectContainer`, `glassEffectID`, types, and backward compatibility, see `axiom-liquid-glass-ref`. + +--- + +## Resources + +**WWDC**: 2025-219, 2025-256, 2025-323 (Build a SwiftUI app with the new design) + +**Docs**: /technologyoverviews/adopting-liquid-glass, /swiftui/landmarks-building-an-app-with-liquid-glass, /swiftui/applying-liquid-glass-to-custom-views + +**Skills**: axiom-liquid-glass-ref + +--- + +**Platforms:** iOS 26+, iPadOS 26+, macOS Tahoe, axiom-visionOS 3 +**Xcode:** 26+ +**History:** See git log for changes diff --git a/.claude/skills/axiom-liquid-glass/agents/openai.yaml b/.claude/skills/axiom-liquid-glass/agents/openai.yaml new file mode 100644 index 0000000..94e991e --- /dev/null +++ b/.claude/skills/axiom-liquid-glass/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Liquid Glass" + short_description: "Implementing Liquid Glass effects, reviewing UI for Liquid Glass adoption, debugging visual artifacts, optimizing per..." diff --git a/.claude/skills/axiom-lldb-ref/.openskills.json b/.claude/skills/axiom-lldb-ref/.openskills.json new file mode 100644 index 0000000..3b4d9bc --- /dev/null +++ b/.claude/skills/axiom-lldb-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-lldb-ref", + "installedAt": "2026-04-12T08:06:27.426Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-lldb-ref/SKILL.md b/.claude/skills/axiom-lldb-ref/SKILL.md new file mode 100644 index 0000000..68b8b91 --- /dev/null +++ b/.claude/skills/axiom-lldb-ref/SKILL.md @@ -0,0 +1,480 @@ +--- +name: axiom-lldb-ref +description: Complete LLDB command reference — variable inspection, breakpoints, threads, expression evaluation, process control, memory commands, and .lldbinit customization +license: MIT +--- + +# LLDB Command Reference + +Complete command reference for LLDB in Xcode. Organized by task so you can find the exact command you need. + +For debugging workflows and decision trees, see `/skill axiom-lldb`. + +--- + +## Part 1: Variable Inspection + +### `v` / `frame variable` + +Reads memory directly. No compilation. Most reliable for Swift values. + +``` +(lldb) v # All variables in current frame +(lldb) v self # Self in current context +(lldb) v self.propertyName # Specific property +(lldb) v localVariable # Local variable +(lldb) v self.array[0] # Collection element +(lldb) v self._showDetails # SwiftUI @State backing store (underscore prefix) +``` + +**Flags:** + +| Flag | Effect | +|------|--------| +| `-d run` | Run dynamic type resolution (slower but more accurate) | +| `-T` | Show types | +| `-R` | Show raw (unformatted) output | +| `-D N` | Limit depth of nested types to N levels | +| `-P N` | Limit pointer depth to N levels | +| `-F` | Flat output (no hierarchy) | + +**Limitations:** Cannot evaluate expressions, computed properties, or function calls. Use `p` for those. + +### `p` / `expression` (with format) + +Compiles and executes an expression. Shows formatted result. + +``` +(lldb) p self.computedProperty +(lldb) p items.count +(lldb) p someFunction() +(lldb) p String(describing: someValue) +(lldb) p (1...10).map { $0 * 2 } +``` + +Result stored in numbered variables: + +``` +(lldb) p someValue +$R0 = 42 +(lldb) p $R0 + 10 +$R1 = 52 +``` + +### `po` / `expression --object-description` + +Calls `debugDescription` (or `description`) on the result. + +``` +(lldb) po myObject +(lldb) po error +(lldb) po notification.userInfo +(lldb) po NSHomeDirectory() +``` + +**When `po` adds value:** Classes with `CustomDebugStringConvertible`, `NSError`, `NSNotification`, collections of objects. + +**When `po` fails:** Swift structs without `CustomDebugStringConvertible`, protocol-typed values (use `v` instead — it performs iterative dynamic type resolution that `po` doesn't). + +### `expression` (full form) + +Full expression evaluation with all options. + +``` +(lldb) expression self.view.backgroundColor = UIColor.red +(lldb) expression self.debugFlag = true +(lldb) expression myArray.append("test") +(lldb) expression CATransaction.flush() # Force UI update +(lldb) expression Self._printChanges() # SwiftUI debug +``` + +**Flags:** + +| Flag | Effect | +|------|--------| +| `-l objc` | Evaluate as Objective-C | +| `-l swift` | Evaluate as Swift (default) | +| `-O` | Object description (same as `po`) | +| `-i false` | Stop on breakpoints hit during evaluation (default: ignore) | +| `--` | Separator between flags and expression | + +**ObjC expressions for Swift debugging:** + +``` +(lldb) expr -l objc -- (void)[[[UIApplication sharedApplication] keyWindow] recursiveDescription] +(lldb) expr -l objc -- (void)[CATransaction flush] +(lldb) expr -l objc -- (int)[[UIApplication sharedApplication] _isForeground] +``` + +### `register read` + +Low-level register inspection: + +``` +(lldb) register read +(lldb) register read x0 x1 # Specific registers (ARM64) +(lldb) register read --all # All register sets +``` + +--- + +## Part 2: Breakpoints + +### Setting Breakpoints + +``` +(lldb) breakpoint set -f File.swift -l 42 # File + line +(lldb) b File.swift:42 # Short form +(lldb) breakpoint set -n methodName # By function name +(lldb) breakpoint set -n "MyClass.myMethod" # Qualified name +(lldb) breakpoint set -S layoutSubviews # ObjC selector +(lldb) breakpoint set -r "viewDid.*" # Regex on name +(lldb) breakpoint set -a 0x100abc123 # Memory address +``` + +### Conditional Breakpoints + +``` +(lldb) breakpoint set -f File.swift -l 42 -c "value == nil" +(lldb) breakpoint set -f File.swift -l 42 -c "index > 100" +(lldb) breakpoint set -f File.swift -l 42 -c 'name == "test"' +``` + +### Ignore Count + +``` +(lldb) breakpoint set -f File.swift -l 42 -i 50 # Skip first 50 hits +``` + +### One-Shot Breakpoints + +``` +(lldb) breakpoint set -f File.swift -l 42 -o # Delete after first hit +``` + +### Breakpoint Commands (Logpoints) + +Add commands that execute when breakpoint hits: + +``` +(lldb) breakpoint command add 1 +> v self.state +> p self.items.count +> continue +> DONE +``` + +Or in one line: + +``` +(lldb) breakpoint command add 1 -o "v self.state" +``` + +### Exception Breakpoints + +``` +(lldb) breakpoint set -E swift # All Swift errors +(lldb) breakpoint set -E objc # All ObjC exceptions +# Filtering by exception name requires Xcode's GUI (Edit Breakpoint → Exception field) +``` + +### Symbolic Breakpoints + +``` +(lldb) breakpoint set -n UIViewAlertForUnsatisfiableConstraints # Auto Layout +(lldb) breakpoint set -n "-[UIApplication _run]" # App launch +(lldb) breakpoint set -n swift_willThrow # Swift throw +``` + +### Managing Breakpoints + +``` +(lldb) breakpoint list # List all +(lldb) breakpoint list -b # Brief format +(lldb) breakpoint enable 3 # Enable breakpoint 3 +(lldb) breakpoint disable 3 # Disable breakpoint 3 +(lldb) breakpoint delete 3 # Delete breakpoint 3 +(lldb) breakpoint delete # Delete ALL (asks confirmation) +(lldb) breakpoint modify 3 -c "x > 10" # Add condition to existing +``` + +### Watchpoints + +Break when a variable's memory changes: + +``` +(lldb) watchpoint set variable self.count # Watch for write +(lldb) watchpoint set variable -w read_write myGlobal # Watch for read or write +(lldb) watchpoint set expression -- &myVariable # Watch memory address +(lldb) watchpoint list # List all +(lldb) watchpoint delete 1 # Delete watchpoint 1 +(lldb) watchpoint modify 1 -c "self.count > 10" # Add condition +``` + +**Note:** Hardware watchpoints are limited (~4 per process). Use sparingly. + +--- + +## Part 3: Thread & Backtrace + +### Backtraces + +``` +(lldb) bt # Current thread backtrace +(lldb) bt 10 # Limit to 10 frames +(lldb) bt all # All threads +(lldb) thread backtrace all # Same as bt all +``` + +### Thread Navigation + +``` +(lldb) thread list # List all threads with state +(lldb) thread info # Current thread details + stop reason +(lldb) thread select 3 # Switch to thread 3 +``` + +### Frame Navigation + +``` +(lldb) frame info # Current frame details +(lldb) frame select 5 # Jump to frame 5 +(lldb) up # Go up one frame (toward caller) +(lldb) down # Shortcut: go down one frame +``` + +### Thread Return (Skip Code) + +Force an early return from the current function: + +``` +(lldb) thread return # Return void +(lldb) thread return 42 # Return specific value +``` + +**Use with caution** — skips cleanup code, can leave state inconsistent. + +--- + +## Part 4: Expression Evaluation + +### Swift Expressions + +``` +(lldb) expr let x = 42; print(x) +(lldb) expr self.view.backgroundColor = UIColor.red +(lldb) expr UIApplication.shared.windows.first?.rootViewController +(lldb) expr UserDefaults.standard.set(true, forKey: "debug") +``` + +### Objective-C Expressions + +Switch to ObjC when Swift expression parser fails: + +``` +(lldb) expr -l objc -- (void)[CATransaction flush] +(lldb) expr -l objc -- (id)[[UIApplication sharedApplication] keyWindow] +(lldb) expr -l objc -- (void)[[NSNotificationCenter defaultCenter] postNotificationName:@"test" object:nil] +``` + +### UI Debugging Expressions + +``` +(lldb) expr -l objc -- (void)[[[UIApplication sharedApplication] keyWindow] recursiveDescription] +(lldb) po UIApplication.shared.windows.first?.rootViewController?.view.recursiveDescription() +``` + +### SwiftUI Debugging + +``` +(lldb) expr Self._printChanges() # Print what triggered body re-eval (inside view body only) +``` + +### Runtime Type Information + +``` +(lldb) expr type(of: someValue) +(lldb) expr String(describing: type(of: someValue)) +``` + +--- + +## Part 5: Process Control + +### Execution Control + +``` +(lldb) continue # Resume execution (c) +(lldb) c # Short form +(lldb) process interrupt # Pause running process +(lldb) thread step-over # Step over (n / next) +(lldb) n # Short form +(lldb) thread step-in # Step into (s / step) +(lldb) s # Short form +(lldb) thread step-out # Step out (finish) +(lldb) finish # Short form +(lldb) thread step-inst # Step one instruction (assembly-level) +(lldb) ni # Step over one instruction +``` + +### Process Management + +``` +(lldb) process launch # Launch/restart +(lldb) process attach --pid 1234 # Attach to running process +(lldb) process attach --name MyApp # Attach by name +(lldb) process detach # Detach without killing +(lldb) kill # Kill debugged process +``` + +--- + +## Part 6: Memory & Image + +### Memory Reading + +``` +(lldb) memory read 0x100abc123 # Read memory at address +(lldb) memory read -c 64 0x100abc123 # Read 64 bytes +(lldb) memory read -f x 0x100abc123 # Format as hex +(lldb) memory read -f s 0x100abc123 # Format as string +``` + +### Memory Search + +``` +(lldb) memory find -s "searchString" -- 0x100000000 0x200000000 +``` + +### Image/Module Inspection + +``` +(lldb) image lookup -a 0x100abc123 # Lookup symbol at address +(lldb) image lookup -n myFunction # Find function by name +(lldb) image lookup -rn "MyClass.*" # Regex search +(lldb) image list # List all loaded images/frameworks +(lldb) image list -b # Brief format +``` + +**Common use:** Finding which framework a crash address belongs to: + +``` +(lldb) image lookup -a 0x1a2b3c4d5 +``` + +--- + +## Part 7: .lldbinit & Customization + +### File Location + +LLDB reads `~/.lldbinit` at startup. Per-project init files are also supported when configured in Xcode's scheme settings. + +### Useful Aliases + +Add to `~/.lldbinit`: + +``` +# Quick reload — flush UI changes made via expression +command alias flush expr -l objc -- (void)[CATransaction flush] + +# Print view hierarchy +command alias views expr -l objc -- (void)[[[UIApplication sharedApplication] keyWindow] recursiveDescription] + +# Print auto layout constraints +command alias constraints po [[UIWindow keyWindow] _autolayoutTrace] +``` + +### Custom Type Summaries + +``` +# Show CLLocationCoordinate2D as "lat, lon" +type summary add CLLocationCoordinate2D --summary-string "${var.latitude}, ${var.longitude}" +``` + +### Settings + +``` +(lldb) settings show target.language # Current language +(lldb) settings set target.language swift # Force Swift mode +(lldb) settings set target.max-children-count 100 # Show more collection items +``` + +### Per-Project .lldbinit + +In Xcode: Edit Scheme → Run → Options → "LLDB Init File" field. + +Put project-specific aliases and breakpoints in a `.lldbinit` file in your project root. + +--- + +## Part 8: Troubleshooting LLDB Itself + +### "expression failed to parse" + +**Cause:** Swift expression parser can't resolve types from the current module. + +**Fixes:** +1. Use `v` instead (no compilation needed) +2. Simplify the expression +3. Try `expr -l objc -- ...` for ObjC-bridge types +4. Clean derived data and rebuild + +### "variable not available" + +**Cause:** Compiler optimized the variable out. + +**Fixes:** +1. Switch to Debug build configuration +2. Set `-Onone` for the specific file (Build Settings → per-file compiler flags) +3. Use `register read` to check if the value is in a register + +### "wrong language mode" + +**Cause:** LLDB defaults to ObjC in some contexts (especially in frameworks). + +**Fix:** +``` +(lldb) settings set target.language swift +(lldb) expr -l swift -- mySwiftExpression +``` + +### "expression caused a crash" + +**Cause:** The expression you evaluated had a side effect that crashed. + +**Fix:** +1. Don't evaluate expressions that modify state unless you intend to +2. Use `v` for read-only inspection +3. If the crash corrupted state, restart the debug session + +### LLDB Hangs or Is Slow + +**Cause:** Usually compiling a complex expression or resolving types in a large project. + +**Fix:** +1. Use `v` instead of `p`/`po` (no compilation) +2. Reduce expression complexity +3. If LLDB hangs during `po`, Ctrl+C to cancel and use `v` instead + +### Breakpoint Not Hit + +**Causes and fixes:** + +| Cause | Fix | +|-------|-----| +| Wrong file/line (code moved) | Re-set breakpoint on current code | +| Breakpoint disabled | `breakpoint enable N` | +| Code not executed | Verify the code path is reached | +| Optimized out (Release) | Switch to Debug configuration | +| In a framework/SPM package | Set symbolic breakpoint by function name | + +--- + +## Resources + +**WWDC**: 2019-429, 2018-412, 2022-110370, 2015-402 + +**Docs**: /xcode/stepping-through-code-and-inspecting-variables-to-isolate-bugs, /xcode/setting-breakpoints-to-pause-your-running-app + +**Skills**: axiom-lldb, axiom-xcode-debugging diff --git a/.claude/skills/axiom-lldb-ref/agents/openai.yaml b/.claude/skills/axiom-lldb-ref/agents/openai.yaml new file mode 100644 index 0000000..68ba2e3 --- /dev/null +++ b/.claude/skills/axiom-lldb-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "LLDB Reference" + short_description: "Complete LLDB command reference" diff --git a/.claude/skills/axiom-lldb/.openskills.json b/.claude/skills/axiom-lldb/.openskills.json new file mode 100644 index 0000000..9e1712a --- /dev/null +++ b/.claude/skills/axiom-lldb/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-lldb", + "installedAt": "2026-04-12T08:06:27.156Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-lldb/SKILL.md b/.claude/skills/axiom-lldb/SKILL.md new file mode 100644 index 0000000..9677fa9 --- /dev/null +++ b/.claude/skills/axiom-lldb/SKILL.md @@ -0,0 +1,631 @@ +--- +name: axiom-lldb +description: Use when ANY runtime debugging is needed — setting breakpoints, inspecting variables, evaluating expressions, analyzing threads, or reproducing crashes interactively with LLDB +license: MIT +--- + +# LLDB Debugging + +Interactive debugging with LLDB. The debugger freezes time so you can interrogate your running app — inspect variables, evaluate expressions, navigate threads, and understand exactly why something went wrong. + +**Core insight:** "LLDB is useless" really means "I don't know which command to use for Swift types." This is a knowledge-gap problem, not a tool problem. + +## Red Flags — Check This Skill When + +| Symptom | This Skill Applies | +|---------|-------------------| +| Need to inspect a variable at runtime | Yes — breakpoint + inspect | +| Crash you can reproduce locally | Yes — breakpoint before crash site | +| Wrong value at runtime but code looks correct | Yes — step through and inspect | +| Need to understand thread state during hang | Yes — pause + thread backtrace | +| `po` doesn't work / shows garbage | Yes — Playbook 3 has alternatives | +| Crash log analyzed, need to reproduce | Yes — set breakpoints from crash context | +| Need to test a fix without rebuilding | Yes — expression evaluation | +| Want to break on all exceptions | Yes — exception breakpoints | +| App feels slow but responsive | No — use axiom-performance-profiling | +| Memory grows over time | No — use axiom-memory-debugging first | +| App completely frozen | Maybe — use axiom-hang-diagnostics first, then LLDB for thread inspection | +| Crash in production, no local repro | No — use axiom-testflight-triage first | + +## LLDB vs Other Tools + +```dot +digraph tool_selection { + "What do you need?" [shape=diamond]; + + "axiom-testflight-triage" [shape=box]; + "axiom-hang-diagnostics" [shape=box]; + "axiom-memory-debugging" [shape=box]; + "axiom-performance-profiling" [shape=box]; + "LLDB (this skill)" [shape=box, style=bold]; + + "What do you need?" -> "axiom-testflight-triage" [label="Crash log from field,\ncan't reproduce locally"]; + "What do you need?" -> "axiom-hang-diagnostics" [label="App frozen,\nneed diagnosis approach"]; + "What do you need?" -> "axiom-memory-debugging" [label="Memory growing,\nneed leak pattern"]; + "What do you need?" -> "axiom-performance-profiling" [label="Need to measure\nCPU/memory over time"]; + "What do you need?" -> "LLDB (this skill)" [label="Need to inspect state\nat a specific moment"]; +} +``` + +**Rule of thumb:** Instruments *measures*. LLDB *inspects*. If you need to understand what's happening at a specific moment in time, use LLDB. If you need to understand trends over time, use Instruments. + +## Response Format + +When helping with LLDB debugging, structure your output as: + +1. **Immediate diagnosis** (1-3 bullets, confidence-tagged: HIGH/MEDIUM/LOW) +2. **Commands to run** (numbered, copy-paste ready, with `(lldb)` prefix) +3. **What to look for** (command → expected output → interpretation) +4. **Likely root causes** (ranked by probability) +5. **Next breakpoint plan** (catch it earlier next time) +6. **If no debugger attached** (crash-log-only fallback path) + +--- + +## Playbook 1: Crash Triage + +**Goal:** Understand why the app crashed, starting from the stop point. + +### Step 1: Read the Stop Reason + +When the debugger stops, the first thing to check: + +``` +(lldb) thread info +``` + +This shows the stop reason. Common stop reasons: + +| Stop Reason | Meaning | Next Step | +|-------------|---------|-----------| +| `EXC_BAD_ACCESS (SIGSEGV)` | Accessed invalid memory (null pointer, dangling reference) | Check the address — `0x0` to `0x10` = nil dereference | +| `EXC_BAD_ACCESS (SIGBUS)` | Misaligned or invalid address | Usually C interop or unsafe pointer issue | +| `EXC_BREAKPOINT (SIGTRAP)` | Hit a trap — Swift runtime check failed | Check for `fatalError()`, `preconditionFailure()`, force-unwrap of nil, array out of bounds | +| `EXC_CRASH (SIGABRT)` | Deliberate abort — assertion or uncaught exception | Look at "Application Specific Information" for the message | +| `breakpoint` | Your breakpoint was hit | Normal — inspect state | + +### Step 2: Get the Backtrace + +``` +(lldb) bt +``` + +Read top-to-bottom. Find the first frame in YOUR code (not system frameworks). That's where to start investigating. + +``` +(lldb) bt 10 +``` + +Limit to 10 frames if the full trace is noisy. + +### Step 3: Navigate to Your Frame + +``` +(lldb) frame select 3 +``` + +Jump to frame 3 (or whichever frame is in your code). + +### Step 4: Inspect State + +``` +(lldb) v +(lldb) v self.someProperty +(lldb) v localVariable +``` + +Use `v` (not `po`) for reliable Swift value inspection. See Playbook 3 for details. + +### Step 5: Classify and Fix + +| Exception Type | Typical Cause | Fix Pattern | +|----------------|---------------|-------------| +| `EXC_BAD_ACCESS` at low address | Force-unwrap nil optional | `guard let` / `if let` | +| `EXC_BAD_ACCESS` at high address | Use-after-free / dangling pointer | Check object lifetime, `[weak self]` | +| `EXC_BREAKPOINT` | Swift runtime trap (bounds, unwrap, precondition) | Fix the violated precondition | +| `SIGABRT` | Uncaught ObjC exception or `fatalError()` | Read the exception message, fix the root cause | + +### Step 6: Set a Conditional Breakpoint to Catch It Earlier + +``` +(lldb) breakpoint set -f MyFile.swift -l 42 -c "value == nil" +``` + +This breaks only when `value` is nil at line 42 — catches the problem before the crash. + +--- + +## Playbook 2: Hang/Deadlock Diagnosis + +**Goal:** Understand why the app is frozen by inspecting all thread states. + +### Step 1: Pause the App + +If the app is hung, press the pause button in Xcode (⌃⌘Y) or: + +``` +(lldb) process interrupt +``` + +### Step 2: Get All Thread Backtraces + +``` +(lldb) thread backtrace all +``` + +Or the shorthand: + +``` +(lldb) bt all +``` + +### Step 3: Classify Thread States + +Look at Thread 0 (main thread) — it processes all UI events. If it's blocked, the app is frozen. + +**Main thread blocked on synchronous wait:** +``` +frame #0: libsystem_kernel.dylib`__psynch_mutexwait +frame #1: libsystem_pthread.dylib`_pthread_mutex_firstfit_lock_wait +... +frame #5: MyApp`ViewController.viewDidLoad() +``` +Translation: Main thread is waiting for a mutex lock. Something else holds it. + +**Main thread blocked on dispatch_sync:** +``` +frame #0: libdispatch.dylib`_dispatch_sync_f_slow +... +frame #3: MyApp`DataManager.fetchData() +``` +Translation: `DispatchQueue.main.sync` called from background → classic deadlock. + +**Main thread busy (CPU-bound):** +``` +frame #0: MyApp`ImageProcessor.processAllImages() +frame #1: MyApp`ViewController.viewDidLoad() +``` +Translation: Expensive work on main thread. Move to background. + +### Step 4: Check for Deadlocks + +If two threads are both waiting on something the other holds: + +``` +(lldb) thread list +``` + +Look for multiple threads with state `waiting` that reference each other's locks. + +### Step 5: Inspect Specific Thread + +``` +(lldb) thread select 3 +(lldb) bt +(lldb) v +``` + +Switch to another thread to inspect its state. + +**Cross-reference:** For fix patterns once you've identified the hang cause → `/skill axiom-hang-diagnostics` + +--- + +## Playbook 3: Swift Value Inspection + +**This is the core value of this skill.** Most developers abandon LLDB because `po` doesn't work reliably with Swift types. Here's what actually works. + +### The Four Print Commands + +| Command | Full Form | What It Does | Best For | +|---------|-----------|--------------|----------| +| `v` | `frame variable` | Reads memory directly, no compilation | Swift structs, enums, locals — **your default** | +| `p` | `expression` (with formatter) | Compiles expression, shows formatted result | Computed properties, function calls | +| `po` | `expression --object-description` | Calls `debugDescription` | Classes with `CustomDebugStringConvertible` | +| `expr` | `expression` | Evaluates arbitrary code | Calling methods, modifying state | + +### When to Use Each + +**Start with `v`** — it's fastest and most reliable for stored properties: + +``` +(lldb) v self.userName +(lldb) v self.items[0] +(lldb) v localStruct +``` + +`v` works by reading memory directly. It doesn't compile anything, so it can't fail due to expression compilation errors. + +**`v` limitation:** It only reads stored properties — computed properties, `lazy var` (before first access), and property wrapper projected values (`$binding`) won't show meaningful values. If a field looks wrong or missing with `v`, try `p` instead. + +**Use `p` when `v` can't reach it:** + +``` +(lldb) p self.computedProperty +(lldb) p self.items.count +(lldb) p someFunction() +``` + +`p` compiles and executes the expression. Needed for computed properties and function calls. + +**Use `po` for class descriptions:** + +``` +(lldb) po myObject +(lldb) po error +(lldb) po notification +``` + +`po` calls `debugDescription` on the result. Best for objects that have meaningful descriptions (NSError, Notification, etc.). + +### The "LLDB Is Broken" Moments + +| What You See | Why | Fix | +|--------------|-----|-----| +| `` | `po` failed; variable hasn't been populated by optimizer | Use `v` instead | +| `expression failed to parse, unknown type name` | Swift expression parser can't resolve the type | Try `expr -l objc -- (id)0x12345` for ObjC objects, or use `v` | +| `` | Compiler optimized it out (Release build) | Rebuild with Debug, per-file `-Onone`, or `register read` as last resort | +| `error: Couldn't apply expression side effects` | Expression had side effects LLDB couldn't reverse | Try a simpler expression; avoid mutating state | +| `po` shows memory address instead of value | Object doesn't conform to `CustomDebugStringConvertible` | Use `v` for raw value, or implement the protocol | +| `cannot find 'self' in scope` | Breakpoint is in a context without `self` (static, closure) | Use `v` with the explicit variable name | +| `p` shows `$R0 = ...` but `po` crashes | Different compilation paths | Use `p` when it works; `po` adds an extra description step that can fail | + +### Inspecting Optionals + +``` +(lldb) v optionalValue +``` +Shows: `(String?) some = "hello"` or `(String?) none` + +Don't use `po optionalValue` — it may show just `Optional("hello")` which is less useful. + +### Inspecting Collections + +``` +(lldb) v myArray +(lldb) v myArray[2] +(lldb) v myDict +``` + +For large collections, limit output: + +``` +(lldb) p Array(myArray.prefix(5)) +``` + +### Inspecting SwiftUI State + +SwiftUI `@State` is backed by stored properties with underscore prefix: + +``` +(lldb) v self._isPresented +(lldb) v self._items +``` + +For `@Observable` models: + +``` +(lldb) v self.viewModel.propertyName +``` + +**Diagnosing "view doesn't update":** If a property changes (confirmed with `v`) but the SwiftUI view doesn't re-render, check which thread the mutation happens on with `bt`. `@Observable` mutations must happen on `@MainActor` for SwiftUI to observe them — mutations on a background actor won't trigger view updates. Use `Self._printChanges()` inside a view body to see which property triggered (or didn't trigger) a re-render: + +``` +(lldb) expr Self._printChanges() +``` + +For the full observation diagnostic tree → `/skill axiom-swiftui-debugging` + +### Inspecting Actors + +Actor state is best inspected with `v`, which reads memory directly without isolation concerns: + +``` +(lldb) v actor +``` + +Shows all stored properties. This works because LLDB pauses the entire process — you can read any memory regardless of actor isolation (which is a compile-time concept). + +### Modifying Values at Runtime + +``` +(lldb) expr self.debugFlag = true +(lldb) expr myArray.append("test") +(lldb) expr self.view.backgroundColor = UIColor.red +``` + +Modify values without rebuilding. Useful for testing theories. + +### Referencing Previous Results + +LLDB assigns result variables (`$R0`, `$R1`, etc.): + +``` +(lldb) p someValue +$R0 = 42 +(lldb) p $R0 + 10 +$R1 = 52 +``` + +--- + +## Playbook 4: Breakpoint Strategies + +### Source Breakpoints (Basic) + +``` +(lldb) breakpoint set -f ViewController.swift -l 42 +(lldb) b ViewController.swift:42 +``` + +Short form `b` works for simple cases. + +### Conditional Breakpoints + +Break only when a condition is true: + +``` +(lldb) breakpoint set -f MyFile.swift -l 42 -c "index > 100" +(lldb) breakpoint set -f MyFile.swift -l 42 -c "name == \"test\"" +``` + +**Iteration-based:** Break after N hits: + +``` +(lldb) breakpoint set -f MyFile.swift -l 42 -i 50 +``` + +Ignores the first 50 hits, then breaks. + +### Logpoints (Action + Auto-Continue) + +Log without stopping — like a print statement but no rebuild needed: + +``` +(lldb) breakpoint set -f MyFile.swift -l 42 +(lldb) breakpoint command add 1 +> v self.value +> continue +> DONE +``` + +Or in Xcode: Edit breakpoint → Add Action → "Log Message" → use `@self.value@` token syntax → Check "Automatically continue" + +### Symbolic Breakpoints + +Break on ANY call to a method by name: + +``` +(lldb) breakpoint set -n viewDidLoad +(lldb) breakpoint set -n "MyClass.myMethod" +``` + +Break on all ObjC messages to a selector: + +``` +(lldb) breakpoint set -S "layoutSubviews" +``` + +### Exception Breakpoints + +**Swift errors (break on throw):** +``` +(lldb) breakpoint set -E swift +``` + +**Objective-C exceptions (break on throw):** +``` +(lldb) breakpoint set -E objc +``` + +**In Xcode:** Breakpoint Navigator → + → Swift Error Breakpoint / Exception Breakpoint + +This is the single most useful breakpoint for crash debugging. It stops at the throw site instead of the catch/crash site. + +### Watchpoints + +Break when a variable's value changes: + +``` +(lldb) watchpoint set variable self.count +(lldb) watchpoint set variable -w read_write myGlobal +``` + +Watchpoints are hardware-backed — limited to ~4 per process but very fast. + +### One-Shot Breakpoints + +Break once, then auto-delete: + +``` +(lldb) breakpoint set -f MyFile.swift -l 42 -o +``` + +### Managing Breakpoints + +``` +(lldb) breakpoint list +(lldb) breakpoint disable 3 +(lldb) breakpoint enable 3 +(lldb) breakpoint delete 3 +(lldb) breakpoint delete +``` + +--- + +## Playbook 5: Async/Concurrency Debugging + +### Identifying Async Frames + +Swift concurrency backtraces are noisy — expect `swift_task_switch`, `_dispatch_call_block_and_release`, and executor internals mixed in with your code. Don't be discouraged by 40+ frames of runtime noise. Focus on frames from YOUR module. + +In Swift concurrency backtraces, look for `swift-task` frames: + +``` +Thread 3: +frame #0: MyApp`MyActor.doWork() +frame #1: swift_task_switch +frame #2: MyApp`closure #1 in ViewController.loadData() +``` + +The `swift_task_switch` frame indicates an async suspension point. Your code frames are the ones prefixed with your module name (`MyApp` above). + +### Inspecting Task State + +``` +(lldb) thread backtrace all +``` + +Look for threads with `swift_task` in their frames. Each represents an active Swift task. + +### Actor-Isolated Code + +When stopped inside an actor: + +``` +(lldb) v self +``` + +Shows all actor state. This works because LLDB pauses the entire process — actor isolation is a compile-time concept, not a runtime lock (for default actors). + +### Task Group Inspection + +When debugging task groups, break inside the group closure and inspect: + +``` +(lldb) v +(lldb) bt +``` + +Each child task runs on its own thread. Use `bt all` to see them. + +**Cross-reference:** For Swift concurrency patterns and fix strategies → `/skill axiom-swift-concurrency`. For profiling async performance → `/skill axiom-concurrency-profiling` + +--- + +## Pressure Scenarios + +### Scenario 1: "Release-Only Crash — LLDB Is Useless in Release" + +**Situation:** Crash happens in Release builds but not Debug. Team says "we can't debug it." + +**Why this fails:** Release optimizations change timing, memory layout, and can eliminate variables — making the crash non-reproducible in Debug. + +**Correct approach:** + +1. Build with Debug configuration but Release-like settings: + - Optimization Level: `-O` (not `-Onone`) + - Still include debug symbols (`DEBUG_INFORMATION_FORMAT = dwarf-with-dsym`) +2. Enable Address Sanitizer (`-fsanitize=address`) — catches memory errors with 2-3x overhead +3. Use the crash report to set breakpoints at the crash site +4. Set exception breakpoints to catch the error before the crash: + ``` + (lldb) breakpoint set -E swift + (lldb) breakpoint set -E objc + ``` +5. If variable shows ``, reduce optimization for that one file: + - Build Settings → Per-file flags → `-Onone` for the specific file +6. Last resort — read register values directly (variables live in registers before being optimized out): + ``` + (lldb) register read + (lldb) register read x0 x1 x2 + ``` + On ARM64: `x0` = self, `x1`-`x7` = first 7 arguments. Check `/skill axiom-lldb-ref` Part 1 for details. + +### Scenario 2: "Just Add Print Statements" + +**Situation:** Developer adds `print()` calls to debug, rebuilds, runs, reads console. Repeat. + +**Why this fails:** Each print-debug cycle costs 3-5 minutes (edit → build → run → navigate to state → read output). An LLDB breakpoint costs 30 seconds. + +**Correct approach:** + +1. Set a breakpoint at the line you'd add a `print()`: + ``` + (lldb) b MyFile.swift:42 + ``` +2. Add a logpoint for "print-like" behavior without rebuilding: + - Edit breakpoint → Add Action → Log Message → Check "Auto continue" +3. Inspect variables directly: `v self.someValue` +4. Modify variables at runtime to test theories: `expr self.debugMode = true` +5. **One breakpoint session replaces 5-10 print-debug cycles.** + +**Time comparison** (typical control-flow debugging): +| Approach | Per investigation | 5 variables | +|----------|-------------------|-------------| +| print() statements | 3-5 min (build + run) | 15-25 min | +| LLDB breakpoint | 30 sec (set + inspect) | 2.5 min | + +**Exception:** In tight loops (thousands of hits/sec), logpoints add per-hit overhead. Use `-i` to skip to the iteration you care about, or use a temporary `print()` for that specific loop. + +### Scenario 3: "po Doesn't Work So LLDB Is Broken" + +**Situation:** Developer types `po myStruct` and gets garbage. Concludes LLDB is broken for Swift. Goes back to print debugging. + +**This is the #1 reason developers abandon LLDB.** + +**Why `po` fails with Swift structs:** `po` calls `debugDescription` which requires compiling an expression in the debugger context. For Swift structs, this compilation often fails due to missing type metadata, generics, or module resolution issues. + +**Correct approach:** + +1. Use `v` instead of `po` — reads memory directly, no compilation: + ``` + (lldb) v myStruct + (lldb) v myStruct.propertyName + ``` +2. Use `p` for computed properties: + ``` + (lldb) p myStruct.computedValue + ``` +3. Use `po` only for classes with `CustomDebugStringConvertible` +4. If `p` also fails, try specifying the language: + ``` + (lldb) expr -l objc -- (id)0x12345 + ``` +5. If everything fails, `v self` always works inside a method. + +--- + +## Anti-Patterns + +| Anti-Pattern | Why It's Wrong | Better Alternative | +|---|---|---| +| `po` everything | Fails for Swift structs, enums, optionals | `v` for values, `po` only for classes | +| Print-debug cycles | 3-5 min per cycle vs 30 sec breakpoint | Breakpoints with logpoint actions | +| "LLDB doesn't work with Swift" | It does — wrong command choice | `v` is designed for Swift values | +| Ignoring backtraces | Jumping to guesses instead of reading the trace | `bt` first, then navigate frames | +| Conditional breakpoints on every hit | Slows execution if condition is expensive | Use `-i` (ignore count) when possible | +| Debugging optimized (Release) builds | Variables missing, code reordered | Debug configuration, or per-file `-Onone` | +| Force-continuing past exceptions | Hides the real error | Fix the exception, don't suppress it | +| No exception breakpoints set | Crashes land in system code, not throw site | Always add Swift Error + ObjC Exception breakpoints | + +## Debugging Checklist + +Before starting a debug session: + +- [ ] Debug build configuration (not Release) +- [ ] Exception breakpoints enabled (Swift Error + ObjC Exception) +- [ ] Breakpoint set before suspected problem area +- [ ] Know which command to use: `v` for values, `p` for computed, `po` for descriptions + +During debug session: + +- [ ] Read stop reason (`thread info`) before anything else +- [ ] Get backtrace (`bt`) — find your frame +- [ ] Navigate to your frame (`frame select N`) +- [ ] Inspect relevant state (`v self`, `v localVar`) +- [ ] Understand the cause before writing any fix + +After finding the issue: + +- [ ] Set conditional breakpoint to catch recurrence +- [ ] Consider adding assertion/precondition for this case +- [ ] Remove temporary breakpoints + +## Resources + +**WWDC**: 2019-429, 2018-412, 2022-110370 + +**Docs**: /xcode/stepping-through-code-and-inspecting-variables-to-isolate-bugs, /xcode/setting-breakpoints-to-pause-your-running-app, /xcode/diagnosing-memory-thread-and-crash-issues-early + +**Skills**: axiom-lldb-ref, axiom-testflight-triage, axiom-hang-diagnostics, axiom-memory-debugging, axiom-swift-concurrency, axiom-concurrency-profiling diff --git a/.claude/skills/axiom-lldb/agents/openai.yaml b/.claude/skills/axiom-lldb/agents/openai.yaml new file mode 100644 index 0000000..a04677c --- /dev/null +++ b/.claude/skills/axiom-lldb/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "LLDB" + short_description: "ANY runtime debugging is needed" diff --git a/.claude/skills/axiom-localization/.openskills.json b/.claude/skills/axiom-localization/.openskills.json new file mode 100644 index 0000000..76a0355 --- /dev/null +++ b/.claude/skills/axiom-localization/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-localization", + "installedAt": "2026-04-12T08:06:27.703Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-localization/SKILL.md b/.claude/skills/axiom-localization/SKILL.md new file mode 100644 index 0000000..3d1fc84 --- /dev/null +++ b/.claude/skills/axiom-localization/SKILL.md @@ -0,0 +1,1098 @@ +--- +name: axiom-localization +description: Use when localizing apps, using String Catalogs, generating type-safe symbols (Xcode 26+), handling plurals, RTL layouts, locale-aware formatting, or migrating from .strings files - comprehensive i18n patterns for Xcode 15-26 +license: MIT +metadata: + version: "1.1.0" + last-updated: "2025-12-16" +--- + +# Localization & Internationalization + +Comprehensive guide to app localization using String Catalogs. Apple Design Award Inclusivity winners always support multiple languages with excellent RTL (Right-to-Left) support. + +## Overview + +String Catalogs (`.xcstrings`) are Xcode 15's unified format for managing app localization. They replace legacy `.strings` and `.stringsdict` files with a single JSON-based format that's easier to maintain, diff, and integrate with translation workflows. + +This skill covers String Catalogs, SwiftUI/UIKit localization APIs, plural handling, RTL support, locale-aware formatting, and migration strategies from legacy formats. + +## When to Use This Skill + +- Setting up String Catalogs in Xcode 15+ +- Localizing SwiftUI and UIKit apps +- Handling plural forms correctly (critical for many languages) +- Supporting RTL languages (Arabic, Hebrew) +- Formatting dates, numbers, and currencies by locale +- Migrating from legacy `.strings`/`.stringsdict` files +- Preparing App Shortcuts and App Intents for localization +- Debugging missing translations or incorrect plural forms + +## System Requirements + +- **Xcode 15+** for String Catalogs (`.xcstrings`) +- **Xcode 26+** for automatic symbol generation, `#bundle` macro, and AI-powered comment generation +- **iOS 15+** for `LocalizedStringResource` +- **iOS 16+** for App Shortcuts localization +- Earlier iOS versions use legacy `.strings` files + +--- + +## Part 1: String Catalogs (WWDC 2023/10155) + +### Creating a String Catalog + +**Method 1: Xcode Navigator** +1. File → New → File +2. Choose "String Catalog" +3. Name it (e.g., `Localizable.xcstrings`) +4. Add to target + +**Method 2: Automatic Extraction** + +Xcode 15 can automatically extract strings from: +- SwiftUI views (string literals in `Text`, `Label`, `Button`) +- Swift code (`String(localized:)`) +- Objective-C (`NSLocalizedString`) +- C (`CFCopyLocalizedString`) +- Interface Builder files (`.storyboard`, `.xib`) +- Info.plist values +- App Shortcuts phrases + +**Build Settings Required**: +- **"Use Compiler to Extract Swift Strings"** → Yes +- **"Localization Prefers String Catalogs"** → Yes + +### String Catalog Structure + +Each entry has: +- **Key**: Unique identifier (default: the English string) +- **Default Value**: Fallback if translation missing +- **Comment**: Context for translators +- **String Table**: Organization container (default: "Localizable") + +**Example `.xcstrings` JSON**: +```json +{ + "sourceLanguage" : "en", + "strings" : { + "Thanks for shopping with us!" : { + "comment" : "Label above checkout button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thanks for shopping with us!" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Gracias por comprar con nosotros!" + } + } + } + } + }, + "version" : "1.0" +} +``` + +### Translation States + +Xcode tracks state for each translation: + +- **New** (⚪) - String hasn't been translated yet +- **Needs Review** (🟡) - Source changed, translation may be outdated +- **Reviewed** (✅) - Translation approved and current +- **Stale** (🔴) - String no longer found in source code + +**Workflow**: +1. Developer adds string → **New** +2. Translator adds translation → **Reviewed** +3. Developer changes source → **Needs Review** +4. Translator updates → **Reviewed** +5. Developer removes code → **Stale** + +--- + +## Part 2: SwiftUI Localization + +### LocalizedStringKey (Automatic) + +SwiftUI views with `String` parameters automatically support localization: + +```swift +// ✅ Automatically localizable +Text("Welcome to WWDC!") +Label("Thanks for shopping with us!", systemImage: "bag") +Button("Checkout") { } + +// Xcode extracts these strings to String Catalog +``` + +**How it works**: SwiftUI uses `LocalizedStringKey` internally, which looks up strings in String Catalogs. + +### String(localized:) with Comments + +For explicit localization in Swift code: + +```swift +// Basic +let title = String(localized: "Welcome to WWDC!") + +// With comment for translators +let title = String(localized: "Welcome to WWDC!", + comment: "Notification banner title") + +// With custom table +let title = String(localized: "Welcome to WWDC!", + table: "WWDCNotifications", + comment: "Notification banner title") + +// With default value (key ≠ English text) +let title = String(localized: "WWDC_NOTIFICATION_TITLE", + defaultValue: "Welcome to WWDC!", + comment: "Notification banner title") +``` + +**Best practice**: Always include `comment` to give translators context. + +### LocalizedStringResource (Deferred Localization) + +For passing localizable strings to other functions: + +```swift +import Foundation + +struct CardView: View { + let title: LocalizedStringResource + let subtitle: LocalizedStringResource + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 10.0) + VStack { + Text(title) // Resolved at render time + Text(subtitle) + } + .padding() + } + } +} + +// Usage +CardView( + title: "Recent Purchases", + subtitle: "Items you've ordered in the past week." +) +``` + +**Key difference**: `LocalizedStringResource` defers lookup until used, allowing custom views to be fully localizable. + +### AttributedString with Markdown + +```swift +// Markdown formatting is preserved across localizations +let subtitle = AttributedString(localized: "**Bold** and _italic_ text") +``` + +--- + +## Part 3: UIKit & Foundation + +### NSLocalizedString Macro + +```swift +// Basic +let title = NSLocalizedString("Recent Purchases", comment: "Button Title") + +// With table +let title = NSLocalizedString("Recent Purchases", + tableName: "Shopping", + comment: "Button Title") + +// With bundle +let title = NSLocalizedString("Recent Purchases", + tableName: nil, + bundle: .main, + value: "", + comment: "Button Title") +``` + +### Bundle.localizedString + +```swift +let customBundle = Bundle(for: MyFramework.self) +let text = customBundle.localizedString(forKey: "Welcome", + value: nil, + table: "MyFramework") +``` + +### Custom Macros + +```objc +// Objective-C +#define MyLocalizedString(key, comment) \ + [myBundle localizedStringForKey:key value:nil table:nil] +``` + +### Info.plist Localization + +Localize app name, permissions, etc.: + +1. Select `Info.plist` +2. Editor → Add Localization +3. Create `InfoPlist.strings` for each language: + +``` +// InfoPlist.strings (Spanish) +"CFBundleName" = "Mi Aplicación"; +"NSCameraUsageDescription" = "La app necesita acceso a la cámara para tomar fotos."; +``` + +--- + +## Part 4: Pluralization + +Different languages have different plural rules: + +- **English**: 2 forms (one, other) +- **Russian**: 3 forms (one, few, many) +- **Polish**: 3 forms (one, few, other) +- **Arabic**: 6 forms (zero, one, two, few, many, other) + +### SwiftUI Plural Handling + +```swift +// Xcode automatically creates plural variations +Text("\(count) items") + +// With custom formatting +Text("\(visitorCount) Recent Visitors") +``` + +**In String Catalog**: +```json +{ + "strings" : { + "%lld Recent Visitors" : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Recent Visitor" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Recent Visitors" + } + } + } + } + } + } + } + } +} +``` + +### XLIFF Export Format + +When exporting for translation (File → Export Localizations): + +**Legacy (stringsdict)**: +```xml + + %#@recentVisitors@ + + + + %lld Recent Visitor + %lld Visitante Recente + +``` + +**String Catalog (cleaner)**: +```xml + + %lld Recent Visitor + %lld Visitante Recente + + + + %lld Recent Visitors + %lld Visitantes Recentes + +``` + +### Substitutions with Plural Variables + +```swift +// Multiple variables with different plural forms +let message = String(localized: "\(songCount) songs on \(albumCount) albums") +``` + +Xcode creates variations for **each** variable's plural form: +- `songCount`: one, other +- `albumCount`: one, other +- Total combinations: 2 × 2 = 4 translation entries + +--- + +## Part 5: Device & Width Variations + +### Device-Specific Strings + +Different text for different platforms: + +```swift +// Same code, different strings per device +Text("Bird Food Shop") +``` + +**String Catalog variations**: +```json +{ + "Bird Food Shop" : { + "localizations" : { + "en" : { + "variations" : { + "device" : { + "applewatch" : { + "stringUnit" : { + "value" : "Bird Food" + } + }, + "other" : { + "stringUnit" : { + "value" : "Bird Food Shop" + } + } + } + } + } + } + } +} +``` + +**Result**: +- iPhone/iPad: "Bird Food Shop" +- Apple Watch: "Bird Food" (shorter for small screen) + +### Width Variations + +For dynamic type and size classes: + +```swift +Text("Application Settings") +``` + +String Catalog can provide shorter text for narrow widths. + +--- + +## Part 6: RTL Support + +### Layout Mirroring + +SwiftUI automatically mirrors layouts for RTL languages: + +```swift +// ✅ Automatically mirrors for Arabic/Hebrew +HStack { + Image(systemName: "chevron.right") + Text("Next") +} + +// iPhone (English): [>] Next +// iPhone (Arabic): Next [<] +``` + +### Leading/Trailing vs Left/Right + +**Always use semantic directions**: + +```swift +// ✅ Correct - mirrors automatically +.padding(.leading, 16) +.frame(maxWidth: .infinity, alignment: .leading) + +// ❌ Wrong - doesn't mirror +.padding(.left, 16) +.frame(maxWidth: .infinity, alignment: .left) +``` + +### Images and Icons + +Mark images that should/shouldn't flip: + +```swift +// ✅ Directional - mirrors for RTL +Image(systemName: "chevron.forward") + +// ✅ Non-directional - never mirrors +Image(systemName: "star.fill") + +// Custom images +Image("backButton") + .flipsForRightToLeftLayoutDirection(true) +``` + +### Testing in RTL Mode + +**Xcode Scheme**: +1. Edit Scheme → Run → Options +2. Application Language: Arabic / Hebrew +3. OR: App Language → Right-to-Left Pseudolanguage + +**Simulator**: +Settings → General → Language & Region → Preferred Language Order + +**SwiftUI Preview**: +```swift +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + .environment(\.layoutDirection, .rightToLeft) + .environment(\.locale, Locale(identifier: "ar")) + } +} +``` + +--- + +## Part 7: Locale-Aware Formatting + +### DateFormatter + +```swift +let formatter = DateFormatter() +formatter.locale = Locale.current // ✅ Use current locale +formatter.dateStyle = .long +formatter.timeStyle = .short + +let dateString = formatter.string(from: Date()) + +// US: "January 15, 2024 at 3:30 PM" +// France: "15 janvier 2024 à 15:30" +// Japan: "2024年1月15日 15:30" +``` + +**Never hardcode date format strings**: +```swift +// ❌ Wrong - breaks in other locales +formatter.dateFormat = "MM/dd/yyyy" + +// ✅ Correct - adapts to locale +formatter.dateStyle = .short +``` + +### NumberFormatter for Currency + +```swift +let formatter = NumberFormatter() +formatter.locale = Locale.current +formatter.numberStyle = .currency + +let priceString = formatter.string(from: 29.99) + +// US: "$29.99" +// UK: "£29.99" +// Japan: "¥30" (rounds to integer) +// France: "29,99 €" (comma decimal, space before symbol) +``` + +### MeasurementFormatter + +```swift +let distance = Measurement(value: 100, unit: UnitLength.meters) + +let formatter = MeasurementFormatter() +formatter.locale = Locale.current + +let distanceString = formatter.string(from: distance) + +// US: "328 ft" (converts to imperial) +// Metric countries: "100 m" +``` + +### Locale-Specific Sorting + +```swift +let names = ["Ångström", "Zebra", "Apple"] + +// ✅ Locale-aware sort +let sorted = names.sorted { (lhs, rhs) in + lhs.localizedStandardCompare(rhs) == .orderedAscending +} + +// Sweden: ["Ångström", "Apple", "Zebra"] (Å comes first in Swedish) +// US: ["Ångström", "Apple", "Zebra"] (Å treated as A) +``` + +--- + +## Part 8: App Shortcuts Localization + +### Phrases with Parameters + +```swift +import AppIntents + +struct ShowTopDonutsIntent: AppIntent { + static var title: LocalizedStringResource = "Show Top Donuts" + + @Parameter(title: "Timeframe") + var timeframe: Timeframe + + static var parameterSummary: some ParameterSummary { + Summary("\(.applicationName) Trends for \(\.$timeframe)") { + \.$timeframe + } + } +} +``` + +**String Catalog automatically extracts**: +- Intent title +- Parameter names +- Phrase templates with placeholders + +**Localized phrases**: +``` +English: "Food Truck Trends for this week" +Spanish: "Tendencias de Food Truck para esta semana" +``` + +### AppShortcutsProvider Localization + +```swift +struct FoodTruckShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: ShowTopDonutsIntent(), + phrases: [ + "\(.applicationName) Trends for \(\.$timeframe)", + "Show trending donuts for \(\.$timeframe) in \(.applicationName)", + "Give me trends for \(\.$timeframe) in \(.applicationName)" + ] + ) + } +} +``` + +Xcode extracts all 3 phrases into String Catalog for translation. + +--- + +## Part 9: Migration from Legacy + +### Converting .strings to .xcstrings + +**Automatic migration**: +1. Select `.strings` file in Navigator +2. Editor → Convert to String Catalog +3. Xcode creates `.xcstrings` and preserves translations + +**Manual approach**: +1. Create new String Catalog +2. Build project (Xcode extracts strings from code) +3. Import translations via File → Import Localizations (XLIFF) +4. Delete old `.strings` files + +### Converting .stringsdict + +**Plural files automatically merge**: +1. Keep `.strings` and `.stringsdict` together +2. Convert → Both merge into single `.xcstrings` +3. Plural variations preserved + +### Gradual Migration Strategy + +**Phase 1**: New code uses String Catalogs +- Create `Localizable.xcstrings` +- Write new code with `String(localized:)` +- Keep legacy `.strings` files for old code + +**Phase 2**: Migrate existing strings +- Convert one `.strings` table at a time +- Test translations after each conversion +- Update code using old `NSLocalizedString` calls + +**Phase 3**: Remove legacy files +- Delete `.strings` and `.stringsdict` files +- Verify all strings in String Catalog +- Submit to App Store + +**Coexistence**: `.strings` and `.xcstrings` work together - Xcode checks both. + +--- + +## Common Mistakes + +### Hardcoded Strings + +```swift +// ❌ Wrong - not localizable +Text("Welcome") +let title = "Settings" + +// ✅ Correct - localizable +Text("Welcome") // SwiftUI auto-localizes +let title = String(localized: "Settings") +``` + +### Concatenating Localized Strings + +```swift +// ❌ Wrong - word order varies by language +let message = String(localized: "You have") + " \(count) " + String(localized: "items") + +// ✅ Correct - single localizable string with substitution +let message = String(localized: "You have \(count) items") +``` + +**Why wrong**: Some languages put numbers before nouns, some after. + +### Missing Plural Forms + +```swift +// ❌ Wrong - grammatically incorrect for many languages +Text("\(count) item(s)") + +// ✅ Correct - proper plural handling +Text("\(count) items") // Xcode creates plural variations +``` + +### Ignoring RTL + +```swift +// ❌ Wrong - breaks in RTL languages +.padding(.left, 20) +HStack { + backButton + Spacer() + title +} + +// ✅ Correct - mirrors automatically +.padding(.leading, 20) +HStack { + backButton // Appears on right in RTL + Spacer() + title +} +``` + +### Wrong Date/Number Formats + +```swift +// ❌ Wrong - US-only format +let formatter = DateFormatter() +formatter.dateFormat = "MM/dd/yyyy" + +// ✅ Correct - adapts to locale +formatter.dateStyle = .short +formatter.locale = Locale.current +``` + +### Forgetting Comments + +```swift +// ❌ Wrong - translator has no context +String(localized: "Confirm") + +// ✅ Correct - clear context +String(localized: "Confirm", comment: "Button to confirm delete action") +``` + +**Impact**: "Confirm" could mean "verify" or "acknowledge" - context matters for accurate translation. + +--- + +## Troubleshooting + +### Strings not appearing in String Catalog + +**Cause**: Build settings not enabled + +**Solution**: +1. Build Settings → "Use Compiler to Extract Swift Strings" → Yes +2. Clean Build Folder (Cmd+Shift+K) +3. Build project + +### Translations not showing in app + +**Cause 1**: Language not added to project +1. Project → Info → Localizations → + button +2. Add target language + +**Cause 2**: String marked as "Stale" +- Remove stale strings or verify code still uses them + +### Plural forms incorrect + +**Cause**: Using `String.localizedStringWithFormat` instead of String Catalog + +**Solution**: Use String Catalog's automatic plural handling: +```swift +// ✅ Correct +Text("\(count) items") + +// ❌ Wrong +Text(String.localizedStringWithFormat(NSLocalizedString("%d items", comment: ""), count)) +``` + +### XLIFF export missing strings + +**Cause**: "Localization Prefers String Catalogs" not set + +**Solution**: +1. Build Settings → "Localization Prefers String Catalogs" → Yes +2. Export Localizations again + +### Generated symbols not appearing (Xcode 26+) + +**Cause 1**: Build setting not enabled + +**Solution**: +1. Build Settings → "Generate String Catalog Symbols" → Yes +2. Clean Build Folder (Cmd+Shift+K) +3. Rebuild project + +**Cause 2**: String not manually added to catalog + +**Solution**: Symbols only generate for manually-added strings (+ button in String Catalog). Auto-extracted strings don't generate symbols. + +### #bundle macro not working (Xcode 26+) + +**Cause**: Wrong syntax or missing import + +**Solution**: +```swift +import Foundation // Required for #bundle +Text("My Collections", bundle: #bundle, comment: "Section title") +``` + +Verify you're using `#bundle` not `.module`. + +### Refactoring to symbols fails (Xcode 26+) + +**Cause 1**: String not in String Catalog +1. Ensure string exists in `.xcstrings` file +2. Build project to refresh catalog +3. Try refactoring again + +**Cause 2**: Build setting not enabled +- Enable "Generate String Catalog Symbols" in Build Settings +- Clean and rebuild + +--- + +## Part 10: Xcode 26 Localization Enhancements + +Xcode 26 introduces type-safe localization with generated symbols, automatic comment generation using on-device AI, and improved Swift Package support with the `#bundle` macro. Based on WWDC 2025 session 225 "Explore localization with Xcode". + +### Generated Symbols (Type-Safe Localization) + +**The problem**: String-based localization fails silently when typos occur. + +```swift +// ❌ Typo - fails silently at runtime +Text("App.HomeScren.Title") // Missing 'e' in Screen +``` + +**The solution**: Xcode 26 generates type-safe symbols from manually-added strings. + +#### How It Works + +1. **Add strings manually** to String Catalog using the + button +2. **Enable build setting**: "Generate String Catalog Symbols" (ON by default in new projects) +3. **Use symbols** instead of strings + +```swift +// ✅ Type-safe - compiler catches typos +Text(.appHomeScreenTitle) +``` + +#### Symbol Generation Rules + +| String Type | Generated Symbol Type | Usage Example | +|-------------|----------------------|---------------| +| No placeholders | Static property | `Text(.introductionTitle)` | +| With placeholders | Function with labeled arguments | `.subtitle(friendsPosts: 42)` | + +**Key naming conversion**: +- `App.HomeScreen.Title` → `.appHomeScreenTitle` +- Periods removed, camel-cased +- Available on `LocalizedStringResource` + +#### Code Examples + +```swift +// SwiftUI views +struct ContentView: View { + var body: some View { + NavigationStack { + Text(.introductionTitle) + .navigationSubtitle(.subtitle(friendsPosts: 42)) + } + } +} + +// Foundation String +let message = String(localized: .curatedCollection) + +// Custom views with LocalizedStringResource +struct CollectionDetailEditingView: View { + let title: LocalizedStringResource + + init(title: LocalizedStringResource) { + self.title = title + } + + var body: some View { + Text(title) + } +} + +CollectionDetailEditingView(title: .editingTitle) +``` + +--- + +### Automatic Comment Generation + +Xcode 26 uses an **on-device model** to automatically generate contextual comments for localizable strings. + +#### Enabling the Feature + +1. Open Xcode Settings → Editing +2. Enable "automatically generate string catalog comments" +3. New strings added to code automatically receive generated comments + +#### Example + +For a button string, Xcode generates: + +> "The text label on a button to cancel the deletion of a collection" + +This context helps translators understand where and how the string is used. + +#### XLIFF Export + +Auto-generated comments are marked in exported XLIFF files: + +```xml + + Grand Canyon + Grand Canyon + Suggestion for searching landmarks + +``` + +**Benefits**: +- Saves developer time writing translator context +- Provides consistent, clear descriptions +- Improves translation quality + +--- + +### Swift Package & Framework Localization + +#### The Problem + +SwiftUI uses the `.main` bundle by default. Swift Packages and frameworks need to reference their own bundle: + +```swift +// ❌ Wrong - uses main bundle, strings not found +Text("My Collections", comment: "Section title") +``` + +#### The Solution: #bundle Macro (NEW in Xcode 26) + +The `#bundle` macro automatically references the correct bundle for the current target: + +```swift +// ✅ Correct - automatically uses package/framework bundle +Text("My Collections", bundle: #bundle, comment: "Section title") +``` + +**Key advantages**: +- Works in main app, frameworks, and Swift Packages +- Backwards-compatible with older OS versions +- Eliminates manual `.module` bundle management + +#### With Custom Table Names + +```swift +// Main app +Text("My Collections", + tableName: "Discover", + comment: "Section title") + +// Framework or Swift Package +Text("My Collections", + tableName: "Discover", + bundle: #bundle, + comment: "Section title") +``` + +--- + +### Custom Table Symbol Access + +When using multiple String Catalogs for organization: + +#### Default "Localizable" Table + +Symbols are directly accessible on `LocalizedStringResource`: + +```swift +Text(.welcomeMessage) // From Localizable.xcstrings +``` + +**Note**: Xcode automatically resolves symbols from the default "Localizable" table. Explicit table selection is rarely needed—use it only for debugging or testing specific catalogs. + +#### Custom Tables + +Symbols are nested in the table namespace: + +```swift +// From Discover.xcstrings +Text(Discover.featuredCollection) + +// From Settings.xcstrings +Text(Settings.privacyPolicy) +``` + +**Organization strategy for large apps**: +- **Localizable.xcstrings** - Core app strings +- **FeatureName.xcstrings** - Feature-specific strings (e.g., Onboarding, Settings, Discover) +- Benefits: Easier to manage, clearer ownership, better XLIFF organization + +--- + +### Two Localization Workflows + +Xcode 26 supports two complementary workflows: + +#### Workflow 1: String Extraction (Recommended for new projects) + +**Process**: +1. Write strings directly in code +2. Use SwiftUI views (`Text`, `Button`) and `String(localized:)` +3. Xcode automatically extracts to String Catalog +4. Leverage automatic comment generation + +**Pros**: Simple initial setup, immediate start + +**Cons**: Less control over string organization + +```swift +// ✅ String extraction workflow +Text("Welcome to WWDC!", comment: "Main welcome message") +``` + +#### Workflow 2: Generated Symbols (Recommended as complexity grows) + +**Process**: +1. Manually add strings to String Catalog +2. Reference via type-safe symbols +3. Organize into custom tables + +**Pros**: Better control, type safety, easier to maintain across frameworks + +**Cons**: Requires planning string catalog structure upfront + +```swift +// ✅ Generated symbols workflow +Text(.welcomeMessage) +``` + +| Workflow | Best For | Trade-offs | +|----------|----------|------------| +| String Extraction | New projects, simple apps, prototyping | Automatic extraction, less control over organization | +| Generated Symbols | Large apps, frameworks, multiple teams | Type safety, better organization, requires upfront planning | + +--- + +### Refactoring Between Workflows + +Xcode 26 allows converting between workflows without manual rewriting. + +#### Converting Strings to Symbols + +1. **Right-click** on a string literal in code +2. Select **"Refactor > Convert Strings to Symbols"** +3. **Preview** all affected locations +4. **Customize** symbol names before confirming +5. **Apply** to entire table or individual strings + +**Example**: + +```swift +// Before +Text("Welcome to WWDC!", comment: "Main welcome message") + +// After refactoring +Text(.welcomeToWWDC) +``` + +**Benefits**: +- Batch conversion of entire String Catalogs +- Preview changes before applying +- Maintain localization without code rewrites + +--- + +### Implementation Checklist + +After adopting Xcode 26 generated symbols, verify: + +**Build Configuration:** +- [ ] "Generate String Catalog Symbols" build setting enabled +- [ ] Project builds without "Cannot find 'symbolName' in scope" errors +- [ ] Clean build succeeds (Cmd+Shift+K, then Cmd+B) + +**String Catalog Setup:** +- [ ] Strings manually added to catalog using + button (not auto-extracted) +- [ ] Symbol names follow conventions (camelCase, no periods) +- [ ] Custom tables organized by feature (if using multiple catalogs) + +**Swift Package Integration:** +- [ ] All `Text()` and `String(localized:)` calls in packages use `bundle: #bundle` +- [ ] Import Foundation added where `#bundle` is used +- [ ] Tested package builds independently and as dependency + +**Refactoring & Migration:** +- [ ] Tested refactoring tool on sample strings +- [ ] Preview showed expected changes before applying +- [ ] Old string-based calls still work during transition period + +**Optional Features:** +- [ ] Automatic comment generation enabled in Xcode Settings → Editing (optional) +- [ ] Tested AI-generated comments for accuracy +- [ ] XLIFF export includes auto-generated comments + +**Testing:** +- [ ] Symbols resolve correctly in SwiftUI previews +- [ ] Localization works across all supported languages +- [ ] App runs on minimum supported iOS version + +--- + +## Resources + +**WWDC**: 2025-225, 2023-10155, 2022-10110 + +**Docs**: /xcode/localization, /xcode/localizing-and-varying-text-with-a-string-catalog + +**Skills**: axiom-app-intents-ref, axiom-hig, axiom-accessibility-diag diff --git a/.claude/skills/axiom-localization/agents/openai.yaml b/.claude/skills/axiom-localization/agents/openai.yaml new file mode 100644 index 0000000..3c04f53 --- /dev/null +++ b/.claude/skills/axiom-localization/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Localization" + short_description: "Localizing apps, using String Catalogs, generating type-safe symbols (Xcode 26+), handling plurals, RTL layouts, loca..." diff --git a/.claude/skills/axiom-mapkit-diag/.openskills.json b/.claude/skills/axiom-mapkit-diag/.openskills.json new file mode 100644 index 0000000..14b6744 --- /dev/null +++ b/.claude/skills/axiom-mapkit-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-mapkit-diag", + "installedAt": "2026-04-12T08:06:28.154Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-mapkit-diag/SKILL.md b/.claude/skills/axiom-mapkit-diag/SKILL.md new file mode 100644 index 0000000..d8038b0 --- /dev/null +++ b/.claude/skills/axiom-mapkit-diag/SKILL.md @@ -0,0 +1,462 @@ +--- +name: axiom-mapkit-diag +description: MapKit troubleshooting — annotations not appearing, region jumping, clustering not working, search failures, overlay rendering issues, user location problems +license: MIT +metadata: + version: "1.0.0" + last-updated: "2026-02-26" +--- + +# MapKit Diagnostics + +Symptom-based MapKit troubleshooting. Start with the symptom you're seeing, follow the diagnostic path. + +## Related Skills + +- `axiom-mapkit` — Patterns, decision trees, anti-patterns +- `axiom-mapkit-ref` — API reference, code examples + +--- + +## Quick Reference + +| Symptom | Check First | Common Fix | +|---|---|---| +| Annotations not appearing | Coordinate values (lat/lng swapped?) | Verify coordinate, check viewFor delegate | +| Map region jumps/loops | updateUIView guard | Add region equality check | +| Slow with many annotations | Annotation count, view reuse | Enable clustering, implement view reuse | +| Clustering not working | clusteringIdentifier set? | Set same identifier on all views | +| Overlays not rendering | renderer delegate method | Return correct MKOverlayRenderer subclass | +| Search returns no results | resultTypes, region bias | Set appropriate resultTypes and region | +| User location not showing | Authorization status | Request CLLocationManager authorization first | +| Coordinates appear wrong | lat/lng order | MapKit uses (latitude, longitude) — verify data source | + +--- + +## Symptom 1: Annotations Not Appearing + +### Decision Tree + +``` +Q1: Are coordinates valid? +├─ 0,0 or NaN → Data source returning default/empty values +│ Fix: Validate coordinates before adding annotations +│ Debug: print("\(annotation.coordinate.latitude), \(annotation.coordinate.longitude)") +│ +└─ Valid numbers → Check next + +Q2: Are lat/lng swapped? +├─ YES (common with GeoJSON which uses [longitude, latitude]) → Swap values +│ GeoJSON: [lng, lat] — MapKit: CLLocationCoordinate2D(latitude:, longitude:) +│ Fix: CLLocationCoordinate2D(latitude: json[1], longitude: json[0]) +│ +└─ NO → Check next + +Q3: (MKMapView) Is mapView(_:viewFor:) delegate returning nil for your annotations? +├─ Not implemented → System uses default pin (should appear) +├─ Returns nil → System uses default pin (should appear) +├─ Returns wrong view → Check implementation +│ +└─ Check delegate is set + +Q4: (MKMapView) Is delegate set? +├─ NO → mapView.delegate = self (or context.coordinator in UIViewRepresentable) +│ Without delegate: default pins appear. But if viewFor returns nil, check annotation type +│ +└─ YES → Check next + +Q5: (SwiftUI) Are annotations in Map content builder? +├─ NO → Annotations must be inside Map { ... } content closure +│ Fix: Map(position: $pos) { Marker("Name", coordinate: coord) } +│ +└─ YES → Check next + +Q6: Is the map region showing the annotation coordinates? +├─ Map centered elsewhere → Adjust camera/region to include annotation coordinates +│ Debug: Compare mapView.region with annotation coordinates +│ Fix: Use .automatic camera position or set region to fit annotations +│ +└─ Region includes annotations → Check displayPriority + +Q7: (MKMapView) Is displayPriority too low? +├─ .defaultLow → System may hide annotations at certain zoom levels +│ Fix: view.displayPriority = .required for must-show annotations +│ +└─ .required → Annotation should appear — file a bug report with minimal repro +``` + +--- + +## Symptom 2: Map Region Jumping / Infinite Loops + +### Decision Tree + +``` +Q1: (UIViewRepresentable) Is setRegion called in updateUIView without guard? +├─ YES → Classic infinite loop: +│ 1. SwiftUI state changes → updateUIView called +│ 2. updateUIView calls setRegion +│ 3. setRegion triggers regionDidChangeAnimated delegate +│ 4. Delegate updates SwiftUI state → back to step 1 +│ +│ Fix: Guard against unnecessary updates +│ if mapView.region.center.latitude != region.center.latitude +│ || mapView.region.center.longitude != region.center.longitude { +│ mapView.setRegion(region, animated: true) +│ } +│ +│ Alternative: Use a flag in coordinator +│ coordinator.isUpdating = true +│ mapView.setRegion(region, animated: true) +│ coordinator.isUpdating = false +│ // In regionDidChangeAnimated: guard !isUpdating +│ +└─ NO → Check next + +Q2: Are multiple state sources fighting over the region? +├─ YES → Two bindings or state variables controlling the same region +│ Fix: Single source of truth for camera position +│ One @State var cameraPosition, not two conflicting values +│ +└─ NO → Check next + +Q3: (SwiftUI) Is MapCameraPosition properly bound? +├─ Using .constant() or recreating position on each render → Camera resets +│ Fix: @State private var cameraPosition: MapCameraPosition = .automatic +│ Use the binding: Map(position: $cameraPosition) +│ +└─ Properly bound → Check next + +Q4: Animation conflict? +├─ Using animated: true in updateUIView alongside SwiftUI animations → Double animation +│ Fix: Avoid animated: true in updateUIView, or disable SwiftUI animation for map +│ +└─ NO → Check next + +Q5: Is onMapCameraChange triggering state updates that move the camera? +├─ YES → Camera change → callback → state change → camera change +│ Fix: Only update non-camera state in the callback +│ Don't set cameraPosition inside onMapCameraChange +│ +└─ NO → Check delegate implementation for unintended state mutations +``` + +--- + +## Symptom 3: Performance Issues + +### Decision Tree + +``` +Q1: How many annotations? +├─ > 500 without clustering → Enable clustering +│ SwiftUI: .mapItemClusteringIdentifier("poi") +│ MKMapView: view.clusteringIdentifier = "poi" +│ +├─ > 1000 → Consider visible-region filtering +│ Only load annotations within mapView.region +│ Use .onMapCameraChange to fetch when user scrolls +│ +└─ < 500 → Check next + +Q2: (MKMapView) Using dequeueReusableAnnotationView? +├─ NO → Every annotation creates a new view → memory spike +│ Fix: Register view class and dequeue in delegate +│ mapView.register(MKMarkerAnnotationView.self, forAnnotationViewWithReuseIdentifier: "marker") +│ +└─ YES → Check next + +Q3: Complex custom annotation views? +├─ YES → Rich SwiftUI views or complex UIViews per annotation +│ Fix: Pre-render to UIImage for MKAnnotationView.image +│ Or simplify to MKMarkerAnnotationView with glyph +│ +└─ NO → Check next + +Q4: Overlays with many coordinates? +├─ YES → Polylines/polygons with 10K+ points +│ Fix: Simplify geometry (Douglas-Peucker algorithm) +│ Or render at reduced detail for zoomed-out views +│ +└─ NO → Check next + +Q5: Geocoding in a loop? +├─ YES → CLGeocoder has rate limit (~1/second) +│ Fix: Batch geocoding, throttle requests, cache results +│ Use MKLocalSearch for batch lookups instead of per-item geocoding +│ +└─ NO → Profile with Instruments → Time Profiler for CPU, Allocations for memory +``` + +--- + +## Symptom 4: Clustering Not Working + +### Decision Tree + +``` +Q1: Is clusteringIdentifier set on annotation views? +├─ NO → Clustering requires an identifier on each annotation view +│ MKMapView: view.clusteringIdentifier = "poi" in viewFor delegate +│ SwiftUI: .mapItemClusteringIdentifier("poi") on content +│ +└─ YES → Check next + +Q2: Are ALL relevant views using the SAME identifier? +├─ NO → Different identifiers = different cluster groups +│ Fix: Use consistent identifier for annotations that should cluster together +│ +└─ YES → Check next + +Q3: (MKMapView) Is mapView(_:clusterAnnotationForMemberAnnotations:) needed? +├─ Not implemented → System creates default cluster +│ If you need custom cluster appearance, implement this delegate method +│ +└─ Implemented → Check return value + +Q4: Too few annotations in visible area? +├─ YES → Clustering only activates when annotations physically overlap +│ At low zoom (city level), 10 annotations might cluster +│ At high zoom (street level), same 10 might all be visible individually +│ +└─ NO → Check next + +Q5: (MKMapView) Are annotation views registered? +├─ NO → Register both individual and cluster view classes +│ mapView.register(MKMarkerAnnotationView.self, forAnnotationViewWithReuseIdentifier: "marker") +│ +└─ YES → Verify viewFor delegate handles both MKClusterAnnotation and individual annotations +``` + +--- + +## Symptom 5: Overlays Not Rendering + +### Decision Tree + +``` +Q1: (MKMapView) Is mapView(_:rendererFor:) delegate method implemented? +├─ NO → Overlays require a renderer — without this delegate method, nothing renders +│ Fix: Implement the delegate method, return appropriate renderer subclass +│ +└─ YES → Check next + +Q2: Is the correct renderer subclass returned? +├─ MKCircle → MKCircleRenderer +│ MKPolyline → MKPolylineRenderer +│ MKPolygon → MKPolygonRenderer +│ MKTileOverlay → MKTileOverlayRenderer +│ Mismatch → Crash or silent failure +│ +└─ Correct → Check next + +Q3: Is renderer styled? +├─ No strokeColor/fillColor/lineWidth set → Renderer exists but invisible +│ Fix: Set at minimum strokeColor and lineWidth +│ renderer.strokeColor = .systemBlue +│ renderer.lineWidth = 2 +│ +└─ Styled → Check next + +Q4: Overlay level wrong? +├─ .aboveRoads → Overlay may be behind labels (hard to see) +│ Try: mapView.addOverlay(overlay, level: .aboveLabels) +│ +└─ Check overlay coordinates match visible region + +Q5: (SwiftUI) Using MapCircle/MapPolyline without styling? +├─ No .foregroundStyle or .stroke → May render transparent +│ Fix: MapCircle(center: coord, radius: 500) +│ .foregroundStyle(.blue.opacity(0.3)) +│ .stroke(.blue, lineWidth: 2) +│ +└─ Styled → Check coordinates are within visible map region +``` + +--- + +## Symptom 6: Search / Directions Failures + +### Decision Tree + +``` +Q1: Network available? +├─ NO → MapKit search requires network connectivity +│ Fix: Check URLSession connectivity or NWPathMonitor +│ +└─ YES → Check next + +Q2: resultTypes too restrictive? +├─ Only .physicalFeature but searching for "Starbucks" → No results +│ Fix: Use .pointOfInterest for businesses, .address for streets +│ Or combine: [.pointOfInterest, .address] +│ +└─ Appropriate → Check next + +Q3: Region bias missing? +├─ NO region set → Results may be from anywhere in the world +│ Fix: request.region = mapView.region (or visible region) +│ This biases results to what the user can see +│ +└─ Region set → Check next + +Q4: Natural language query format? +├─ Structured format (lat/lng, codes) → Won't parse +│ Good: "coffee shops near San Francisco" +│ Good: "123 Main St" +│ Bad: "lat:37.7 lng:-122.4 coffee" +│ Bad: "POI_TYPE=cafe" +│ +└─ Natural language → Check next + +Q5: Rate limited? +├─ Getting errors after many requests → Apple rate-limits MapKit search +│ Fix: Throttle searches, use MKLocalSearchCompleter for autocomplete +│ Don't fire MKLocalSearch on every keystroke +│ +└─ NO → Check next + +Q6: (Directions) Source and destination valid? +├─ source or destination is nil → Request will fail +│ Fix: Verify both are valid MKMapItem instances +│ MKMapItem.forCurrentLocation() requires location authorization +│ +└─ Both valid → Check transportType availability + Transit directions not available in all regions + Walking/driving available globally +``` + +--- + +## Symptom 7: User Location Not Showing + +### Decision Tree + +``` +Q1: What is CLLocationManager.authorizationStatus? +├─ .notDetermined → Authorization never requested +│ Fix: Request authorization first, then enable user location +│ CLServiceSession(authorization: .whenInUse) +│ +├─ .denied → User denied location access +│ Fix: Show UI explaining value, link to Settings +│ +├─ .restricted → Parental controls block access +│ Fix: Inform user, cannot override +│ +└─ .authorizedWhenInUse / .authorizedAlways → Check next + +Q2: (MKMapView) Is showsUserLocation set to true? +├─ NO → mapView.showsUserLocation = true +│ +└─ YES → Check next + +Q3: (SwiftUI) Using UserAnnotation() in Map content? +├─ NO → Add UserAnnotation() inside Map { ... } +│ +└─ YES → Check next + +Q4: Running in Simulator? +├─ YES, no custom location set → Simulator doesn't have GPS +│ Fix: Debug menu → Location → Custom Location (or Apple/City Bicycle Ride/etc.) +│ Xcode: Debug → Simulate Location → pick a location +│ +└─ Physical device → Check next + +Q5: MapKit implicitly requests authorization — was it previously denied? +├─ MapKit shows no prompt if already denied +│ Check: Settings → Privacy & Security → Location Services → Your App +│ If "Never": User must manually re-enable +│ +└─ Authorized → Check if location services enabled system-wide + Settings → Privacy & Security → Location Services → toggle at top + +Q6: Location icon appearing but blue dot not on screen? +├─ User is outside the visible map region +│ Fix: Use MapCameraPosition.userLocation(fallback: .automatic) +│ Or add MapUserLocationButton() in .mapControls +│ +└─ See axiom-core-location-diag for deeper location troubleshooting +``` + +--- + +## Symptom 8: Coordinate System Confusion + +Common coordinate mistakes that cause annotations to appear in wrong locations. + +### MapKit vs GeoJSON + +| System | Order | Example | +|---|---|---| +| MapKit (CLLocationCoordinate2D) | latitude, longitude | `CLLocationCoordinate2D(latitude: 37.77, longitude: -122.42)` | +| GeoJSON | longitude, latitude | `[-122.42, 37.77]` | +| Google Maps | latitude, longitude | Same as MapKit | +| PostGIS ST_MakePoint | longitude, latitude | Same as GeoJSON | + +**The #1 coordinate bug**: Swapping lat/lng when parsing GeoJSON. + +```swift +// ❌ WRONG: Using GeoJSON order directly +let coord = CLLocationCoordinate2D( + latitude: geoJson[0], // This is longitude! + longitude: geoJson[1] // This is latitude! +) + +// ✅ RIGHT: GeoJSON is [lng, lat], MapKit wants (lat, lng) +let coord = CLLocationCoordinate2D( + latitude: geoJson[1], + longitude: geoJson[0] +) +``` + +### MKMapPoint vs CLLocationCoordinate2D + +- `CLLocationCoordinate2D` — geographic coordinates (lat/lng in degrees) +- `MKMapPoint` — projected coordinates for flat map rendering +- Convert: `MKMapPoint(coordinate)` and `coordinate` property on MKMapPoint +- Never use MKMapPoint x/y as lat/lng — they're completely different number spaces + +### Validation + +```swift +func isValidCoordinate(_ coord: CLLocationCoordinate2D) -> Bool { + coord.latitude >= -90 && coord.latitude <= 90 + && coord.longitude >= -180 && coord.longitude <= 180 + && !coord.latitude.isNaN && !coord.longitude.isNaN +} +``` + +If latitude > 90 or longitude > 180, coordinates are likely swapped or in wrong format. + +--- + +## Console Debugging + +### MapKit Logs + +```bash +# View MapKit-related logs +log stream --predicate 'subsystem == "com.apple.MapKit"' --level debug + +# Filter for your app +log stream --predicate 'process == "YourApp" AND (subsystem == "com.apple.MapKit" OR subsystem == "com.apple.CoreLocation")' +``` + +### Common Console Messages + +| Message | Meaning | +|---|---| +| `No renderer for overlay` | Missing rendererFor delegate method | +| `Reuse identifier not registered` | Call register before dequeue | +| `CLLocationManager authorizationStatus is denied` | User denied location | + +--- + +## Resources + +**WWDC**: 2023-10043, 2024-10094 + +**Docs**: /mapkit, /mapkit/mklocalsearch + +**Skills**: axiom-mapkit, axiom-mapkit-ref, axiom-core-location-diag diff --git a/.claude/skills/axiom-mapkit-diag/agents/openai.yaml b/.claude/skills/axiom-mapkit-diag/agents/openai.yaml new file mode 100644 index 0000000..d5e5eab --- /dev/null +++ b/.claude/skills/axiom-mapkit-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "MapKit Diagnostics" + short_description: "MapKit troubleshooting" diff --git a/.claude/skills/axiom-mapkit-ref/.openskills.json b/.claude/skills/axiom-mapkit-ref/.openskills.json new file mode 100644 index 0000000..9c07068 --- /dev/null +++ b/.claude/skills/axiom-mapkit-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-mapkit-ref", + "installedAt": "2026-04-12T08:06:28.375Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-mapkit-ref/SKILL.md b/.claude/skills/axiom-mapkit-ref/SKILL.md new file mode 100644 index 0000000..b18a43f --- /dev/null +++ b/.claude/skills/axiom-mapkit-ref/SKILL.md @@ -0,0 +1,915 @@ +--- +name: axiom-mapkit-ref +description: MapKit API reference — SwiftUI Map, MKMapView, Marker, Annotation, MKLocalSearch, MKDirections, Look Around, MKMapSnapshotter, clustering, overlays, GeoToolbox PlaceDescriptor, geocoding +license: MIT +metadata: + version: "1.0.0" + last-updated: "2026-02-26" +--- + +# MapKit API Reference + +Complete MapKit API reference for iOS development. Covers both SwiftUI Map (iOS 17+) and MKMapView (UIKit). + +## Related Skills + +- `axiom-mapkit` — Decision trees, anti-patterns, pressure scenarios +- `axiom-mapkit-diag` — Symptom-based troubleshooting + +--- + +## Part 1: Modern API Overview + +| Feature | SwiftUI Map (iOS 17+) | MKMapView | +|---|---|---| +| Declaration | `Map(position:) { content }` | `MKMapView()` | +| Camera control | `MapCameraPosition` binding | `setRegion(_:animated:)` | +| Annotations | `Marker`, `Annotation` in content | `addAnnotation(_:)` + delegate | +| Overlays | `MapCircle`, `MapPolyline`, `MapPolygon` | `addOverlay(_:)` + renderer delegate | +| User location | `UserAnnotation()` | `showsUserLocation = true` | +| Selection | `.mapSelection($selection)` | delegate `didSelect` | +| Controls | `.mapControls { }` | `showsCompass`, `showsScale` | +| Interaction modes | `.mapInteractionModes([])` | delegate methods | +| Clustering | Built-in via `.mapItemClusteringIdentifier` | `MKClusterAnnotation` | + +--- + +## Part 2: SwiftUI Map API + +### Basic Map + +```swift +@State private var cameraPosition: MapCameraPosition = .automatic + +Map(position: $cameraPosition) { + Marker("Home", coordinate: homeCoord) + Annotation("Custom", coordinate: coord) { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + .padding(4) + .background(.blue, in: Circle()) + } + UserAnnotation() + MapCircle(center: coord, radius: 500) + .foregroundStyle(.blue.opacity(0.3)) + MapPolyline(coordinates: routeCoords) + .stroke(.blue, lineWidth: 3) +} +.mapStyle(.standard(elevation: .realistic)) +.mapControls { + MapUserLocationButton() + MapCompass() + MapScaleView() +} +``` + +### MapCameraPosition + +Controls where the camera is positioned: + +```swift +// System manages camera to show all content +.automatic + +// Specific region +.region(MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), + span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) +)) + +// Specific camera with pitch and heading +.camera(MapCamera( + centerCoordinate: coordinate, + distance: 1000, // meters from center + heading: 90, // degrees from north + pitch: 60 // degrees from vertical (0 = top-down) +)) + +// Follow user location +.userLocation(followsHeading: true, fallback: .automatic) + +// Show specific item +.item(mapItem) + +// Show specific rect +.rect(MKMapRect(...)) +``` + +#### Programmatic Camera Changes + +```swift +// Animate to new position +withAnimation { + cameraPosition = .region(newRegion) +} + +// Keyframe animation (iOS 17+) +Map(position: $cameraPosition) + .mapCameraKeyframeAnimator(trigger: flyToTrigger) { initialCamera in + KeyframeTrack(\.centerCoordinate) { + LinearKeyframe(destination, duration: 2.0) + } + KeyframeTrack(\.distance) { + CubicKeyframe(5000, duration: 1.0) + CubicKeyframe(1000, duration: 1.0) + } + } +``` + +### Map Selection + +```swift +@State private var selectedItem: MKMapItem? + +Map(position: $cameraPosition, selection: $selectedItem) { + ForEach(mapItems, id: \.self) { item in + Marker(item: item) + } +} +.onChange(of: selectedItem) { _, newItem in + if let newItem { + // Handle selection + } +} +``` + +### Camera Change Callback + +```swift +Map(position: $cameraPosition) { ... } + .onMapCameraChange { context in + // context.region — visible MKCoordinateRegion + // context.camera — current MapCamera + // context.rect — visible MKMapRect + fetchAnnotations(in: context.region) + } + .onMapCameraChange(frequency: .continuous) { context in + // Called during gesture (not just at end) + } +``` + +### Map Styles + +```swift +.mapStyle(.standard) // Default +.mapStyle(.standard(elevation: .realistic)) // 3D buildings +.mapStyle(.standard(emphasis: .muted)) // Muted colors +.mapStyle(.standard(pointsOfInterest: .including([.restaurant, .cafe]))) +.mapStyle(.imagery) // Satellite +.mapStyle(.imagery(elevation: .realistic)) // 3D satellite +.mapStyle(.hybrid) // Satellite + labels +.mapStyle(.hybrid(elevation: .realistic)) // 3D hybrid +``` + +### Interaction Modes + +```swift +// Allow all interactions (default) +.mapInteractionModes(.all) + +// Read-only map (no interaction) +.mapInteractionModes([]) + +// Pan only, no zoom +.mapInteractionModes([.pan]) + +// Pan and zoom, no rotate/pitch +.mapInteractionModes([.pan, .zoom]) +``` + +--- + +## Part 3: Map Content + +### Marker + +System-styled map marker with callout: + +```swift +// Basic marker +Marker("Coffee Shop", coordinate: coord) + +// With system image +Marker("Coffee Shop", systemImage: "cup.and.saucer.fill", coordinate: coord) + +// With monogram (2 characters max) +Marker("Coffee Shop", monogram: Text("CS"), coordinate: coord) + +// Color +Marker("Coffee Shop", coordinate: coord) + .tint(.brown) + +// From MKMapItem +Marker(item: mapItem) +``` + +### Annotation + +Fully custom view at a coordinate: + +```swift +Annotation("Custom Pin", coordinate: coord) { + VStack { + Image(systemName: "mappin.circle.fill") + .font(.title) + .foregroundStyle(.red) + Text("Here") + .font(.caption) + } +} + +// Anchor point (default is bottom center) +Annotation("Pin", coordinate: coord, anchor: .center) { + Circle() + .fill(.blue) + .frame(width: 20, height: 20) +} +``` + +### UserAnnotation + +Current user location indicator: + +```swift +UserAnnotation() + +// Custom appearance +UserAnnotation(anchor: .center) { + Image(systemName: "location.circle.fill") + .foregroundStyle(.blue) +} +``` + +### Shape Overlays + +```swift +// Circle +MapCircle(center: coord, radius: 1000) // radius in meters + .foregroundStyle(.blue.opacity(0.2)) + .stroke(.blue, lineWidth: 2) + +// Polygon +MapPolygon(coordinates: polygonCoords) + .foregroundStyle(.green.opacity(0.3)) + .stroke(.green, lineWidth: 2) + +// Polyline +MapPolyline(coordinates: routeCoords) + .stroke(.blue, lineWidth: 4) + +// From MKRoute +MapPolyline(route.polyline) + .stroke(.blue, lineWidth: 5) +``` + +### Clustering + +```swift +ForEach(locations) { location in + Marker(location.name, coordinate: location.coordinate) + .tag(location.id) +} +.mapItemClusteringIdentifier("locations") +``` + +--- + +## Part 4: MKMapView Lifecycle and Delegates + +### Creating MKMapView in SwiftUI + +```swift +struct MapViewWrapper: UIViewRepresentable { + @Binding var region: MKCoordinateRegion + let annotations: [MKAnnotation] + + func makeUIView(context: Context) -> MKMapView { + let mapView = MKMapView() + mapView.delegate = context.coordinator + mapView.showsUserLocation = true + mapView.register( + MKMarkerAnnotationView.self, + forAnnotationViewWithReuseIdentifier: "marker" + ) + return mapView + } + + func updateUIView(_ mapView: MKMapView, context: Context) { + // Guard against infinite loops + if !regionsAreEqual(mapView.region, region) { + mapView.setRegion(region, animated: true) + } + + // Diff annotations instead of removing all + let current = Set(mapView.annotations.compactMap { $0 as? MyAnnotation }) + let desired = Set(annotations.compactMap { $0 as? MyAnnotation }) + let toAdd = desired.subtracting(current) + let toRemove = current.subtracting(desired) + mapView.addAnnotations(Array(toAdd)) + mapView.removeAnnotations(Array(toRemove)) + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + static func dismantleUIView(_ mapView: MKMapView, coordinator: Coordinator) { + mapView.removeAnnotations(mapView.annotations) + mapView.removeOverlays(mapView.overlays) + } +} +``` + +### Key MKMapViewDelegate Methods + +```swift +class Coordinator: NSObject, MKMapViewDelegate { + var parent: MapViewWrapper + + init(_ parent: MapViewWrapper) { + self.parent = parent + } + + // Annotation view customization + func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { + guard !(annotation is MKUserLocation) else { return nil } // Use default for user + + let view = mapView.dequeueReusableAnnotationView( + withIdentifier: "marker", + for: annotation + ) as! MKMarkerAnnotationView + view.markerTintColor = .systemRed + view.glyphImage = UIImage(systemName: "mappin") + view.clusteringIdentifier = "poi" + view.canShowCallout = true + return view + } + + // Overlay rendering + func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { + if let circle = overlay as? MKCircle { + let renderer = MKCircleRenderer(circle: circle) + renderer.fillColor = UIColor.systemBlue.withAlphaComponent(0.2) + renderer.strokeColor = .systemBlue + renderer.lineWidth = 2 + return renderer + } + if let polyline = overlay as? MKPolyline { + let renderer = MKPolylineRenderer(polyline: polyline) + renderer.strokeColor = .systemBlue + renderer.lineWidth = 4 + return renderer + } + if let polygon = overlay as? MKPolygon { + let renderer = MKPolygonRenderer(polygon: polygon) + renderer.fillColor = UIColor.systemGreen.withAlphaComponent(0.3) + renderer.strokeColor = .systemGreen + renderer.lineWidth = 2 + return renderer + } + return MKOverlayRenderer(overlay: overlay) + } + + // Region change tracking + func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { + parent.region = mapView.region + } + + // Annotation selection + func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) { + // Handle tap + } + + // Cluster annotation + func mapView( + _ mapView: MKMapView, + clusterAnnotationForMemberAnnotations memberAnnotations: [MKAnnotation] + ) -> MKClusterAnnotation { + MKClusterAnnotation(memberAnnotations: memberAnnotations) + } +} +``` + +--- + +## Part 5: Annotation Types and Customization + +### MKMarkerAnnotationView (iOS 11+) + +Balloon-shaped marker with glyph: + +```swift +let view = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: "marker") +view.markerTintColor = .systemPurple +view.glyphImage = UIImage(systemName: "star.fill") +view.glyphText = "A" // Text glyph (overrides image) +view.displayPriority = .required // Always visible +view.clusteringIdentifier = "category" // Enable clustering +view.canShowCallout = true +view.titleVisibility = .adaptive // Show title based on space +view.subtitleVisibility = .hidden +``` + +### MKAnnotationView + +Fully custom annotation view: + +```swift +let view = MKAnnotationView(annotation: annotation, reuseIdentifier: "custom") +view.image = UIImage(named: "custom-pin") +view.centerOffset = CGPoint(x: 0, y: -view.image!.size.height / 2) +view.canShowCallout = true +view.leftCalloutAccessoryView = UIImageView(image: thumbnail) +view.rightCalloutAccessoryView = UIButton(type: .detailDisclosure) +``` + +### Custom Callout + +```swift +func mapView( + _ mapView: MKMapView, + annotationView view: MKAnnotationView, + calloutAccessoryControlTapped control: UIControl +) { + guard let annotation = view.annotation as? MyAnnotation else { return } + // Navigate to detail view +} +``` + +### Annotation View Reuse + +Always use `dequeueReusableAnnotationView(withIdentifier:for:)`: + +```swift +// Register in makeUIView (once) +mapView.register(MKMarkerAnnotationView.self, forAnnotationViewWithReuseIdentifier: "marker") + +// Dequeue in delegate (every time) +func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { + let view = mapView.dequeueReusableAnnotationView(withIdentifier: "marker", for: annotation) + // Configure... + return view +} +``` + +Without reuse: 1000 annotations = 1000 views in memory. +With reuse: ~20-30 views recycled as user scrolls. + +--- + +## Part 6: MKLocalSearch and MKLocalSearchCompleter + +### MKLocalSearchCompleter — Real-Time Autocomplete + +```swift +let completer = MKLocalSearchCompleter() +completer.delegate = self +completer.resultTypes = [.pointOfInterest, .address] +completer.region = visibleMapRegion // Bias results to visible area + +// Update on each keystroke +completer.queryFragment = "coffee" + +// Delegate receives results +func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { + let results = completer.results // [MKLocalSearchCompletion] + for result in results { + // result.title — "Starbucks" + // result.subtitle — "123 Main St, San Francisco, CA" + // result.titleHighlightRanges — Ranges matching query + } +} + +func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { + // Network error, rate limit, etc. +} +``` + +### MKLocalSearch — Full Search + +```swift +// From autocomplete completion +let request = MKLocalSearch.Request(completion: selectedCompletion) + +// From natural language +let request = MKLocalSearch.Request() +request.naturalLanguageQuery = "coffee shops" +request.region = mapRegion // Bias results +request.resultTypes = .pointOfInterest // Filter type +request.pointOfInterestFilter = MKPointOfInterestFilter( + including: [.cafe, .restaurant] +) + +let search = MKLocalSearch(request: request) +let response = try await search.start() + +for item in response.mapItems { + // item.name — "Starbucks" + // item.placemark — MKPlacemark with address + // item.placemark.coordinate — CLLocationCoordinate2D + // item.phoneNumber — optional phone + // item.url — optional website + // item.pointOfInterestCategory — .cafe, .restaurant, etc. +} +``` + +### Result Types + +```swift +// Filter what kind of results to return +request.resultTypes = .address // Street addresses only +request.resultTypes = .pointOfInterest // Businesses, landmarks +request.resultTypes = .physicalFeature // Mountains, lakes, parks +request.resultTypes = .query // Suggested search queries (iOS 18+) +request.resultTypes = [.pointOfInterest, .address] // Multiple types +``` + +### Rate Limiting + +- `MKLocalSearchCompleter` handles its own throttling — safe to call on every keystroke +- `MKLocalSearch` — Apple rate-limits these; don't fire more than ~1/second +- If rate-limited, you'll get an error in the completion handler +- Reuse `MKLocalSearchCompleter` instances — don't create new ones per query + +--- + +## Part 7: MKDirections and MKRoute + +### Calculate Directions + +```swift +let request = MKDirections.Request() +request.source = MKMapItem.forCurrentLocation() +request.destination = destinationMapItem +request.transportType = .automobile +request.requestsAlternateRoutes = true // Get multiple routes + +let directions = MKDirections(request: request) +let response = try await directions.calculate() + +for route in response.routes { + route.polyline // MKPolyline — display on map + route.expectedTravelTime // TimeInterval in seconds + route.distance // CLLocationDistance in meters + route.name // "I-280 S" — route name + route.advisoryNotices // [String] — warnings + route.steps // [MKRoute.Step] — turn-by-turn +} +``` + +### Transport Types + +```swift +.automobile // Driving directions +.walking // Pedestrian directions +.transit // Public transit (where available) +.any // All modes +``` + +### ETA Only (Faster) + +```swift +let directions = MKDirections(request: request) +let eta = try await directions.calculateETA() +eta.expectedTravelTime // TimeInterval +eta.distance // CLLocationDistance +eta.expectedArrivalDate // Date +eta.expectedDepartureDate // Date +eta.transportType // MKDirectionsTransportType +``` + +### Turn-by-Turn Steps + +```swift +for step in route.steps { + step.instructions // "Turn right onto Main St" + step.distance // CLLocationDistance in meters + step.polyline // MKPolyline for this step's segment + step.transportType // May change for transit routes + step.notice // Optional advisory +} +``` + +--- + +## Part 8: Look Around + +### Check Availability + +```swift +let request = MKLookAroundSceneRequest(coordinate: coordinate) +do { + let scene = try await request.scene + // scene is non-nil — Look Around available at this coordinate +} catch { + // Look Around not available here +} +``` + +### SwiftUI + +```swift +@State private var lookAroundScene: MKLookAroundScene? + +LookAroundPreview(scene: $lookAroundScene) + .frame(height: 200) + +// Load scene +func loadLookAround(for coordinate: CLLocationCoordinate2D) async { + let request = MKLookAroundSceneRequest(coordinate: coordinate) + lookAroundScene = try? await request.scene +} +``` + +### UIKit + +```swift +let controller = MKLookAroundViewController(scene: scene) +// Present modally or embed as child view controller +``` + +### Static Snapshot + +```swift +let snapshotter = MKLookAroundSnapshotter(scene: scene, options: .init()) +let snapshot = try await snapshotter.snapshot +let image = snapshot.image // UIImage +``` + +--- + +## Part 9: Overlays and Renderers + +### Adding Overlays (MKMapView) + +```swift +// Circle +let circle = MKCircle(center: coordinate, radius: 1000) +mapView.addOverlay(circle) + +// Polygon +let polygon = MKPolygon(coordinates: &coords, count: coords.count) +mapView.addOverlay(polygon) + +// Polyline +let polyline = MKPolyline(coordinates: &coords, count: coords.count) +mapView.addOverlay(polyline, level: .aboveRoads) + +// Custom tile overlay +let template = "https://tile.example.com/{z}/{x}/{y}.png" +let tileOverlay = MKTileOverlay(urlTemplate: template) +tileOverlay.canReplaceMapContent = true // Hides Apple Maps base layer +mapView.addOverlay(tileOverlay, level: .aboveLabels) +``` + +### Renderer Delegate + +```swift +func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { + switch overlay { + case let circle as MKCircle: + let renderer = MKCircleRenderer(circle: circle) + renderer.fillColor = UIColor.systemBlue.withAlphaComponent(0.2) + renderer.strokeColor = .systemBlue + renderer.lineWidth = 2 + return renderer + + case let polyline as MKPolyline: + let renderer = MKPolylineRenderer(polyline: polyline) + renderer.strokeColor = .systemBlue + renderer.lineWidth = 4 + return renderer + + case let polygon as MKPolygon: + let renderer = MKPolygonRenderer(polygon: polygon) + renderer.fillColor = UIColor.systemGreen.withAlphaComponent(0.3) + renderer.strokeColor = .systemGreen + renderer.lineWidth = 2 + return renderer + + case let tile as MKTileOverlay: + return MKTileOverlayRenderer(tileOverlay: tile) + + default: + return MKOverlayRenderer(overlay: overlay) + } +} +``` + +### Overlay Levels + +```swift +mapView.addOverlay(overlay, level: .aboveRoads) // Above roads, below labels +mapView.addOverlay(overlay, level: .aboveLabels) // Above everything +``` + +### Gradient Polyline + +```swift +let renderer = MKGradientPolylineRenderer(polyline: polyline) +renderer.setColors([.green, .yellow, .red], locations: [0.0, 0.5, 1.0]) +renderer.lineWidth = 6 +``` + +--- + +## Part 10: Map Snapshots + +Generate static map images for sharing, thumbnails, or offline display: + +```swift +let options = MKMapSnapshotter.Options() +options.region = MKCoordinateRegion( + center: coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) +) +options.size = CGSize(width: 300, height: 200) +options.scale = UIScreen.main.scale // Retina support +options.mapType = .standard +options.showsBuildings = true +options.pointOfInterestFilter = .excludingAll // Clean map + +let snapshotter = MKMapSnapshotter(options: options) +let snapshot = try await snapshotter.start() +let image = snapshot.image + +// Draw custom annotations on snapshot +UIGraphicsBeginImageContextWithOptions(image.size, true, image.scale) +image.draw(at: .zero) + +let pinImage = UIImage(systemName: "mappin.circle.fill")! +let point = snapshot.point(for: coordinate) +pinImage.draw(at: CGPoint( + x: point.x - pinImage.size.width / 2, + y: point.y - pinImage.size.height +)) + +let finalImage = UIGraphicsGetImageFromCurrentImageContext() +UIGraphicsEndImageContext() +``` + +#### Snapshot Coordinate Conversion + +```swift +// Convert coordinate to point in snapshot image +let point = snapshot.point(for: coordinate) + +// Check if coordinate is in snapshot bounds +let isVisible = CGRect(origin: .zero, size: snapshot.image.size).contains(point) +``` + +--- + +## Part 11: iOS Version Feature Matrix + +| Feature | iOS Version | +|---|---| +| MKMapView | 3.0+ | +| MKLocalSearch | 6.1+ | +| MKDirections | 7.0+ | +| MKMarkerAnnotationView | 11.0+ | +| MKMapSnapshotter | 7.0+ | +| MKLookAroundSceneRequest | 16.0+ | +| LookAroundPreview (SwiftUI) | 17.0+ | +| SwiftUI Map (content builder) | 17.0+ | +| MapCameraPosition | 17.0+ | +| .mapSelection | 17.0+ | +| .mapCameraKeyframeAnimator | 17.0+ | +| .onMapCameraChange | 17.0+ | +| MapUserLocationButton | 17.0+ | +| MapCompass | 17.0+ | +| MapScaleView | 17.0+ | +| .mapInteractionModes | 17.0+ | +| MKLocalSearch.ResultType.query | 18.0+ | +| GeoToolbox / PlaceDescriptor | 26.0+ | +| MKGeocodingRequest | 26.0+ | +| MKReverseGeocodingRequest | 26.0+ | +| MKAddress | 26.0+ | + +--- + +## Part 12: GeoToolbox and Geocoding + +### GeoToolbox Framework + +`GeoToolbox` provides `PlaceDescriptor` — a standardized representation of physical locations that works across MapKit and third-party mapping services. + +```swift +import GeoToolbox + +// From address +let fountain = PlaceDescriptor( + representations: [.address("121-122 James's St \n Dublin 8 \n D08 ET27 \n Ireland")], + commonName: "Obelisk Fountain" +) + +// From coordinates +let tower = PlaceDescriptor( + representations: [.coordinate(CLLocationCoordinate2D(latitude: 48.8584, longitude: 2.2945))], + commonName: "Eiffel Tower" +) + +// Multiple representations +let statue = PlaceDescriptor( + representations: [ + .coordinate(CLLocationCoordinate2D(latitude: 40.6892, longitude: -74.0445)), + .address("Liberty Island, New York, NY 10004, United States") + ], + commonName: "Statue of Liberty" +) + +// From MKMapItem +let descriptor = PlaceDescriptor(item: mapItem) // Returns optional +``` + +### PlaceRepresentation + +Enum representing a place using common mapping concepts: + +| Case | Usage | +|---|---| +| `.coordinate(CLLocationCoordinate2D)` | Latitude/longitude | +| `.address(String)` | Full address string | + +Convenience accessors on `PlaceDescriptor`: + +```swift +descriptor.coordinate // CLLocationCoordinate2D? +descriptor.address // String? +descriptor.commonName // String? +``` + +### SupportingPlaceRepresentation + +Proprietary identifiers for places from different mapping services: + +```swift +let place = PlaceDescriptor( + representations: [.coordinate(CLLocationCoordinate2D(latitude: 51.5074, longitude: -0.1278))], + commonName: "London Eye", + supportingRepresentations: [ + .serviceIdentifiers([ + "com.apple.maps": "AppleMapsID123", + "com.google.maps": "GoogleMapsID456" + ]) + ] +) + +// Retrieve a specific service identifier +let appleID = place.serviceIdentifier(for: "com.apple.maps") +``` + +### MKGeocodingRequest — Forward Geocoding + +Convert an address string to map items (address to coordinates): + +```swift +guard let request = MKGeocodingRequest(addressString: "1 Apple Park Way, Cupertino, CA") else { + return +} +let mapItems = try await request.mapItems +``` + +### MKReverseGeocodingRequest — Reverse Geocoding + +Convert coordinates to map items (coordinates to address): + +```swift +let location = CLLocation(latitude: 37.3349, longitude: -122.0090) +guard let request = MKReverseGeocodingRequest(location: location) else { + return +} +let mapItems = try await request.mapItems +``` + +### MKAddress + +Structured address type used when creating `MKMapItem` from a `PlaceDescriptor`: + +```swift +if let coordinate = descriptor.coordinate { + let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) + let address = MKAddress() + let mapItem = MKMapItem(location: location, address: address) +} +``` + +### Geocoding vs MKLocalSearch + +| Need | Use | +|---|---| +| Address string to coordinates | `MKGeocodingRequest` | +| Coordinates to address | `MKReverseGeocodingRequest` | +| Natural language place search | `MKLocalSearch` | +| Autocomplete suggestions | `MKLocalSearchCompleter` | +| Cross-service place identifiers | `PlaceDescriptor` with `SupportingPlaceRepresentation` | + +--- + +## Resources + +**WWDC**: 2023-10043, 2024-10094 + +**Docs**: /mapkit, /mapkit/map, /mapkit/mklocalsearch, /mapkit/mkdirections, /geotoolbox, /geotoolbox/placedescriptor, /mapkit/mkgeocodingrequest, /mapkit/mkreversegeocodingrequest, /mapkit/mkaddress + +**Skills**: mapkit, mapkit-diag, core-location-ref diff --git a/.claude/skills/axiom-mapkit-ref/agents/openai.yaml b/.claude/skills/axiom-mapkit-ref/agents/openai.yaml new file mode 100644 index 0000000..1d108d5 --- /dev/null +++ b/.claude/skills/axiom-mapkit-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "MapKit Reference" + short_description: "MapKit API reference" diff --git a/.claude/skills/axiom-mapkit/.openskills.json b/.claude/skills/axiom-mapkit/.openskills.json new file mode 100644 index 0000000..67a5123 --- /dev/null +++ b/.claude/skills/axiom-mapkit/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-mapkit", + "installedAt": "2026-04-12T08:06:27.939Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-mapkit/SKILL.md b/.claude/skills/axiom-mapkit/SKILL.md new file mode 100644 index 0000000..fecee02 --- /dev/null +++ b/.claude/skills/axiom-mapkit/SKILL.md @@ -0,0 +1,554 @@ +--- +name: axiom-mapkit +description: Use when implementing maps, annotations, search, directions, or debugging MapKit display/performance issues - SwiftUI Map, MKMapView, MKLocalSearch, clustering, Look Around +license: MIT +metadata: + version: "1.0.0" + last-updated: "2026-02-26" +--- + +# MapKit Patterns + +MapKit patterns and anti-patterns for iOS apps. Prevents common mistakes: using MKMapView when SwiftUI Map suffices, annotations in view bodies, setRegion loops, and performance issues with large annotation counts. + +## When to Use + +- Adding a map to your iOS app +- Displaying annotations, markers, or custom pins +- Implementing search (address, POI, autocomplete) +- Adding directions/routing to a map +- Debugging map display issues (annotations not showing, region jumping) +- Optimizing map performance with many annotations +- Deciding between SwiftUI Map and MKMapView + +## Related Skills + +- `axiom-mapkit-ref` — Complete API reference +- `axiom-mapkit-diag` — Symptom-based troubleshooting +- `axiom-core-location` — Location authorization and monitoring + +--- + +## Part 1: Anti-Patterns (with Time Costs) + +| Anti-Pattern | Time Cost | Fix | +|---|---|---| +| Using MKMapView when SwiftUI Map suffices | 2-4 hours UIViewRepresentable boilerplate | Use SwiftUI `Map {}` for standard map features (iOS 17+) | +| Creating annotations in SwiftUI view body | UI freeze with 100+ items, view recreation on every update | Move annotations to model, use `@State` or `@Observable` | +| No annotation view reuse (MKMapView) | Memory spikes, scroll lag with 500+ annotations | `dequeueReusableAnnotationView(withIdentifier:for:)` | +| `setRegion` in `updateUIView` without guard | Infinite loop — region change triggers update, update sets region | Guard with `mapView.region != region` or use flag | +| Ignoring MapCameraPosition (SwiftUI) | Can't programmatically control camera, broken "center on user" | Bind `position` parameter to `@State var cameraPosition` | +| Synchronous geocoding on main thread | UI freeze for 1-3 seconds per geocode | Use `CLGeocoder().geocodeAddressString` with async/await | +| Not filtering annotations to visible region | Loading all 10K annotations at once | Use `mapView.annotations(in:)` or fetch by visible region | +| Ignoring `resultTypes` in MKLocalSearch | Irrelevant results, slow search | Set `.resultTypes = [.pointOfInterest]` or `.address` to filter | + +--- + +## Part 2: Decision Trees + +### Decision Tree 1: SwiftUI Map vs MKMapView + +```dot +digraph { + "Need map in app?" [shape=diamond]; + "iOS 17+ target?" [shape=diamond]; + "Need custom tile overlay?" [shape=diamond]; + "Need fine-grained delegate control?" [shape=diamond]; + "Use SwiftUI Map" [shape=box]; + "Use MKMapView\nvia UIViewRepresentable" [shape=box]; + + "Need map in app?" -> "iOS 17+ target?" [label="yes"]; + "iOS 17+ target?" -> "Need custom tile overlay?" [label="yes"]; + "iOS 17+ target?" -> "Use MKMapView\nvia UIViewRepresentable" [label="no"]; + "Need custom tile overlay?" -> "Use MKMapView\nvia UIViewRepresentable" [label="yes"]; + "Need custom tile overlay?" -> "Need fine-grained delegate control?" [label="no"]; + "Need fine-grained delegate control?" -> "Use MKMapView\nvia UIViewRepresentable" [label="yes"]; + "Need fine-grained delegate control?" -> "Use SwiftUI Map" [label="no"]; +} +``` + +#### When SwiftUI Map is Right (most apps) + +- Standard map with markers and annotations +- Programmatic camera control +- Built-in user location display +- Shape overlays (circle, polygon, polyline) +- Map style selection (standard, imagery, hybrid) +- Selection handling +- Clustering + +#### When MKMapView is Required + +- Custom tile overlays (e.g., OpenStreetMap, custom imagery) +- Fine-grained delegate control (willBeginLoadingMap, didFinishLoadingMap) +- Custom annotation view animations beyond SwiftUI +- Pre-iOS 17 deployment target +- Advanced overlay rendering with custom MKOverlayRenderer subclasses + +### Decision Tree 2: Annotation Strategy by Count + +``` +Annotation count? +├─ < 100 → Use Marker/Annotation directly in Map {} content builder +│ Simple, declarative, no performance concern +│ +├─ 100-1000 → Enable clustering +│ Set .clusteringIdentifier on annotation views +│ SwiftUI: Marker("", coordinate:).tag(id) +│ MKMapView: view.clusteringIdentifier = "poi" +│ +└─ 1000+ → Server-side clustering or visible-region filtering + Fetch only annotations within mapView.region + Or pre-cluster on server, send cluster centroids + MKMapView with view reuse is preferred for very large datasets +``` + +#### Visible-Region Filtering (SwiftUI) + +Only load annotations within the visible map region. Prevents loading all 10K+ annotations at once: + +```swift +struct MapView: View { + @State private var cameraPosition: MapCameraPosition = .automatic + @State private var visibleAnnotations: [Location] = [] + + let allLocations: [Location] // Full dataset + + var body: some View { + Map(position: $cameraPosition) { + ForEach(visibleAnnotations) { location in + Marker(location.name, coordinate: location.coordinate) + } + } + .onMapCameraChange(frequency: .onEnd) { context in + visibleAnnotations = allLocations.filter { location in + context.region.contains(location.coordinate) + } + } + } +} + +extension MKCoordinateRegion { + func contains(_ coordinate: CLLocationCoordinate2D) -> Bool { + let latRange = (center.latitude - span.latitudeDelta / 2)...(center.latitude + span.latitudeDelta / 2) + let lngRange = (center.longitude - span.longitudeDelta / 2)...(center.longitude + span.longitudeDelta / 2) + return latRange.contains(coordinate.latitude) && lngRange.contains(coordinate.longitude) + } +} +``` + +#### Why Clustering Matters + +Without clustering at 500 annotations: +- Map is unreadable (pins overlap completely) +- Scroll/zoom lag increases with every annotation +- Memory grows linearly with annotation count + +With clustering: +- User sees meaningful groups with counts +- Only visible cluster markers rendered +- Tap to expand reveals individual annotations + +### Decision Tree 3: Search and Directions + +``` +Search implementation: +├─ User types search query +│ └─ MKLocalSearchCompleter (real-time autocomplete) +│ Configure: resultTypes, region bias +│ └─ User selects result +│ └─ MKLocalSearch (full result with MKMapItem) +│ Use completion.title for MKLocalSearch.Request +│ +└─ Programmatic search (e.g., "nearest gas station") + └─ MKLocalSearch with naturalLanguageQuery + Configure: resultTypes, region, pointOfInterestFilter + +Directions implementation: +├─ MKDirections.Request +│ Set source (MKMapItem.forCurrentLocation()) and destination +│ Set transportType (.automobile, .walking, .transit) +│ +└─ MKDirections.calculate() + └─ MKRoute + ├─ .polyline → Display as MapPolyline or MKPolylineRenderer + ├─ .expectedTravelTime → Show ETA + ├─ .distance → Show distance + └─ .steps → Turn-by-turn instructions +``` + +--- + +## Part 3: Pressure Scenarios + +### Scenario 1: "Just Wrap MKMapView in UIViewRepresentable" + +**Setup**: Adding a map to a SwiftUI app. Developer is familiar with MKMapView from UIKit projects. + +**Pressure**: "I know MKMapView well. SwiftUI Map is new and might be limited." + +**Expected with skill**: Check the decision tree. If the app needs standard markers, annotations, camera control, user location, and shape overlays — SwiftUI Map handles all of that. Use it. + +**Anti-pattern without skill**: 200+ lines of UIViewRepresentable + Coordinator wrapping MKMapView, manually bridging state, implementing delegate methods for annotation views, fighting updateUIView infinite loops — when 20 lines of `Map {}` with content builder would have worked. + +**Time cost**: 2-4 hours of unnecessary boilerplate + ongoing maintenance burden. + +**The test**: Can you list a specific feature the app needs that SwiftUI Map cannot provide? If not, use SwiftUI Map. + +### Scenario 2: "Add All 10,000 Pins to the Map" + +**Setup**: App has a database of 10,000 location data points. Product manager wants users to see all locations on the map. + +**Pressure**: "Users need to see ALL locations. Just add them all." + +**Expected with skill**: Use clustering + visible region filtering. 10K annotations without clustering is unusable — pins overlap, scrolling lags, memory spikes. Clustering shows meaningful groups. Visible region filtering loads only what's on screen. + +**Anti-pattern without skill**: Adding all 10,000 annotations at once. Map becomes an unreadable blob of overlapping pins. Scroll lag makes the app feel broken. Memory usage spikes 200-400MB. + +**Implementation path**: +1. Enable clustering (`.clusteringIdentifier`) +2. Fetch annotations only within visible region (`.onMapCameraChange` + query) +3. Server-side pre-clustering for datasets > 5K if possible + +### Scenario 3: "Search Isn't Finding Results" + +**Setup**: MKLocalSearch returns irrelevant or empty results. Developer considers adding Google Maps SDK. + +**Pressure**: "MapKit search is broken. Let me add a third-party SDK." + +**Expected with skill**: Check configuration first. MapKit search needs: +1. `resultTypes` — filter to `.pointOfInterest` or `.address` (default returns everything) +2. `region` — bias results to the visible map region +3. Query format — natural language like "coffee shops" works; structured queries don't + +**Anti-pattern without skill**: Adding Google Maps SDK (50+ MB binary, API key management, billing setup) when MapKit search works correctly with proper configuration. + +**Time cost**: 4-8 hours adding third-party SDK vs 5 minutes configuring MapKit search. + +--- + +## Part 4: Core Location Integration + +MapKit and Core Location interact in ways that surprise developers. + +### Implicit Authorization + +When you set `showsUserLocation = true` on MKMapView or add `UserAnnotation()` in SwiftUI Map, MapKit implicitly requests location authorization if it hasn't been requested yet. + +This means: +- The authorization prompt appears at map display time, not when the developer expects +- The user sees a prompt with no context about why location is needed +- If denied, the blue dot silently doesn't appear + +#### Recommended Pattern + +Request authorization explicitly BEFORE showing the map: + +```swift +// 1. Request authorization with context +let session = CLServiceSession(authorization: .whenInUse) + +// 2. Then show map with user location +Map { + UserAnnotation() +} +``` + +### CLServiceSession (iOS 17+) + +For continuous location display on a map, create a `CLServiceSession`: + +```swift +@Observable +class MapModel { + var cameraPosition: MapCameraPosition = .automatic + private var locationSession: CLServiceSession? + + func startShowingUserLocation() { + locationSession = CLServiceSession(authorization: .whenInUse) + } + + func stopShowingUserLocation() { + locationSession = nil + } +} +``` + +### Cross-Reference + +For full authorization decision trees, monitoring patterns, and background location: +- `axiom-core-location` — Authorization strategy, monitoring approach +- `axiom-core-location-diag` — "Location not working" troubleshooting +- `axiom-energy` — Location as battery subsystem + +--- + +## Part 5: SwiftUI Map Quick Start + +The most common pattern — a map with markers and user location: + +```swift +struct ContentView: View { + @State private var cameraPosition: MapCameraPosition = .automatic + @State private var selectedItem: MKMapItem? + + let locations: [Location] // Your model + + var body: some View { + Map(position: $cameraPosition, selection: $selectedItem) { + UserAnnotation() + + ForEach(locations) { location in + Marker(location.name, coordinate: location.coordinate) + .tint(location.category.color) + } + } + .mapStyle(.standard(elevation: .realistic)) + .mapControls { + MapUserLocationButton() + MapCompass() + MapScaleView() + } + .onChange(of: selectedItem) { _, item in + if let item { + handleSelection(item) + } + } + } +} +``` + +#### Key Points + +- `@State var cameraPosition` — bind for programmatic camera control +- `selection: $selectedItem` — handle tap on markers +- `MapCameraPosition.automatic` — system manages initial view +- `.mapControls {}` — built-in UI for location button, compass, scale +- `ForEach` in content builder — dynamic annotations from data + +--- + +## Part 6: Search Implementation Pattern + +Complete search with autocomplete: + +```swift +@Observable +class SearchModel { + var searchText = "" + var completions: [MKLocalSearchCompletion] = [] + var searchResults: [MKMapItem] = [] + + private let completer = MKLocalSearchCompleter() + private var completerDelegate: CompleterDelegate? + + init() { + completerDelegate = CompleterDelegate { [weak self] results in + self?.completions = results + } + completer.delegate = completerDelegate + completer.resultTypes = [.pointOfInterest, .address] + } + + func updateSearch(_ text: String) { + searchText = text + completer.queryFragment = text + } + + func search(for completion: MKLocalSearchCompletion) async throws { + let request = MKLocalSearch.Request(completion: completion) + request.resultTypes = [.pointOfInterest, .address] + let search = MKLocalSearch(request: request) + let response = try await search.start() + searchResults = response.mapItems + } + + func search(query: String, in region: MKCoordinateRegion) async throws { + let request = MKLocalSearch.Request() + request.naturalLanguageQuery = query + request.region = region + request.resultTypes = .pointOfInterest + let search = MKLocalSearch(request: request) + let response = try await search.start() + searchResults = response.mapItems + } +} +``` + +#### MKLocalSearchCompleter Delegate (Required) + +```swift +class CompleterDelegate: NSObject, MKLocalSearchCompleterDelegate { + let onUpdate: ([MKLocalSearchCompletion]) -> Void + + init(onUpdate: @escaping ([MKLocalSearchCompletion]) -> Void) { + self.onUpdate = onUpdate + } + + func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { + onUpdate(completer.results) + } + + func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { + // Handle error — network issues, rate limiting + } +} +``` + +#### Rate Limiting + +Apple rate-limits MapKit search. For autocomplete: +- `MKLocalSearchCompleter` handles its own throttling internally +- Don't create a new completer per keystroke — reuse one instance +- Set `queryFragment` on each keystroke; the completer debounces + +For `MKLocalSearch`: +- Don't fire a search on every keystroke — use the completer for autocomplete +- Fire `MKLocalSearch` only when the user selects a completion or submits + +--- + +## Part 7: Directions Implementation Pattern + +```swift +func calculateDirections( + from source: CLLocationCoordinate2D, + to destination: MKMapItem, + transportType: MKDirectionsTransportType = .automobile +) async throws -> MKRoute { + let request = MKDirections.Request() + request.source = MKMapItem(placemark: MKPlacemark(coordinate: source)) + request.destination = destination + request.transportType = transportType + + let directions = MKDirections(request: request) + let response = try await directions.calculate() + + guard let route = response.routes.first else { + throw MapError.noRouteFound + } + return route +} +``` + +#### Displaying the Route (SwiftUI) + +```swift +Map(position: $cameraPosition) { + if let route { + MapPolyline(route.polyline) + .stroke(.blue, lineWidth: 5) + } + + Marker("Start", coordinate: startCoord) + Marker("End", coordinate: endCoord) +} +``` + +#### Displaying the Route (MKMapView) + +```swift +// Add overlay +mapView.addOverlay(route.polyline, level: .aboveRoads) + +// Implement renderer delegate +func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { + if let polyline = overlay as? MKPolyline { + let renderer = MKPolylineRenderer(polyline: polyline) + renderer.strokeColor = .systemBlue + renderer.lineWidth = 5 + return renderer + } + return MKOverlayRenderer(overlay: overlay) +} +``` + +#### Route Information + +```swift +let route: MKRoute = ... +let travelTime = route.expectedTravelTime // TimeInterval in seconds +let distance = route.distance // CLLocationDistance in meters +let steps = route.steps // [MKRoute.Step] + +for step in steps { + print("\(step.instructions) — \(step.distance)m") + // "Turn right on Main St — 450m" +} +``` + +--- + +## Part 8: Clustering Pattern + +### SwiftUI (iOS 17+) + +```swift +Map(position: $cameraPosition) { + ForEach(locations) { location in + Marker(location.name, coordinate: location.coordinate) + .tag(location.id) + } + .mapItemClusteringIdentifier("locations") +} +``` + +### MKMapView + +```swift +func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { + if let cluster = annotation as? MKClusterAnnotation { + let view = mapView.dequeueReusableAnnotationView( + withIdentifier: "cluster", + for: annotation + ) as! MKMarkerAnnotationView + view.markerTintColor = .systemBlue + view.glyphText = "\(cluster.memberAnnotations.count)" + return view + } + + let view = mapView.dequeueReusableAnnotationView( + withIdentifier: "pin", + for: annotation + ) as! MKMarkerAnnotationView + view.clusteringIdentifier = "locations" + view.markerTintColor = .systemRed + return view +} +``` + +#### Clustering Requirements + +1. All annotation views that should cluster MUST share the same `clusteringIdentifier` +2. Register annotation view classes: `mapView.register(MKMarkerAnnotationView.self, forAnnotationViewWithReuseIdentifier: "pin")` +3. Clustering only activates when annotations physically overlap at the current zoom level +4. System manages cluster/uncluster animation automatically + +--- + +## Part 9: Pre-Release Checklist + +- [ ] Map loads and displays correctly +- [ ] Annotations appear at correct coordinates (lat/lng not swapped) +- [ ] Clustering works with 100+ annotations +- [ ] Search returns relevant results (resultTypes configured) +- [ ] Camera position controllable programmatically +- [ ] Memory stable when scrolling/zooming with many annotations +- [ ] User location shows correctly (authorization handled before display) +- [ ] Directions render as polyline overlay +- [ ] Map works in Dark Mode (map styles adapt automatically) +- [ ] Accessibility: VoiceOver announces map elements +- [ ] No setRegion/updateUIView infinite loops (if using MKMapView) +- [ ] MKLocalSearchCompleter reused (not recreated per keystroke) +- [ ] Annotation views reused via `dequeueReusableAnnotationView` (MKMapView) +- [ ] Look Around availability checked before displaying (`MKLookAroundSceneRequest`) + +--- + +## Resources + +**WWDC**: 2023-10043, 2024-10094 + +**Docs**: /mapkit, /mapkit/map + +**Skills**: mapkit-ref, mapkit-diag, core-location diff --git a/.claude/skills/axiom-mapkit/agents/openai.yaml b/.claude/skills/axiom-mapkit/agents/openai.yaml new file mode 100644 index 0000000..e50949b --- /dev/null +++ b/.claude/skills/axiom-mapkit/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "MapKit" + short_description: "Implementing maps, annotations, search, directions, or debugging MapKit display/performance issues" diff --git a/.claude/skills/axiom-memory-debugging/.openskills.json b/.claude/skills/axiom-memory-debugging/.openskills.json new file mode 100644 index 0000000..291c69e --- /dev/null +++ b/.claude/skills/axiom-memory-debugging/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-memory-debugging", + "installedAt": "2026-04-12T08:06:28.590Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-memory-debugging/SKILL.md b/.claude/skills/axiom-memory-debugging/SKILL.md new file mode 100644 index 0000000..cc56a36 --- /dev/null +++ b/.claude/skills/axiom-memory-debugging/SKILL.md @@ -0,0 +1,409 @@ +--- +name: axiom-memory-debugging +description: Use when you see memory warnings, 'retain cycle', app crashes from memory pressure, or when asking 'why is my app using so much memory', 'how do I find memory leaks', 'my deinit is never called', 'Instruments shows memory growth', 'app crashes after 10 minutes' - systematic memory leak detection and fixes for iOS/macOS +license: MIT +metadata: + version: "1.0.0" +--- + +# Memory Debugging + +## Overview + +Memory issues manifest as crashes after prolonged use. **Core principle** 90% of memory leaks follow 3 patterns (retain cycles, timer/observer leaks, collection growth). Diagnose systematically with Instruments, never guess. + +## Example Prompts + +- "My app crashes after 10-15 minutes with no error messages" +- "Memory jumps from 50MB to 200MB+ on a specific action — leak or cache?" +- "View controllers don't deallocate after dismiss" +- "Timers/observers causing memory leaks — how to verify?" +- "App uses 200MB and I don't know if that's normal" + +--- + +## Red Flags — Memory Leak Likely + +- Progressive memory growth: 50MB → 100MB → 200MB (not plateauing) +- App crashes after 10-15 minutes with no error in Xcode console +- Memory warnings appear repeatedly in device logs +- View controllers don't deallocate after dismiss (visible in Memory Graph Debugger) +- Same operation run multiple times causes linear memory growth + +**Leak vs normal**: Normal = stays at 100MB. Leak = 50MB → 100MB → 150MB → 200MB → CRASH. + +## Mandatory First Steps + +**ALWAYS diagnose FIRST** (before reading code): + +1. Check device logs for "Memory pressure critical", "Jetsam killed", "Low Memory" +2. Use Memory Graph Debugger (below) — shows object count growth +3. Xcode → Product → Profile → Memory. Perform action 5 times, note if memory keeps growing + +**What this tells you**: Flat = not a leak. Linear growth = classic leak. Spike then flat = normal cache. Spikes stacking = compound leak. + +**Why diagnostics first**: Finding leak with Instruments: 5-15 min. Guessing: 45+ min. + +## Detecting Leaks — Step by Step + +### Step 1: Memory Graph Debugger (Fastest) + +1. Open app in simulator +2. Debug → Memory Graph Debugger (or toolbar icon) +3. Look for PURPLE/RED circles with "⚠" badge +4. Click them → Xcode shows retain cycle chain + +### Step 2: Instruments (Detailed Analysis) + +1. Product → Profile (Cmd+I) → "Memory" template +2. Perform action 5-10 times +3. Memory line goes UP for each action? = Leak confirmed + +Key instruments: Heap Allocations (object count), Leaked Objects (direct detection), VM Tracker (by type). + +### Step 3: Deallocation Check + +```swift +// Add deinit logging to suspect classes +class MyViewController: UIViewController { + deinit { print("✅ MyViewController deallocated") } +} + +@MainActor +class ViewModel: ObservableObject { + deinit { print("✅ ViewModel deallocated") } +} +``` + +Navigate to view, navigate away. See "✅ deallocated"? Yes = no leak. No = retained somewhere. + +## Jetsam (Memory Pressure Termination) + +**Jetsam is not a bug** — iOS terminates background apps to free memory. Not a crash (no crash log), but frequent kills hurt UX. + +| Termination | Cause | Solution | +|-------------|-------|----------| +| **Memory Limit Exceeded** | Your app used too much memory | Reduce peak footprint | +| **Jetsam** | System needed memory for other apps | Reduce background memory to <50MB | + +### Reducing Jetsam Rate + +Clear caches on backgrounding: + +```swift +// SwiftUI +.onChange(of: scenePhase) { _, newPhase in + if newPhase == .background { + imageCache.clearAll() + URLCache.shared.removeAllCachedResponses() + } +} +``` + +### State Restoration + +Users shouldn't notice jetsam. Use `@SceneStorage` (SwiftUI) or `stateRestorationActivity` (UIKit) to restore navigation position, drafts, and scroll position. + +### Monitoring with MetricKit + +```swift +class JetsamMonitor: NSObject, MXMetricManagerSubscriber { + func didReceive(_ payloads: [MXMetricPayload]) { + for payload in payloads { + guard let exitData = payload.applicationExitMetrics else { continue } + let bgData = exitData.backgroundExitData + if bgData.cumulativeMemoryPressureExitCount > 0 { + // Send to analytics + } + } + } +} +``` + +``` +App memory grows while in USE? → Memory leak (fix retention) +App killed in BACKGROUND? → Jetsam (reduce bg memory) +``` + +## Common Memory Leak Patterns (With Fixes) + +### Pattern 1: Timer Leaks (Most Common — 50% of leaks) + +**Why `[weak self]` alone doesn't fix timer leaks**: The RunLoop retains scheduled timers. `[weak self]` only prevents the closure from retaining `self` — the Timer object itself continues to exist and fire. You must explicitly `invalidate()` to break the RunLoop's retention. + +#### ❌ Leak — Timer never invalidated +```swift +progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.updateProgress() +} +// Timer never stopped → RunLoop keeps it alive and firing forever +``` + +#### ✅ Best fix: Combine (auto-cleanup) +```swift +cancellable = Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .default) + .autoconnect() + .sink { [weak self] _ in self?.updateProgress() } +// No deinit needed — cancellable auto-cleans when released +``` + +**Alternative**: Call `timer?.invalidate(); timer = nil` in both the appropriate teardown method (`viewWillDisappear`, stop method, etc.) AND `deinit`. + +> For timer crash patterns (EXC_BAD_INSTRUCTION) and RunLoop mode issues, see `axiom-timer-patterns`. + +### Pattern 2: Observer/Notification Leaks (25% of leaks) + +#### ❌ Leak — No removeObserver +```swift +NotificationCenter.default.addObserver(self, selector: #selector(handle), + name: AVAudioSession.routeChangeNotification, object: nil) +// No matching removeObserver → accumulates listeners +``` + +#### ✅ Best fix: Combine publisher +```swift +NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification) + .sink { [weak self] _ in self?.handleChange() } + .store(in: &cancellables) // Auto-cleanup with viewModel +``` + +**Alternative**: `NotificationCenter.default.removeObserver(self)` in `deinit`. + +### Pattern 3: Closure Capture Leaks (15% of leaks) + +#### ❌ Leak — Closure in array captures self +```swift +updateCallbacks.append { [self] track in + self.refreshUI(with: track) // Strong capture → cycle +} +``` + +#### ✅ Fix: Use [weak self] +```swift +updateCallbacks.append { [weak self] track in + self?.refreshUI(with: track) +} +``` + +Clear callback arrays in `deinit`. Use `[unowned self]` only when certain self outlives the closure. + +### Pattern 4: Strong Reference Cycles + +#### ❌ Leak — Mutual strong references +```swift +player?.onPlaybackEnd = { [self] in self.playNextTrack() } +// self → player → closure → self (cycle) +``` + +#### ✅ Fix: [weak self] in closure +```swift +player?.onPlaybackEnd = { [weak self] in self?.playNextTrack() } +``` + +### Pattern 5: View/Layout Callback Leaks + +Use the delegation pattern with `AnyObject` protocol (enables weak references) instead of closures that capture view controllers. + +### Pattern 6: PhotoKit Image Request Leaks + +`PHImageManager.requestImage()` returns a `PHImageRequestID` that must be cancelled. Without cancellation, pending requests queue up and hold memory when scrolling. + +```swift +class PhotoCell: UICollectionViewCell { + private var imageRequestID: PHImageRequestID = PHInvalidImageRequestID + + func configure(with asset: PHAsset, imageManager: PHImageManager) { + if imageRequestID != PHInvalidImageRequestID { + imageManager.cancelImageRequest(imageRequestID) + } + imageRequestID = imageManager.requestImage(for: asset, targetSize: PHImageManagerMaximumSize, + contentMode: .aspectFill, options: nil) { [weak self] image, _ in + self?.imageView.image = image + } + } + + override func prepareForReuse() { + super.prepareForReuse() + if imageRequestID != PHInvalidImageRequestID { + PHImageManager.default().cancelImageRequest(imageRequestID) + imageRequestID = PHInvalidImageRequestID + } + imageView.image = nil + } +} +``` + +Similar patterns: `AVAssetImageGenerator` → `cancelAllCGImageGeneration()`, `URLSession.dataTask()` → `cancel()`. + +## Systematic Debugging Workflow + +### Phase 1: Confirm Leak (5 min) + +Profile with Memory template, repeat action 10 times. Flat = not a leak (stop). Steady climb = leak (continue). + +### Phase 2: Locate Leak (10-15 min) + +Memory Graph Debugger → purple/red circles → click → read retain cycle chain. + +Common locations: Timers (50%), Notifications/KVO (25%), Closures in collections (15%), Delegate cycles (10%). + +### Phase 3: Fix and Verify (5 min) + +Apply fix from patterns above. Add `deinit { print("✅ deallocated") }`. Run Instruments again — memory should stay flat. + +### Compound Leaks + +Real apps often have 2-3 leaks stacking. Fix the largest first, re-run Instruments, repeat until flat. + +## Non-Reproducible / Intermittent Leaks + +When Instruments prevents reproduction (Heisenbug) or leaks only happen with specific user data: + +**Lightweight diagnostics** (when Instruments can't be attached): +1. **deinit logging as primary diagnostic** — Add `deinit { print("✅ ClassName deallocated") }` to all suspect classes. Run 20+ sessions. When the leak occurs (e.g., 1 in 5 runs), missing deinit messages reveal which objects are retained. +2. **Isolate the trigger** — Test each navigation path independently. Rapidly toggle background/foreground if timing-dependent. Narrow to the specific path that leaks. +3. **MetricKit for field diagnostics** — Monitor peak memory in production via `MXMetricPayload.memoryMetrics.peakMemoryUsage`. Alert when exceeding threshold (e.g., 400MB). This catches leaks that only manifest with real user data volumes. + +**Common cause of intermittent leaks**: Notification observers added on lifecycle events (`viewWillAppear`, `applicationDidBecomeActive`) without removing duplicates first. Each re-registration accumulates a listener — timing determines whether the duplicate fires. + +**TestFlight verification**: Ship diagnostic build to affected users. Add `os_log` memory milestones. Monitor MetricKit for 24-48 hours after fix deployment. + +## Common Mistakes + +- **[weak self] without invalidate()** — Timer keeps running, consuming CPU. ALWAYS call `invalidate()` or `cancel()` +- **Invalidate without nil** — `timer?.invalidate()` stops firing but reference remains. Always follow with `timer = nil` +- **Local AnyCancellable** — Goes out of scope immediately, subscription dies. Store in `Set` property +- **deinit with only logging** — Add actual cleanup (invalidate timers, remove observers), not just print statements +- **Wrong Instruments template** — Memory shows usage. Leaks detects actual leaks. Use both + +## Instruments Quick Reference + +| Scenario | Tool | What to Look For | +|----------|------|------------------| +| Progressive memory growth | Memory | Line steadily climbing = leak | +| Specific object leaking | Memory Graph | Purple/red circles = leak objects | +| Direct leak detection | Leaks | Red "! Leak" badge = confirmed leak | +| Memory by type | VM Tracker | Objects consuming most memory | +| Cache behavior | Allocations | Objects allocated but not freed | + +## CLI Quick Checks (No Instruments) + +Xcode ships CLI tools for fast memory diagnostics without opening Instruments. Use these for quick checks during development. + +### leaks — Detect Leaks in Running Process + +```bash +# Check running app by name (positional argument, not --process) +xcrun leaks MyApp + +# Check by PID +xcrun leaks 12345 + +# Show full stack traces for each leak +xcrun leaks --fullStacks MyApp + +# Analyze a memgraph file (from Xcode's Debug Memory Graph) +xcrun leaks MyApp.memgraph +``` + +**When to use**: Quick leak check without recording an Instruments trace. Run after exercising a suspect code path. + +### heap — Inspect Live Heap Allocations + +```bash +# Show heap summary by class (process name is positional) +xcrun heap MyApp + +# Show all instances of a specific class +xcrun heap --addresses=MyViewController MyApp + +# Sort by size (find biggest consumers) +xcrun heap -s MyApp + +# Analyze a memgraph +xcrun heap MyApp.memgraph +``` + +**When to use**: Finding what's consuming memory right now. Answers "how many MyViewController instances exist?" without Instruments. + +### vmmap — Virtual Memory Map + +```bash +# Summary view (dirty, clean, swapped) +xcrun vmmap --summary MyApp.memgraph + +# Full memory regions +xcrun vmmap MyApp.memgraph +``` + +**When to use**: Understanding memory composition. Shows dirty pages (your data), clean pages (mapped files), and compressed memory. + +### stringdups — Find Duplicate Strings + +```bash +# Find duplicate strings in running process (positional argument) +xcrun stringdups MyApp + +# Analyze a memgraph +xcrun stringdups MyApp.memgraph +``` + +**When to use**: Reducing memory footprint from repeated string allocations. No GUI equivalent. + +### malloc_history — Track Allocation Origins + +```bash +# Enable malloc logging first: set MallocStackLogging=1 in scheme env vars +# Then query a specific address +xcrun malloc_history
+ +# Show all allocations sorted by size +xcrun malloc_history -allBySize +``` + +**When to use**: Tracing where a leaked object was allocated. Requires `MallocStackLogging=1` environment variable in scheme. + +### Quick Diagnosis Workflow + +```bash +# 1. Is there a leak? (30 seconds) +xcrun leaks MyApp + +# 2. What's on the heap? (30 seconds) +xcrun heap -s MyApp + +# 3. Any duplicate strings wasting memory? (30 seconds) +xcrun stringdups MyApp + +# 4. Where is memory allocated? (requires memgraph) +xcrun vmmap --summary MyApp.memgraph +``` + +**Time cost**: 2 minutes for a full CLI memory check vs 10+ minutes launching Instruments. + +### xctrace (Headless Instruments) + +```bash +# Record memory trace without GUI +xcrun xctrace record --instrument 'Allocations' --attach 'MyApp' --time-limit 30s --output memory.trace + +# Record leak detection +xcrun xctrace record --instrument 'Leaks' --attach 'MyApp' --time-limit 30s --output leaks.trace +``` + +## Real-World Impact + +**Before**: 50+ PlayerViewModel instances with uncleared timers → 50MB → 200MB → Crash (13min) +**After**: Timer properly invalidated → 50MB stable for hours + +**Key insight** 90% of leaks come from forgetting to stop timers, observers, or subscriptions. Always clean up in `deinit` or use reactive patterns that auto-cleanup. + +--- + +## Resources + +**WWDC**: 2021-10180, 2020-10078, 2018-416 + +**Docs**: /xcode/gathering-information-about-memory-use, /metrickit/mxbackgroundexitdata + +**Skills**: axiom-performance-profiling, axiom-objc-block-retain-cycles, axiom-metrickit-ref, axiom-lldb (inspect retain cycles interactively) diff --git a/.claude/skills/axiom-memory-debugging/agents/openai.yaml b/.claude/skills/axiom-memory-debugging/agents/openai.yaml new file mode 100644 index 0000000..3bee1bb --- /dev/null +++ b/.claude/skills/axiom-memory-debugging/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Memory Debugging" + short_description: "You see memory warnings, 'retain cycle', app crashes from memory pressure, or when asking 'why is my app using so muc..." diff --git a/.claude/skills/axiom-metal-migration-diag/.openskills.json b/.claude/skills/axiom-metal-migration-diag/.openskills.json new file mode 100644 index 0000000..4e9f000 --- /dev/null +++ b/.claude/skills/axiom-metal-migration-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-metal-migration-diag", + "installedAt": "2026-04-12T08:06:29.030Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-metal-migration-diag/SKILL.md b/.claude/skills/axiom-metal-migration-diag/SKILL.md new file mode 100644 index 0000000..ff19561 --- /dev/null +++ b/.claude/skills/axiom-metal-migration-diag/SKILL.md @@ -0,0 +1,473 @@ +--- +name: axiom-metal-migration-diag +description: Use when ANY Metal porting issue occurs - black screen, rendering artifacts, shader errors, wrong colors, performance regressions, GPU crashes +license: MIT +compatibility: [iOS 12+, macOS 10.14+, tvOS 12+] +metadata: + version: "1.0.0" +--- + +# Metal Migration Diagnostics + +Systematic diagnosis for common Metal porting issues. + +## When to Use This Diagnostic Skill + +Use this skill when: +- Screen is black after porting to Metal +- Shaders fail to compile in Metal +- Colors or coordinates are wrong +- Performance is worse than the original +- Rendering artifacts appear +- App crashes during GPU work + +## Mandatory First Step: Enable Metal Validation + +**Time cost**: 30 seconds setup vs hours of blind debugging + +Before ANY debugging, enable Metal validation: + +``` +Xcode → Edit Scheme → Run → Diagnostics +✓ Metal API Validation +✓ Metal Shader Validation +✓ GPU Frame Capture (Metal) +``` + +Most Metal bugs produce clear validation errors. If you're debugging without validation enabled, **stop and enable it first**. + +## Symptom 1: Black Screen + +### Decision Tree + +``` +Black screen after porting +│ +├─ Are there Metal validation errors in console? +│ └─ YES → Fix validation errors first (see below) +│ +├─ Is the render pass descriptor valid? +│ ├─ Check: view.currentRenderPassDescriptor != nil +│ ├─ Check: drawable = view.currentDrawable != nil +│ └─ FIX: Ensure MTKView.device is set, view is on screen +│ +├─ Is the pipeline state created? +│ ├─ Check: makeRenderPipelineState doesn't throw +│ └─ FIX: Check shader function names match library +│ +├─ Are draw calls being issued? +│ ├─ Add: encoder.label = "Main Pass" for frame capture +│ └─ DEBUG: GPU Frame Capture → verify draw calls appear +│ +├─ Are resources bound? +│ ├─ Check: setVertexBuffer, setFragmentTexture called +│ └─ FIX: Metal requires explicit binding every frame +│ +├─ Is the vertex data correct? +│ ├─ DEBUG: GPU Frame Capture → inspect vertex buffer +│ └─ FIX: Check buffer offsets, vertex count +│ +├─ Are coordinates in Metal's range? +│ ├─ Metal NDC: X [-1,1], Y [-1,1], Z [0,1] +│ ├─ OpenGL NDC: X [-1,1], Y [-1,1], Z [-1,1] +│ └─ FIX: Adjust projection matrix or vertex shader +│ +└─ Is clear color set? + ├─ Default clear color is (0,0,0,0) — transparent black + └─ FIX: Set view.clearColor or renderPassDescriptor.colorAttachments[0].clearColor +``` + +### Common Fixes + +**Missing Drawable**: +```swift +// BAD: Drawing before view is ready +override func viewDidLoad() { + draw() // metalView.currentDrawable is nil +} + +// GOOD: Wait for delegate callback +func draw(in view: MTKView) { + guard let drawable = view.currentDrawable else { return } + // Safe to draw +} +``` + +**Wrong Function Names**: +```swift +// BAD: Function name doesn't match .metal file +descriptor.vertexFunction = library.makeFunction(name: "vertexMain") +// .metal file has: vertex VertexOut vertexShader(...) + +// GOOD: Names must match exactly +descriptor.vertexFunction = library.makeFunction(name: "vertexShader") +``` + +**Missing Resource Binding**: +```swift +// BAD: Assumed state persists like OpenGL +encoder.setRenderPipelineState(pso) +encoder.drawPrimitives(...) // No buffers bound! + +// GOOD: Bind everything explicitly +encoder.setRenderPipelineState(pso) +encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) +encoder.setVertexBytes(&uniforms, length: uniformsSize, index: 1) +encoder.setFragmentTexture(texture, index: 0) +encoder.drawPrimitives(...) +``` + +**Time cost**: GPU Frame Capture diagnosis: 5-10 min. Guessing without tools: 1-4 hours. + +## Symptom 2: Shader Compilation Errors + +### Decision Tree + +``` +Shader fails to compile +│ +├─ "Use of undeclared identifier" +│ ├─ Check: #include +│ ├─ Check: using namespace metal; +│ └─ FIX: Standard functions need metal_stdlib +│ +├─ "No matching function for call to 'texture'" +│ └─ GLSL texture() → MSL tex.sample(sampler, uv) +│ FIX: Texture sampling is a method, needs sampler +│ +├─ "Invalid type 'vec4'" +│ └─ GLSL vec4 → MSL float4 +│ FIX: See type mapping table in metal-migration-ref +│ +├─ "No matching constructor" +│ ├─ GLSL: vec4(vec3, float) works +│ ├─ MSL: float4(float3, float) works +│ └─ Check: Argument types match exactly +│ +├─ "Attribute index out of range" +│ ├─ Check: [[attribute(N)]] matches vertex descriptor +│ └─ FIX: vertexDescriptor.attributes[N] must be configured +│ +├─ "Buffer binding index out of range" +│ ├─ Check: [[buffer(N)]] where N < 31 +│ └─ FIX: Metal has max 31 buffer bindings per stage +│ +└─ "Cannot convert value of type" + ├─ MSL is stricter than GLSL about implicit conversions + └─ FIX: Add explicit casts: float(intValue), int(floatValue) +``` + +### Common Conversions + +```metal +// GLSL +vec4 color = texture(sampler2D, uv); + +// MSL — texture and sampler are separate +float4 color = tex.sample(samp, uv); + +// GLSL — mod() for floats +float x = mod(y, z); + +// MSL — fmod() for floats +float x = fmod(y, z); + +// GLSL — atan(y, x) +float angle = atan(y, x); + +// MSL — atan2(y, x) +float angle = atan2(y, x); + +// GLSL — inversesqrt +float invSqrt = inversesqrt(x); + +// MSL — rsqrt +float invSqrt = rsqrt(x); +``` + +**Time cost**: With conversion table: 2-5 min per shader. Without: 15-30 min per shader. + +## Symptom 3: Wrong Colors or Coordinates + +### Decision Tree + +``` +Rendering looks wrong +│ +├─ Image is upside down +│ ├─ Cause: Metal Y-axis is opposite OpenGL +│ ├─ FIX (vertex shader): pos.y = -pos.y +│ ├─ FIX (texture load): MTKTextureLoader .origin: .bottomLeft +│ └─ FIX (UV): uv.y = 1.0 - uv.y in fragment shader +│ +├─ Image is mirrored +│ ├─ Cause: Winding order or cull mode wrong +│ ├─ FIX: encoder.setFrontFacing(.counterClockwise) +│ └─ FIX: encoder.setCullMode(.back) or .none to test +│ +├─ Colors are swapped (red/blue) +│ ├─ Cause: Pixel format mismatch +│ ├─ Check: .bgra8Unorm vs .rgba8Unorm +│ └─ FIX: Match texture pixel format to data format +│ +├─ Colors are washed out / too bright +│ ├─ Cause: sRGB vs linear color space +│ ├─ Check: Using .bgra8Unorm_srgb for sRGB textures? +│ └─ FIX: Use _srgb format variants for gamma-correct rendering +│ +├─ Depth fighting / z-fighting +│ ├─ Cause: NDC Z range difference +│ ├─ OpenGL: Z in [-1, 1] +│ ├─ Metal: Z in [0, 1] +│ └─ FIX: Adjust projection matrix for Metal's Z range +│ +├─ Objects clipped incorrectly +│ ├─ Cause: Near/far plane or viewport +│ ├─ Check: Viewport size matches drawable size +│ └─ FIX: encoder.setViewport(MTLViewport(...)) +│ +└─ Transparency wrong + ├─ Cause: Blend state not configured + ├─ FIX: pipelineDescriptor.colorAttachments[0].isBlendingEnabled = true + └─ FIX: Set sourceRGBBlendFactor, destinationRGBBlendFactor +``` + +### Coordinate System Fix + +```swift +// Fix projection matrix for Metal's Z range [0, 1] +func metalPerspectiveProjection(fovY: Float, aspect: Float, near: Float, far: Float) -> simd_float4x4 { + let yScale = 1.0 / tan(fovY * 0.5) + let xScale = yScale / aspect + let zRange = far - near + + return simd_float4x4(rows: [ + SIMD4(xScale, 0, 0, 0), + SIMD4(0, yScale, 0, 0), + SIMD4(0, 0, far / zRange, 1), // Metal: [0, 1] + SIMD4(0, 0, -near * far / zRange, 0) + ]) +} +``` + +**Time cost**: With GPU Frame Capture texture inspection: 5-10 min. Without: 1-2 hours. + +## Symptom 4: Performance Regression + +### Decision Tree + +``` +Performance worse than OpenGL +│ +├─ Enabling validation? +│ └─ Validation adds ~30% overhead +│ FIX: Disable for release builds, keep for debug +│ +├─ Creating resources every frame? +│ ├─ BAD: device.makeBuffer() in draw() +│ └─ FIX: Create buffers once, reuse with triple buffering +│ +├─ Creating pipeline state every frame? +│ ├─ BAD: makeRenderPipelineState() in draw() +│ └─ FIX: Create PSO once at init, store as property +│ +├─ Too many draw calls? +│ ├─ DEBUG: GPU Frame Capture → count draw calls +│ └─ FIX: Batch geometry, use instancing, indirect draws +│ +├─ GPU-CPU sync stalls? +│ ├─ DEBUG: Metal System Trace → look for stalls +│ ├─ Cause: waitUntilCompleted() blocks CPU +│ └─ FIX: Triple buffering with semaphore +│ +├─ Inefficient buffer updates? +│ ├─ BAD: Recreating buffer to update +│ └─ FIX: buffer.contents().copyMemory() for dynamic data +│ +├─ Wrong storage mode? +│ ├─ .shared: Good for small dynamic data +│ ├─ .private: Good for static GPU-only data +│ └─ FIX: Use .private for geometry that doesn't change +│ +└─ Missing Metal-specific optimizations? + ├─ Argument buffers reduce binding overhead + ├─ Indirect draws reduce CPU work + └─ See WWDC sessions on Metal optimization +``` + +### Triple Buffering Pattern + +```swift +class TripleBufferedRenderer { + static let maxInflightFrames = 3 + + let inflightSemaphore = DispatchSemaphore(value: maxInflightFrames) + var uniformBuffers: [MTLBuffer] = [] + var currentBufferIndex = 0 + + init(device: MTLDevice) { + for _ in 0..10 sec on iOS) +│ ├─ Check: Infinite loop in shader? +│ └─ FIX: Add early exit conditions, reduce work +│ +├─ "-[MTLDebugRenderCommandEncoder validateDrawCallWithArray:...]" +│ ├─ Cause: Validation caught misuse +│ └─ FIX: Read the validation message — it tells you exactly what's wrong +│ +├─ "Fragment shader writes to non-existent render target" +│ ├─ Cause: Shader returns color but no color attachment +│ └─ FIX: Configure colorAttachments[0].pixelFormat +│ +├─ Crash in shader (SIGABRT) +│ ├─ Cause: Out-of-bounds buffer access +│ ├─ DEBUG: Enable shader validation +│ └─ FIX: Check array bounds, buffer sizes +│ +└─ Device disconnected / GPU restart + ├─ Cause: Severe GPU hang + ├─ Check: Infinite loop or massive overdraw + └─ FIX: Simplify shader, reduce draw complexity +``` + +### Resource Lifetime Fix + +```swift +// BAD: Buffer released before GPU finishes +func draw(in view: MTKView) { + let buffer = device.makeBuffer(...) // Created here + encoder.setVertexBuffer(buffer, ...) + commandBuffer.commit() + // buffer released at end of scope — GPU still using it! +} + +// GOOD: Keep reference until completion +class Renderer { + var currentBuffer: MTLBuffer? // Strong reference + + func draw(in view: MTKView) { + currentBuffer = device.makeBuffer(...) + encoder.setVertexBuffer(currentBuffer!, ...) + commandBuffer.addCompletedHandler { [weak self] _ in + // Safe to release now + self?.currentBuffer = nil + } + commandBuffer.commit() + } +} +``` + +## Debugging Tools Quick Reference + +### GPU Frame Capture + +``` +Xcode → Debug → Capture GPU Frame (Cmd+Opt+Shift+G) +``` + +**Use for**: +- Inspecting buffer contents +- Viewing intermediate textures +- Checking draw call sequence +- Debugging shader variable values +- Understanding why something isn't rendering + +### Metal System Trace (Instruments) + +``` +Instruments → Metal System Trace template +``` + +**Use for**: +- GPU/CPU timeline analysis +- Finding synchronization stalls +- Measuring encoder/buffer overhead +- Identifying bottlenecks + +### Shader Debugger + +``` +GPU Frame Capture → Select draw call → Debug button +``` + +**Use for**: +- Step through shader execution +- Inspect variable values per pixel/vertex +- Find logic errors in shaders + +### Validation Messages + +Most validation messages include: +- What went wrong +- Which resource/state +- What the expected value was + +**Always read the full message** — it usually tells you exactly how to fix the problem. + +## Diagnostic Checklist + +When something doesn't work: + +- [ ] **Metal validation enabled?** (Most bugs produce validation errors) +- [ ] **GPU Frame Capture available?** (Visual debugging is fastest) +- [ ] **Console error messages?** (Read them fully) +- [ ] **Resources bound?** (Metal requires explicit binding) +- [ ] **Coordinates correct?** (Y-flip, NDC Z range) +- [ ] **Pipeline state created successfully?** (Check for throw) +- [ ] **Drawable available?** (View must be on screen) + +## Resources + +**WWDC**: 2019-00611, 2020-10602, 2020-10603 + +**Docs**: /metal/debugging-metal-applications, /metal/gpu-capture + +**Skills**: axiom-metal-migration, axiom-metal-migration-ref + +--- + +**Last Updated**: 2025-12-29 +**Platforms**: iOS 12+, macOS 10.14+, tvOS 12+ +**Status**: Comprehensive Metal porting diagnostics diff --git a/.claude/skills/axiom-metal-migration-diag/agents/openai.yaml b/.claude/skills/axiom-metal-migration-diag/agents/openai.yaml new file mode 100644 index 0000000..21e4d56 --- /dev/null +++ b/.claude/skills/axiom-metal-migration-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Metal Migration Diagnostics" + short_description: "ANY Metal porting issue occurs" diff --git a/.claude/skills/axiom-metal-migration-ref/.openskills.json b/.claude/skills/axiom-metal-migration-ref/.openskills.json new file mode 100644 index 0000000..6979061 --- /dev/null +++ b/.claude/skills/axiom-metal-migration-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-metal-migration-ref", + "installedAt": "2026-04-12T08:06:29.229Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-metal-migration-ref/SKILL.md b/.claude/skills/axiom-metal-migration-ref/SKILL.md new file mode 100644 index 0000000..97d7389 --- /dev/null +++ b/.claude/skills/axiom-metal-migration-ref/SKILL.md @@ -0,0 +1,649 @@ +--- +name: axiom-metal-migration-ref +description: Use when converting shaders or looking up API equivalents - GLSL to MSL, HLSL to MSL, GL/DirectX to Metal mappings, MTKView setup code +license: MIT +compatibility: [iOS 12+, macOS 10.14+, tvOS 12+] +metadata: + version: "1.0.0" +--- + +# Metal Migration Reference + +Complete reference for converting OpenGL/DirectX code to Metal. + +## When to Use This Reference + +Use this reference when: +- Converting GLSL shaders to Metal Shading Language (MSL) +- Converting HLSL shaders to MSL +- Looking up GL/D3D API equivalents in Metal +- Setting up MTKView or CAMetalLayer +- Building render pipelines +- Using Metal Shader Converter for DirectX + +## Part 1: GLSL to MSL Conversion + +### Type Mappings + +| GLSL | MSL | Notes | +|------|-----|-------| +| `void` | `void` | | +| `bool` | `bool` | | +| `int` | `int` | 32-bit signed | +| `uint` | `uint` | 32-bit unsigned | +| `float` | `float` | 32-bit | +| `double` | N/A | Use `float` (no 64-bit float in MSL) | +| `vec2` | `float2` | | +| `vec3` | `float3` | | +| `vec4` | `float4` | | +| `ivec2` | `int2` | | +| `ivec3` | `int3` | | +| `ivec4` | `int4` | | +| `uvec2` | `uint2` | | +| `uvec3` | `uint3` | | +| `uvec4` | `uint4` | | +| `bvec2` | `bool2` | | +| `bvec3` | `bool3` | | +| `bvec4` | `bool4` | | +| `mat2` | `float2x2` | | +| `mat3` | `float3x3` | | +| `mat4` | `float4x4` | | +| `mat2x3` | `float2x3` | Columns x Rows | +| `mat3x4` | `float3x4` | | +| `sampler2D` | `texture2d` + `sampler` | Separate in MSL | +| `sampler3D` | `texture3d` + `sampler` | | +| `samplerCube` | `texturecube` + `sampler` | | +| `sampler2DArray` | `texture2d_array` + `sampler` | | +| `sampler2DShadow` | `depth2d` + `sampler` | | + +### Built-in Variable Mappings + +| GLSL | MSL | Stage | +|------|-----|-------| +| `gl_Position` | Return `[[position]]` | Vertex | +| `gl_PointSize` | Return `[[point_size]]` | Vertex | +| `gl_VertexID` | `[[vertex_id]]` parameter | Vertex | +| `gl_InstanceID` | `[[instance_id]]` parameter | Vertex | +| `gl_FragCoord` | `[[position]]` parameter | Fragment | +| `gl_FrontFacing` | `[[front_facing]]` parameter | Fragment | +| `gl_PointCoord` | `[[point_coord]]` parameter | Fragment | +| `gl_FragDepth` | Return `[[depth(any)]]` | Fragment | +| `gl_SampleID` | `[[sample_id]]` parameter | Fragment | +| `gl_SamplePosition` | `[[sample_position]]` parameter | Fragment | + +### Function Mappings + +| GLSL | MSL | Notes | +|------|-----|-------| +| `texture(sampler, uv)` | `tex.sample(sampler, uv)` | Method on texture | +| `textureLod(sampler, uv, lod)` | `tex.sample(sampler, uv, level(lod))` | | +| `textureGrad(sampler, uv, ddx, ddy)` | `tex.sample(sampler, uv, gradient2d(ddx, ddy))` | | +| `texelFetch(sampler, coord, lod)` | `tex.read(coord, lod)` | Integer coords | +| `textureSize(sampler, lod)` | `tex.get_width(lod)`, `tex.get_height(lod)` | Separate calls | +| `dFdx(v)` | `dfdx(v)` | | +| `dFdy(v)` | `dfdy(v)` | | +| `fwidth(v)` | `fwidth(v)` | Same | +| `mix(a, b, t)` | `mix(a, b, t)` | Same | +| `clamp(v, lo, hi)` | `clamp(v, lo, hi)` | Same | +| `smoothstep(e0, e1, x)` | `smoothstep(e0, e1, x)` | Same | +| `step(edge, x)` | `step(edge, x)` | Same | +| `mod(x, y)` | `fmod(x, y)` | Different name | +| `fract(x)` | `fract(x)` | Same | +| `inversesqrt(x)` | `rsqrt(x)` | Different name | +| `atan(y, x)` | `atan2(y, x)` | Different name | + +### Shader Structure Conversion + +**GLSL Vertex Shader**: +```glsl +#version 300 es +precision highp float; + +layout(location = 0) in vec3 aPosition; +layout(location = 1) in vec2 aTexCoord; + +uniform mat4 uModelViewProjection; + +out vec2 vTexCoord; + +void main() { + gl_Position = uModelViewProjection * vec4(aPosition, 1.0); + vTexCoord = aTexCoord; +} +``` + +**MSL Vertex Shader**: +```metal +#include +using namespace metal; + +struct VertexIn { + float3 position [[attribute(0)]]; + float2 texCoord [[attribute(1)]]; +}; + +struct VertexOut { + float4 position [[position]]; + float2 texCoord; +}; + +struct Uniforms { + float4x4 modelViewProjection; +}; + +vertex VertexOut vertexShader( + VertexIn in [[stage_in]], + constant Uniforms& uniforms [[buffer(1)]] +) { + VertexOut out; + out.position = uniforms.modelViewProjection * float4(in.position, 1.0); + out.texCoord = in.texCoord; + return out; +} +``` + +**GLSL Fragment Shader**: +```glsl +#version 300 es +precision highp float; + +in vec2 vTexCoord; +uniform sampler2D uTexture; + +out vec4 fragColor; + +void main() { + fragColor = texture(uTexture, vTexCoord); +} +``` + +**MSL Fragment Shader**: +```metal +fragment float4 fragmentShader( + VertexOut in [[stage_in]], + texture2d tex [[texture(0)]], + sampler samp [[sampler(0)]] +) { + return tex.sample(samp, in.texCoord); +} +``` + +### Precision Qualifiers + +GLSL precision qualifiers have no direct MSL equivalent — MSL uses explicit types: + +| GLSL | MSL Equivalent | +|------|----------------| +| `lowp float` | `half` (16-bit) | +| `mediump float` | `half` (16-bit) | +| `highp float` | `float` (32-bit) | +| `lowp int` | `short` (16-bit) | +| `mediump int` | `short` (16-bit) | +| `highp int` | `int` (32-bit) | + +### Buffer Alignment (Critical) + +**GLSL/C assumes**: +- `vec3`: 12 bytes, any alignment +- `vec4`: 16 bytes + +**MSL requires**: +- `float3`: 12 bytes storage, **16-byte aligned** +- `float4`: 16 bytes storage, 16-byte aligned + +**Solution**: Use `simd` types in Swift for CPU-GPU shared structs: + +```swift +import simd + +struct Uniforms { + var modelViewProjection: simd_float4x4 // Correct alignment + var cameraPosition: simd_float3 // 16-byte aligned + var padding: Float = 0 // Explicit padding if needed +} +``` + +Or use packed types in MSL (slower): +```metal +struct VertexPacked { + packed_float3 position; // 12 bytes, no padding + packed_float2 texCoord; // 8 bytes +}; +``` + +## Part 2: HLSL to MSL Conversion + +### Type Mappings + +| HLSL | MSL | Notes | +|------|-----|-------| +| `float` | `float` | | +| `float2` | `float2` | | +| `float3` | `float3` | | +| `float4` | `float4` | | +| `half` | `half` | | +| `int` | `int` | | +| `uint` | `uint` | | +| `bool` | `bool` | | +| `float2x2` | `float2x2` | | +| `float3x3` | `float3x3` | | +| `float4x4` | `float4x4` | | +| `Texture2D` | `texture2d` | | +| `Texture3D` | `texture3d` | | +| `TextureCube` | `texturecube` | | +| `SamplerState` | `sampler` | | +| `RWTexture2D` | `texture2d` | | +| `RWBuffer` | `device float* [[buffer(n)]]` | | +| `StructuredBuffer` | `constant T* [[buffer(n)]]` | | +| `RWStructuredBuffer` | `device T* [[buffer(n)]]` | | + +### Semantic Mappings + +| HLSL Semantic | MSL Attribute | +|---------------|---------------| +| `SV_Position` | `[[position]]` | +| `SV_Target0` | Return value / `[[color(0)]]` | +| `SV_Target1` | `[[color(1)]]` | +| `SV_Depth` | `[[depth(any)]]` | +| `SV_VertexID` | `[[vertex_id]]` | +| `SV_InstanceID` | `[[instance_id]]` | +| `SV_IsFrontFace` | `[[front_facing]]` | +| `SV_SampleIndex` | `[[sample_id]]` | +| `SV_PrimitiveID` | `[[primitive_id]]` | +| `SV_DispatchThreadID` | `[[thread_position_in_grid]]` | +| `SV_GroupThreadID` | `[[thread_position_in_threadgroup]]` | +| `SV_GroupID` | `[[threadgroup_position_in_grid]]` | +| `SV_GroupIndex` | `[[thread_index_in_threadgroup]]` | + +### Function Mappings + +| HLSL | MSL | Notes | +|------|-----|-------| +| `tex.Sample(samp, uv)` | `tex.sample(samp, uv)` | Lowercase | +| `tex.SampleLevel(samp, uv, lod)` | `tex.sample(samp, uv, level(lod))` | | +| `tex.SampleGrad(samp, uv, ddx, ddy)` | `tex.sample(samp, uv, gradient2d(ddx, ddy))` | | +| `tex.Load(coord)` | `tex.read(coord.xy, coord.z)` | Split coord | +| `mul(a, b)` | `a * b` | Operator | +| `saturate(x)` | `saturate(x)` | Same | +| `lerp(a, b, t)` | `mix(a, b, t)` | Different name | +| `frac(x)` | `fract(x)` | Different name | +| `ddx(v)` | `dfdx(v)` | Different name | +| `ddy(v)` | `dfdy(v)` | Different name | +| `clip(x)` | `if (x < 0) discard_fragment()` | Manual | +| `discard` | `discard_fragment()` | Function call | + +### Metal Shader Converter (DirectX → Metal) + +Apple's official tool for converting DXIL (compiled HLSL) to Metal libraries. + +**Requirements**: +- macOS 13+ with Xcode 15+ +- OR Windows 10+ with VS 2019+ +- Target devices: Argument Buffers Tier 2 (macOS 14+, iOS 17+) + +**Workflow**: + +```bash +# Step 1: Compile HLSL to DXIL using DXC +dxc -T vs_6_0 -E MainVS -Fo vertex.dxil shader.hlsl +dxc -T ps_6_0 -E MainPS -Fo fragment.dxil shader.hlsl + +# Step 2: Convert DXIL to Metal library +metal-shaderconverter vertex.dxil -o vertex.metallib +metal-shaderconverter fragment.dxil -o fragment.metallib + +# Step 3: Load in Swift +let vertexLib = try device.makeLibrary(URL: vertexURL) +let fragmentLib = try device.makeLibrary(URL: fragmentURL) +``` + +**Key Options**: + +| Option | Purpose | +|--------|---------| +| `-o ` | Output metallib path | +| `--minimum-gpu-family` | Target GPU family | +| `--minimum-os-build-version` | Minimum OS version | +| `--vertex-stage-in` | Separate vertex fetch function | +| `-dualSourceBlending` | Enable dual-source blending | + +**Supported Shader Models**: SM 6.0 - 6.6 (with limitations on 6.6 features) + +## Part 3: OpenGL API to Metal API + +### View/Context Setup + +| OpenGL | Metal | +|--------|-------| +| `NSOpenGLView` | `MTKView` | +| `GLKView` | `MTKView` | +| `EAGLContext` | `MTLDevice` + `MTLCommandQueue` | +| `CGLContextObj` | `MTLDevice` | + +### Resource Creation + +| OpenGL | Metal | +|--------|-------| +| `glGenBuffers` + `glBufferData` | `device.makeBuffer(bytes:length:options:)` | +| `glGenTextures` + `glTexImage2D` | `device.makeTexture(descriptor:)` + `texture.replace(region:...)` | +| `glGenFramebuffers` | `MTLRenderPassDescriptor` | +| `glGenVertexArrays` | `MTLVertexDescriptor` | +| `glCreateShader` + `glCompileShader` | Build-time compilation → `MTLLibrary` | +| `glCreateProgram` + `glLinkProgram` | `MTLRenderPipelineDescriptor` → `MTLRenderPipelineState` | + +### State Management + +| OpenGL | Metal | +|--------|-------| +| `glEnable(GL_DEPTH_TEST)` | `MTLDepthStencilDescriptor` → `MTLDepthStencilState` | +| `glDepthFunc(GL_LESS)` | `descriptor.depthCompareFunction = .less` | +| `glEnable(GL_BLEND)` | `pipelineDescriptor.colorAttachments[0].isBlendingEnabled = true` | +| `glBlendFunc` | `sourceRGBBlendFactor`, `destinationRGBBlendFactor` | +| `glCullFace` | `encoder.setCullMode(.back)` | +| `glFrontFace` | `encoder.setFrontFacing(.counterClockwise)` | +| `glViewport` | `encoder.setViewport(MTLViewport(...))` | +| `glScissor` | `encoder.setScissorRect(MTLScissorRect(...))` | + +### Draw Commands + +| OpenGL | Metal | +|--------|-------| +| `glDrawArrays(mode, first, count)` | `encoder.drawPrimitives(type:vertexStart:vertexCount:)` | +| `glDrawElements(mode, count, type, indices)` | `encoder.drawIndexedPrimitives(type:indexCount:indexType:indexBuffer:indexBufferOffset:)` | +| `glDrawArraysInstanced` | `encoder.drawPrimitives(type:vertexStart:vertexCount:instanceCount:)` | +| `glDrawElementsInstanced` | `encoder.drawIndexedPrimitives(...instanceCount:)` | + +### Primitive Types + +| OpenGL | Metal | +|--------|-------| +| `GL_POINTS` | `.point` | +| `GL_LINES` | `.line` | +| `GL_LINE_STRIP` | `.lineStrip` | +| `GL_TRIANGLES` | `.triangle` | +| `GL_TRIANGLE_STRIP` | `.triangleStrip` | +| `GL_TRIANGLE_FAN` | N/A (decompose to triangles) | + +## Part 4: Complete Setup Examples + +### MTKView Setup (Recommended) + +```swift +import MetalKit + +class GameViewController: UIViewController { + var metalView: MTKView! + var renderer: Renderer! + + override func viewDidLoad() { + super.viewDidLoad() + + // Create Metal view + guard let device = MTLCreateSystemDefaultDevice() else { + fatalError("Metal not supported") + } + + metalView = MTKView(frame: view.bounds, device: device) + metalView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + metalView.colorPixelFormat = .bgra8Unorm + metalView.depthStencilPixelFormat = .depth32Float + metalView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1) + metalView.preferredFramesPerSecond = 60 + view.addSubview(metalView) + + // Create renderer + renderer = Renderer(metalView: metalView) + metalView.delegate = renderer + } +} + +class Renderer: NSObject, MTKViewDelegate { + let device: MTLDevice + let commandQueue: MTLCommandQueue + var pipelineState: MTLRenderPipelineState! + var depthState: MTLDepthStencilState! + var vertexBuffer: MTLBuffer! + + init(metalView: MTKView) { + device = metalView.device! + commandQueue = device.makeCommandQueue()! + super.init() + + buildPipeline(metalView: metalView) + buildDepthStencil() + buildBuffers() + } + + private func buildPipeline(metalView: MTKView) { + let library = device.makeDefaultLibrary()! + + let descriptor = MTLRenderPipelineDescriptor() + descriptor.vertexFunction = library.makeFunction(name: "vertexShader") + descriptor.fragmentFunction = library.makeFunction(name: "fragmentShader") + descriptor.colorAttachments[0].pixelFormat = metalView.colorPixelFormat + descriptor.depthAttachmentPixelFormat = metalView.depthStencilPixelFormat + + // Vertex descriptor (matches shader's VertexIn struct) + let vertexDescriptor = MTLVertexDescriptor() + vertexDescriptor.attributes[0].format = .float3 + vertexDescriptor.attributes[0].offset = 0 + vertexDescriptor.attributes[0].bufferIndex = 0 + vertexDescriptor.attributes[1].format = .float2 + vertexDescriptor.attributes[1].offset = MemoryLayout>.stride + vertexDescriptor.attributes[1].bufferIndex = 0 + vertexDescriptor.layouts[0].stride = MemoryLayout.stride + descriptor.vertexDescriptor = vertexDescriptor + + pipelineState = try! device.makeRenderPipelineState(descriptor: descriptor) + } + + private func buildDepthStencil() { + let descriptor = MTLDepthStencilDescriptor() + descriptor.depthCompareFunction = .less + descriptor.isDepthWriteEnabled = true + depthState = device.makeDepthStencilState(descriptor: descriptor) + } + + func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { + // Handle resize + } + + func draw(in view: MTKView) { + guard let drawable = view.currentDrawable, + let descriptor = view.currentRenderPassDescriptor, + let commandBuffer = commandQueue.makeCommandBuffer(), + let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { + return + } + + encoder.setRenderPipelineState(pipelineState) + encoder.setDepthStencilState(depthState) + encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: vertexCount) + encoder.endEncoding() + + commandBuffer.present(drawable) + commandBuffer.commit() + } +} +``` + +### CAMetalLayer Setup (Custom Control) + +```swift +import Metal +import QuartzCore + +class MetalLayerView: UIView { + var metalLayer: CAMetalLayer! + var device: MTLDevice! + var commandQueue: MTLCommandQueue! + var displayLink: CADisplayLink? + + override class var layerClass: AnyClass { CAMetalLayer.self } + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + private func setup() { + device = MTLCreateSystemDefaultDevice()! + commandQueue = device.makeCommandQueue()! + + metalLayer = layer as? CAMetalLayer + metalLayer.device = device + metalLayer.pixelFormat = .bgra8Unorm + metalLayer.framebufferOnly = true + + displayLink = CADisplayLink(target: self, selector: #selector(render)) + displayLink?.add(to: .main, forMode: .common) + } + + override func layoutSubviews() { + super.layoutSubviews() + metalLayer.drawableSize = CGSize( + width: bounds.width * contentScaleFactor, + height: bounds.height * contentScaleFactor + ) + } + + @objc func render() { + guard let drawable = metalLayer.nextDrawable(), + let commandBuffer = commandQueue.makeCommandBuffer() else { + return + } + + let descriptor = MTLRenderPassDescriptor() + descriptor.colorAttachments[0].texture = drawable.texture + descriptor.colorAttachments[0].loadAction = .clear + descriptor.colorAttachments[0].storeAction = .store + descriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1) + + guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { + return + } + + // Draw commands here + encoder.endEncoding() + + commandBuffer.present(drawable) + commandBuffer.commit() + } +} +``` + +### Compute Shader Setup + +```swift +class ComputeProcessor { + let device: MTLDevice + let commandQueue: MTLCommandQueue + var computePipeline: MTLComputePipelineState! + + init() { + device = MTLCreateSystemDefaultDevice()! + commandQueue = device.makeCommandQueue()! + + let library = device.makeDefaultLibrary()! + let function = library.makeFunction(name: "computeKernel")! + computePipeline = try! device.makeComputePipelineState(function: function) + } + + func process(input: MTLBuffer, output: MTLBuffer, count: Int) { + let commandBuffer = commandQueue.makeCommandBuffer()! + let encoder = commandBuffer.makeComputeCommandEncoder()! + + encoder.setComputePipelineState(computePipeline) + encoder.setBuffer(input, offset: 0, index: 0) + encoder.setBuffer(output, offset: 0, index: 1) + + let threadGroupSize = MTLSize(width: 256, height: 1, depth: 1) + let threadGroups = MTLSize( + width: (count + 255) / 256, + height: 1, + depth: 1 + ) + + encoder.dispatchThreadgroups(threadGroups, threadsPerThreadgroup: threadGroupSize) + encoder.endEncoding() + + commandBuffer.commit() + commandBuffer.waitUntilCompleted() + } +} +``` + +```metal +// Compute shader +kernel void computeKernel( + device float* input [[buffer(0)]], + device float* output [[buffer(1)]], + uint id [[thread_position_in_grid]] +) { + output[id] = input[id] * 2.0; +} +``` + +## Part 5: Storage Modes & Synchronization + +### Buffer Storage Modes + +| Mode | CPU Access | GPU Access | Use Case | +|------|------------|------------|----------| +| `.shared` | Read/Write | Read/Write | Small dynamic data, uniforms | +| `.private` | None | Read/Write | Static assets, render targets | +| `.managed` (macOS) | Read/Write | Read/Write | Large buffers with partial updates | + +```swift +// Shared: CPU and GPU both access (iOS typical) +let uniformBuffer = device.makeBuffer(length: size, options: .storageModeShared) + +// Private: GPU only (best for static geometry) +let vertexBuffer = device.makeBuffer(bytes: vertices, length: size, options: .storageModePrivate) + +// Managed: Explicit sync (macOS) +#if os(macOS) +let buffer = device.makeBuffer(length: size, options: .storageModeManaged) +// After CPU write: +buffer.didModifyRange(0..` | Different preamble | + +**Example conversion:** + +```glsl +// GLSL vertex shader +#version 300 es +uniform mat4 u_mvp; +in vec3 a_position; +in vec2 a_texCoord; +out vec2 v_texCoord; + +void main() { + v_texCoord = a_texCoord; + gl_Position = u_mvp * vec4(a_position, 1.0); +} +``` + +```metal +// Equivalent MSL vertex shader +#include +using namespace metal; + +struct VertexIn { + float3 position [[attribute(0)]]; + float2 texCoord [[attribute(1)]]; +}; + +struct VertexOut { + float4 position [[position]]; + float2 texCoord; +}; + +struct Uniforms { + float4x4 mvp; +}; + +vertex VertexOut vertexShader(VertexIn in [[stage_in]], + constant Uniforms &uniforms [[buffer(1)]]) { + VertexOut out; + out.texCoord = in.texCoord; + out.position = uniforms.mvp * float4(in.position, 1.0); + return out; +} +``` + +**Key differences to watch:** +- GLSL globals → MSL function parameters with `[[attribute]]` qualifiers +- Implicit uniform binding → explicit `[[buffer(N)]]` indices +- `sampler2D` combines texture+sampler → Metal separates `texture2d` and `sampler` +- GLSL preprocessor → Metal uses C++ `#include` and `using namespace metal` + +### Core Architecture Differences + +| Concept | OpenGL | Metal | +|---------|--------|-------| +| State model | Implicit, mutable | Explicit, immutable PSO | +| Validation | At draw time | At PSO creation | +| Shader compilation | Runtime (JIT) | Build time (AOT) | +| Command submission | Implicit | Explicit command buffers | +| Resource binding | Global state | Per-encoder binding | +| Synchronization | Driver-managed | App-managed | + +### MTKView Setup (Native Metal) + +```swift +import MetalKit + +class MetalRenderer: NSObject, MTKViewDelegate { + let device: MTLDevice + let commandQueue: MTLCommandQueue + var pipelineState: MTLRenderPipelineState! + + init?(metalView: MTKView) { + guard let device = MTLCreateSystemDefaultDevice(), + let queue = device.makeCommandQueue() else { + return nil + } + self.device = device + self.commandQueue = queue + + metalView.device = device + metalView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1) + metalView.depthStencilPixelFormat = .depth32Float + + super.init() + metalView.delegate = self + + buildPipeline(metalView: metalView) + } + + private func buildPipeline(metalView: MTKView) { + let library = device.makeDefaultLibrary()! + let vertexFunction = library.makeFunction(name: "vertexShader") + let fragmentFunction = library.makeFunction(name: "fragmentShader") + + let descriptor = MTLRenderPipelineDescriptor() + descriptor.vertexFunction = vertexFunction + descriptor.fragmentFunction = fragmentFunction + descriptor.colorAttachments[0].pixelFormat = metalView.colorPixelFormat + descriptor.depthAttachmentPixelFormat = metalView.depthStencilPixelFormat + + // Pre-validated at creation, not at draw time + pipelineState = try! device.makeRenderPipelineState(descriptor: descriptor) + } + + func draw(in view: MTKView) { + guard let drawable = view.currentDrawable, + let descriptor = view.currentRenderPassDescriptor, + let commandBuffer = commandQueue.makeCommandBuffer(), + let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { + return + } + + encoder.setRenderPipelineState(pipelineState) + // Bind resources explicitly - nothing persists between draws + encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + encoder.setFragmentTexture(texture, index: 0) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: vertexCount) + encoder.endEncoding() + + commandBuffer.present(drawable) + commandBuffer.commit() + } +} +``` + +## Common Migration Anti-Patterns + +### Anti-Pattern 1: Keeping GL State Machine Mentality + +❌ **BAD** — Thinking in GL's implicit state: +```swift +// GL mental model: "set state, then draw" +glBindTexture(GL_TEXTURE_2D, texture) +glBindBuffer(GL_ARRAY_BUFFER, vbo) +glUseProgram(program) +glDrawArrays(GL_TRIANGLES, 0, vertexCount) +// State persists until changed — can draw again without rebinding +``` + +✅ **GOOD** — Metal's explicit model: +```swift +// Metal: encode everything explicitly per draw +let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: rpd)! +encoder.setRenderPipelineState(pipelineState) // Always set +encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) // Always bind +encoder.setFragmentTexture(texture, index: 0) // Always bind +encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: count) +encoder.endEncoding() +// Nothing persists — next encoder starts fresh +``` + +**Time cost**: 30-60 min debugging "why did my texture disappear" vs 2 min understanding the model upfront. + +### Anti-Pattern 2: Ignoring Coordinate System Differences + +❌ **BAD** — Assuming GL coordinates work in Metal: +``` +OpenGL: +- Origin: bottom-left +- Y-axis: up +- NDC Z range: [-1, 1] +- Texture origin: bottom-left + +Metal: +- Origin: top-left +- Y-axis: down +- NDC Z range: [0, 1] +- Texture origin: top-left +``` + +✅ **GOOD** — Explicit coordinate handling: +```metal +// Option 1: Flip Y in vertex shader +vertex float4 vertexShader(VertexIn in [[stage_in]]) { + float4 pos = uniforms.mvp * float4(in.position, 1.0); + pos.y = -pos.y; // Flip Y for Metal's coordinate system + return pos; +} + +// Option 2: Flip texture coordinates in fragment shader +fragment float4 fragmentShader(VertexOut in [[stage_in]], + texture2d tex [[texture(0)]], + sampler samp [[sampler(0)]]) { + float2 uv = in.texCoord; + uv.y = 1.0 - uv.y; // Flip V for Metal's texture origin + return tex.sample(samp, uv); +} +``` + +```swift +// Option 3: Use MTKTextureLoader with origin option +let options: [MTKTextureLoader.Option: Any] = [ + .origin: MTKTextureLoader.Origin.bottomLeft // Match GL convention +] +let texture = try textureLoader.newTexture(URL: url, options: options) +``` + +**Time cost**: 2-4 hours debugging "upside down" or "mirrored" rendering vs 5 min reading this pattern. + +### Anti-Pattern 3: No Validation Layer During Development + +❌ **BAD** — Disabling validation for "performance": +```swift +// No validation — API misuse silently corrupts or crashes later +``` + +✅ **GOOD** — Always enable during development: +``` +In Xcode: Edit Scheme → Run → Diagnostics +✓ Metal API Validation +✓ Metal Shader Validation +✓ GPU Frame Capture (Metal) +``` + +**Time cost**: Hours debugging silent corruption vs immediate error messages with call stacks. + +### Anti-Pattern 4: Single Buffer Without Synchronization + +❌ **BAD** — CPU and GPU fight over same buffer: +```swift +// Frame N: CPU writes to buffer +// Frame N: GPU reads from buffer +// Frame N+1: CPU writes again — RACE CONDITION +buffer.contents().copyMemory(from: data, byteCount: size) +``` + +✅ **GOOD** — Triple buffering with semaphore: +```swift +class TripleBufferedRenderer { + let inflightSemaphore = DispatchSemaphore(value: 3) + var buffers: [MTLBuffer] = [] + var bufferIndex = 0 + + func draw(in view: MTKView) { + // Wait for a buffer to become available + inflightSemaphore.wait() + + let buffer = buffers[bufferIndex] + // Safe to write — GPU finished with this buffer + buffer.contents().copyMemory(from: data, byteCount: size) + + let commandBuffer = commandQueue.makeCommandBuffer()! + commandBuffer.addCompletedHandler { [weak self] _ in + self?.inflightSemaphore.signal() // Release buffer + } + + // ... encode and commit + + bufferIndex = (bufferIndex + 1) % 3 + } +} +``` + +**Time cost**: Hours debugging intermittent visual glitches vs 15 min implementing triple buffering. + +## Pressure Scenarios + +### Scenario 1: "Just Ship with MetalANGLE" + +**Situation**: Deadline in 2 weeks. MetalANGLE demo works. PM says ship it. + +**Pressure**: "We can optimize later. Users won't notice 20% overhead." + +**Why this fails**: +- Translation overhead compounds with complex scenes (visualizers, games) +- No compute shader support limits future features +- Technical debt grows — team learns MetalANGLE quirks, not Metal +- Apple deprecation risk (OpenGL ES deprecated since iOS 12) +- Battery/thermal complaints from users + +**Response template**: +> "MetalANGLE is viable for the demo milestone. For production, I recommend a 3-week buffer to implement native Metal for the render loop. This recovers the 20-30% overhead and eliminates deprecation risk. Can we scope the MVP to fewer visual effects to hit the deadline with native Metal?" + +### Scenario 2: "Port All Shaders This Sprint" + +**Situation**: 50 GLSL shaders. Sprint is 2 weeks. Manager wants all converted. + +**Pressure**: "They're just text files. How hard can shader conversion be?" + +**Why this fails**: +- GLSL → MSL isn't 1:1 (precision qualifiers, built-ins, sampling) +- Each shader needs visual validation, not just compilation +- Complex shaders need performance profiling +- Bugs compound — broken shader A masks broken shader B + +**Response template**: +> "Shader conversion requires visual validation, not just compilation. I can convert 10-15 shaders/week with confidence. For 50 shaders: (1) Prioritize by usage — convert the 10 most-used first, (2) Automate mappings — type conversions, boilerplate, (3) Parallel validation — run GL and Metal side-by-side. Realistic timeline: 4-5 weeks for full conversion with quality." + +### Scenario 3: "We Don't Need GPU Frame Capture" + +**Situation**: Developer says "I'll just use print statements to debug shaders." + +**Pressure**: "GPU tools are overkill. I know what I'm doing." + +**Why this fails**: +- Print statements don't work in shaders +- Visual bugs require seeing intermediate render targets +- Performance issues require GPU timeline analysis +- Metal validation errors need call stack context + +**Response template**: +> "GPU Frame Capture is the only way to inspect shader variables, see intermediate textures, and understand GPU timing. It takes 30 seconds to capture a frame. Without it, shader debugging is 10x slower — you're guessing instead of observing." + +## Pre-Migration Checklist + +Before starting any port: + +- [ ] **Inventory shaders**: Count GLSL/HLSL files, complexity (LOC, features used) +- [ ] **Identify extensions**: Which GL extensions does the code use? Metal equivalents? +- [ ] **Audit state management**: How stateful is the renderer? Global state count? +- [ ] **Check compute usage**: Any GL compute shaders? GPGPU? (MetalANGLE won't help) +- [ ] **Profile baseline**: FPS, frame time, memory, thermal on reference platform +- [ ] **Define success criteria**: Target FPS, memory budget, thermal envelope +- [ ] **Set up A/B testing**: Can you run GL and Metal side-by-side for validation? +- [ ] **Enable validation**: Metal API Validation, Shader Validation, Frame Capture + +## Post-Migration Checklist + +After completing the port: + +- [ ] **Visual parity**: Side-by-side screenshots match reference +- [ ] **Performance parity or better**: Frame time ≤ GL baseline +- [ ] **No validation errors**: Clean run with Metal validation enabled +- [ ] **Thermal acceptable**: Device doesn't throttle during normal use +- [ ] **Memory stable**: No leaks over extended use +- [ ] **All code paths tested**: Edge cases, error states, resize/rotate + +## Resources + +**WWDC**: 2016-00602, 2018-00604, 2019-00611 + +**Docs**: /metal/migrating-opengl-code-to-metal, /metal/shader-converter + +**Tools**: MetalANGLE, MoltenVK + +**Skills**: axiom-metal-migration-ref, axiom-metal-migration-diag + +--- + +**Last Updated**: 2025-12-29 +**Platforms**: iOS 12+, macOS 10.14+, tvOS 12+ +**Status**: Production-ready Metal migration patterns diff --git a/.claude/skills/axiom-metal-migration/agents/openai.yaml b/.claude/skills/axiom-metal-migration/agents/openai.yaml new file mode 100644 index 0000000..f7d590a --- /dev/null +++ b/.claude/skills/axiom-metal-migration/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Metal Migration" + short_description: "Porting OpenGL/DirectX to Metal" diff --git a/.claude/skills/axiom-metrickit-ref/.openskills.json b/.claude/skills/axiom-metrickit-ref/.openskills.json new file mode 100644 index 0000000..5767803 --- /dev/null +++ b/.claude/skills/axiom-metrickit-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-metrickit-ref", + "installedAt": "2026-04-12T08:06:29.424Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-metrickit-ref/SKILL.md b/.claude/skills/axiom-metrickit-ref/SKILL.md new file mode 100644 index 0000000..1d69868 --- /dev/null +++ b/.claude/skills/axiom-metrickit-ref/SKILL.md @@ -0,0 +1,676 @@ +--- +name: axiom-metrickit-ref +description: MetricKit API reference for field diagnostics - MXMetricPayload, MXDiagnosticPayload, MXCallStackTree parsing, crash and hang collection +license: MIT +--- + +# MetricKit API Reference + +Complete API reference for collecting field performance metrics and diagnostics using MetricKit. + +## Overview + +MetricKit provides aggregated, on-device performance and diagnostic data from users who opt into sharing analytics. Data is delivered daily (or on-demand in development). + +## When to Use This Reference + +Use this reference when: +- Setting up MetricKit subscriber in your app +- Parsing MXMetricPayload or MXDiagnosticPayload +- Symbolicating MXCallStackTree crash data +- Understanding background exit reasons (jetsam, watchdog) +- Integrating MetricKit with existing crash reporters + +For hang diagnosis workflows, see `axiom-hang-diagnostics`. +For general profiling with Instruments, see `axiom-performance-profiling`. +For memory debugging including jetsam, see `axiom-memory-debugging`. + +## Common Gotchas + +1. **24-hour delay** — MetricKit data arrives once daily; it's not real-time debugging +2. **Call stacks require symbolication** — MXCallStackTree frames are unsymbolicated; keep dSYMs +3. **Opt-in only** — Only users who enable "Share with App Developers" contribute data +4. **Aggregated, not individual** — You get counts and averages, not per-user traces +5. **Simulator doesn't work** — MetricKit only collects on physical devices + +**iOS Version Support**: +| Feature | iOS Version | +|---------|-------------| +| Basic metrics (battery, CPU, memory) | iOS 13+ | +| Diagnostic payloads | iOS 14+ | +| Hang diagnostics | iOS 14+ | +| Launch diagnostics | iOS 16+ | +| Immediate delivery in dev | iOS 15+ | + +## Part 1: Setup + +### Basic Integration + +```swift +import MetricKit + +class AppMetricsSubscriber: NSObject, MXMetricManagerSubscriber { + + override init() { + super.init() + MXMetricManager.shared.add(self) + } + + deinit { + MXMetricManager.shared.remove(self) + } + + // MARK: - MXMetricManagerSubscriber + + func didReceive(_ payloads: [MXMetricPayload]) { + for payload in payloads { + processMetrics(payload) + } + } + + func didReceive(_ payloads: [MXDiagnosticPayload]) { + for payload in payloads { + processDiagnostics(payload) + } + } +} +``` + +### Registration Timing + +Register subscriber early in app lifecycle: + +```swift +@main +struct MyApp: App { + @StateObject private var metricsSubscriber = AppMetricsSubscriber() + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +``` + +Or in AppDelegate: + +```swift +func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + metricsSubscriber = AppMetricsSubscriber() + return true +} +``` + +### Development Testing + +In iOS 15+, trigger immediate delivery via Debug menu: + +**Xcode > Debug > Simulate MetricKit Payloads** + +Or programmatically (debug builds only): + +```swift +#if DEBUG +// Payloads delivered immediately in development +// No special code needed - just run and wait +#endif +``` + +## Part 2: MXMetricPayload + +`MXMetricPayload` contains aggregated performance metrics from the past 24 hours. + +### Payload Structure + +```swift +func processMetrics(_ payload: MXMetricPayload) { + // Time range for this payload + let start = payload.timeStampBegin + let end = payload.timeStampEnd + + // App version that generated this data + let version = payload.metaData?.applicationBuildVersion + + // Access specific metric categories + if let cpuMetrics = payload.cpuMetrics { + processCPU(cpuMetrics) + } + + if let memoryMetrics = payload.memoryMetrics { + processMemory(memoryMetrics) + } + + if let launchMetrics = payload.applicationLaunchMetrics { + processLaunches(launchMetrics) + } + + // ... other categories +} +``` + +### CPU Metrics (MXCPUMetric) + +```swift +func processCPU(_ metrics: MXCPUMetric) { + // Cumulative CPU time + let cpuTime = metrics.cumulativeCPUTime // Measurement + + // iOS 14+: CPU instruction count + if #available(iOS 14.0, *) { + let instructions = metrics.cumulativeCPUInstructions // Measurement + } +} +``` + +### Memory Metrics (MXMemoryMetric) + +```swift +func processMemory(_ metrics: MXMemoryMetric) { + // Peak memory usage + let peakMemory = metrics.peakMemoryUsage // Measurement + + // Average suspended memory + let avgSuspended = metrics.averageSuspendedMemory // MXAverage +} +``` + +### Launch Metrics (MXAppLaunchMetric) + +```swift +func processLaunches(_ metrics: MXAppLaunchMetric) { + // First draw (cold launch) histogram + let firstDrawHistogram = metrics.histogrammedTimeToFirstDraw + + // Resume time histogram + let resumeHistogram = metrics.histogrammedApplicationResumeTime + + // Optimized time to first draw (iOS 15.2+) + if #available(iOS 15.2, *) { + let optimizedLaunch = metrics.histogrammedOptimizedTimeToFirstDraw + } + + // Parse histogram buckets + for bucket in firstDrawHistogram.bucketEnumerator { + if let bucket = bucket as? MXHistogramBucket { + let start = bucket.bucketStart // e.g., 0ms + let end = bucket.bucketEnd // e.g., 100ms + let count = bucket.bucketCount // Number of launches in this range + } + } +} +``` + +### Application Exit Metrics (MXAppExitMetric) — iOS 14+ + +```swift +@available(iOS 14.0, *) +func processExits(_ metrics: MXAppExitMetric) { + let fg = metrics.foregroundExitData + let bg = metrics.backgroundExitData + + // Foreground (onscreen) exits + let fgNormal = fg.cumulativeNormalAppExitCount + let fgWatchdog = fg.cumulativeAppWatchdogExitCount + let fgMemoryLimit = fg.cumulativeMemoryResourceLimitExitCount + let fgMemoryPressure = fg.cumulativeMemoryPressureExitCount + let fgBadAccess = fg.cumulativeBadAccessExitCount + let fgIllegalInstruction = fg.cumulativeIllegalInstructionExitCount + let fgAbnormal = fg.cumulativeAbnormalExitCount + + // Background exits + let bgSuspended = bg.cumulativeSuspendedWithLockedFileExitCount + let bgTaskTimeout = bg.cumulativeBackgroundTaskAssertionTimeoutExitCount + let bgCPULimit = bg.cumulativeCPUResourceLimitExitCount +} +``` + +### Scroll Hitch Metrics (MXAnimationMetric) — iOS 14+ + +```swift +@available(iOS 14.0, *) +func processHitches(_ metrics: MXAnimationMetric) { + // Scroll hitch rate (hitches per scroll) + let scrollHitchRate = metrics.scrollHitchTimeRatio // Double (0.0 - 1.0) +} +``` + +### Disk I/O Metrics (MXDiskIOMetric) + +```swift +func processDiskIO(_ metrics: MXDiskIOMetric) { + let logicalWrites = metrics.cumulativeLogicalWrites // Measurement +} +``` + +### Network Metrics (MXNetworkTransferMetric) + +```swift +func processNetwork(_ metrics: MXNetworkTransferMetric) { + let cellUpload = metrics.cumulativeCellularUpload + let cellDownload = metrics.cumulativeCellularDownload + let wifiUpload = metrics.cumulativeWifiUpload + let wifiDownload = metrics.cumulativeWifiDownload +} +``` + +### Signpost Metrics (MXSignpostMetric) + +Track custom operations with signposts: + +```swift +// In your code: emit signposts +import os.signpost + +let log = MXMetricManager.makeLogHandle(category: "ImageProcessing") + +func processImage(_ image: UIImage) { + mxSignpost(.begin, log: log, name: "ProcessImage") + // ... do work ... + mxSignpost(.end, log: log, name: "ProcessImage") +} + +// In metrics subscriber: read signpost data +func processSignposts(_ metrics: MXSignpostMetric) { + let name = metrics.signpostName + let category = metrics.signpostCategory + + // Histogram of durations + let histogram = metrics.signpostIntervalData.histogrammedSignpostDurations + + // Total count + let count = metrics.totalCount +} +``` + +### Exporting Payload as JSON + +```swift +func exportPayload(_ payload: MXMetricPayload) { + // JSON representation for upload to analytics + let jsonData = payload.jsonRepresentation() + + // Or as Dictionary + if let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] { + uploadToAnalytics(json) + } +} +``` + +## Part 3: MXDiagnosticPayload — iOS 14+ + +`MXDiagnosticPayload` contains diagnostic reports for crashes, hangs, disk write exceptions, and CPU exceptions. + +### Payload Structure + +```swift +@available(iOS 14.0, *) +func processDiagnostics(_ payload: MXDiagnosticPayload) { + // Crash diagnostics + if let crashes = payload.crashDiagnostics { + for crash in crashes { + processCrash(crash) + } + } + + // Hang diagnostics + if let hangs = payload.hangDiagnostics { + for hang in hangs { + processHang(hang) + } + } + + // Disk write exceptions + if let diskWrites = payload.diskWriteExceptionDiagnostics { + for diskWrite in diskWrites { + processDiskWriteException(diskWrite) + } + } + + // CPU exceptions + if let cpuExceptions = payload.cpuExceptionDiagnostics { + for cpuException in cpuExceptions { + processCPUException(cpuException) + } + } +} +``` + +### MXCrashDiagnostic + +```swift +@available(iOS 14.0, *) +func processCrash(_ diagnostic: MXCrashDiagnostic) { + // Call stack tree (needs symbolication) + let callStackTree = diagnostic.callStackTree + + // Crash metadata + let signal = diagnostic.signal // e.g., SIGSEGV + let exceptionType = diagnostic.exceptionType // e.g., EXC_BAD_ACCESS + let exceptionCode = diagnostic.exceptionCode + let terminationReason = diagnostic.terminationReason + + // Virtual memory info + let virtualMemoryRegionInfo = diagnostic.virtualMemoryRegionInfo + + // Unique identifier for grouping similar crashes + // (not available - use call stack signature) +} +``` + +### MXHangDiagnostic + +```swift +@available(iOS 14.0, *) +func processHang(_ diagnostic: MXHangDiagnostic) { + // How long the hang lasted + let duration = diagnostic.hangDuration // Measurement + + // Call stack when hang occurred + let callStackTree = diagnostic.callStackTree +} +``` + +### MXDiskWriteExceptionDiagnostic + +```swift +@available(iOS 14.0, *) +func processDiskWriteException(_ diagnostic: MXDiskWriteExceptionDiagnostic) { + // Total bytes written that triggered exception + let totalWrites = diagnostic.totalWritesCaused // Measurement + + // Call stack of writes + let callStackTree = diagnostic.callStackTree +} +``` + +### MXCPUExceptionDiagnostic + +```swift +@available(iOS 14.0, *) +func processCPUException(_ diagnostic: MXCPUExceptionDiagnostic) { + // Total CPU time that triggered exception + let totalCPUTime = diagnostic.totalCPUTime // Measurement + + // Total sampled time + let totalSampledTime = diagnostic.totalSampledTime + + // Call stack of CPU-intensive code + let callStackTree = diagnostic.callStackTree +} +``` + +## Part 4: MXCallStackTree + +`MXCallStackTree` contains stack frames from diagnostics. Frames are NOT symbolicated—you must symbolicate using your dSYM. + +### Structure + +```swift +@available(iOS 14.0, *) +func parseCallStackTree(_ tree: MXCallStackTree) { + // JSON representation + let jsonData = tree.jsonRepresentation() + + // Parse the JSON + guard let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], + let callStacks = json["callStacks"] as? [[String: Any]] else { + return + } + + for callStack in callStacks { + guard let threadAttributed = callStack["threadAttributed"] as? Bool, + let frames = callStack["callStackRootFrames"] as? [[String: Any]] else { + continue + } + + // threadAttributed = true means this thread caused the issue + if threadAttributed { + parseFrames(frames) + } + } +} + +func parseFrames(_ frames: [[String: Any]]) { + for frame in frames { + // Binary image UUID (match to dSYM) + let binaryUUID = frame["binaryUUID"] as? String + + // Address offset within binary + let offsetIntoBinaryTextSegment = frame["offsetIntoBinaryTextSegment"] as? Int + + // Binary name (e.g., "MyApp", "UIKitCore") + let binaryName = frame["binaryName"] as? String + + // Address (for symbolication) + let address = frame["address"] as? Int + + // Sample count (how many times this frame appeared) + let sampleCount = frame["sampleCount"] as? Int + + // Sub-frames (tree structure) + let subFrames = frame["subFrames"] as? [[String: Any]] + } +} +``` + +### JSON Structure Example + +```json +{ + "callStacks": [ + { + "threadAttributed": true, + "callStackRootFrames": [ + { + "binaryUUID": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890", + "offsetIntoBinaryTextSegment": 123456, + "binaryName": "MyApp", + "address": 4384712345, + "sampleCount": 10, + "subFrames": [ + { + "binaryUUID": "F1E2D3C4-B5A6-7890-1234-567890ABCDEF", + "offsetIntoBinaryTextSegment": 78901, + "binaryName": "UIKitCore", + "address": 7234567890, + "sampleCount": 10 + } + ] + } + ] + } + ] +} +``` + +### Symbolication + +MetricKit call stacks are **unsymbolicated**. To symbolicate: + +1. **Keep your dSYM files** for every App Store build +2. **Match UUID** from `binaryUUID` to your dSYM +3. **Use atos** to symbolicate: + +```bash +# Find dSYM for binary UUID +mdfind "com_apple_xcode_dsym_uuids == A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + +# Symbolicate address +atos -arch arm64 -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -l 0x100000000 0x105234567 +``` + +Or use a crash reporting service that handles symbolication (Crashlytics, Sentry, etc.). + +## Part 5: MXBackgroundExitData + +Track why your app was terminated in the background: + +```swift +@available(iOS 14.0, *) +func analyzeBackgroundExits(_ data: MXBackgroundExitData) { + // Normal exits (user closed, system reclaimed) + let normal = data.cumulativeNormalAppExitCount + + // Memory issues + let memoryLimit = data.cumulativeMemoryResourceLimitExitCount // Exceeded memory limit + let memoryPressure = data.cumulativeMemoryPressureExitCount // Jetsam + + // Crashes + let badAccess = data.cumulativeBadAccessExitCount // SIGSEGV + let illegalInstruction = data.cumulativeIllegalInstructionExitCount // SIGILL + let abnormal = data.cumulativeAbnormalExitCount // Other crashes + + // System terminations + let watchdog = data.cumulativeAppWatchdogExitCount // Timeout during transition + let taskTimeout = data.cumulativeBackgroundTaskAssertionTimeoutExitCount // Background task timeout + let cpuLimit = data.cumulativeCPUResourceLimitExitCount // Exceeded CPU quota + let lockedFile = data.cumulativeSuspendedWithLockedFileExitCount // File lock held +} +``` + +### Exit Type Interpretation + +| Exit Type | Meaning | Action | +|-----------|---------|--------| +| `normalAppExitCount` | Clean exit | None (expected) | +| `memoryResourceLimitExitCount` | Used too much memory | Reduce footprint | +| `memoryPressureExitCount` | Jetsam (system reclaimed) | Reduce background memory to <50MB | +| `badAccessExitCount` | SIGSEGV crash | Check null pointers, invalid memory | +| `illegalInstructionExitCount` | SIGILL crash | Check invalid function pointers | +| `abnormalExitCount` | Other crash | Check crash diagnostics | +| `appWatchdogExitCount` | Hung during transition | Reduce launch/background work | +| `backgroundTaskAssertionTimeoutExitCount` | Didn't end background task | Call `endBackgroundTask` properly | +| `cpuResourceLimitExitCount` | Too much background CPU | Move to BGProcessingTask | +| `suspendedWithLockedFileExitCount` | Held file lock while suspended | Release locks before suspend | + +## Part 6: Integration Patterns + +### Upload to Analytics Service + +```swift +class MetricsUploader { + func upload(_ payload: MXMetricPayload) { + let jsonData = payload.jsonRepresentation() + + var request = URLRequest(url: analyticsEndpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = jsonData + + URLSession.shared.dataTask(with: request) { _, response, error in + if let error = error { + // Queue for retry + self.queueForRetry(jsonData) + } + }.resume() + } +} +``` + +### Combine with Crash Reporter + +```swift +class HybridCrashReporter: MXMetricManagerSubscriber { + let crashlytics: Crashlytics // or Sentry, etc. + + func didReceive(_ payloads: [MXDiagnosticPayload]) { + for payload in payloads { + // MetricKit captures crashes that traditional reporters might miss + // (e.g., watchdog kills, memory pressure exits) + + if let crashes = payload.crashDiagnostics { + for crash in crashes { + crashlytics.recordException( + name: crash.exceptionType?.description ?? "Unknown", + reason: crash.terminationReason ?? "MetricKit crash", + callStack: parseCallStack(crash.callStackTree) + ) + } + } + } + } +} +``` + +### Alert on Regressions + +```swift +class MetricsMonitor: MXMetricManagerSubscriber { + let thresholds = MetricThresholds( + launchTime: 2.0, // seconds + hangRate: 0.01, // 1% of sessions + memoryPeak: 200 // MB + ) + + func didReceive(_ payloads: [MXMetricPayload]) { + for payload in payloads { + checkThresholds(payload) + } + } + + private func checkThresholds(_ payload: MXMetricPayload) { + // Check launch time + if let launches = payload.applicationLaunchMetrics { + let p50 = calculateP50(launches.histogrammedTimeToFirstDraw) + if p50 > thresholds.launchTime { + sendAlert("Launch time regression: \(p50)s > \(thresholds.launchTime)s") + } + } + + // Check memory + if let memory = payload.memoryMetrics { + let peakMB = memory.peakMemoryUsage.converted(to: .megabytes).value + if peakMB > Double(thresholds.memoryPeak) { + sendAlert("Memory peak regression: \(peakMB)MB > \(thresholds.memoryPeak)MB") + } + } + } +} +``` + +## Part 7: Best Practices + +### Do + +- **Register subscriber early** — In `application(_:didFinishLaunchingWithOptions:)` or App init +- **Keep dSYM files** — Required for symbolicating call stacks +- **Upload payloads to server** — Local processing loses data on uninstall +- **Set up alerting** — Detect regressions before users report them +- **Test with simulated payloads** — Xcode Debug menu in iOS 15+ + +### Don't + +- **Don't rely solely on MetricKit** — 24-hour delay, requires user opt-in +- **Don't ignore background exits** — Jetsam and task timeouts affect UX +- **Don't skip symbolication** — Raw addresses are unusable +- **Don't process on main thread** — Payload processing can be expensive + +### Privacy Considerations + +- MetricKit data is **aggregated and anonymized** +- Data only from users who **opted into sharing analytics** +- No personally identifiable information +- Safe to upload to your servers + +## Part 8: MetricKit vs Xcode Organizer + +| Feature | MetricKit | Xcode Organizer | +|---------|-----------|-----------------| +| **Data source** | Devices running your app | App Store Connect aggregation | +| **Delivery** | Daily to your subscriber | On-demand in Xcode | +| **Customization** | Full access to raw data | Predefined views | +| **Symbolication** | You must symbolicate | Pre-symbolicated | +| **Historical data** | Only when subscriber active | Last 16 versions | +| **Requires code** | Yes | No | + +**Use both**: Organizer for quick overview, MetricKit for custom analytics and alerting. + +## Resources + +**WWDC**: 2019-417, 2020-10081, 2021-10087 + +**Docs**: /metrickit, /metrickit/mxmetricmanager, /metrickit/mxdiagnosticpayload + +**Skills**: axiom-hang-diagnostics, axiom-performance-profiling, axiom-testflight-triage diff --git a/.claude/skills/axiom-metrickit-ref/agents/openai.yaml b/.claude/skills/axiom-metrickit-ref/agents/openai.yaml new file mode 100644 index 0000000..da68480 --- /dev/null +++ b/.claude/skills/axiom-metrickit-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "MetricKit Reference" + short_description: "MetricKit API reference for field diagnostics" diff --git a/.claude/skills/axiom-modernize/.openskills.json b/.claude/skills/axiom-modernize/.openskills.json new file mode 100644 index 0000000..c896854 --- /dev/null +++ b/.claude/skills/axiom-modernize/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-modernize", + "installedAt": "2026-04-12T08:06:29.425Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-modernize/SKILL.md b/.claude/skills/axiom-modernize/SKILL.md new file mode 100644 index 0000000..cd982db --- /dev/null +++ b/.claude/skills/axiom-modernize/SKILL.md @@ -0,0 +1,546 @@ +--- +name: axiom-modernize +description: Use when the user wants to modernize iOS code to iOS 17/18 patterns, migrate from ObservableObject to @Observable, update @StateObject to @State, or adopt modern SwiftUI APIs. +license: MIT +disable-model-invocation: true +--- +# Modernization Helper Agent + +You are an expert at migrating iOS apps to modern iOS 17/18+ patterns. + +## Your Mission + +Scan the codebase for legacy patterns and provide migration paths: +- `ObservableObject` → `@Observable` +- `@StateObject` → `@State` with Observable +- `@ObservedObject` → Direct property or `@Bindable` +- `@EnvironmentObject` → `@Environment` +- Legacy SwiftUI modifiers → Modern equivalents +- Completion handlers → async/await + +Report findings with: +- File:line references +- Priority (HIGH/MEDIUM/LOW based on benefit) +- Migration code examples +- Breaking change warnings + +## Files to Scan + +**Swift files**: `**/*.swift` +Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Modernization Patterns (iOS 17+ / iOS 18+) + +### Pattern 1: ObservableObject → @Observable (HIGH) + +**Why migrate**: Better performance (view updates only when accessed properties change), simpler syntax, no `@Published` needed + +**Requirement**: iOS 17+ + +**Detection**: +``` +Grep: class.*ObservableObject +Grep: : ObservableObject +Grep: @Published +``` + +```swift +// ❌ LEGACY (iOS 14-16) +class ContentViewModel: ObservableObject { + @Published var items: [Item] = [] + @Published var isLoading = false + @Published var errorMessage: String? +} + +// ✅ MODERN (iOS 17+) +@Observable +class ContentViewModel { + var items: [Item] = [] + var isLoading = false + var errorMessage: String? + + // Use @ObservationIgnored for non-observed properties + @ObservationIgnored + var internalCache: [String: Any] = [:] +} +``` + +**Migration steps**: +1. Replace `: ObservableObject` with `@Observable` macro +2. Remove all `@Published` property wrappers +3. Add `@ObservationIgnored` to properties that shouldn't trigger updates +4. Update consuming views (see patterns below) + +### Pattern 2: @StateObject → @State (HIGH) + +**Why migrate**: Simpler, consistent with value types, works with @Observable + +**Requirement**: iOS 17+ with @Observable model + +**Detection**: +``` +Grep: @StateObject +``` + +```swift +// ❌ LEGACY +struct ContentView: View { + @StateObject private var viewModel = ContentViewModel() + + var body: some View { ... } +} + +// ✅ MODERN (with @Observable model) +struct ContentView: View { + @State private var viewModel = ContentViewModel() + + var body: some View { ... } +} +``` + +**Note**: Only migrate after the model uses `@Observable`. If model still uses `ObservableObject`, keep `@StateObject`. + +### Pattern 3: @ObservedObject → Direct Property or @Bindable (HIGH) + +**Why migrate**: Simpler code, explicit binding when needed + +**Requirement**: iOS 17+ with @Observable model + +**Detection**: +``` +Grep: @ObservedObject +``` + +```swift +// ❌ LEGACY +struct ItemView: View { + @ObservedObject var item: ItemModel + + var body: some View { + Text(item.name) + } +} + +// ✅ MODERN - Direct property (read-only access) +struct ItemView: View { + var item: ItemModel // No wrapper needed! + + var body: some View { + Text(item.name) + } +} + +// ✅ MODERN - @Bindable (for two-way binding) +struct ItemEditorView: View { + @Bindable var item: ItemModel + + var body: some View { + TextField("Name", text: $item.name) // Binding works + } +} +``` + +**Decision tree**: +- Need binding (`$item.property`)? → Use `@Bindable` +- Just reading properties? → Use plain property (no wrapper) + +### Pattern 4: @EnvironmentObject → @Environment (HIGH) + +**Why migrate**: Type-safe, works with @Observable + +**Requirement**: iOS 17+ with @Observable model + +**Detection**: +``` +Grep: @EnvironmentObject +Grep: \.environmentObject\( +``` + +```swift +// ❌ LEGACY - Setting +ContentView() + .environmentObject(settings) + +// ❌ LEGACY - Reading +struct SettingsView: View { + @EnvironmentObject var settings: AppSettings + + var body: some View { ... } +} + +// ✅ MODERN - Setting +ContentView() + .environment(settings) + +// ✅ MODERN - Reading +struct SettingsView: View { + @Environment(AppSettings.self) var settings + + var body: some View { ... } +} + +// ✅ MODERN - With binding +struct SettingsEditorView: View { + @Environment(AppSettings.self) var settings + + var body: some View { + @Bindable var settings = settings + Toggle("Dark Mode", isOn: $settings.darkMode) + } +} +``` + +### Pattern 5: onChange(of:perform:) → onChange(of:initial:_:) (MEDIUM) + +**Why migrate**: Deprecated modifier, new API has `initial` parameter + +**Requirement**: iOS 17+ + +**Detection**: +``` +Grep: \.onChange\(of:.*perform: +``` + +```swift +// ❌ DEPRECATED +.onChange(of: searchText) { newValue in + performSearch(newValue) +} + +// ✅ MODERN (iOS 17+) +.onChange(of: searchText) { oldValue, newValue in + performSearch(newValue) +} + +// ✅ With initial execution +.onChange(of: searchText, initial: true) { oldValue, newValue in + performSearch(newValue) +} +``` + +### Pattern 6: Completion Handlers → async/await (MEDIUM) + +**Why migrate**: Cleaner code, better error handling, structured concurrency + +**Requirement**: iOS 15+ (widely adopted in iOS 17+) + +**Detection**: +``` +Grep: completion:\s*@escaping +Grep: completionHandler: +Grep: DispatchQueue\.main\.async +``` + +```swift +// ❌ LEGACY +func fetchUser(id: String, completion: @escaping (Result) -> Void) { + URLSession.shared.dataTask(with: url) { data, response, error in + DispatchQueue.main.async { + if let error = error { + completion(.failure(error)) + return + } + // Parse and return + completion(.success(user)) + } + }.resume() +} + +// ✅ MODERN +func fetchUser(id: String) async throws -> User { + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode(User.self, from: data) +} +``` + +### Pattern 7: withAnimation Closures → Animation Parameter (LOW) + +**Why migrate**: Cleaner API, avoids closure + +**Requirement**: iOS 17+ + +**Detection**: +``` +Grep: withAnimation.*\{ +``` + +```swift +// ❌ LEGACY +withAnimation(.spring()) { + isExpanded.toggle() +} + +// ✅ MODERN (simple cases) +isExpanded.toggle() +// Apply animation to view: +.animation(.spring(), value: isExpanded) + +// Or use new binding animation: +$isExpanded.animation(.spring()).wrappedValue.toggle() +``` + +### Pattern 8: Swift Language Modernization (LOW) + +**Why migrate**: Clearer, more efficient, modern Swift idioms + +**Detection**: +``` +Grep: Date\(\) +Grep: CGFloat +Grep: replacingOccurrences +Grep: DateFormatter\(\) +Grep: \.filter\(.*\)\.count +Grep: Task\.sleep\(nanoseconds: +``` + +**Reference**: See `axiom-swift-modern` skill for the full modern API replacement table. + +Report matches as LOW priority unless they appear in hot paths (then MEDIUM). + +## Audit Process + +### Step 1: Find Swift Files + +``` +Glob: **/*.swift +``` + +### Step 2: Detect Legacy Patterns + +**ObservableObject**: +``` +Grep: ObservableObject +Grep: @Published +``` + +**Property Wrappers**: +``` +Grep: @StateObject|@ObservedObject|@EnvironmentObject +``` + +**Deprecated Modifiers**: +``` +Grep: onChange\(of:.*perform: +``` + +**Completion Handlers**: +``` +Grep: completion:\s*@escaping +Grep: completionHandler: +``` + +### Step 3: Categorize by Priority + +**HIGH Priority** (significant benefits): +- ObservableObject → @Observable +- Property wrapper migrations + +**MEDIUM Priority** (code quality): +- Deprecated modifiers +- async/await adoption + +**LOW Priority** (minor improvements): +- Animation syntax +- Minor API updates + +## Output Format + +```markdown +# Modernization Analysis Results + +## Summary +- **HIGH Priority**: [count] (Significant performance/maintainability gains) +- **MEDIUM Priority**: [count] (Deprecated APIs, code quality) +- **LOW Priority**: [count] (Minor improvements) + +## Minimum Deployment Target Impact +- Current patterns support: iOS 14+ +- After full modernization: iOS 17+ + +## HIGH Priority Migrations + +### ObservableObject → @Observable + +**Files affected**: 5 +**Estimated effort**: 2-3 hours + +#### Models to Migrate + +1. `Models/ContentViewModel.swift:12` + ```swift + // Current + class ContentViewModel: ObservableObject { + @Published var items: [Item] = [] + @Published var isLoading = false + } + + // Migrated + @Observable + class ContentViewModel { + var items: [Item] = [] + var isLoading = false + } + ``` + +2. `Models/UserSettings.swift:8` + [Similar migration...] + +#### Views to Update After Model Migration + +| File | Change | +|------|--------| +| `Views/ContentView.swift:15` | `@StateObject` → `@State` | +| `Views/ItemList.swift:23` | `@ObservedObject` → plain property | +| `Views/SettingsView.swift:8` | `@EnvironmentObject` → `@Environment` | + +### @EnvironmentObject → @Environment + +- `Views/RootView.swift:45` + ```swift + // Current + .environmentObject(settings) + + // Migrated + .environment(settings) + ``` + +- `Views/SettingsView.swift:12` + ```swift + // Current + @EnvironmentObject var settings: AppSettings + + // Migrated + @Environment(AppSettings.self) var settings + ``` + +## MEDIUM Priority Migrations + +### Deprecated onChange Modifier + +- `Views/SearchView.swift:34` + ```swift + // Deprecated + .onChange(of: query) { newValue in + search(newValue) + } + + // Modern + .onChange(of: query) { oldValue, newValue in + search(newValue) + } + ``` + +### async/await Opportunities + +- `Services/NetworkService.swift` - 3 completion handler methods + - `fetchUser(completion:)` → `fetchUser() async throws` + - `fetchItems(completion:)` → `fetchItems() async throws` + - `uploadData(completion:)` → `uploadData() async throws` + +## Migration Order + +1. **First**: Migrate models to `@Observable` + - All `ObservableObject` → `@Observable` + - Remove all `@Published` + +2. **Second**: Update view property wrappers + - `@StateObject` → `@State` (for owned models) + - `@ObservedObject` → plain or `@Bindable` + - `@EnvironmentObject` → `@Environment` + +3. **Third**: Update view modifiers + - `.environmentObject()` → `.environment()` + - Deprecated `onChange` syntax + +4. **Fourth**: Adopt async/await (optional, but recommended) + +## Breaking Changes Warning + +⚠️ **Deployment Target**: Full migration requires iOS 17+ + +If you need to support iOS 16 or earlier: +- Keep `ObservableObject` for those models +- Use conditional compilation: + ```swift + #if os(iOS) && swift(>=5.9) + @Observable + class ViewModel { ... } + #else + class ViewModel: ObservableObject { ... } + #endif + ``` + +## Verification + +After migration: +1. Build and fix any compiler errors +2. Test view updates (properties should still trigger UI refresh) +3. Test bindings (TextField, Toggle still work) +4. Test environment injection +``` + +## When No Migration Needed + +```markdown +# Modernization Analysis Results + +## Summary +Codebase is already using modern patterns! + +## Verified +- ✅ Using `@Observable` macro +- ✅ Using `@State` with Observable models +- ✅ Using `@Environment` for shared state +- ✅ No deprecated modifiers detected + +## Optional Improvements +- Consider adopting iOS 18+ features when available +- Review remaining completion handlers for async/await conversion +``` + +## Decision Flowchart + +``` +Is model a class with published properties? +├─ YES: Does it conform to ObservableObject? +│ ├─ YES: Target iOS 17+? +│ │ ├─ YES → Migrate to @Observable +│ │ └─ NO → Keep ObservableObject +│ └─ NO: Already modern or not observable +└─ NO: Check if it's a struct (usually fine) + +Is view using @StateObject? +├─ YES: Is the model @Observable? +│ ├─ YES → Change to @State +│ └─ NO → Keep @StateObject until model migrated +└─ NO: Check other wrappers + +Is view using @ObservedObject? +├─ YES: Is the model @Observable? +│ ├─ YES: Need binding? +│ │ ├─ YES → Use @Bindable +│ │ └─ NO → Remove wrapper, use plain property +│ └─ NO → Keep @ObservedObject +└─ NO: Already modern + +Is view using @EnvironmentObject? +├─ YES: Is the model @Observable? +│ ├─ YES → Change to @Environment(Type.self) +│ └─ NO → Keep @EnvironmentObject +└─ NO: Already modern +``` + +## False Positives to Avoid + +**Not issues**: +- Third-party SDK types using ObservableObject +- Models that intentionally support iOS 14-16 +- Combine publishers (not the same as @Published) +- Already migrated code using @Observable +- Apple protocol families unrelated to Observation — classes conforming to `AppIntent`, `EntityQuery`, `AppEntity`, `WidgetConfiguration`, `TimelineProvider`, or other App Intents / WidgetKit protocols are NOT `ObservableObject` and should not be flagged for `@Observable` migration + +**Check before reporting**: +- Verify file is in your project, not dependencies +- Check deployment target constraints +- Confirm model is actually used in SwiftUI views +- Confirm the class actually conforms to `ObservableObject` — do not flag classes just because they are classes diff --git a/.claude/skills/axiom-modernize/agents/openai.yaml b/.claude/skills/axiom-modernize/agents/openai.yaml new file mode 100644 index 0000000..1477e33 --- /dev/null +++ b/.claude/skills/axiom-modernize/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Modernize" + short_description: "The user wants to modernize iOS code to iOS 17/18 patterns, migrate from ObservableObject to @Observable, update @Sta..." diff --git a/.claude/skills/axiom-network-framework-ref/.openskills.json b/.claude/skills/axiom-network-framework-ref/.openskills.json new file mode 100644 index 0000000..2aad087 --- /dev/null +++ b/.claude/skills/axiom-network-framework-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-network-framework-ref", + "installedAt": "2026-04-12T08:06:29.616Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-network-framework-ref/SKILL.md b/.claude/skills/axiom-network-framework-ref/SKILL.md new file mode 100644 index 0000000..2499307 --- /dev/null +++ b/.claude/skills/axiom-network-framework-ref/SKILL.md @@ -0,0 +1,1413 @@ +--- +name: axiom-network-framework-ref +description: Reference — Comprehensive Network.framework guide covering NetworkConnection (iOS 26+), NWConnection (iOS 12-25), TLV framing, Coder protocol, NetworkListener, NetworkBrowser, Wi-Fi Aware discovery, and migration strategies +license: MIT +compatibility: iOS 12+ (NWConnection), iOS 26+ (NetworkConnection) +metadata: + version: "1.0.0" + last-updated: "2025-12-02" +--- + +# Network.framework API Reference + +## Overview + +Network.framework is Apple's modern networking API that replaces Berkeley sockets, providing smart connection establishment, user-space networking, built-in TLS support, and seamless mobility. Introduced in iOS 12 (2018) with NWConnection and evolved in iOS 26 (2025) with NetworkConnection for structured concurrency. + +#### Evolution timeline +- **2018 (iOS 12)** NWConnection with completion handlers, deprecates CFSocket/NSStream/SCNetworkReachability +- **2019 (iOS 13)** User-space networking (30% CPU reduction), TLS 1.3 default +- **2025 (iOS 26)** NetworkConnection with async/await, TLV framing built-in, Coder protocol, Wi-Fi Aware discovery + +#### Key capabilities +- **Smart connection establishment** Happy Eyeballs (IPv4/IPv6 racing), proxy evaluation (PAC), VPN detection, WiFi Assist fallback +- **User-space networking** ~30% lower CPU usage vs sockets, memory-mapped regions, reduced context switches +- **Built-in security** TLS 1.3 by default, DTLS for UDP, certificate pinning support +- **Mobility** Automatic network transition handling (WiFi ↔ cellular), viability notifications, Multipath TCP +- **Performance** ECN (Explicit Congestion Notification), service class marking, TCP Fast Open, UDP batching + +#### When to use vs URLSession +- **URLSession** HTTP, HTTPS, WebSocket, simple TCP/TLS streams → Use URLSession (optimized for these) +- **Network.framework** UDP, custom protocols, low-level control, peer-to-peer, gaming, streaming → Use Network.framework + +#### Related Skills +- Use `axiom-networking` for anti-patterns, common patterns, pressure scenarios +- Use `axiom-networking-diag` for systematic troubleshooting of connection failures + +--- + +## When to Use This Skill + +Use this skill when: +- **Planning migration** from BSD sockets, CFSocket, NSStream, or SCNetworkReachability +- **Understanding API differences** between NWConnection (iOS 12-25) and NetworkConnection (iOS 26+) +- **Implementing all 12 WWDC 2025 examples** (TLS connection, TLV framing, Coder protocol, NetworkListener, Wi-Fi Aware) +- **Choosing protocols** (TCP, UDP, TLS, QUIC) for your use case +- **Peer-to-peer discovery** setup with NetworkBrowser and Wi-Fi Aware +- **Optimizing performance** with user-space networking, batching, pacing +- **Migrating** from completion handlers to async/await (NWConnection → NetworkConnection) + +--- + +## API Evolution + +### Timeline + +| Year | iOS Version | Key Features | +|------|-------------|--------------| +| 2018 | iOS 12 | NWConnection, NWListener, NWBrowser introduced | +| 2019 | iOS 13 | User-space networking (30% CPU reduction), TLS 1.3 default | +| 2021 | iOS 15 | WebSocket support in URLSession | +| 2025 | iOS 26 | NetworkConnection (async/await), TLV framing, Coder protocol, Wi-Fi Aware | + +### NWConnection (iOS 12-25) vs NetworkConnection (iOS 26+) + +| Feature | NWConnection (iOS 12-25) | NetworkConnection (iOS 26+) | +|---------|-------------------------|----------------------------| +| **Async model** | Completion handlers | async/await structured concurrency | +| **State updates** | `stateUpdateHandler` callback | `states` AsyncSequence | +| **Send** | `send(content:completion:)` callback | `try await send(content)` suspending | +| **Receive** | `receive(minimumIncompleteLength:maximumLength:completion:)` | `try await receive(exactly:)` suspending | +| **Framing** | Manual or custom NWFramer | TLV built-in (`TLV { TLS() }`) | +| **Codable** | Manual JSON encode/decode | Coder protocol (`Coder(MyType.self, using: .json)`) | +| **Memory** | Requires `[weak self]` in all closures | No `[weak self]` needed (Task cancellation automatic) | +| **Error handling** | Check error in completion | `throws` with natural propagation | +| **State machine** | Callbacks on state changes | `for await state in connection.states` | +| **Discovery** | NWBrowser (Bonjour only) | NetworkBrowser (Bonjour + Wi-Fi Aware) | + +#### Recommendation +- New apps targeting iOS 26+: Use NetworkConnection (cleaner, safer) +- Apps supporting iOS 12-25: Use NWConnection (backward compatible) +- Migration: Both APIs coexist, migrate incrementally + +--- + +## NetworkConnection (iOS 26+) Complete Reference + +### 4.1 Creating Connections + +NetworkConnection uses declarative protocol stack composition. + +#### Example 1: Basic TLS Connection (WWDC 4:04) + +```swift +import Network + +// Basic connection with TLS (TCP and IP inferred) +let connection = NetworkConnection( + to: .hostPort(host: "www.example.com", port: 1029) +) { + TLS() +} + +// Send and receive with async/await +public func sendAndReceiveWithTLS() async throws { + let outgoingData = Data("Hello, world!".utf8) + try await connection.send(outgoingData) + + let incomingData = try await connection.receive(exactly: 98).content + print("Received data: \(incomingData)") +} +``` + +#### Key points +- `TLS()` infers `TCP()` and `IP()` automatically +- No explicit connection.start() needed (happens on first send/receive) +- Async/await eliminates callback nesting + +#### Example 2: Custom IP Options (WWDC 4:41) + +```swift +// Customize IP fragmentation +let connection = NetworkConnection( + to: .hostPort(host: "www.example.com", port: 1029) +) { + TLS { + TCP { + IP() + .fragmentationEnabled(false) // Disable IP fragmentation + } + } +} +``` + +#### When to customize IP +- `.fragmentationEnabled(false)` — For protocols that handle fragmentation themselves (QUIC) +- `.ipVersion(.v6)` — Force IPv6 only (testing) + +#### Example 3: Custom Parameters (WWDC 5:07) + +```swift +// Constrained paths (low data mode) + custom IP +let connection = NetworkConnection( + to: .hostPort(host: "www.example.com", port: 1029), + using: .parameters { + TLS { + TCP { + IP() + .fragmentationEnabled(false) + } + } + } + .constrainedPathsProhibited(true) // Don't use cellular in low data mode +) +``` + +#### Common parameters +- `.constrainedPathsProhibited(true)` — Respect low data mode +- `.expensivePathsProhibited(true)` — Don't use cellular/hotspot +- `.multipathServiceType(.handover)` — Enable Multipath TCP + +#### Endpoint Types + +```swift +// Host + Port +.hostPort(host: "example.com", port: 443) + +// Service (Bonjour) +.service(name: "MyPrinter", type: "_ipp._tcp", domain: "local.", interface: nil) + +// Unix domain socket +.unix(path: "/tmp/my.sock") +``` + +#### Protocol Stack Composition + +```swift +// TLS over TCP (most common) +TLS() + +// QUIC (TLS + UDP, multiplexed streams) +QUIC() + +// UDP (datagrams) +UDP() + +// TCP (stream, no encryption) +TCP() + +// WebSocket over TLS +WebSocket { + TLS() +} + +// Custom framing +TLV { + TLS() +} +``` + +--- + +### 4.2 State Machine + +NetworkConnection transitions through these states: + +``` +setup + ↓ +preparing (DNS, TCP handshake, TLS handshake) + ↓ +┌─ waiting (no network, retrying) +│ ↓ +└→ ready (can send/receive) + ↓ + failed (error) or cancelled +``` + +#### Monitoring States + +```swift +// Option 1: Async sequence (monitor in background) +Task { + for await state in connection.states { + switch state { + case .preparing: + print("Connecting...") + case .waiting(let error): + print("Waiting for network: \(error)") + case .ready: + print("Connected!") + case .failed(let error): + print("Failed: \(error)") + case .cancelled: + print("Cancelled") + @unknown default: + break + } + } +} +``` + +#### Key states +- **.preparing** DNS lookup, TCP SYN, TLS handshake +- **.waiting** No network available, framework retries automatically +- **.ready** Connection established, can send/receive +- **.failed** Unrecoverable error (server refused, TLS failed, timeout) +- **.cancelled** Task cancelled or connection.cancel() called + +--- + +### 4.3 Send/Receive Patterns + +#### Send: Basic + +```swift +let data = Data("Hello".utf8) +try await connection.send(data) +``` + +#### Receive: Exact Byte Count (WWDC 7:30) + +```swift +// Receive exactly 98 bytes +let incomingData = try await connection.receive(exactly: 98).content +print("Received \(incomingData.count) bytes") +``` + +#### Receive: Variable Length (WWDC 8:29) + +```swift +// Read UInt32 length prefix, then read that many bytes +let remaining32 = try await connection.receive(as: UInt32.self).content +guard var remaining = Int(exactly: remaining32) else { throw MyError.invalidLength } + +while remaining > 0 { + let chunk = try await connection.receive(atLeast: 1, atMost: remaining).content + remaining -= chunk.count + // Process chunk... +} +``` + +#### receive() variants +- `receive(exactly: n)` — Wait for exactly n bytes +- `receive(atLeast: min, atMost: max)` — Get between min and max bytes +- `receive(as: UInt32.self)` — Read fixed-size type (network byte order) + +--- + +### 4.4 TLV Framing (iOS 26+) + +**TLV (Type-Length-Value)** solves message boundary problem on stream protocols (TCP/TLS). + +#### Format +- Type: UInt32 (message identifier) +- Length: UInt32 (message size, automatic) +- Value: Message bytes + +#### Example: GameMessage with TLV (WWDC 11:06, 11:24, 11:53) + +```swift +import Network + +// Define message types +enum GameMessage: Int { + case selectedCharacter = 0 + case move = 1 +} + +struct GameCharacter: Codable { + let character: String +} + +struct GameMove: Codable { + let row: Int + let column: Int +} + +// Connection with TLV framing +let connection = NetworkConnection( + to: .hostPort(host: "www.example.com", port: 1029) +) { + TLV { + TLS() + } +} + +// Send typed message +public func sendWithTLV() async throws { + let characterData = try JSONEncoder().encode(GameCharacter(character: "🐨")) + try await connection.send(characterData, type: GameMessage.selectedCharacter.rawValue) +} + +// Receive typed message +public func receiveWithTLV() async throws { + let (incomingData, metadata) = try await connection.receive() + + switch GameMessage(rawValue: metadata.type) { + case .selectedCharacter: + let character = try JSONDecoder().decode(GameCharacter.self, from: incomingData) + print("Character selected: \(character)") + case .move: + let move = try JSONDecoder().decode(GameMove.self, from: incomingData) + print("Move: row=\(move.row), column=\(move.column)") + case .none: + print("Unknown message type: \(metadata.type)") + } +} +``` + +#### Benefits +- Message boundaries preserved (send 3 messages → receive exactly 3) +- Type-safe message handling (enum-based routing) +- Minimal overhead (8 bytes per message: type + length) + +#### When to use +- Mixed message types (chat + presence + typing) +- Existing protocols using TLV +- Need message boundaries without heavy framing + +--- + +### 4.5 Coder Protocol (iOS 26+) + +**Coder** eliminates manual JSON encoding/decoding boilerplate. + +#### Example: GameMessage with Coder (WWDC 12:50, 13:13, 13:53) + +```swift +import Network + +// Define message types as Codable enum +enum GameMessage: Codable { + case selectedCharacter(String) + case move(row: Int, column: Int) +} + +// Connection with Coder +let connection = NetworkConnection( + to: .hostPort(host: "www.example.com", port: 1029) +) { + Coder(GameMessage.self, using: .json) { + TLS() + } +} + +// Send Codable directly (no encoding needed!) +public func sendWithCoder() async throws { + let selectedCharacter: GameMessage = .selectedCharacter("🐨") + try await connection.send(selectedCharacter) +} + +// Receive Codable directly (no decoding needed!) +public func receiveWithCoder() async throws { + let gameMessage = try await connection.receive().content // Returns GameMessage! + + switch gameMessage { + case .selectedCharacter(let character): + print("Character selected: \(character)") + case .move(let row, let column): + print("Move: (\(row), \(column))") + } +} +``` + +#### Supported formats +- `.json` — JSON encoding (human-readable, widely compatible) +- `.propertyList` — Property list (faster, smaller) + +#### Benefits +- No JSON boilerplate (~50 lines → ~10 lines) +- Type-safe (compiler catches message structure changes) +- Automatic framing (handles message boundaries) + +#### When to use +- App-to-app communication (you control both ends) +- Prototyping (fastest time to working code) +- Type-safe protocols + +#### When NOT to use +- Interoperating with non-Swift servers +- Need custom wire format +- Performance-critical (prefer manual encoding for control) + +--- + +### 4.6 NetworkListener (iOS 26+) + +Listen for incoming connections with automatic subtask management. + +#### Example: Listening for Connections (WWDC 15:16) + +```swift +import Network + +// Listener with Coder protocol +public func listenForIncomingConnections() async throws { + try await NetworkListener { + Coder(GameMessage.self, using: .json) { + TLS() + } + }.run { connection in + // Each connection gets its own subtask + for try await (gameMessage, _) in connection.messages { + switch gameMessage { + case .selectedCharacter(let character): + print("Player chose: \(character)") + case .move(let row, let column): + print("Player moved: (\(row), \(column))") + } + } + } +} +``` + +#### Key features +- Automatic subtask per connection (no manual Task management) +- Structured concurrency (all subtasks cancelled when listener exits) +- `connection.messages` async sequence for receiving + +#### Listener configuration + +```swift +// Specify port +NetworkListener(port: 1029) { TLS() } + +// Let system choose port +NetworkListener { TLS() } + +// Bonjour advertising +NetworkListener(service: .init(name: "MyApp", type: "_myapp._tcp")) { TLS() } +``` + +--- + +### 4.7 NetworkBrowser & Wi-Fi Aware (iOS 26+) + +Discover endpoints on local network or nearby devices. + +#### Example: Wi-Fi Aware Discovery (WWDC 17:39) + +```swift +import Network +import WiFiAware + +// Browse for nearby paired Wi-Fi Aware devices +public func findNearbyDevice() async throws { + let endpoint = try await NetworkBrowser( + for: .wifiAware(.connecting(to: .allPairedDevices, from: .ticTacToeService)) + ).run { endpoints in + .finish(endpoints.first!) // Use first discovered device + } + + // Make connection to the discovered endpoint + let connection = NetworkConnection(to: endpoint) { + Coder(GameMessage.self, using: .json) { + TLS() + } + } +} +``` + +#### Wi-Fi Aware features +- Peer-to-peer without infrastructure (no WiFi router needed) +- Automatic discovery of paired devices +- Low latency, axiom-high throughput +- iOS 26+ only + +#### Browse descriptors + +```swift +// Bonjour +.bonjour(type: "_http._tcp", domain: "local") + +// Wi-Fi Aware (all paired devices) +.wifiAware(.connecting(to: .allPairedDevices, from: .myService)) + +// Wi-Fi Aware (specific device) +.wifiAware(.connecting(to: .pairedDevice(identifier: deviceID), from: .myService)) +``` + +--- + +## NWConnection (iOS 12-25) Complete Reference + +### 5.1 Creating Connections + +NWConnection uses completion handlers (pre-async/await). + +#### Basic TLS Connection (WWDC 2018 lines 133-166) + +```swift +import Network + +// Create connection +let connection = NWConnection( + host: NWEndpoint.Host("mail.example.com"), + port: NWEndpoint.Port(integerLiteral: 993), + using: .tls // TCP inferred +) + +// Handle connection state changes +connection.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + print("Connection established") + self?.sendData() + + case .waiting(let error): + print("Waiting for network: \(error)") + // Show "Waiting..." UI, don't fail immediately + + case .failed(let error): + print("Connection failed: \(error)") + + case .cancelled: + print("Connection cancelled") + + default: + break + } +} + +// Start connection +connection.start(queue: .main) +``` + +**Critical** Always use `[weak self]` in stateUpdateHandler to prevent retain cycles. + +#### Custom Parameters + +```swift +// Create custom parameters +let parameters = NWParameters.tls + +// Prohibit expensive networks +parameters.prohibitExpensivePaths = true // Don't use cellular/hotspot + +// Prohibit constrained networks +parameters.prohibitConstrainedPaths = true // Respect low data mode + +// Require IPv6 +parameters.requiredInterfaceType = .wifi +parameters.ipOptions.version = .v6 + +let connection = NWConnection(host: "example.com", port: 443, using: parameters) +``` + +--- + +### 5.2 State Handling + +NWConnection state machine (same as NetworkConnection): + +``` +setup → preparing → waiting/ready → failed/cancelled +``` + +#### State handling best practices + +```swift +connection.stateUpdateHandler = { [weak self] state in + guard let self = self else { return } + + switch state { + case .preparing: + // DNS lookup, TCP SYN, TLS handshake in progress + self.updateUI(.connecting) + + case .waiting(let error): + // Network unavailable or blocked + // DON'T fail immediately, framework retries automatically + print("Waiting: \(error.localizedDescription)") + self.updateUI(.waiting) + + case .ready: + // Connection established, can send/receive + self.updateUI(.connected) + self.startSending() + + case .failed(let error): + // Unrecoverable error after all retry attempts + print("Failed: \(error.localizedDescription)") + self.updateUI(.failed) + + case .cancelled: + // connection.cancel() called + self.updateUI(.disconnected) + + default: + break + } +} +``` + +--- + +### 5.3 Send/Receive with Callbacks + +#### Send with Pacing (WWDC 2018 lines 320-341) + +```swift +// Send with contentProcessed callback for pacing +func sendData() { + let data = Data("Hello, world!".utf8) + + connection.send(content: data, completion: .contentProcessed { [weak self] error in + if let error = error { + print("Send error: \(error)") + return + } + + // contentProcessed = network stack consumed data + // NOW send next chunk (pacing) + self?.sendNextData() + }) +} +``` + +**contentProcessed callback** Invoked when network stack consumes your data (equivalent to when blocking socket call would return). Use this for pacing to avoid buffering excessive data. + +#### Receive with Exact Byte Count + +```swift +// Receive exactly 10 bytes +connection.receive(minimumIncompleteLength: 10, maximumLength: 10) { [weak self] (data, context, isComplete, error) in + if let error = error { + print("Receive error: \(error)") + return + } + + if let data = data { + print("Received \(data.count) bytes") + // Process data... + + // Continue receiving + self?.receiveMore() + } +} +``` + +#### Receive parameters +- `minimumIncompleteLength`: Minimum bytes before callback (1 = return any data) +- `maximumLength`: Maximum bytes per callback +- For "exactly n bytes": Set both to n + +--- + +### 5.4 UDP Batching (WWDC 2018 lines 343-347) + +#### Batch sending for 30% CPU reduction. + +```swift +// UDP connection +let connection = NWConnection( + host: NWEndpoint.Host("game-server.example.com"), + port: NWEndpoint.Port(integerLiteral: 9000), + using: .udp +) + +connection.start(queue: .main) + +// Batch multiple datagrams +func sendVideoFrames(_ frames: [Data]) { + connection.batch { + for frame in frames { + connection.send(content: frame, completion: .contentProcessed { error in + if let error = error { + print("Send error: \(error)") + } + }) + } + } + // All sends batched into ~1 syscall + // Result: 30% lower CPU usage vs individual sends +} +``` + +**Without batch** 100 datagrams = 100 syscalls = high CPU +**With batch** 100 datagrams = ~1 syscall = 30% lower CPU (measured with Instruments) + +--- + +### 5.5 NWListener (WWDC 2018 lines 233-293) + +Accept incoming connections. + +```swift +import Network + +// Create listener on port 1029 +let listener = try NWListener(using: .tcp, on: 1029) + +// Advertise Bonjour service +listener.service = NWListener.Service(name: "MyApp", type: "_myapp._tcp") + +// Handle service registration +listener.serviceRegistrationUpdateHandler = { update in + switch update { + case .add(let endpoint): + if case .service(let name, let type, let domain, _) = endpoint { + print("Advertising: \(name).\(type)\(domain)") + } + default: + break + } +} + +// Handle new connections +listener.newConnectionHandler = { [weak self] newConnection in + print("New connection from: \(newConnection.endpoint)") + + newConnection.stateUpdateHandler = { state in + if case .ready = state { + print("Client connected") + self?.handleClient(newConnection) + } + } + + newConnection.start(queue: .main) +} + +// Handle listener state +listener.stateUpdateHandler = { state in + switch state { + case .ready: + print("Listener ready on port \(listener.port ?? 0)") + case .failed(let error): + print("Listener failed: \(error)") + default: + break + } +} + +// Start listening +listener.start(queue: .main) +``` + +--- + +### 5.6 NWBrowser (Bonjour Discovery) + +Discover services on local network. + +```swift +import Network + +// Browse for Bonjour services +let browser = NWBrowser( + for: .bonjour(type: "_http._tcp", domain: nil), + using: .tcp +) + +// Handle discovered services +browser.browseResultsChangedHandler = { results, changes in + for result in results { + switch result.endpoint { + case .service(let name, let type, let domain, _): + print("Found service: \(name).\(type)\(domain)") + + // Connect to this service + let connection = NWConnection(to: result.endpoint, using: .tcp) + connection.start(queue: .main) + + default: + break + } + } +} + +// Handle browser state +browser.stateUpdateHandler = { state in + switch state { + case .ready: + print("Browser ready") + case .failed(let error): + print("Browser failed: \(error)") + default: + break + } +} + +// Start browsing +browser.start(queue: .main) +``` + +--- + +## Mobility & Network Transitions + +### Connection Viability (WWDC 2018 lines 453-463) + +Viability = connection can send/receive data (has valid route). + +```swift +connection.viabilityUpdateHandler = { isViable in + if isViable { + print("✅ Connection viable (can send/receive)") + } else { + print("⚠️ Connection not viable (no route)") + // Don't tear down immediately, may recover + // Show UI: "Connection interrupted" + } +} +``` + +#### When viability changes +- Walk into elevator (WiFi signal lost) → not viable +- Walk out of elevator (WiFi returns) → viable again +- Switch WiFi → cellular → not viable briefly → viable on cellular + +**Best practice** Don't tear down connection on viability loss. Framework will recover when network returns. + +### Better Path Available (WWDC 2018 lines 464-477) + +Better path = alternative network with better characteristics. + +```swift +connection.betterPathUpdateHandler = { betterPathAvailable in + if betterPathAvailable { + print("📶 Better path available (e.g., WiFi while on cellular)") + // Consider migrating to new connection + self.migrateToNewConnection() + } +} +``` + +#### Scenarios +- Connected on cellular, walk into building with WiFi → better path available +- Connected on WiFi, WiFi quality degrades, cellular available → better path available + +#### Migration pattern + +```swift +func migrateToNewConnection() { + // Create new connection + let newConnection = NWConnection(host: host, port: port, using: parameters) + + newConnection.stateUpdateHandler = { [weak self] state in + if case .ready = state { + // New connection ready, switch over + self?.currentConnection?.cancel() + self?.currentConnection = newConnection + } + } + + newConnection.start(queue: .main) + + // Keep old connection until new one ready +} +``` + +### Multipath TCP (WWDC 2018 lines 480-487) + +Automatically migrate between networks without application intervention. + +```swift +let parameters = NWParameters.tcp +parameters.multipathServiceType = .handover // Seamless network transition + +let connection = NWConnection(host: "example.com", port: 443, using: parameters) +``` + +#### Multipath TCP modes +- `.handover` — Seamless handoff between networks (WiFi ↔ cellular) +- `.interactive` — Use multiple paths simultaneously (lowest latency) +- `.aggregate` — Use multiple paths simultaneously (highest throughput) + +#### Benefits +- Automatic network transition (no viability handlers needed) +- No connection interruption when switching networks +- Fallback to single-path if MPTCP unavailable + +### NWPathMonitor (WWDC 2018 lines 489-496) + +Monitor network state changes (replaces SCNetworkReachability). + +```swift +import Network + +let monitor = NWPathMonitor() + +monitor.pathUpdateHandler = { path in + if path.status == .satisfied { + print("✅ Network available") + + // Check interface types + if path.usesInterfaceType(.wifi) { + print("Using WiFi") + } else if path.usesInterfaceType(.cellular) { + print("Using cellular") + } + + // Check if expensive + if path.isExpensive { + print("⚠️ Expensive path (cellular/hotspot)") + } + + } else { + print("❌ No network") + } +} + +monitor.start(queue: .main) +``` + +#### Use cases +- Show "No network" UI when path.status == .unsatisfied +- Disable high-bandwidth features when path.isExpensive +- Adjust quality based on interface type + +#### When to use +- Global network state monitoring +- When "waiting for connectivity" isn't enough +- Need to know available interfaces before connecting + +#### When NOT to use +- Checking before connecting (use waiting state instead) +- Per-connection monitoring (use viability handlers instead) + +--- + +## Security Configuration + +### TLS Version + +```swift +// iOS 13+ requires TLS 1.2+ by default +let tlsOptions = NWProtocolTLS.Options() + +// Allow TLS 1.2 and 1.3 +tlsOptions.minimumTLSProtocolVersion = .TLSv12 + +// Require TLS 1.3 only +tlsOptions.minimumTLSProtocolVersion = .TLSv13 + +let parameters = NWParameters(tls: tlsOptions) +let connection = NWConnection(host: "example.com", port: 443, using: parameters) +``` + +### Certificate Pinning + +```swift +// Production-grade certificate pinning +let tlsOptions = NWProtocolTLS.Options() + +sec_protocol_options_set_verify_block( + tlsOptions.securityProtocolOptions, + { (metadata, trust, complete) in + // Get server certificate + let serverCert = sec_protocol_metadata_copy_peer_public_key(metadata) + + // Compare with pinned certificate + let pinnedCertData = Data(/* your pinned cert */) + let serverCertData = SecCertificateCopyData(serverCert) as Data + + if serverCertData == pinnedCertData { + complete(true) // Accept + } else { + complete(false) // Reject (prevents MITM attacks) + } + }, + .main +) + +let parameters = NWParameters(tls: tlsOptions) +``` + +### Certificate Pinning + Corporate Proxies + +Corporate networks often use TLS inspection proxies that present their own certificates. Strict pinning breaks these environments. + +**Strategy**: Pin against the public key (SPKI) rather than the full certificate, and provide a configuration escape hatch: + +```swift +sec_protocol_options_set_verify_block( + tlsOptions.securityProtocolOptions, + { (metadata, trust, complete) in + // 1. Check if system trusts the certificate chain (handles corporate CAs) + let secTrust = sec_trust_copy_ref(trust).takeRetainedValue() + SecTrustEvaluateAsyncWithError(secTrust, .main) { _, result, _ in + guard result else { complete(false); return } + + // 2. If pinning enabled, also verify public key + if PinningConfig.isEnabled { + let serverKey = SecTrustCopyKey(secTrust) + let matches = pinnedKeys.contains { $0 == serverKey } + complete(matches) + } else { + complete(true) // System trust only (enterprise mode) + } + } + }, + .main +) +``` + +**Rules**: +- Always validate system trust first (`SecTrustEvaluateAsyncWithError`) — this respects enterprise-installed root CAs +- Use public key pinning over certificate pinning (survives cert rotation) +- Provide a managed configuration (MDM profile or app config) to disable pinning in enterprise environments +- Pin at least 2 keys (current + backup) to survive rotation + +### Cipher Suites + +```swift +let tlsOptions = NWProtocolTLS.Options() + +// Specify allowed cipher suites +tlsOptions.tlsCipherSuites = [ + tls_ciphersuite_t(rawValue: 0x1301), // TLS_AES_128_GCM_SHA256 + tls_ciphersuite_t(rawValue: 0x1302), // TLS_AES_256_GCM_SHA384 +] + +// iOS defaults to secure modern ciphers, only customize if required +``` + +--- + +## Performance Optimization + +### User-Space Networking (WWDC 2018 lines 409-441) + +**Automatic on iOS/tvOS.** Network.framework moves TCP/UDP stack into your app process. + +#### Benefits +- ~30% lower CPU usage (measured with Instruments) +- No kernel→userspace copy (memory-mapped regions) +- Reduced context switches + +#### Legacy vs User-Space + +| Traditional Sockets | User-Space Networking | +|---------------------|----------------------| +| Packet → driver → kernel → copy → userspace | Packet → driver → memory-mapped region → userspace (no copy) | +| 100 datagrams = 100 syscalls | 100 datagrams = ~1 syscall (with batching) | +| ~30% higher CPU | Baseline CPU | + +**WWDC demo** Live UDP video streaming showed 30% CPU difference (sockets vs Network.framework). + +### ECN for UDP (WWDC 2018 lines 365-378) + +Explicit Congestion Notification for smooth UDP transmission. + +```swift +// Create IP metadata with ECN +let ipMetadata = NWProtocolIP.Metadata() +ipMetadata.ecnFlag = .congestionEncountered // Or .ect0, .ect1 + +// Attach to send context +let context = NWConnection.ContentContext( + identifier: "video_frame", + metadata: [ipMetadata] +) + +connection.send(content: data, contentContext: context, completion: .contentProcessed { _ in }) +``` + +#### ECN flags +- `.ect0` / `.ect1` — ECN-capable transport +- `.congestionEncountered` — Congestion notification received + +**Benefits** Network can signal congestion without dropping packets. + +### Service Class (WWDC 2018 lines 379-388) + +Mark traffic priority. + +```swift +// Connection-wide service class +let parameters = NWParameters.tcp +parameters.serviceClass = .background // Low priority + +let connection = NWConnection(host: "example.com", port: 443, using: parameters) + +// Per-packet service class (UDP) +let ipMetadata = NWProtocolIP.Metadata() +ipMetadata.serviceClass = .realTimeInteractive // High priority (voice) + +let context = NWConnection.ContentContext(identifier: "voip", metadata: [ipMetadata]) +connection.send(content: audioData, contentContext: context, completion: .contentProcessed { _ in }) +``` + +#### Service classes +- `.background` — Low priority (large downloads, sync) +- `.default` — Normal priority +- `.responsiveData` — Interactive data (API calls) +- `.realTimeInteractive` — Time-sensitive (voice, gaming) + +### TCP Fast Open (WWDC 2018 lines 389-406) + +Send initial data in TCP SYN packet (saves round trip). + +```swift +let parameters = NWParameters.tcp +parameters.allowFastOpen = true + +let connection = NWConnection(host: "example.com", port: 443, using: parameters) + +// Send initial data BEFORE calling start() +let initialData = Data("GET / HTTP/1.1\r\n".utf8) +connection.send( + content: initialData, + contentContext: .defaultMessage, + isComplete: false, + completion: .idempotent // Data is safe to replay +) + +// Now start connection (initial data sent in SYN) +connection.start(queue: .main) +``` + +**Benefits** Reduces connection establishment time by 1 RTT. +**Requirements** Data must be idempotent (safe to replay if SYN retransmitted). + +--- + +## Migration Strategies + +### From BSD Sockets to NWConnection + +| BSD Sockets | NWConnection | Notes | +|-------------|--------------|-------| +| `socket() + connect()` | `NWConnection(host:port:using:) + start()` | Non-blocking by default | +| `send() / sendto()` | `connection.send(content:completion:)` | Async callback | +| `recv() / recvfrom()` | `connection.receive(min:max:completion:)` | Async callback | +| `bind() + listen()` | `NWListener(using:on:)` | Automatic port binding | +| `accept()` | `listener.newConnectionHandler` | Callback per connection | +| `getaddrinfo()` | Use `NWEndpoint.Host(hostname)` | DNS automatic | +| `SCNetworkReachability` | `connection.stateUpdateHandler` waiting state | No race conditions | +| `setsockopt()` | `NWParameters` | Type-safe options | + +#### Migration example + +#### Before (blocking sockets) +```c +int sock = socket(AF_INET, SOCK_STREAM, 0); +connect(sock, &addr, addrlen); // BLOCKS +send(sock, data, len, 0); +``` + +#### After (NWConnection) +```swift +let connection = NWConnection(host: "example.com", port: 443, using: .tls) +connection.stateUpdateHandler = { state in + if case .ready = state { + connection.send(content: data, completion: .contentProcessed { _ in }) + } +} +connection.start(queue: .main) +``` + +### From URLSession StreamTask to NetworkConnection + +#### When to migrate +- Need UDP (StreamTask only supports TCP) +- Need custom protocols +- Need low-level control + +#### When to STAY with URLSession +- HTTP/HTTPS (URLSession optimized for this) +- WebSocket support +- Built-in caching, cookies + +#### Migration example + +#### Before (URLSession StreamTask) +```swift +let task = URLSession.shared.streamTask(withHostName: "example.com", port: 443) +task.resume() +task.write(Data("Hello".utf8), timeout: 10) { _ in } +``` + +#### After (NetworkConnection iOS 26+) +```swift +let connection = NetworkConnection(to: .hostPort(host: "example.com", port: 443)) { TLS() } +try await connection.send(Data("Hello".utf8)) +``` + +### From NWConnection to NetworkConnection + +#### Benefits of migration +- Async/await (no callback nesting) +- No `[weak self]` needed +- TLV framing built-in +- Coder protocol for Codable types + +#### Migration mapping + +| NWConnection | NetworkConnection | +|--------------|-------------------| +| `connection.stateUpdateHandler = { }` | `for await state in connection.states { }` | +| `connection.send(content:completion:)` | `try await connection.send(content)` | +| `connection.receive(min:max:completion:)` | `try await connection.receive(exactly:)` | +| Manual JSON | `Coder(MyType.self, using: .json)` | +| Custom framer | `TLV { TLS() }` | +| `[weak self]` everywhere | No `[weak self]` needed | + +#### Migration example + +#### Before (NWConnection) +```swift +connection.stateUpdateHandler = { [weak self] state in + if case .ready = state { + self?.sendData() + } +} + +func sendData() { + connection.send(content: data, completion: .contentProcessed { [weak self] error in + self?.receiveData() + }) +} +``` + +#### After (NetworkConnection) +```swift +Task { + for await state in connection.states { + if case .ready = state { + try await connection.send(data) + let received = try await connection.receive(exactly: 10).content + } + } +} +``` + +--- + +## Testing Checklist + +Before shipping networking code: + +### Device Testing +- [ ] Tested on real device (not just simulator) +- [ ] Tested on multiple iOS versions (12, 15, 26) +- [ ] Tested on iPhone and iPad (different network characteristics) + +### Network Conditions +- [ ] WiFi (home network) +- [ ] Cellular (disable WiFi) +- [ ] Airplane Mode → WiFi (test waiting state) +- [ ] WiFi → cellular transition (walk out of building) +- [ ] Cellular → WiFi transition (walk into building) +- [ ] Weak signal (basement, elevator) +- [ ] Network Link Conditioner (100ms latency, 3% packet loss) + +### Network Types +- [ ] IPv4-only network +- [ ] IPv6-only network (some cellular carriers) +- [ ] Dual-stack (IPv4 + IPv6) +- [ ] Corporate VPN active +- [ ] Personal hotspot (expensive path) + +### Performance +- [ ] Connection establishment < 500ms (check logs) +- [ ] Using batch for UDP (verify with Instruments) +- [ ] Using contentProcessed for pacing (check send timing) +- [ ] Profiled with Instruments Network template +- [ ] CPU usage acceptable (< 10% for networking) +- [ ] Memory stable (no leaks, check [weak self]) + +### Error Handling +- [ ] Handling .waiting state (show "Waiting..." UI) +- [ ] Handling .failed state (specific error messages) +- [ ] TLS handshake errors logged +- [ ] Timeout handling (don't wait forever) +- [ ] User-facing errors actionable ("Check network" not "POSIX 61") + +### iOS 26+ Features (if using NetworkConnection) +- [ ] Using TLV framing if need message boundaries +- [ ] Using Coder protocol if sending Codable types +- [ ] Using NetworkListener instead of NWListener +- [ ] Using NetworkBrowser for Wi-Fi Aware if peer-to-peer + +--- + +## API Quick Reference + +### NetworkConnection (iOS 26+) + +```swift +// Create connection +NetworkConnection(to: .hostPort(host: "example.com", port: 443)) { TLS() } + +// Send +try await connection.send(data) + +// Receive +try await connection.receive(exactly: n).content + +// States +for await state in connection.states { } + +// TLV framing +NetworkConnection(to: endpoint) { TLV { TLS() } } + +// Coder protocol +NetworkConnection(to: endpoint) { Coder(MyType.self, using: .json) { TLS() } } + +// Listener +NetworkListener { TLS() }.run { connection in } + +// Browser +NetworkBrowser(for: .wifiAware(...)).run { endpoints in } +``` + +### NWConnection (iOS 12-25) + +```swift +// Create connection +let connection = NWConnection(host: "example.com", port: 443, using: .tls) + +// State handler +connection.stateUpdateHandler = { [weak self] state in } + +// Start +connection.start(queue: .main) + +// Send +connection.send(content: data, completion: .contentProcessed { [weak self] error in }) + +// Receive +connection.receive(minimumIncompleteLength: min, maximumLength: max) { [weak self] data, context, isComplete, error in } + +// Viability +connection.viabilityUpdateHandler = { isViable in } + +// Better path +connection.betterPathUpdateHandler = { betterPathAvailable in } + +// Cancel +connection.cancel() +``` + +### NWListener (iOS 12-25) + +```swift +let listener = try NWListener(using: .tcp, on: 1029) +listener.newConnectionHandler = { newConnection in } +listener.start(queue: .main) +``` + +### NWBrowser (iOS 12-25) + +```swift +let browser = NWBrowser(for: .bonjour(type: "_http._tcp", domain: nil), using: .tcp) +browser.browseResultsChangedHandler = { results, changes in } +browser.start(queue: .main) +``` + +### NWPathMonitor + +```swift +let monitor = NWPathMonitor() +monitor.pathUpdateHandler = { path in } +monitor.start(queue: .main) +``` + +--- + +## Resources + +**WWDC**: 2018-715, 2025-250 + +**Docs**: /network, /network/nwconnection, /network/networkconnection + +**Skills**: axiom-networking, axiom-networking-diag + +--- + +**Last Updated** 2025-12-02 +**Status** Production-ready reference from WWDC 2018 and WWDC 2025 +**Coverage** NWConnection (iOS 12-25), NetworkConnection (iOS 26+), all 12 WWDC 2025 code examples diff --git a/.claude/skills/axiom-network-framework-ref/agents/openai.yaml b/.claude/skills/axiom-network-framework-ref/agents/openai.yaml new file mode 100644 index 0000000..ef7e24a --- /dev/null +++ b/.claude/skills/axiom-network-framework-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Network Framework Reference" + short_description: "Reference — Comprehensive Network.framework guide covering NetworkConnection (iOS 26+), NWConnection (iOS 12-25), TLV..." diff --git a/.claude/skills/axiom-networking-diag/.openskills.json b/.claude/skills/axiom-networking-diag/.openskills.json new file mode 100644 index 0000000..f1779e5 --- /dev/null +++ b/.claude/skills/axiom-networking-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-networking-diag", + "installedAt": "2026-04-12T08:06:30.010Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-networking-diag/SKILL.md b/.claude/skills/axiom-networking-diag/SKILL.md new file mode 100644 index 0000000..0eb2c73 --- /dev/null +++ b/.claude/skills/axiom-networking-diag/SKILL.md @@ -0,0 +1,1068 @@ +--- +name: axiom-networking-diag +description: Use when debugging connection timeouts, TLS handshake failures, data not arriving, connection drops, performance issues, or proxy/VPN interference - systematic Network.framework diagnostics with production crisis defense +license: MIT +metadata: + version: "1.0.0" +--- + +# Network.framework Diagnostics + +## Overview + +**Core principle** 85% of networking problems stem from misunderstanding connection states, not handling network transitions, or improper error handling—not Network.framework defects. + +Network.framework is battle-tested in every iOS app (powers URLSession internally), handles trillions of requests daily, and provides smart connection establishment with Happy Eyeballs, proxy evaluation, and WiFi Assist. If your connection is failing, timing out, or behaving unexpectedly, the issue is almost always in how you're using the framework, not the framework itself. + +This skill provides systematic diagnostics to identify root causes in minutes, not hours. + +## Red Flags — Suspect Networking Issue + +If you see ANY of these, suspect a networking misconfiguration, not framework breakage: + +- Connection times out after 60 seconds with no clear error +- TLS handshake fails with "certificate invalid" on some networks +- Data sent but never arrives at receiver +- Connection drops when switching WiFi to cellular +- Works perfectly on WiFi but fails 100% of time on cellular +- Works in simulator but fails on real device +- Connection succeeds on your network but fails for users + +- ❌ **FORBIDDEN** "Network.framework is broken, we should rewrite with sockets" + - Network.framework powers URLSession, used in every iOS app + - Handles edge cases you'll spend months discovering with sockets + - Apple engineers have 10+ years of production debugging baked into framework + - Switching to sockets will expose you to 100+ edge cases + +**Critical distinction** Simulator uses macOS networking stack (not iOS), hides cellular-specific issues (IPv6-only networks), and doesn't simulate network transitions. **MANDATORY: Test on real device with real network conditions.** + +## Mandatory First Steps + +**ALWAYS run these commands FIRST** (before changing code): + +```swift +// 1. Enable Network.framework logging +// Add to Xcode scheme: Product → Scheme → Edit Scheme → Arguments +// -NWLoggingEnabled 1 +// -NWConnectionLoggingEnabled 1 + +// 2. Check connection state history +connection.stateUpdateHandler = { state in + print("\(Date()): Connection state: \(state)") + // Log every state transition with timestamp +} + +// 3. Check TLS configuration +// If using custom TLS parameters: +print("TLS version: \(tlsParameters.minimumTLSProtocolVersion)") +print("Cipher suites: \(tlsParameters.tlsCipherSuites ?? [])") + +// 4. Test with packet capture (Charles Proxy or Wireshark) +// On device: Settings → WiFi → (i) → Configure Proxy → Manual +// Charles: Help → SSL Proxying → Install Charles Root Certificate on iOS + +// 5. Test on different networks +// - WiFi +// - Cellular (disable WiFi) +// - Airplane Mode → WiFi (test waiting state) +// - VPN active +// - IPv6-only (some cellular carriers) +``` + +#### What this tells you + +| Observation | Diagnosis | Next Step | +|-------------|-----------|-----------| +| Stuck in .preparing > 5 seconds | DNS failure or network down | Pattern 1a | +| Moves to .waiting immediately | No connectivity (Airplane Mode, no signal) | Pattern 1b | +| .failed with POSIX error 61 | Connection refused (server not listening) | Pattern 1c | +| .failed with POSIX error 50 | Network down (interface disabled) | Pattern 1d | +| .ready then immediate .failed | TLS handshake failure | Pattern 2b | +| .ready, send succeeds, no data arrives | Framing problem or receiver not processing | Pattern 3a | +| Works WiFi, fails cellular | IPv6-only network (hardcoded IPv4) | Pattern 5a | +| Works without VPN, fails with VPN | Proxy interference or DNS override | Pattern 5b | + +#### MANDATORY INTERPRETATION + +Before changing ANY code, identify ONE of these: + +1. If stuck in .preparing AND network is available → DNS failure (check nslookup) +2. If .waiting immediately AND Airplane Mode is off → Interface-specific issue (cellular blocked) +3. If .failed POSIX 61 → Server issue (check server logs) +4. If .failed with TLS error -9806 → Certificate validation (check with openssl) +5. If .ready but data not arriving → Framing or receiver issue (enable packet capture) + +#### If diagnostics are contradictory or unclear +- STOP. Do NOT proceed to patterns yet +- Add timestamp logging to every send/receive call +- Enable packet capture (Charles/Wireshark) +- Test on different device to isolate hardware vs software issue + +## Decision Tree + +Use this to reach the correct diagnostic pattern in 2 minutes: + +``` +Network problem? +├─ Connection never reaches .ready? +│ ├─ Stuck in .preparing for >5 seconds? +│ │ ├─ DNS lookup timing out? → Pattern 1a (DNS Failure) +│ │ ├─ Network available but can't reach host? → Pattern 1c (Connection Refused) +│ │ └─ First connection slow, subsequent fast? → Pattern 1e (DNS Caching) +│ │ +│ ├─ Moves to .waiting immediately? +│ │ ├─ Airplane Mode or no signal? → Pattern 1b (No Connectivity) +│ │ ├─ Cellular blocked by parameters? → Pattern 1b (Interface Restrictions) +│ │ └─ VPN connecting? → Wait and retry +│ │ +│ ├─ .failed with POSIX error 61? +│ │ └─ → Pattern 1c (Connection Refused) +│ │ +│ └─ .failed with POSIX error 50? +│ └─ → Pattern 1d (Network Down) +│ +├─ Connection reaches .ready, then fails? +│ ├─ Fails immediately after .ready? +│ │ ├─ TLS error -9806? → Pattern 2b (Certificate Validation) +│ │ ├─ TLS error -9801? → Pattern 2b (Protocol Version) +│ │ └─ POSIX error 54? → Pattern 2d (Connection Reset) +│ │ +│ ├─ Fails after network change (WiFi → cellular)? +│ │ ├─ No viabilityUpdateHandler? → Pattern 2a (Viability Not Handled) +│ │ ├─ Didn't detect better path? → Pattern 2a (Better Path) +│ │ └─ IPv6 → IPv4 transition? → Pattern 5a (Dual Stack) +│ │ +│ ├─ Fails after timeout? +│ │ └─ → Pattern 2c (Receiver Not Responding) +│ │ +│ └─ Random disconnects? +│ └─ → Pattern 2d (Network Instability) +│ +├─ Data not arriving? +│ ├─ Send succeeds, receive never returns? +│ │ ├─ No message framing? → Pattern 3a (Framing Problem) +│ │ ├─ Wrong byte count? → Pattern 3b (Min/Max Bytes) +│ │ └─ Receiver not calling receive()? → Check receiver code +│ │ +│ ├─ Partial data arrives? +│ │ ├─ receive(exactly:) too large? → Pattern 3b (Chunking) +│ │ ├─ Sender closing too early? → Check sender lifecycle +│ │ └─ Buffer overflow? → Pattern 3b (Buffer Management) +│ │ +│ ├─ Data corrupted? +│ │ ├─ TLS disabled? → Pattern 3c (No Encryption) +│ │ ├─ Binary vs text encoding? → Check ContentType +│ │ └─ Byte order (endianness)? → Use network byte order +│ │ +│ └─ Works sometimes, fails intermittently? +│ └─ → Pattern 3d (Race Condition) +│ +├─ Performance degrading? +│ ├─ Latency increasing over time? +│ │ ├─ TCP congestion? → Pattern 4a (Congestion Control) +│ │ ├─ No contentProcessed pacing? → Pattern 4a (Buffering) +│ │ └─ Server overloaded? → Check server metrics +│ │ +│ ├─ Throughput decreasing? +│ │ ├─ Network transition WiFi → cellular? → Pattern 4b (Bandwidth Change) +│ │ ├─ Packet loss increasing? → Pattern 4b (Network Quality) +│ │ └─ Multiple streams competing? → Pattern 4b (Prioritization) +│ │ +│ ├─ High CPU usage? +│ │ ├─ Not using batch for UDP? → Pattern 4c (Batching) +│ │ ├─ Too many small sends? → Pattern 4c (Coalescing) +│ │ └─ Using sockets instead of Network.framework? → Migrate (30% CPU savings) +│ │ +│ └─ Memory growing? +│ ├─ Not releasing connections? → Pattern 4d (Connection Leaks) +│ ├─ Not cancelling on deinit? → Pattern 4d (Lifecycle) +│ └─ Missing [weak self]? → Pattern 4d (Retain Cycles) +│ +└─ Works on WiFi, fails on cellular/VPN? + ├─ IPv6-only cellular network? + │ ├─ Hardcoded IPv4 address? → Pattern 5a (IPv4 Literal) + │ ├─ getaddrinfo with AF_INET only? → Pattern 5a (Address Family) + │ └─ Works on some carriers, not others? → Pattern 5a (Regional IPv6) + │ + ├─ Corporate VPN active? + │ ├─ Proxy configuration failing? → Pattern 5b (PAC) + │ ├─ DNS override blocking hostname? → Pattern 5b (DNS) + │ └─ Certificate pinning failing? → Pattern 5b (TLS in VPN) + │ + ├─ Port blocked by firewall? + │ ├─ Non-standard port? → Pattern 5c (Firewall) + │ ├─ Outbound only? → Pattern 5c (NATing) + │ └─ Works on port 443, not 8080? → Pattern 5c (Port Scanning) + │ + ├─ Peer-to-peer connection failing? + │ ├─ NAT traversal issue? → Pattern 5d (STUN/TURN) + │ ├─ Symmetric NAT? → Pattern 5d (NAT Type) + │ └─ Local network only? → Pattern 5d (Bonjour/mDNS) + │ + └─ URLSession fails but NWConnection works? + ├─ HTTP URL blocked? → Pattern 6a (ATS HTTP Block) + ├─ "SSL error" on HTTPS? → Pattern 6b (ATS TLS Version) + └─ Works on older iOS? → Pattern 6a/6b (ATS enforcement) +``` + +## Pattern Selection Rules (MANDATORY) + +Before proceeding to a pattern: + +1. **Connection never reaching .ready** → Start with Pattern 1 (DNS, connectivity, refused) +2. **TLS error codes** → Jump directly to Pattern 2b (Certificate validation) +3. **Data not arriving** → Enable packet capture FIRST, then Pattern 3 +4. **Network-specific (works WiFi, fails cellular)** → Test on that exact network, Pattern 5 +5. **Performance degradation** → Profile with Instruments Network template, Pattern 4 + +#### Apply ONE pattern at a time +- Implement the fix from one pattern +- Test thoroughly +- Only if issue persists, try next pattern +- DO NOT apply multiple patterns simultaneously (can't isolate cause) + +#### FORBIDDEN +- Guessing at solutions without diagnostics +- Changing multiple things at once +- Assuming "just needs more timeout" +- Disabling TLS "temporarily" +- Switching to sockets to "avoid framework issues" + +## Diagnostic Patterns + +### Pattern 1a: DNS Resolution Failure + +**Time cost** 10-15 minutes + +#### Symptom +- Connection stuck in .preparing for >5 seconds +- Eventually fails or times out +- Works with IP address but not hostname +- Works on one network, fails on another + +#### Diagnosis +```swift +// Enable DNS logging +// -NWLoggingEnabled 1 + +// Check DNS resolution manually +// Terminal: nslookup example.com +// Terminal: dig example.com + +// Logs show: +// "DNS lookup timed out" +// "getaddrinfo failed: 8 (nodename nor servname provided)" +``` + +#### Common causes +1. DNS server unreachable (corporate network blocks external DNS) +2. Hostname typo or doesn't exist +3. DNS caching stale entry (rare, but happens) +4. VPN blocking DNS resolution + +#### Fix + +```swift +// ❌ WRONG — Adding timeout doesn't fix DNS +/* +let parameters = NWParameters.tls +parameters.expiredDNSBehavior = .allow // Doesn't help if DNS never resolves +*/ + +// ✅ CORRECT — Verify hostname, test DNS manually +// 1. Test DNS manually: +// $ nslookup your-hostname.com +// If this fails, DNS is the problem (not your code) + +// 2. If DNS works manually but not in app: +// Check if VPN or enterprise config blocking app DNS + +// 3. If hostname doesn't exist: +let connection = NWConnection( + host: NWEndpoint.Host("correct-hostname.com"), // Fix typo + port: 443, + using: .tls +) + +// 4. If DNS caching issue (rare): +// Restart device to clear DNS cache +// Or use IP address temporarily while investigating DNS server issue +``` + +#### Verification +- Run `nslookup your-hostname.com` — should return IP in <1 second +- Test on cellular (different DNS servers) — should work +- Check corporate network DNS configuration + +#### Prevention +- Use well-known hostnames (don't rely on internal DNS) +- Test on multiple networks during development +- Don't hardcode IPs (if DNS fails, you need to fix DNS, not bypass it) + +--- + +### Pattern 2b: TLS Certificate Validation Failure + +**Time cost** 15-20 minutes + +#### Symptom +- Connection reaches .ready briefly, then .failed immediately +- Error: `-9806` (kSSLPeerCertInvalid) +- Error: `-9807` (kSSLPeerCertExpired) +- Error: `-9801` (kSSLProtocol) +- Works on some servers, fails on others + +#### Diagnosis +```bash +# Test TLS manually with openssl +openssl s_client -connect example.com:443 -showcerts + +# Check certificate details +openssl s_client -connect example.com:443 | openssl x509 -noout -dates +# notBefore: Jan 1 00:00:00 2024 GMT +# notAfter: Dec 31 23:59:59 2024 GMT ← Check if expired + +# Check certificate chain +openssl s_client -connect example.com:443 -showcerts | grep "CN=" +# Should show: Subject CN=example.com, Issuer CN=Trusted CA +``` + +#### Common causes +1. Self-signed certificate (dev/staging servers) +2. Expired certificate +3. Certificate hostname mismatch (cert for "example.com" but connecting to "www.example.com") +4. Missing intermediate CA certificate +5. TLS 1.0/1.1 (iOS 13+ requires TLS 1.2+) + +#### Fix + +#### For production servers with invalid certs +```swift +// ❌ WRONG — Never disable certificate validation in production +/* +let tlsOptions = NWProtocolTLS.Options() +sec_protocol_options_set_verify_block(tlsOptions.securityProtocolOptions, { ... }, .main) +// This disables validation → security vulnerability +*/ + +// ✅ CORRECT — Fix the certificate on server +// 1. Renew expired certificate (Let's Encrypt, DigiCert, etc.) +// 2. Ensure hostname matches (CN=example.com or SAN includes example.com) +// 3. Include intermediate CA certificates on server +// 4. Test with: openssl s_client -connect example.com:443 +``` + +#### For development servers (temporary) +```swift +// ⚠️ ONLY for development/staging +#if DEBUG +let tlsOptions = NWProtocolTLS.Options() + +sec_protocol_options_set_verify_block( + tlsOptions.securityProtocolOptions, + { (sec_protocol_metadata, sec_trust, sec_protocol_verify_complete) in + // Trust any certificate (DEV ONLY) + sec_protocol_verify_complete(true) + }, + .main +) + +let parameters = NWParameters(tls: tlsOptions) +let connection = NWConnection(host: "dev-server.example.com", port: 443, using: parameters) +#endif +``` + +#### For certificate pinning +```swift +// Production-grade certificate pinning +let tlsOptions = NWProtocolTLS.Options() + +sec_protocol_options_set_verify_block( + tlsOptions.securityProtocolOptions, + { (metadata, trust, complete) in + let trust = sec_protocol_metadata_copy_peer_public_key(metadata) + // Compare trust with pinned certificate + let pinnedCertificateData = Data(/* your cert */) + let serverCertificateData = SecCertificateCopyData(trust) as Data + + if serverCertificateData == pinnedCertificateData { + complete(true) + } else { + complete(false) // Reject non-pinned certificates + } + }, + .main +) +``` + +#### Verification +- `openssl s_client -connect example.com:443` shows `Verify return code: 0 (ok)` +- Certificate expiration > 30 days in future +- Certificate CN matches hostname +- Test on real iOS device (not just simulator) + +--- + +### Pattern 3a: Message Framing Problem + +**Time cost** 20-30 minutes + +#### Symptom +- connection.send() succeeds with no error +- connection.receive() never returns data +- Or receive() returns partial data +- Packet capture shows bytes on wire, but app doesn't process them + +#### Diagnosis +```swift +// Enable detailed logging +connection.send(content: data, completion: .contentProcessed { error in + if let error = error { + print("Send error: \(error)") + } else { + print("✅ Sent \(data.count) bytes at \(Date())") + } +}) + +connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, context, isComplete, error in + if let error = error { + print("Receive error: \(error)") + } else if let data = data { + print("✅ Received \(data.count) bytes at \(Date())") + } +} + +// Use Charles Proxy or Wireshark to verify bytes on wire +``` + +**Common cause** Stream protocols (TCP/TLS) don't preserve message boundaries. + +#### Example +```swift +// Sender sends 3 messages: +send("Hello") // 5 bytes +send("World") // 5 bytes +send("!") // 1 byte + +// Receiver might get: +receive() → "HelloWorld!" // All 11 bytes at once +// Or: +receive() → "Hel" // 3 bytes +receive() → "loWorld!" // 8 bytes + +// Message boundaries lost! +``` + +#### Fix + +#### Solution 1: Use TLV Framing (iOS 26+) +```swift +// NetworkConnection with TLV +let connection = NetworkConnection( + to: .hostPort(host: "example.com", port: 1029) +) { + TLV { + TLS() + } +} + +// Send typed messages +enum MessageType: Int { + case chat = 1 + case ping = 2 +} + +let chatData = Data("Hello".utf8) +try await connection.send(chatData, type: MessageType.chat.rawValue) + +// Receive typed messages +let (data, metadata) = try await connection.receive() +if metadata.type == MessageType.chat.rawValue { + print("Chat message: \(String(data: data, encoding: .utf8)!)") +} +``` + +#### Solution 2: Manual Length Prefix (iOS 12-25) +```swift +// Sender: Prefix message with UInt32 length +func sendMessage(_ message: Data) { + var length = UInt32(message.count).bigEndian + let lengthData = Data(bytes: &length, count: 4) + + connection.send(content: lengthData, completion: .contentProcessed { _ in + connection.send(content: message, completion: .contentProcessed { _ in + print("Sent message with length prefix") + }) + }) +} + +// Receiver: Read length, then read message +func receiveMessage() { + // 1. Read 4-byte length + connection.receive(minimumIncompleteLength: 4, maximumLength: 4) { lengthData, _, _, error in + guard let lengthData = lengthData else { return } + + let length = lengthData.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian } + + // 2. Read message of exact length + connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { messageData, _, _, error in + guard let messageData = messageData else { return } + print("Received complete message: \(messageData.count) bytes") + } + } +} +``` + +#### Verification +- Send 10 messages, verify receiver gets exactly 10 messages +- Send messages of varying sizes (1 byte, 1000 bytes, 64KB) +- Test with packet loss simulation (Network Link Conditioner) + +--- + +### Pattern 4a: TCP Congestion and Buffering + +**Time cost** 15-25 minutes + +#### Symptom +- First few sends fast, then increasingly slow +- Latency grows from 50ms → 500ms → 2000ms over time +- Memory usage growing (buffering unsent data) +- User reports app "feels sluggish" after 5 minutes + +#### Diagnosis +```swift +// Monitor send completion time +let sendStart = Date() +connection.send(content: data, completion: .contentProcessed { error in + let elapsed = Date().timeIntervalSince(sendStart) + print("Send completed in \(elapsed)s") // Should be < 0.1s normally + // If > 1s, TCP congestion or receiver not draining fast enough +}) + +// Profile with Instruments +// Xcode → Product → Profile → Network template +// Check "Bytes Sent" vs "Time" graph +// Should be smooth line, not stepped/stalled +``` + +#### Common causes +1. Sender sending faster than receiver can process (back pressure) +2. Network congestion (packet loss, retransmits) +3. No pacing with contentProcessed callback +4. Sending on connection that lost viability + +#### Fix + +```swift +// ❌ WRONG — Sending without pacing +/* +for frame in videoFrames { + connection.send(content: frame, completion: .contentProcessed { _ in }) + // Buffers all frames immediately → memory spike → congestion +} +*/ + +// ✅ CORRECT — Pace with contentProcessed callback +func sendFrameWithPacing() { + guard let nextFrame = getNextFrame() else { return } + + connection.send(content: nextFrame, completion: .contentProcessed { [weak self] error in + if let error = error { + print("Send error: \(error)") + return + } + + // contentProcessed = network stack consumed frame + // NOW send next frame (pacing) + self?.sendFrameWithPacing() + }) +} + +// Start pacing +sendFrameWithPacing() +``` + +#### Alternative: Async/await (iOS 26+) +```swift +// NetworkConnection with natural back pressure +func sendFrames() async throws { + for frame in videoFrames { + try await connection.send(frame) + // Suspends automatically if network can't keep up + // Built-in back pressure, no manual pacing needed + } +} +``` + +#### Verification +- Send 1000 messages, monitor memory usage (should stay flat) +- Monitor send completion time (should stay < 100ms) +- Test with Network Link Conditioner (100ms latency, 3% packet loss) + +--- + +### Pattern 5a: IPv6-Only Cellular Network (Hardcoded IPv4) + +**Time cost** 10-15 minutes + +#### Symptom +- Works perfectly on WiFi (dual-stack IPv4/IPv6) +- Fails 100% of time on cellular (IPv6-only) +- Works on some carriers (T-Mobile), fails on others (Verizon) +- Logs show "Host unreachable" or POSIX error 65 (EHOSTUNREACH) + +#### Diagnosis +```bash +# Check if hostname has IPv6 +dig AAAA example.com + +# Check if device is on IPv6-only network +# Settings → WiFi/Cellular → (i) → IP Address +# If starts with "2001:" or "fe80:" → IPv6 +# If "192.168" or "10." → IPv4 + +# Test with IPv6-only simulator +# Xcode → Devices → (device) → Use as Development Target +# Settings → Developer → Networking → DNS64/NAT64 +``` + +#### Common causes +1. Hardcoded IPv4 address ("192.168.1.1") +2. getaddrinfo with AF_INET only (filters out IPv6) +3. Server has no IPv6 address (AAAA record) +4. Not using Connect by Name (manual DNS) + +#### Fix + +```swift +// ❌ WRONG — Hardcoded IPv4 +/* +let host = "192.168.1.100" // Fails on IPv6-only cellular +*/ + +// ❌ WRONG — Forcing IPv4 +/* +let parameters = NWParameters.tcp +parameters.requiredInterfaceType = .wifi +parameters.ipOptions.version = .v4 // Fails on IPv6-only +*/ + +// ✅ CORRECT — Use hostname, let framework handle IPv4/IPv6 +let connection = NWConnection( + host: NWEndpoint.Host("example.com"), // Hostname, not IP + port: 443, + using: .tls +) +// Framework automatically: +// 1. Resolves both A (IPv4) and AAAA (IPv6) records +// 2. Tries IPv6 first (if available) +// 3. Falls back to IPv4 (Happy Eyeballs) +// 4. Works on any network (IPv4, IPv6, dual-stack) +``` + +#### Verification +- Test on real device with cellular (disable WiFi) +- Test with multiple carriers (Verizon, AT&T, T-Mobile) +- Enable DNS64/NAT64 in developer settings +- Run `dig AAAA your-hostname.com` to verify IPv6 record exists + +--- + +## Production Crisis Scenario + +### Context: iOS Update Causes 15% Connection Failures + +#### Situation +- Your company releases iOS app update (v4.2) on Monday morning +- By noon, Customer Support reports surge in "app doesn't work" tickets +- Analytics show 15% of users experiencing connection failures (10,000+ users) +- CEO sends Slack message: "What's going on? How fast can we fix this?" +- Engineering manager asks for ETA +- You're the networking engineer + +#### Pressure signals +- 🚨 **Production outage** 10K+ users affected, revenue impact, negative App Store reviews incoming +- ⏰ **Time pressure** "Need fix ASAP, trending on Twitter" +- 👔 **Executive visibility** CEO personally asking for updates +- 📊 **Public image** App Store rating dropping from 4.8 → 4.1 in 3 hours +- 💸 **Financial impact** E-commerce app, each minute costs $5K in lost sales + +#### Rationalization traps (DO NOT fall into these) + +1. *"Just roll back to v4.1"* + - Tempting but takes 1-2 hours for app review, another 24 hours for users to update + - Doesn't find root cause (might happen again) + - Loses v4.2 features you worked on for weeks + +2. *"Disable TLS temporarily to narrow it down"* + - Security vulnerability, will cause App Store rejection + - Doesn't solve actual problem (masks symptoms) + - When would you re-enable? (spoiler: never, because fixing it "later" never happens) + +3. *"It works on my device, must be user error"* + - Arrogance, not diagnosis + - 10K users having same "error"? That's not user error. + +4. *"Let's add retry logic and more timeouts"* + - Doesn't address root cause + - Makes problem worse (more retries = more load on failing path) + +#### MANDATORY Diagnostic Protocol + +You have 1 hour to provide CEO with: +1. Root cause +2. Fix timeline +3. Mitigation plan + +#### Step 1: Establish Baseline (5 minutes) + +```swift +// Check what changed in v4.2 +git diff v4.1 v4.2 -- NetworkClient.swift + +// Most likely culprits: +// - TLS configuration changed +// - Added certificate pinning +// - Changed connection parameters +// - Updated hostname +``` + +#### Step 2: Reproduce in Production Environment (10 minutes) + +```swift +// Check failure pattern: +// - Random 15%? Or specific user segment? +// - Specific iOS version? (check analytics) +// - Specific network? (WiFi vs cellular) + +// Enable logging on production builds (emergency flag): +#if PRODUCTION +if UserDefaults.standard.bool(forKey: "EnableNetworkLogging") { + // -NWLoggingEnabled 1 +} +#endif + +// Ask Customer Support to enable for affected users +// Check logs for specific error code +``` + +#### Step 3: Check Recent Code Changes (5 minutes) + +```swift +// Found in git diff: +// v4.1: +let parameters = NWParameters.tls + +// v4.2: +let tlsOptions = NWProtocolTLS.Options() +tlsOptions.minimumTLSProtocolVersion = .TLSv13 // ← SMOKING GUN +let parameters = NWParameters(tls: tlsOptions) +``` + +**Root Cause Identified** Some users' backend infrastructure (load balancers, proxy servers) don't support TLS 1.3. v4.1 negotiated TLS 1.2, v4.2 requires TLS 1.3 → connection fails. + +#### Step 4: Apply Targeted Fix (15 minutes) + +```swift +// Fix: Support both TLS 1.2 and TLS 1.3 +let tlsOptions = NWProtocolTLS.Options() +tlsOptions.minimumTLSProtocolVersion = .TLSv12 // ✅ Support older infrastructure +// TLS 1.3 will still be used where supported (automatic negotiation) +let parameters = NWParameters(tls: tlsOptions) +``` + +#### Step 5: Deploy Hotfix (20 minutes) + +```bash +# Build hotfix v4.2.1 +# Test on affected user's network (critical!) +# Submit to App Store with expedited review request +# Explain: "Production outage affecting 15% of users" +``` + +#### Professional Communication Templates + +#### To CEO (15 minutes after crisis starts) + +``` +Found root cause: v4.2 requires TLS 1.3, but 15% of users on older infrastructure +(enterprise proxies, older load balancers) that only support TLS 1.2. + +Fix: Change minimum TLS version to 1.2 (backward compatible, 1.3 still used when available). + +ETA: Hotfix v4.2.1 in App Store in 1 hour (expedited review). +Full rollout to users: 24 hours. + +Mitigation now: Telling affected users to update immediately when available. +``` + +#### To Engineering Manager + +``` +Root cause: TLS version requirement changed in v4.2 (TLS 1.3 only). +15% of users behind infrastructure that doesn't support TLS 1.3. + +Technical fix: Set tlsOptions.minimumTLSProtocolVersion = .TLSv12 +This allows backward compatibility while still using TLS 1.3 where supported. + +Testing: Verified fix on user's network (enterprise VPN with old proxy). +Deployment: Hotfix build in progress, ETA 30 minutes to submit. + +Prevention: Add TLS compatibility testing to pre-release checklist. +``` + +#### To Customer Support + +``` +Update: We've identified the issue and have a fix deploying within 1 hour. + +Affected users: Those on enterprise networks or older ISP infrastructure. +Workaround: None (network level issue). + +Expected resolution: v4.2.1 will be available in App Store in 1 hour. +Ask users to update immediately. + +Updates: I'll notify you every 30 minutes. +``` + +#### Time Saved + +| Approach | Time to Resolution | User Impact | +|----------|-------------------|-------------| +| ❌ Panic rollback | 1-2 hours app review + 24 hours user updates = 26 hours | 10K users down for 26 hours | +| ❌ "Add more retries" | Unknown (doesn't fix root cause) | Permanent 15% failure rate | +| ❌ "Works for me" | Days of debugging wrong thing | Frustrated users, bad reviews | +| ✅ Systematic diagnosis | 30 min diagnosis + 20 min fix + 1 hour review = 2 hours | 10K users down for 2 hours | + +#### Lessons Learned + +1. **Test on diverse networks** Don't just test on your WiFi. Test on cellular, VPN, enterprise networks. +2. **Monitor TLS compatibility** If you change TLS config, verify backend supports it. +3. **Gradual rollout** Use phased rollout (10% → 50% → 100%) to catch issues early. +4. **Emergency logging** Have a way to enable detailed logging in production for diagnosis. +5. **Communication cadence** Update stakeholders every 30 minutes, even if just "still investigating." + +--- + +## Quick Reference Table + +| Symptom | Likely Cause | First Check | Pattern | Fix Time | +|---------|--------------|-------------|---------|----------| +| Stuck in .preparing | DNS failure | `nslookup hostname` | 1a | 10-15 min | +| .waiting immediately | No connectivity | Airplane Mode? | 1b | 5 min | +| .failed POSIX 61 | Connection refused | Server listening? | 1c | 5-10 min | +| .failed POSIX 50 | Network down | Check interface | 1d | 5 min | +| TLS error -9806 | Certificate invalid | `openssl s_client` | 2b | 15-20 min | +| Data not received | Framing problem | Packet capture | 3a | 20-30 min | +| Partial data | Min/max bytes wrong | Check receive() params | 3b | 10 min | +| Latency increasing | TCP congestion | contentProcessed pacing | 4a | 15-25 min | +| High CPU | No batching | Use connection.batch | 4c | 10 min | +| Memory growing | Connection leaks | Check [weak self] | 4d | 10-15 min | +| Works WiFi, fails cellular | IPv6-only network | `dig AAAA hostname` | 5a | 10-15 min | +| Works without VPN, fails with VPN | Proxy interference | Test PAC file | 5b | 20-30 min | +| Port blocked | Firewall | Try 443 vs 8080 | 5c | 10 min | +| HTTP URL blocked silently | ATS enforcement | Check Info.plist | 6a | 5-10 min | +| "An SSL error has occurred" | ATS TLS requirements | Check server TLS version | 6b | 10-15 min | + +--- + +## Pattern 6: App Transport Security (ATS) Failures + +**Time cost** 5-15 minutes + +ATS enforces HTTPS for all connections by default (iOS 9+). ATS failures are silent — connections fail with generic errors, no ATS-specific message in console. + +### Pattern 6a: HTTP Blocked by ATS + +#### Symptom +- URLSession request fails with `NSURLErrorSecureConnectionFailed` (-1200) or `NSURLErrorAppTransportSecurityRequiresSecureConnection` (-1022) +- Network.framework connection works but URLSession doesn't +- Works in older iOS versions, fails in newer ones +- No clear error message — just "connection failed" + +#### Diagnosis + +```bash +# Check if ATS is blocking the connection +nscurl --ats-diagnostics https://yourserver.com +# Shows exactly which ATS policy the server fails +``` + +```swift +// In console, look for: +// "App Transport Security has blocked a cleartext HTTP (http://) resource load" +// This only appears if OS-level logging is enabled +``` + +#### Fix — Allow Specific HTTP Domain (Preferred) + +```xml + +NSAppTransportSecurity + + NSExceptionDomains + + api.legacy-server.com + + NSExceptionAllowsInsecureHTTPLoads + + + + +``` + +**Do NOT use `NSAllowsArbitraryLoads`** — disables ATS entirely. App Store Review flags this and may reject. Use domain-specific exceptions. + +### Pattern 6b: ATS TLS Version Requirements + +#### Symptom +- HTTPS connection fails with "SSL error" despite valid certificate +- Server uses TLS 1.0 or 1.1 (ATS requires TLS 1.2+) +- `nscurl --ats-diagnostics` shows TLS version failure + +#### Diagnosis + +```bash +# Check server's TLS version +openssl s_client -connect yourserver.com:443 -tls1_2 +# If this fails but -tls1 succeeds → server doesn't support TLS 1.2 +``` + +#### Fix — Upgrade Server (Preferred) or Add Exception + +```xml + +NSAppTransportSecurity + + NSExceptionDomains + + legacy-api.example.com + + NSExceptionMinimumTLSVersion + TLSv1.0 + + + +``` + +**Better fix**: Upgrade the server to TLS 1.2+. ATS exceptions for TLS downgrade trigger App Store Review scrutiny. + +### ATS vs Network.framework Distinction + +ATS applies to **URLSession** and **WKWebView** connections. **Network.framework** (NWConnection/NetworkConnection) is NOT subject to ATS — it handles TLS configuration directly via `tlsOptions`. If URLSession fails but NWConnection succeeds for the same server, ATS is almost certainly the cause. + +--- + +## Common Mistakes + +### Mistake 1: Not Enabling Logging Before Debugging + +**Problem** Trying to debug networking issues without seeing framework's internal state. + +**Why it fails** You're guessing what's happening. Logs show exact state transitions, error codes, timing. + +#### Fix +```swift +// Add to Xcode scheme BEFORE debugging: +// -NWLoggingEnabled 1 +// -NWConnectionLoggingEnabled 1 + +// Or programmatically: +#if DEBUG +ProcessInfo.processInfo.environment["NW_LOGGING_ENABLED"] = "1" +#endif +``` + +### Mistake 2: Testing Only on WiFi + +**Problem** WiFi and cellular have different characteristics (IPv6-only, proxy configs, packet loss). + +**Why it fails** 40% of connection failures are network-specific. If you only test WiFi, you miss cellular issues. + +#### Fix +- Test on real device with WiFi OFF +- Test on multiple carriers (Verizon, AT&T, T-Mobile have different configs) +- Test with VPN active (enterprise users) +- Use Network Link Conditioner (Xcode → Devices) + +### Mistake 3: Ignoring POSIX Error Codes + +**Problem** Seeing `.failed(let error)` and just showing generic "Connection failed" to user. + +**Why it fails** Different error codes require different fixes. POSIX 61 = server issue, POSIX 50 = client network issue. + +#### Fix +```swift +if case .failed(let error) = state { + let posixError = (error as NSError).code + switch posixError { + case 61: // ECONNREFUSED + print("Server not listening, check server logs") + case 50: // ENETDOWN + print("Network interface down, check WiFi/cellular") + case 60: // ETIMEDOUT + print("Connection timeout, check firewall/DNS") + default: + print("Connection failed: \(error)") + } +} +``` + +### Mistake 4: Not Testing State Transitions + +**Problem** Testing only happy path (.preparing → .ready). Not testing .waiting, network changes, failures. + +**Why it fails** Real users experience network transitions (WiFi → cellular), Airplane Mode, weak signal. + +#### Fix +```swift +// Test with Network Link Conditioner: +// 1. 100% Loss — verify .waiting state shows "Waiting for network" +// 2. WiFi → None → WiFi — verify automatic reconnection +// 3. 3% packet loss — verify performance graceful degradation +``` + +### Mistake 5: Assuming Simulator = Device + +**Problem** Testing only in simulator. Simulator uses macOS networking (different from iOS), no cellular. + +**Why it fails** Simulator hides IPv6-only issues, doesn't simulate network transitions, has different DNS. + +#### Fix +- ALWAYS test on real device before shipping +- Test with Airplane Mode toggle (simulate network transitions) +- Test with cellular only (disable WiFi) + +--- + +## Cross-References + +### For Preventive Patterns + +**networking skill** — Discipline-enforcing anti-patterns: +- Red Flags: SCNetworkReachability, blocking sockets, hardcoded IPs +- Pattern 1a: NetworkConnection with TLS (correct implementation) +- Pattern 2a: NWConnection with proper state handling +- Pressure Scenarios: How to handle deadline pressure without cutting corners + +### For API Reference + +**network-framework-ref skill** — Complete API documentation: +- NetworkConnection (iOS 26+): All 12 WWDC 2025 examples +- NWConnection (iOS 12-25): Complete API with examples +- TLV framing, Coder protocol, NetworkListener, NetworkBrowser +- Migration strategies from sockets, URLSession, NWConnection + +### For Related Issues + +**swift-concurrency skill** — If using async/await: +- Pattern 3: Weak self in Task closures (similar memory leak prevention) +- @MainActor usage for connection state updates +- Task cancellation when connection fails + +--- + +**Last Updated** 2025-12-02 +**Status** Production-ready diagnostics from WWDC 2018/2025 +**Tested** Diagnostic patterns validated against real production issues diff --git a/.claude/skills/axiom-networking-diag/agents/openai.yaml b/.claude/skills/axiom-networking-diag/agents/openai.yaml new file mode 100644 index 0000000..64fd500 --- /dev/null +++ b/.claude/skills/axiom-networking-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Networking Diagnostics" + short_description: "Debugging connection timeouts, TLS handshake failures, data not arriving, connection drops, performance issues, or pr..." diff --git a/.claude/skills/axiom-networking-legacy/.openskills.json b/.claude/skills/axiom-networking-legacy/.openskills.json new file mode 100644 index 0000000..c289ccf --- /dev/null +++ b/.claude/skills/axiom-networking-legacy/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-networking-legacy", + "installedAt": "2026-04-12T08:06:30.207Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-networking-legacy/SKILL.md b/.claude/skills/axiom-networking-legacy/SKILL.md new file mode 100644 index 0000000..777664b --- /dev/null +++ b/.claude/skills/axiom-networking-legacy/SKILL.md @@ -0,0 +1,373 @@ +--- +name: axiom-networking-legacy +description: This skill should be used when working with NWConnection patterns for iOS 12-25, supporting apps that can't use async/await yet, or maintaining backward compatibility with completion handler networking. +license: MIT +--- + +# Legacy iOS 12-25 NWConnection Patterns + +These patterns use NWConnection with completion handlers for apps supporting iOS 12-25. If your app targets iOS 26+, use NetworkConnection with async/await instead (see axiom-network-framework-ref skill). + +## Pattern 2a: NWConnection with TLS (iOS 12-25) + +**Use when** Supporting iOS 12-25, need TLS encryption, can't use async/await yet + +**Time cost** 10-15 minutes + +### GOOD: NWConnection with Completion Handlers + +```swift +import Network + +// Create connection with TLS +let connection = NWConnection( + host: NWEndpoint.Host("mail.example.com"), + port: NWEndpoint.Port(integerLiteral: 993), + using: .tls // TCP inferred +) + +// Handle connection state changes +connection.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + print("Connection established") + self?.sendInitialData() + case .waiting(let error): + print("Waiting for network: \(error)") + // Show "Waiting..." UI, don't fail immediately + case .failed(let error): + print("Connection failed: \(error)") + case .cancelled: + print("Connection cancelled") + default: + break + } +} + +// Start connection +connection.start(queue: .main) + +// Send data with pacing +func sendData() { + let data = Data("Hello, world!".utf8) + connection.send(content: data, completion: .contentProcessed { [weak self] error in + if let error = error { + print("Send error: \(error)") + return + } + // contentProcessed callback = network stack consumed data + // This is when you should send next chunk (pacing) + self?.sendNextChunk() + }) +} + +// Receive exact byte count +func receiveData() { + connection.receive(minimumIncompleteLength: 10, maximumLength: 10) { [weak self] (data, context, isComplete, error) in + if let error = error { + print("Receive error: \(error)") + return + } + + if let data = data { + print("Received \(data.count) bytes") + // Process data... + self?.receiveData() // Continue receiving + } + } +} +``` + +### Key differences from NetworkConnection +- Must use `[weak self]` in all completion handlers to prevent retain cycles +- stateUpdateHandler receives state, not async sequence +- send/receive use completion callbacks, not async/await + +### When to use +- Supporting iOS 12-15 (70% of devices as of 2024) +- Codebases not yet using async/await +- Libraries needing backward compatibility + +### Migration to NetworkConnection (iOS 26+) +- stateUpdateHandler -> connection.states async sequence +- Completion handlers -> try await calls +- [weak self] -> No longer needed (async/await handles cancellation) + +## Pattern 2b: NWConnection UDP Batch (iOS 12-25) + +**Use when** Supporting iOS 12-25, sending multiple UDP datagrams efficiently, need ~30% CPU reduction + +**Time cost** 10-15 minutes + +**Background** Traditional UDP sockets send one datagram per syscall. If you're sending 100 small packets, that's 100 context switches. Batching reduces this to ~1 syscall. + +### BAD: Individual UDP Sends (High CPU) +```swift +// WRONG — 100 context switches for 100 packets +for frame in videoFrames { + sendto(socket, frame.bytes, frame.count, 0, &addr, addrlen) + // Each send = context switch to kernel +} +``` + +### GOOD: Batched UDP Sends (30% Lower CPU) + +```swift +import Network + +// UDP connection +let connection = NWConnection( + host: NWEndpoint.Host("stream-server.example.com"), + port: NWEndpoint.Port(integerLiteral: 9000), + using: .udp +) + +connection.stateUpdateHandler = { state in + if case .ready = state { + print("Ready to send UDP") + } +} + +connection.start(queue: .main) + +// Batch sending for efficiency +func sendVideoFrames(_ frames: [Data]) { + connection.batch { + for frame in frames { + connection.send(content: frame, completion: .contentProcessed { error in + if let error = error { + print("Send error: \(error)") + } + }) + } + } + // All sends batched into ~1 syscall + // 30% lower CPU usage vs individual sends +} + +// Receive UDP datagrams +func receiveFrames() { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] (data, context, isComplete, error) in + if let error = error { + print("Receive error: \(error)") + return + } + + if let data = data { + // Process video frame + self?.displayFrame(data) + self?.receiveFrames() // Continue receiving + } + } +} +``` + +### Performance characteristics +- **Without batch** 100 datagrams = 100 syscalls = 100 context switches +- **With batch** 100 datagrams = ~1 syscall = 1 context switch +- **Result** ~30% lower CPU usage (measured with Instruments) + +### When to use +- Real-time video/audio streaming +- Gaming with frequent updates (player position) +- High-frequency sensor data (IoT) + +**WWDC 2018 demo** Live video streaming showed 30% lower CPU on receiver with user-space networking + batching + +## Pattern 2c: NWListener (iOS 12-25) + +**Use when** Need to accept incoming connections, building servers or peer-to-peer apps, supporting iOS 12-25 + +**Time cost** 20-25 minutes + +### BAD: Manual Socket Listening +```swift +// WRONG — Manual socket management +let sock = socket(AF_INET, SOCK_STREAM, 0) +bind(sock, &addr, addrlen) +listen(sock, 5) +while true { + let client = accept(sock, nil, nil) // Blocks thread + // Handle client... +} +``` + +### GOOD: NWListener with Automatic Connection Handling + +```swift +import Network + +// Create listener with default parameters +let listener = try NWListener(using: .tcp, on: 1029) + +// Advertise Bonjour service +listener.service = NWListener.Service(name: "MyApp", type: "_myservice._tcp") + +// Handle service registration updates +listener.serviceRegistrationUpdateHandler = { update in + switch update { + case .add(let endpoint): + if case .service(let name, let type, let domain, _) = endpoint { + print("Advertising as: \(name).\(type)\(domain)") + } + default: + break + } +} + +// Handle incoming connections +listener.newConnectionHandler = { [weak self] newConnection in + print("New connection from: \(newConnection.endpoint)") + + // Configure connection + newConnection.stateUpdateHandler = { state in + switch state { + case .ready: + print("Client connected") + self?.handleClient(newConnection) + case .failed(let error): + print("Client connection failed: \(error)") + default: + break + } + } + + // Start handling this connection + newConnection.start(queue: .main) +} + +// Handle listener state +listener.stateUpdateHandler = { state in + switch state { + case .ready: + print("Listener ready on port \(listener.port ?? 0)") + case .failed(let error): + print("Listener failed: \(error)") + default: + break + } +} + +// Start listening +listener.start(queue: .main) + +// Handle client data +func handleClient(_ connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] (data, context, isComplete, error) in + if let error = error { + print("Receive error: \(error)") + return + } + + if let data = data { + print("Received \(data.count) bytes") + + // Echo back + connection.send(content: data, completion: .contentProcessed { error in + if let error = error { + print("Send error: \(error)") + } + }) + + self?.handleClient(connection) // Continue receiving + } + } +} +``` + +### When to use +- Peer-to-peer apps (file sharing, messaging) +- Local network services +- Development/testing servers + +### Bonjour advertising +- Automatic service discovery on local network +- No hardcoded IPs needed +- Works with NWBrowser for discovery + +### Security considerations +- Use TLS parameters for encryption: `NWListener(using: .tls, on: port)` +- Validate client connections before processing data +- Set connection limits to prevent DoS + +## Pattern 2d: Network Discovery (iOS 12-25) + +**Use when** Discovering services on local network (Bonjour), building peer-to-peer apps, supporting iOS 12-25 + +**Time cost** 25-30 minutes + +### BAD: Hardcoded IP Addresses +```swift +// WRONG — Brittle, requires manual configuration +let connection = NWConnection(host: "192.168.1.100", port: 9000, using: .tcp) +// What if IP changes? What if multiple devices? +``` + +### GOOD: NWBrowser for Service Discovery + +```swift +import Network + +// Browse for services on local network +let browser = NWBrowser(for: .bonjour(type: "_myservice._tcp", domain: nil), using: .tcp) + +// Handle discovered services +browser.browseResultsChangedHandler = { results, changes in + for result in results { + switch result.endpoint { + case .service(let name, let type, let domain, _): + print("Found service: \(name).\(type)\(domain)") + // Connect to this service + self.connectToService(result.endpoint) + default: + break + } + } +} + +// Handle browser state +browser.stateUpdateHandler = { state in + switch state { + case .ready: + print("Browser ready") + case .failed(let error): + print("Browser failed: \(error)") + default: + break + } +} + +// Start browsing +browser.start(queue: .main) + +// Connect to discovered service +func connectToService(_ endpoint: NWEndpoint) { + let connection = NWConnection(to: endpoint, using: .tcp) + + connection.stateUpdateHandler = { state in + if case .ready = state { + print("Connected to service") + } + } + + connection.start(queue: .main) +} +``` + +### When to use +- Peer-to-peer discovery (AirDrop-like features) +- Local network printers, media servers +- Development/testing (find test servers automatically) + +### Performance characteristics +- mDNS-based (multicast DNS, no central server) +- Near-instant discovery on same subnet +- Automatic updates when services appear/disappear + +### iOS 26+ alternative +- Use NetworkBrowser with Wi-Fi Aware for peer-to-peer without infrastructure +- See Pattern 1d in axiom-network-framework-ref skill + +## Resources + +**Skills**: axiom-networking, axiom-network-framework-ref, axiom-networking-migration diff --git a/.claude/skills/axiom-networking-legacy/agents/openai.yaml b/.claude/skills/axiom-networking-legacy/agents/openai.yaml new file mode 100644 index 0000000..13819e0 --- /dev/null +++ b/.claude/skills/axiom-networking-legacy/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Networking Legacy" + short_description: "This skill should be used when working with NWConnection patterns for iOS 12-25, supporting apps that can't use async..." diff --git a/.claude/skills/axiom-networking-migration/.openskills.json b/.claude/skills/axiom-networking-migration/.openskills.json new file mode 100644 index 0000000..1171aed --- /dev/null +++ b/.claude/skills/axiom-networking-migration/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-networking-migration", + "installedAt": "2026-04-12T08:06:30.409Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-networking-migration/SKILL.md b/.claude/skills/axiom-networking-migration/SKILL.md new file mode 100644 index 0000000..88f7428 --- /dev/null +++ b/.claude/skills/axiom-networking-migration/SKILL.md @@ -0,0 +1,278 @@ +--- +name: axiom-networking-migration +description: Network framework migration guides. Use when migrating from BSD sockets to NWConnection, NWConnection to NetworkConnection (iOS 26+), or URLSession StreamTask to NetworkConnection. +license: MIT +--- + +# Network Framework Migration Guides + +## Do I Need to Migrate? + +``` +What networking API are you using? + +├─ URLSession for HTTP/HTTPS REST APIs? +│ └─ Stay with URLSession — it's the RIGHT tool for HTTP +│ URLSession handles caching, cookies, auth challenges, +│ HTTP/2/3, and is heavily optimized for web APIs. +│ Network.framework is for custom protocols, NOT HTTP. +│ +├─ BSD Sockets (socket, connect, send, recv)? +│ └─ Migrate to NWConnection (iOS 12+) +│ → See Migration 1 below +│ +├─ NWConnection / NWListener? +│ ├─ Need async/await? → Migrate to NetworkConnection (iOS 26+) +│ │ → See Migration 2 below +│ └─ Callback-based code working fine? → Stay (not deprecated) +│ +├─ URLSession StreamTask for TCP/TLS? +│ └─ Need UDP or custom protocols? → NetworkConnection +│ Need just TCP/TLS for HTTP? → Stay with URLSession +│ → See Migration 3 below +│ +├─ SCNetworkReachability? +│ └─ DEPRECATED — Replace with NWPathMonitor (iOS 12+) +│ let monitor = NWPathMonitor() +│ monitor.pathUpdateHandler = { path in +│ print(path.status == .satisfied ? "Online" : "Offline") +│ } +│ +└─ CFSocket / NSStream? + └─ DEPRECATED — Replace with NWConnection (iOS 12+) + → See Migration 1 below +``` + +## Migration 1: From BSD Sockets to NWConnection + +### Migration mapping + +| BSD Sockets | NWConnection | Notes | +|-------------|--------------|-------| +| `socket() + connect()` | `NWConnection(host:port:using:) + start()` | Non-blocking by default | +| `send() / sendto()` | `connection.send(content:completion:)` | Async, returns immediately | +| `recv() / recvfrom()` | `connection.receive(minimumIncompleteLength:maximumLength:completion:)` | Async, returns immediately | +| `bind() + listen()` | `NWListener(using:on:)` | Automatic port binding | +| `accept()` | `listener.newConnectionHandler` | Callback for each connection | +| `getaddrinfo()` | Let NWConnection handle DNS | Smart resolution with racing | +| `SCNetworkReachability` | `connection.stateUpdateHandler` waiting state | No race conditions | +| `setsockopt()` | `NWParameters` configuration | Type-safe options | + +### Example migration + +#### Before (BSD Sockets) +```c +// BEFORE — Blocking, manual DNS, error-prone +var hints = addrinfo() +hints.ai_family = AF_INET +hints.ai_socktype = SOCK_STREAM + +var results: UnsafeMutablePointer? +getaddrinfo("example.com", "443", &hints, &results) + +let sock = socket(results.pointee.ai_family, results.pointee.ai_socktype, 0) +connect(sock, results.pointee.ai_addr, results.pointee.ai_addrlen) // BLOCKS + +let data = "Hello".data(using: .utf8)! +data.withUnsafeBytes { ptr in + send(sock, ptr.baseAddress, data.count, 0) +} +``` + +#### After (NWConnection) +```swift +// AFTER — Non-blocking, automatic DNS, type-safe +let connection = NWConnection( + host: NWEndpoint.Host("example.com"), + port: NWEndpoint.Port(integerLiteral: 443), + using: .tls +) + +connection.stateUpdateHandler = { state in + if case .ready = state { + let data = Data("Hello".utf8) + connection.send(content: data, completion: .contentProcessed { error in + if let error = error { + print("Send failed: \(error)") + } + }) + } +} + +connection.start(queue: .main) +``` + +### Benefits +- 20 lines → 10 lines +- No manual DNS, no blocking, no unsafe pointers +- Automatic Happy Eyeballs, proxy support, WiFi Assist + +--- + +## Migration 2: From NWConnection to NetworkConnection (iOS 26+) + +### Why migrate +- Async/await eliminates callback hell +- TLV framing and Coder protocol built-in +- No [weak self] needed (async/await handles cancellation) +- State monitoring via async sequences + +### Migration mapping + +| NWConnection (iOS 12-25) | NetworkConnection (iOS 26+) | Notes | +|-------------------------|----------------------------|-------| +| `connection.stateUpdateHandler = { state in }` | `for await state in connection.states { }` | Async sequence | +| `connection.send(content:completion:)` | `try await connection.send(content)` | Suspending function | +| `connection.receive(minimumIncompleteLength:maximumLength:completion:)` | `try await connection.receive(exactly:)` | Suspending function | +| Manual JSON encode/decode | `Coder(MyType.self, using: .json)` | Built-in Codable support | +| Custom framer | `TLV { TLS() }` | Built-in Type-Length-Value | +| `[weak self]` everywhere | No `[weak self]` needed | Task cancellation automatic | + +### Example migration + +#### Before (NWConnection) +```swift +// BEFORE — Completion handlers, manual memory management +let connection = NWConnection(host: "example.com", port: 443, using: .tls) + +connection.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + self?.sendData() + case .waiting(let error): + print("Waiting: \(error)") + case .failed(let error): + print("Failed: \(error)") + default: + break + } +} + +connection.start(queue: .main) + +func sendData() { + let data = Data("Hello".utf8) + connection.send(content: data, completion: .contentProcessed { [weak self] error in + if let error = error { + print("Send error: \(error)") + return + } + self?.receiveData() + }) +} + +func receiveData() { + connection.receive(minimumIncompleteLength: 10, maximumLength: 10) { [weak self] (data, context, isComplete, error) in + if let error = error { + print("Receive error: \(error)") + return + } + if let data = data { + print("Received: \(data)") + } + } +} +``` + +#### After (NetworkConnection) +```swift +// AFTER — Async/await, automatic memory management +let connection = NetworkConnection( + to: .hostPort(host: "example.com", port: 443) +) { + TLS() +} + +// Monitor states in background task +Task { + for await state in connection.states { + switch state { + case .preparing: + print("Connecting...") + case .ready: + print("Ready") + case .waiting(let error): + print("Waiting: \(error)") + case .failed(let error): + print("Failed: \(error)") + default: + break + } + } +} + +// Send and receive with async/await +func sendAndReceive() async throws { + let data = Data("Hello".utf8) + try await connection.send(data) + + let received = try await connection.receive(exactly: 10).content + print("Received: \(received)") +} +``` + +### Benefits +- 30 lines → 15 lines +- No callback nesting, no [weak self] +- Errors propagate naturally with throws +- Automatic cancellation on Task exit + +--- + +## Migration 3: From URLSession StreamTask to NetworkConnection + +### When to migrate +- Need UDP (StreamTask only supports TCP) +- Need custom protocols beyond TCP/TLS +- Need low-level control (packet pacing, ECN, service class) + +### When to STAY with URLSession +- Doing HTTP/HTTPS (URLSession optimized for this) +- Need WebSocket support +- Need built-in caching, cookie handling + +### Example migration + +#### Before (URLSession StreamTask) +```swift +// BEFORE — URLSession for TCP/TLS stream +let task = URLSession.shared.streamTask(withHostName: "example.com", port: 443) + +task.resume() + +task.write(Data("Hello".utf8), timeout: 10) { error in + if let error = error { + print("Write error: \(error)") + } +} + +task.readData(ofMinLength: 10, maxLength: 10, timeout: 10) { data, atEOF, error in + if let error = error { + print("Read error: \(error)") + return + } + if let data = data { + print("Received: \(data)") + } +} +``` + +#### After (NetworkConnection) +```swift +// AFTER — NetworkConnection for TCP/TLS +let connection = NetworkConnection( + to: .hostPort(host: "example.com", port: 443) +) { + TLS() +} + +func sendAndReceive() async throws { + try await connection.send(Data("Hello".utf8)) + let data = try await connection.receive(exactly: 10).content + print("Received: \(data)") +} +``` + +## Resources + +**Skills**: axiom-ios-networking, axiom-networking-legacy diff --git a/.claude/skills/axiom-networking-migration/agents/openai.yaml b/.claude/skills/axiom-networking-migration/agents/openai.yaml new file mode 100644 index 0000000..8dd64cb --- /dev/null +++ b/.claude/skills/axiom-networking-migration/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Networking Migration" + short_description: "Network framework migration guides" diff --git a/.claude/skills/axiom-networking/.openskills.json b/.claude/skills/axiom-networking/.openskills.json new file mode 100644 index 0000000..cf79344 --- /dev/null +++ b/.claude/skills/axiom-networking/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-networking", + "installedAt": "2026-04-12T08:06:29.814Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-networking/SKILL.md b/.claude/skills/axiom-networking/SKILL.md new file mode 100644 index 0000000..97c9f4f --- /dev/null +++ b/.claude/skills/axiom-networking/SKILL.md @@ -0,0 +1,979 @@ +--- +name: axiom-networking +description: Use when implementing Network.framework connections, debugging connection failures, migrating from sockets/URLSession streams, or adopting structured concurrency networking patterns - prevents deprecated API usage, reachability anti-patterns, and thread-safety violations with iOS 12-26+ APIs +license: MIT +compatibility: iOS 12+ (NWConnection), iOS 26+ (NetworkConnection) +metadata: + version: "1.0.0" + last-updated: "2025-12-02" +--- + +# Network.framework Networking + +## When to Use This Skill + +Use when: +- Implementing UDP/TCP connections for gaming, streaming, or messaging apps +- Migrating from BSD sockets, CFSocket, NSStream, or SCNetworkReachability +- Debugging connection timeouts or TLS handshake failures +- Supporting network transitions (WiFi ↔ cellular) gracefully +- Adopting structured concurrency networking patterns (iOS 26+) +- Implementing custom protocols over TLS/QUIC +- Requesting code review of networking implementation before shipping + +#### Related Skills +- Use `axiom-networking-diag` for systematic troubleshooting of connection failures, timeouts, and performance issues +- Use `axiom-network-framework-ref` for comprehensive API reference with all WWDC examples + +## Example Prompts + +#### 1. "How do I migrate from SCNetworkReachability? My app checks connectivity before connecting." +#### 2. "My connection times out after 60 seconds. How do I debug this?" +#### 3. "Should I use NWConnection or NetworkConnection? What's the difference?" + +--- + +## Red Flags — Anti-Patterns to Prevent + +If you're doing ANY of these, STOP and use the patterns in this skill: + +### ❌ CRITICAL — Never Do These + +#### 1. Using SCNetworkReachability to check connectivity before connecting +```swift +// ❌ WRONG — Race condition +if SCNetworkReachabilityGetFlags(reachability, &flags) { + connection.start() // Network may change between check and start +} +``` +**Why this fails** Network state changes between reachability check and connect(). You miss Network.framework's smart connection establishment (Happy Eyeballs, proxy handling, WiFi Assist). Apple deprecated this API in 2018. + +#### 2. Blocking socket operations on main thread +```swift +// ❌ WRONG — Guaranteed ANR (Application Not Responding) +let socket = socket(AF_INET, SOCK_STREAM, 0) +connect(socket, &addr, addrlen) // Blocks main thread +``` +**Why this fails** Main thread hang → frozen UI → App Store rejection for responsiveness. Even "quick" connects take 200-500ms. + +#### 3. Manual DNS resolution with getaddrinfo +```swift +// ❌ WRONG — Misses Happy Eyeballs, proxies, VPN +var hints = addrinfo(...) +getaddrinfo("example.com", "443", &hints, &results) +// Now manually try each address... +``` +**Why this fails** You reimplement 10+ years of Apple's connection logic poorly. Misses IPv4/IPv6 racing, proxy evaluation, VPN detection. + +#### 4. Hardcoded IP addresses instead of hostnames +```swift +// ❌ WRONG — Breaks proxy/VPN compatibility +let host = "192.168.1.1" // or any IP literal +``` +**Why this fails** Proxy auto-configuration (PAC) needs hostname to evaluate rules. VPNs can't route properly. DNS-based load balancing broken. + +#### 5. Ignoring waiting state — not handling lack of connectivity +```swift +// ❌ WRONG — Poor UX +connection.stateUpdateHandler = { state in + if case .ready = state { + // Handle ready + } + // Missing: .waiting case +} +``` +**Why this fails** User sees "Connection failed" in Airplane Mode instead of "Waiting for network." No automatic retry when WiFi returns. + +#### 6. Not using [weak self] in NWConnection completion handlers +```swift +// ❌ WRONG — Memory leak +connection.send(content: data, completion: .contentProcessed { error in + self.handleSend(error) // Retain cycle: connection → handler → self → connection +}) +``` +**Why this fails** Connection retains completion handler, handler captures self strongly, self retains connection → memory leak. + +#### 7. Mixing async/await and completion handlers in NetworkConnection (iOS 26+) +```swift +// ❌ WRONG — Structured concurrency violation +Task { + let connection = NetworkConnection(...) + connection.send(data) // async/await + connection.stateUpdateHandler = { ... } // completion handler — don't mix +} +``` +**Why this fails** NetworkConnection designed for pure async/await. Mixing paradigms creates difficult error propagation and cancellation issues. + +#### 8. Not supporting network transitions +```swift +// ❌ WRONG — Connection fails on WiFi → cellular transition +// No viabilityUpdateHandler, no betterPathUpdateHandler +// User walks out of building → connection dies +``` +**Why this fails** Modern apps must handle network changes gracefully. 40% of connection failures happen during network transitions. + +--- + +## Mandatory First Steps + +**ALWAYS complete these steps** before writing any networking code: + +```swift +// Step 1: Identify your use case +// Record: "UDP gaming" vs "TLS messaging" vs "Custom protocol over QUIC" +// Ask: What data am I sending? Real-time? Reliable delivery needed? + +// Step 2: Check if URLSession is sufficient +// URLSession handles: HTTP, HTTPS, WebSocket, TCP/TLS streams (via StreamTask) +// Network.framework handles: UDP, custom protocols, low-level control, peer-to-peer + +// If HTTP/HTTPS/WebSocket → STOP, use URLSession instead +// Example: +URLSession.shared.dataTask(with: url) { ... } // ✅ Correct for HTTP + +// Step 3: Choose API version based on deployment target +if #available(iOS 26, *) { + // Use NetworkConnection (structured concurrency, async/await) + // TLV framing built-in, Coder protocol for Codable types +} else { + // Use NWConnection (completion handlers) + // Manual framing or custom framers +} + +// Step 4: Verify you're NOT using deprecated APIs +// Search your codebase for these: +// - SCNetworkReachability → Use connection waiting state +// - CFSocket → Use NWConnection +// - NSStream, CFStream → Use NWConnection +// - NSNetService → Use NWBrowser or NetworkBrowser +// - getaddrinfo → Let Network.framework handle DNS + +// To search: +// grep -rn "SCNetworkReachability\|CFSocket\|NSStream\|getaddrinfo" . +``` + +#### What this tells you +- If HTTP/HTTPS: Use URLSession, not Network.framework +- If iOS 26+ deployment: Use NetworkConnection with async/await +- If iOS 12-25 support needed: Use NWConnection with completion handlers +- If any deprecated API found: Must migrate before shipping (App Store review concern) + +--- + +## Decision Tree + +Use this to select the correct pattern in 2 minutes: + +``` +Need networking? +├─ HTTP, HTTPS, or WebSocket? +│ └─ YES → Use URLSession (NOT Network.framework) +│ ✅ URLSession.shared.dataTask(with: url) +│ ✅ URLSession.webSocketTask(with: url) +│ ✅ URLSession.streamTask(withHostName:port:) for TCP/TLS +│ +├─ iOS 26+ and can use structured concurrency? +│ └─ YES → NetworkConnection path (async/await) +│ ├─ TCP with TLS security? +│ │ └─ Pattern 1a: NetworkConnection + TLS +│ │ Time: 10-15 minutes +│ │ +│ ├─ UDP for gaming/streaming? +│ │ └─ Pattern 1b: NetworkConnection + UDP +│ │ Time: 10-15 minutes +│ │ +│ ├─ Need message boundaries (framing)? +│ │ └─ Pattern 1c: TLV Framing +│ │ Type-Length-Value for mixed message types +│ │ Time: 15-20 minutes +│ │ +│ └─ Send/receive Codable objects directly? +│ └─ Pattern 1d: Coder Protocol +│ No manual JSON encoding needed +│ Time: 10-15 minutes +│ +└─ iOS 12-25 or need completion handlers? + └─ YES → NWConnection path (callbacks) + ├─ TCP with TLS security? + │ └─ Pattern 2a: NWConnection + TLS + │ stateUpdateHandler, completion-based send/receive + │ Time: 15-20 minutes + │ + ├─ UDP streaming with batching? + │ └─ Pattern 2b: NWConnection + UDP Batch + │ connection.batch for 30% CPU reduction + │ Time: 10-15 minutes + │ + ├─ Listening for incoming connections? + │ └─ Pattern 2c: NWListener + │ Accept inbound connections, newConnectionHandler + │ Time: 20-25 minutes + │ + └─ Network discovery (Bonjour)? + └─ Pattern 2d: NWBrowser + Discover services on local network + Time: 25-30 minutes +``` + +#### Quick selection guide +- Gaming (low latency, some loss OK) → UDP patterns (1b or 2b) +- Messaging (reliable, ordered) → TLS patterns (1a or 2a) +- Mixed message types → TLV or Coder (1c or 1d) +- Peer-to-peer → Discovery patterns (2d) + incoming (2c) + +--- + +## Common Patterns + +### Pattern 1a: NetworkConnection with TLS (iOS 26+) + +**Use when** iOS 26+ deployment, need reliable TCP with TLS security, want async/await + +**Time cost** 10-15 minutes + +#### ❌ BAD: Manual DNS, Blocking Socket +```swift +// WRONG — Don't do this +var hints = addrinfo(...) +getaddrinfo("www.example.com", "1029", &hints, &results) +let sock = socket(AF_INET, SOCK_STREAM, 0) +connect(sock, results.pointee.ai_addr, results.pointee.ai_addrlen) // Blocks! +``` + +#### ✅ GOOD: NetworkConnection with Declarative Stack + +```swift +import Network + +// Basic connection with TLS +let connection = NetworkConnection( + to: .hostPort(host: "www.example.com", port: 1029) +) { + TLS() // TCP and IP inferred automatically +} + +// Send and receive with async/await +public func sendAndReceiveWithTLS() async throws { + let outgoingData = Data("Hello, world!".utf8) + try await connection.send(outgoingData) + + let incomingData = try await connection.receive(exactly: 98).content + print("Received data: \(incomingData)") +} + +// Optional: Monitor connection state for UI updates +Task { + for await state in connection.states { + switch state { + case .preparing: + print("Establishing connection...") + case .ready: + print("Connected!") + case .waiting(let error): + print("Waiting for network: \(error)") + case .failed(let error): + print("Connection failed: \(error)") + case .cancelled: + print("Connection cancelled") + @unknown default: + break + } + } +} +``` + +#### Custom parameters for low data mode + +```swift +let connection = NetworkConnection( + to: .hostPort(host: "www.example.com", port: 1029), + using: .parameters { + TLS { + TCP { + IP() + .fragmentationEnabled(false) + } + } + } + .constrainedPathsProhibited(true) // Don't use cellular in low data mode +) +``` + +#### When to use +- Secure messaging, email protocols (IMAP, SMTP) +- Custom protocols requiring encryption +- APIs using non-HTTP protocols + +#### Performance characteristics +- Smart connection establishment: Happy Eyeballs (IPv4/IPv6 racing), proxy evaluation, VPN detection +- TLS 1.3 by default (faster handshake) +- User-space networking: ~30% lower CPU usage vs sockets + +#### Debugging +- Enable logging: `-NWLoggingEnabled 1 -NWConnectionLoggingEnabled 1` +- Check connection.states async sequence for state transitions +- Test on real device with Airplane Mode toggle + +--- + +### Pattern 1b: NetworkConnection UDP (iOS 26+) + +**Use when** iOS 26+ deployment, need UDP datagrams for gaming or real-time streaming, want async/await + +**Time cost** 10-15 minutes + +#### ❌ BAD: Blocking UDP Socket +```swift +// WRONG — Don't do this +let sock = socket(AF_INET, SOCK_DGRAM, 0) +let sent = sendto(sock, buffer, length, 0, &addr, addrlen) +// Blocks, no batching, axiom-high CPU overhead +``` + +#### ✅ GOOD: NetworkConnection with UDP + +```swift +import Network + +// UDP connection for real-time data +let connection = NetworkConnection( + to: .hostPort(host: "game-server.example.com", port: 9000) +) { + UDP() +} + +// Send game state update +public func sendGameUpdate() async throws { + let gameState = Data("player_position:100,50".utf8) + try await connection.send(gameState) +} + +// Receive game updates +public func receiveGameUpdates() async throws { + while true { + let (data, _) = try await connection.receive() + processGameState(data) + } +} + +// Batch multiple datagrams for efficiency (30% CPU reduction) +public func sendMultipleUpdates(_ updates: [Data]) async throws { + for update in updates { + try await connection.send(update) + } +} +``` + +#### When to use +- Real-time gaming (player position, game state) +- Live streaming (video/audio frames where some loss is acceptable) +- IoT telemetry (sensor data) + +#### Performance characteristics +- User-space networking: ~30% lower CPU vs sockets +- Batching multiple sends reduces context switches +- ECN (Explicit Congestion Notification) enabled automatically + +#### Debugging +- Use Instruments Network template to profile datagram throughput +- Check for packet loss with receive timeouts +- Test on cellular network (higher latency/loss) + +--- + +### Pattern 1c: TLV Framing (iOS 26+) + +**Use when** Need message boundaries on stream protocols (TCP/TLS), have mixed message types, want type-safe message handling + +**Time cost** 15-20 minutes + +**Background** Stream protocols (TCP/TLS) don't preserve message boundaries. If you send 3 chunks, receiver might get them 1 byte at a time, or all at once. TLV (Type-Length-Value) solves this by encoding each message with its type and length. + +#### ❌ BAD: Manual Length Prefix Parsing +```swift +// WRONG — Error-prone, boilerplate-heavy +let lengthData = try await connection.receive(exactly: 4).content +let length = lengthData.withUnsafeBytes { $0.load(as: UInt32.self) } +let messageData = try await connection.receive(exactly: Int(length)).content +// Now decode manually... +``` + +#### ✅ GOOD: TLV Framing with Type Safety + +```swift +import Network + +// Define your message types +enum GameMessage: Int { + case selectedCharacter = 0 + case move = 1 +} + +struct GameCharacter: Codable { + let character: String +} + +struct GameMove: Codable { + let row: Int + let column: Int +} + +// Connection with TLV framing +let connection = NetworkConnection( + to: .hostPort(host: "www.example.com", port: 1029) +) { + TLV { + TLS() + } +} + +// Send typed messages +public func sendWithTLV() async throws { + let characterData = try JSONEncoder().encode(GameCharacter(character: "🐨")) + try await connection.send(characterData, type: GameMessage.selectedCharacter.rawValue) +} + +// Receive typed messages +public func receiveWithTLV() async throws { + let (incomingData, metadata) = try await connection.receive() + + switch GameMessage(rawValue: metadata.type) { + case .selectedCharacter: + let character = try JSONDecoder().decode(GameCharacter.self, from: incomingData) + print("Character selected: \(character)") + case .move: + let move = try JSONDecoder().decode(GameMove.self, from: incomingData) + print("Move: row=\(move.row), column=\(move.column)") + case .none: + print("Unknown message type: \(metadata.type)") + } +} +``` + +#### When to use +- Mixed message types in same connection (chat + presence + typing indicators) +- Existing protocols using TLV (many custom protocols) +- Need message boundaries without heavy framing overhead + +#### How it works +- Type: UInt32 message identifier (your enum raw value) +- Length: UInt32 message size (automatic) +- Value: Actual message bytes + +#### Performance characteristics +- Minimal overhead: 8 bytes per message (type + length) +- No manual parsing: Framework handles framing +- Type-safe: Compiler catches message type errors + +--- + +### Pattern 1d: Coder Protocol (iOS 26+) + +**Use when** Sending/receiving Codable types, want to eliminate JSON boilerplate, need type-safe message handling + +**Time cost** 10-15 minutes + +**Background** Most apps manually encode Codable types to JSON, send bytes, receive bytes, decode JSON. Coder protocol eliminates this boilerplate by handling serialization automatically. + +#### ❌ BAD: Manual JSON Encoding/Decoding +```swift +// WRONG — Boilerplate-heavy, error-prone +let encoder = JSONEncoder() +let data = try encoder.encode(message) +try await connection.send(data) + +let receivedData = try await connection.receive().content +let decoder = JSONDecoder() +let message = try decoder.decode(GameMessage.self, from: receivedData) +``` + +#### ✅ GOOD: Coder Protocol for Direct Codable Send/Receive + +```swift +import Network + +// Define message types as Codable enum +enum GameMessage: Codable { + case selectedCharacter(String) + case move(row: Int, column: Int) +} + +// Connection with Coder protocol +let connection = NetworkConnection( + to: .hostPort(host: "www.example.com", port: 1029) +) { + Coder(GameMessage.self, using: .json) { + TLS() + } +} + +// Send Codable types directly +public func sendWithCoder() async throws { + let selectedCharacter: GameMessage = .selectedCharacter("🐨") + try await connection.send(selectedCharacter) // No encoding needed! +} + +// Receive Codable types directly +public func receiveWithCoder() async throws { + let gameMessage = try await connection.receive().content // Returns GameMessage! + + switch gameMessage { + case .selectedCharacter(let character): + print("Character selected: \(character)") + case .move(let row, let column): + print("Move: (\(row), \(column))") + } +} +``` + +#### Supported formats +- `.json` — JSON encoding (most common, human-readable) +- `.propertyList` — Property list encoding (smaller, faster) + +#### When to use +- App-to-app communication (you control both ends) +- Prototyping (fastest time to working code) +- Type-safe protocols (compiler catches message structure changes) + +#### When NOT to use +- Interoperating with non-Swift servers +- Need custom wire format +- Performance-critical (prefer TLV with manual encoding for control) + +#### Benefits +- No JSON boilerplate: ~50 lines → ~10 lines +- Type-safe: Compiler catches message structure changes +- Automatic framing: Handles message boundaries + +--- + +## Legacy iOS 12-25 Patterns + +For apps supporting iOS 12-25 that can't use async/await yet, invoke `/skill axiom-networking-legacy`: +- Pattern 2a: NWConnection with TLS (completion handlers) +- Pattern 2b: NWConnection UDP Batch (30% CPU reduction) +- Pattern 2c: NWListener (accepting connections, Bonjour) +- Pattern 2d: Network Discovery (NWBrowser for service discovery) + + +## Pressure Scenarios + +### Scenario 1: Reachability Race Condition Under App Store Deadline + +#### Context + +You're 3 days from App Store submission. QA reports connection failures on cellular networks (15% failure rate). Your PM reviews the code and suggests: "Just add a reachability check before connecting. If there's no network, show an error immediately instead of timing out." + +#### Pressure signals +- ⏰ **Deadline pressure** "App Store deadline is Friday. We need this fixed by EOD Wednesday." +- 👔 **Authority pressure** PM (non-technical) suggesting specific implementation +- 💸 **Sunk cost** Already spent 2 hours debugging connection logs, found nothing obvious +- 📊 **Customer impact** "15% of users affected, mostly on cellular" + +#### Rationalization trap + +*"SCNetworkReachability is Apple's API, it must be correct. I've seen it in Stack Overflow answers with 500+ upvotes. Adding a quick reachability check will fix the issue today, and I can refactor it properly after launch. The deadline is more important than perfect code right now."* + +#### Why this fails + +1. **Race condition** Network state changes between reachability check and connection start. You check "WiFi available" at 10:00:00.000, but WiFi disconnects at 10:00:00.050, then you call connection.start() at 10:00:00.100. Connection fails, but reachability said it was available. + +2. **Misses smart connection establishment** Network.framework tries multiple strategies (IPv4, IPv6, proxies, WiFi Assist fallback to cellular). SCNetworkReachability gives you "yes/no" but doesn't tell you which strategy will work. + +3. **Deprecated API** Apple explicitly deprecated SCNetworkReachability in WWDC 2018. App Store Review may flag this as using legacy APIs. + +4. **Doesn't solve actual problem** 15% cellular failures likely caused by not handling waiting state, not by absence of reachability check. + +#### MANDATORY response + +```swift +// ❌ NEVER check reachability before connecting +/* +if SCNetworkReachabilityGetFlags(reachability, &flags) { + if flags.contains(.reachable) { + connection.start() + } else { + showError("No network") // RACE CONDITION + } +} +*/ + +// ✅ ALWAYS let Network.framework handle waiting state +let connection = NWConnection( + host: NWEndpoint.Host("api.example.com"), + port: NWEndpoint.Port(integerLiteral: 443), + using: .tls +) + +connection.stateUpdateHandler = { [weak self] state in + switch state { + case .preparing: + // Show: "Connecting..." + self?.showStatus("Connecting...") + + case .ready: + // Connection established + self?.hideStatus() + self?.sendRequest() + + case .waiting(let error): + // CRITICAL: Don't fail here, show "Waiting for network" + // Network.framework will automatically retry when network returns + print("Waiting for network: \(error)") + self?.showStatus("Waiting for network...") + // User walks out of elevator → WiFi returns → automatic retry + + case .failed(let error): + // Only fail after framework exhausts all options + // (tried IPv4, IPv6, proxies, WiFi Assist, waited for network) + print("Connection failed: \(error)") + self?.showError("Connection failed. Please check your network.") + + case .cancelled: + self?.hideStatus() + + @unknown default: + break + } +} + +connection.start(queue: .main) +``` + +#### Professional push-back template + +*"I understand the deadline pressure. However, adding SCNetworkReachability will create a race condition that will make the 15% failure rate worse, not better. Apple deprecated this API in 2018 specifically because it causes these issues.* + +*The correct fix is to handle the waiting state properly, which Network.framework provides. This will actually solve the cellular failures because the framework will automatically retry when network becomes available (e.g., user walks out of elevator, WiFi returns).* + +*Implementation time: 15 minutes to add waiting state handler vs 2-4 hours debugging reachability race conditions. The waiting state approach is both faster AND more reliable."* + +#### Time saved +- **Reachability approach** 30 min to implement + 2-4 hours debugging race conditions + potential App Store rejection = 3-5 hours total +- **Waiting state approach** 15 minutes to implement + 0 hours debugging = 15 minutes total +- **Savings** 2.5-4.5 hours + avoiding App Store review issues + +#### Actual root cause of 15% cellular failures + +Likely missing waiting state handler. When user is in area with weak cellular, connection moves to waiting state. Without handler, app shows "Connection failed" instead of "Waiting for network," so user force-quits and reports "doesn't work on cellular." + +--- + +### Scenario 2: Blocking Socket Call Causing Main Thread Hang + +#### Context + +Your app has 1-star reviews: "App freezes for 5-10 seconds randomly." After investigation, you find a "quick" socket connect() call on the main thread. Your tech lead says: "This is a legacy code path from 2015. It only connects to localhost (127.0.0.1), so it should be instant. The real fix is a 3-week refactor to move all networking to a background queue, but we don't have time. Just leave it for now." + +#### Pressure signals +- ⏰ **Time pressure** "3-week refactor, we're in feature freeze for 2.0 launch" +- 💸 **Sunk cost** "This code has worked for 8 years, why change it now?" +- 🎯 **Scope pressure** "It's just localhost, not a real network call" +- 📊 **Low frequency** "Only 2% of users see this freeze" + +#### Rationalization trap + +*"Connecting to localhost is basically instant. The freeze must be caused by something else. Besides, refactoring this legacy code is risky—what if I break something? Better to leave working code alone and focus on the new features for 2.0."* + +#### Why this fails + +1. **Even localhost can block** If the app has many threads, the kernel may schedule other work before returning from connect(). Even 50-100ms is visible to users as a stutter. + +2. **ANR (Application Not Responding)** iOS watchdog will terminate your app if main thread blocks for >5 seconds. This explains "random" crashes. + +3. **Localhost isn't always available** If VPN is active, localhost routing can be delayed. If device is under memory pressure, kernel scheduling is slower. + +4. **Guaranteed App Store rejection** Apple's App Store Review Guidelines explicitly check for main thread blocking. This will fail App Review's performance tests. + +#### MANDATORY response + +```swift +// ❌ NEVER call blocking socket APIs on main thread +/* +let sock = socket(AF_INET, SOCK_STREAM, 0) +connect(sock, &addr, addrlen) // BLOCKS MAIN THREAD → ANR +*/ + +// ✅ ALWAYS use async connection, even for localhost +func connectToLocalhost() { + let connection = NWConnection( + host: "127.0.0.1", + port: 8080, + using: .tcp + ) + + connection.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + print("Connected to localhost") + self?.sendRequest(on: connection) + case .failed(let error): + print("Localhost connection failed: \(error)") + default: + break + } + } + + // Non-blocking, returns immediately + connection.start(queue: .main) +} +``` + +#### Alternative: If you must keep legacy socket code (not recommended) + +```swift +// Move blocking call to background queue (minimum viable fix) +DispatchQueue.global(qos: .userInitiated).async { + let sock = socket(AF_INET, SOCK_STREAM, 0) + connect(sock, &addr, addrlen) // Still blocks, but not main thread + + DispatchQueue.main.async { + // Update UI after connection + } +} +``` + +#### Professional push-back template + +*"I understand this code has been stable for 8 years. However, Apple's App Store Review now runs automated performance tests that will fail apps with main thread blocking. This will block our 2.0 release.* + +*The fix doesn't require a 3-week refactor. I can wrap the existing socket code in a background queue dispatch in 30 minutes. Or, I can replace it with NWConnection (non-blocking) in 45 minutes, which also eliminates the socket management code entirely.* + +*Neither approach requires touching other parts of the codebase. We can ship 2.0 on schedule AND fix the ANR crashes."* + +#### Time saved +- **Leave it alone** 0 hours upfront + 4-8 hours when App Review rejects + user churn from 1-star reviews +- **Background queue fix** 30 minutes = main thread safe +- **NWConnection fix** 45 minutes = main thread safe + eliminates socket management +- **Savings** 3-7 hours + avoiding App Store rejection + +--- + +### Scenario 3: Design Review Pressure — "Use WebSockets for Everything" + +#### Context + +Your team is building a multiplayer game with real-time player positions (20 updates/second). In architecture review, the senior architect says: "All our other apps use WebSockets for networking. We should use WebSockets here too for consistency. It's production-proven, and the backend team already knows how to deploy WebSocket servers." + +#### Pressure signals +- 👔 **Authority pressure** Senior architect with 15 years experience +- 🏢 **Org consistency** "All other apps use WebSockets" +- 💼 **Backend expertise** "Backend team doesn't know UDP" +- 📊 **Proven technology** "WebSockets are battle-tested" + +#### Rationalization trap + +*"The architect has way more experience than me. If WebSockets work for the other apps, they'll work here too. UDP sounds complicated and risky. Better to stick with proven technology than introduce something new that might break in production."* + +#### Why this fails for real-time gaming + +1. **Head-of-line blocking** WebSockets use TCP. If one packet is lost, TCP blocks ALL subsequent packets until retransmission succeeds. In a game, this means old player position (frame 100) blocks new position (frame 120), causing stutter. + +2. **Latency overhead** TCP requires 3-way handshake (SYN, SYN-ACK, ACK) before sending data. For 20 updates/second, this overhead adds 50-150ms latency. + +3. **Unnecessary reliability** Game position updates don't need guaranteed delivery. If frame 100 is lost, frame 101 (5ms later) makes it obsolete. TCP retransmits frame 100, wasting bandwidth. + +4. **Connection establishment** WebSockets require HTTP upgrade handshake (4 round trips) before data transfer. UDP starts sending immediately. + +#### MANDATORY response + +```swift +// ❌ WRONG for real-time gaming +/* +let webSocket = URLSession.shared.webSocketTask(with: url) +webSocket.resume() +webSocket.send(.data(positionUpdate)) { error in + // TCP guarantees delivery but blocks on loss + // Old position blocks new position → stutter +} +*/ + +// ✅ CORRECT for real-time gaming +let connection = NWConnection( + host: NWEndpoint.Host("game-server.example.com"), + port: NWEndpoint.Port(integerLiteral: 9000), + using: .udp +) + +connection.stateUpdateHandler = { state in + if case .ready = state { + print("Ready to send game updates") + } +} + +connection.start(queue: .main) + +// Send player position updates (20/second) +func sendPosition(_ position: PlayerPosition) { + let data = encodePosition(position) + connection.send(content: data, completion: .contentProcessed { error in + // Fire and forget, no blocking + // If this frame is lost, next frame (50ms later) makes it obsolete + }) +} +``` + +#### Technical comparison table + +| Aspect | WebSocket (TCP) | UDP | +|--------|----------------|-----| +| Latency (typical) | 50-150ms | 10-30ms | +| Head-of-line blocking | Yes (old data blocks new) | No | +| Connection setup | 4 round trips (HTTP upgrade) | 0 round trips | +| Packet loss handling | Blocks until retransmit | Continues with next packet | +| Bandwidth (20 updates/sec) | ~40 KB/s | ~20 KB/s | +| Best for | Chat, API calls | Gaming, streaming | + +#### Professional push-back template + +*"I appreciate the concern about consistency and proven technology. WebSockets are excellent for our other apps because they're doing chat, notifications, and API calls—use cases where guaranteed delivery matters.* + +*However, real-time gaming has different requirements. Let me explain with a concrete example:* + +*Player moves from position A to B to C (3 updates in 150ms). With WebSockets:* +*- Frame A sent* +*- Frame A packet lost* +*- Frame B sent, but TCP blocks it (waiting for Frame A retransmit)* +*- Frame C sent, also blocked* +*- Frame A retransmits, arrives 200ms later* +*- Frames B and C finally delivered* +*- Result: 200ms of frozen player position, then sudden jump to C* + +*With UDP:* +*- Frame A sent and lost* +*- Frame B sent and delivered (50ms later)* +*- Frame C sent and delivered (50ms later)* +*- Result: Smooth position updates, no freeze* + +*The backend team doesn't need to learn UDP from scratch—they can use the same Network.framework on server-side Swift (Vapor, Hummingbird). Implementation time is the same.* + +*I'm happy to do a proof-of-concept this week showing latency comparison. We can measure both approaches with real data."* + +#### When WebSockets ARE correct +- Chat applications (message delivery must be reliable) +- Turn-based games (moves must arrive in order) +- API calls over persistent connection +- Live notifications/updates + +#### Time saved +- **WebSocket approach** 2 days implementation + 1-2 weeks debugging stutter/lag issues + potential rewrite = 3-4 weeks +- **UDP approach** 2 days implementation + smooth gameplay = 2 days +- **Savings** 2-3 weeks + better user experience + +--- + +## Migration Guides + +For detailed migration guides from legacy networking APIs, invoke `/skill axiom-networking-migration`: +- Migration 1: BSD Sockets → NWConnection +- Migration 2: NWConnection → NetworkConnection (iOS 26+) +- Migration 3: URLSession StreamTask → NetworkConnection + + +## Checklist + +Before shipping networking code, verify: + +### Deprecated API Check +- [ ] Not using SCNetworkReachability anywhere in codebase +- [ ] Not using CFSocket, NSSocket, or BSD sockets directly +- [ ] Not using NSStream or CFStream +- [ ] Not using NSNetService (use NWBrowser instead) +- [ ] Not calling getaddrinfo for manual DNS resolution + +### Connection Configuration +- [ ] Using hostname, NOT hardcoded IP address +- [ ] TLS enabled for sensitive data (passwords, tokens, user content) +- [ ] Handling waiting state with user feedback ("Waiting for network...") +- [ ] Not checking reachability before calling connection.start() + +### Memory Management +- [ ] Using [weak self] in all NWConnection completion handlers +- [ ] Or using NetworkConnection (iOS 26+) with async/await (no [weak self] needed) +- [ ] Calling connection.cancel() when done to free resources + +### Network Transitions +- [ ] Supporting network changes (WiFi → cellular, or vice versa) +- [ ] Using viabilityUpdateHandler or betterPathUpdateHandler (NWConnection) +- [ ] Or monitoring connection.states async sequence (NetworkConnection) +- [ ] NOT tearing down connection immediately on viability change + +### Testing on Real Devices +- [ ] Tested on real device (not just simulator) +- [ ] Tested WiFi → cellular transition (walk out of building) +- [ ] Tested Airplane Mode toggle (enable → disable) +- [ ] Tested on IPv6-only network (some cellular carriers) +- [ ] Tested with corporate VPN active +- [ ] Tested with low signal (basement, elevator) + +### Performance +- [ ] Using connection.batch for multiple UDP datagrams (30% CPU reduction) +- [ ] Using contentProcessed completion for send pacing (not sleep()) +- [ ] Profiled with Instruments Network template +- [ ] Connection establishment < 500ms (check with logging) + +### Error Handling +- [ ] Handling .failed state with specific error +- [ ] Timeout handling (don't wait forever in .preparing) +- [ ] TLS handshake errors logged for debugging +- [ ] User-facing errors are actionable ("Check network" not "POSIX error 61") + +### iOS 26+ Features (if using NetworkConnection) +- [ ] Using TLV framing if need message boundaries +- [ ] Using Coder protocol if sending Codable types +- [ ] Using NetworkListener instead of NWListener +- [ ] Using NetworkBrowser with Wi-Fi Aware for peer-to-peer + +--- + +## Real-World Impact + +### User-Space Networking: 30% CPU Reduction + +**WWDC 2018 Demo** Live UDP video streaming comparison: +- **BSD sockets** ~30% higher CPU usage on receiver +- **Network.framework** ~30% lower CPU usage + +**Why** Traditional sockets copy data kernel → userspace. Network.framework uses memory-mapped regions (no copy) and reduces context switches from 100 syscalls → ~1 syscall (with batching). + +#### Impact for your app +- Lower battery drain (30% less CPU = longer battery life) +- Smoother gameplay (more CPU for rendering) +- Cooler device (less thermal throttling) + +### Smart Connection Establishment: 50% Faster + +#### Traditional approach +1. Call getaddrinfo (100-300ms DNS lookup) +2. Try first IPv6 address, wait 5 seconds for timeout +3. Try IPv4 address, finally connects + +#### Network.framework (Happy Eyeballs) +1. Start DNS lookup in background +2. As soon as first address arrives, try connecting +3. Start second connection attempt 50ms later +4. Use whichever connects first + +**Result** 50% faster connection establishment in dual-stack environments (measured by Apple) + +### Proper State Handling: 10x Crash Reduction + +**Customer report** App crash rate dropped from 5% → 0.5% after implementing waiting state handler. + +**Before** App showed "Connection failed" when no network, users force-quit app → crash report. + +**After** App showed "Waiting for network" and automatically retried when WiFi returned → users saw seamless reconnection. + +--- + +## Resources + +**WWDC**: 2018-715, 2025-250 + +**Skills**: axiom-networking-diag, axiom-network-framework-ref + +--- + +**Last Updated** 2025-12-02 +**Status** Production-ready patterns from WWDC 2018 and WWDC 2025 +**Tested** Patterns validated against Apple documentation and WWDC transcripts diff --git a/.claude/skills/axiom-networking/agents/openai.yaml b/.claude/skills/axiom-networking/agents/openai.yaml new file mode 100644 index 0000000..78444ff --- /dev/null +++ b/.claude/skills/axiom-networking/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Networking" + short_description: "Implementing Network.framework connections, debugging connection failures, migrating from sockets/URLSession streams,..." diff --git a/.claude/skills/axiom-now-playing-carplay/.openskills.json b/.claude/skills/axiom-now-playing-carplay/.openskills.json new file mode 100644 index 0000000..949efe3 --- /dev/null +++ b/.claude/skills/axiom-now-playing-carplay/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-now-playing-carplay", + "installedAt": "2026-04-12T08:06:30.803Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-now-playing-carplay/SKILL.md b/.claude/skills/axiom-now-playing-carplay/SKILL.md new file mode 100644 index 0000000..b2c7f3c --- /dev/null +++ b/.claude/skills/axiom-now-playing-carplay/SKILL.md @@ -0,0 +1,131 @@ +--- +name: axiom-now-playing-carplay +description: CarPlay Now Playing integration patterns. Use when implementing CarPlay audio controls, CPNowPlayingTemplate customization, or debugging CarPlay-specific issues. +license: MIT +--- + +# CarPlay Integration + +**Time cost**: 15-20 minutes (if MPNowPlayingInfoCenter already working) + +## Key Insight + +**CarPlay uses the SAME MPNowPlayingInfoCenter and MPRemoteCommandCenter as Lock Screen and Control Center.** If your Now Playing integration works on iOS, it automatically works in CarPlay with zero additional code. + +## What CarPlay Reads + +| iOS Component | CarPlay Display | +|---------------|-----------------| +| `MPNowPlayingInfoCenter.nowPlayingInfo` | CPNowPlayingTemplate metadata (title, artist, artwork) | +| `MPRemoteCommandCenter` handlers | CPNowPlayingTemplate button responses | +| Artwork from `nowPlayingInfo` | Album art in CarPlay UI | + +No CarPlay-specific metadata needed. Your existing code works. + +## CPNowPlayingTemplate Customization (iOS 14+) + +For custom playback controls beyond standard play/pause/skip: + +```swift +import CarPlay + +@MainActor +class SceneDelegate: UIResponder, UIWindowSceneDelegate, CPTemplateApplicationSceneDelegate { + + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didConnect interfaceController: CPInterfaceController + ) { + // ✅ Configure CPNowPlayingTemplate at connection time (not when pushed) + let nowPlayingTemplate = CPNowPlayingTemplate.shared + + // Enable Album/Artist browsing (shows button that navigates to album/artist view in your app) + nowPlayingTemplate.isAlbumArtistButtonEnabled = true + + // Enable Up Next queue (shows button that displays upcoming tracks) + nowPlayingTemplate.isUpNextButtonEnabled = true + + // Add custom buttons (iOS 14+) + setupCustomButtons(for: nowPlayingTemplate) + } + + private func setupCustomButtons(for template: CPNowPlayingTemplate) { + var buttons: [CPNowPlayingButton] = [] + + // Playback rate button + let rateButton = CPNowPlayingPlaybackRateButton { [weak self] button in + self?.cyclePlaybackRate() + } + buttons.append(rateButton) + + // Shuffle button + let shuffleButton = CPNowPlayingShuffleButton { [weak self] button in + self?.toggleShuffle() + } + buttons.append(shuffleButton) + + // Repeat button + let repeatButton = CPNowPlayingRepeatButton { [weak self] button in + self?.cycleRepeatMode() + } + buttons.append(repeatButton) + + // Update template with custom buttons + template.updateNowPlayingButtons(buttons) + } +} +``` + +## Entitlement Requirement + +CarPlay requires an entitlement in your Xcode project: + +**Info.plist:** +```xml +UIBackgroundModes + + audio + +``` + +**Entitlements file:** +```xml +com.apple.developer.carplay-audio + +``` + +Without the entitlement, CarPlay won't show your app at all. + +## CarPlay-Specific Gotchas + +| Issue | Cause | Fix | Time | +|-------|-------|-----|------| +| CarPlay doesn't show app | Missing entitlement | Add `com.apple.developer.carplay-audio` | 5 min | +| Now Playing blank in CarPlay | MPNowPlayingInfoCenter not set | Same fix as Lock Screen (Pattern 1) | 10 min | +| Custom buttons don't appear | Configured after push | Configure at `templateApplicationScene(_:didConnect:)` | 5 min | +| Buttons work on device, not CarPlay simulator | Debugger interference | Test without debugger attached | 1 min | +| Album art missing | Same as iOS issue | Fix MPMediaItemArtwork (Pattern 3) | 15 min | + +## Testing CarPlay + +**Simulator (Xcode 12+):** +1. I/O → External Displays → CarPlay +2. Tap CarPlay display +3. Find your app in Audio section +4. **Important**: Run without debugger for reliable testing (debugger can interfere with CarPlay audio session activation) + +**Real Vehicle:** +Requires entitlement approval from Apple (automatic for apps with `UIBackgroundModes` audio; no manual request needed). + +## Verification + +- [ ] App appears in CarPlay Audio section +- [ ] Now Playing shows correct metadata +- [ ] Album artwork displays +- [ ] Play/pause/skip buttons respond +- [ ] Custom buttons (if any) appear and work +- [ ] Tested both with and without debugger + +## Resources + +**Skills**: axiom-now-playing, axiom-now-playing-musickit diff --git a/.claude/skills/axiom-now-playing-carplay/agents/openai.yaml b/.claude/skills/axiom-now-playing-carplay/agents/openai.yaml new file mode 100644 index 0000000..5fb40af --- /dev/null +++ b/.claude/skills/axiom-now-playing-carplay/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Now Playing Carplay" + short_description: "CarPlay Now Playing integration patterns" diff --git a/.claude/skills/axiom-now-playing-musickit/.openskills.json b/.claude/skills/axiom-now-playing-musickit/.openskills.json new file mode 100644 index 0000000..4ffe9d6 --- /dev/null +++ b/.claude/skills/axiom-now-playing-musickit/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-now-playing-musickit", + "installedAt": "2026-04-12T08:06:31.004Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-now-playing-musickit/SKILL.md b/.claude/skills/axiom-now-playing-musickit/SKILL.md new file mode 100644 index 0000000..093dff6 --- /dev/null +++ b/.claude/skills/axiom-now-playing-musickit/SKILL.md @@ -0,0 +1,334 @@ +--- +name: axiom-now-playing-musickit +description: MusicKit Now Playing integration patterns. Use when playing Apple Music content with ApplicationMusicPlayer and understanding automatic vs manual Now Playing info updates. +license: MIT +--- + +# MusicKit Integration (Apple Music) + +**Time cost**: 5-10 minutes + +## Key Insight + +**MusicKit's ApplicationMusicPlayer automatically publishes to MPNowPlayingInfoCenter.** You don't need to manually update Now Playing info when playing Apple Music content. + +## What's Automatic + +When using `ApplicationMusicPlayer`: +- Track title, artist, album +- Artwork (Apple's album art) +- Duration and elapsed time +- Playback rate (playing/paused state) + +The system handles all MPNowPlayingInfoCenter updates for you. + +## What's NOT Automatic + +- Custom metadata (chapter markers, custom artist notes) +- Remote command customization beyond standard controls +- Mixing MusicKit content with your own content + +--- + +## Subscription and Authorization + +### Check Music Authorization + +```swift +import MusicKit + +func requestMusicAccess() async -> Bool { + let status = await MusicAuthorization.request() + return status == .authorized +} + +// Check current status without prompting +let currentStatus = MusicAuthorization.currentStatus +// .authorized, .denied, .notDetermined, .restricted +``` + +### Check Apple Music Subscription + +```swift +func checkSubscription() async -> Bool { + do { + let subscription = try await MusicSubscription.current + return subscription.canPlayCatalogContent + } catch { + return false + } +} + +// Observe subscription changes +func observeSubscription() { + Task { + for await subscription in MusicSubscription.subscriptionUpdates { + if subscription.canPlayCatalogContent { + // Full Apple Music access + } else if subscription.canBecomeSubscriber { + // Show subscription offer + showSubscriptionOffer() + } + } + } +} +``` + +### Subscription Offer Sheet + +```swift +import MusicKit +import StoreKit + +// Present Apple Music subscription offer +MusicSubscriptionOffer.Options( + messageIdentifier: .playMusic, + itemID: song.id +) + +// In SwiftUI +.musicSubscriptionOffer(isPresented: $showOffer, options: offerOptions) +``` + +### Graceful Fallback Without Subscription + +```swift +@MainActor +class MusicPlayer: ObservableObject { + @Published var canPlay = false + + func handlePlayRequest(song: Song) async { + let authorized = await requestMusicAccess() + guard authorized else { + showAuthorizationDeniedAlert() + return + } + + do { + let subscription = try await MusicSubscription.current + if subscription.canPlayCatalogContent { + // Full playback + try await play(song: song) + } else { + // Preview only (30-second clips) + if let previewURL = song.previewAssets?.first?.url { + playPreview(url: previewURL) + } + } + } catch { + handleError(error) + } + } +} +``` + +--- + +## Playback + +### Basic Playback + +```swift +import MusicKit + +@MainActor +class MusicKitPlayer { + private let player = ApplicationMusicPlayer.shared + + func play(song: Song) async throws { + // ✅ Just play - MPNowPlayingInfoCenter updates automatically + player.queue = [song] + try await player.play() + + // ❌ DO NOT manually set nowPlayingInfo here + // MPNowPlayingInfoCenter.default().nowPlayingInfo = [...] // WRONG! + } + + func pause() { + player.pause() + } + + func stop() { + player.stop() + } +} +``` + +### Observing Playback State + +```swift +@MainActor +class PlayerViewModel: ObservableObject { + private let player = ApplicationMusicPlayer.shared + @Published var isPlaying = false + @Published var currentEntry: ApplicationMusicPlayer.Queue.Entry? + @Published var playbackTime: TimeInterval = 0 + + func observeState() { + // Observe playback status + Task { + for await state in player.state.objectWillChange.values { + isPlaying = player.state.playbackStatus == .playing + } + } + + // Observe current entry (track changes) + Task { + for await queue in player.queue.objectWillChange.values { + currentEntry = player.queue.currentEntry + } + } + } +} +``` + +--- + +## Queue Management + +### Setting the Queue + +```swift +let player = ApplicationMusicPlayer.shared + +// Single song +player.queue = [song] + +// Album +player.queue = ApplicationMusicPlayer.Queue(album: album) + +// Playlist +player.queue = ApplicationMusicPlayer.Queue(playlist: playlist) + +// Multiple items +player.queue = ApplicationMusicPlayer.Queue(for: [song1, song2, song3]) + +// Start at specific item +player.queue = ApplicationMusicPlayer.Queue(for: songs, startingAt: songs[2]) +``` + +### Queue Operations + +```swift +// Skip to next +try await player.skipToNextEntry() + +// Skip to previous +try await player.skipToPreviousEntry() + +// Restart current track +player.restartCurrentEntry() + +// Append to queue +try await player.queue.insert(song, position: .afterCurrentEntry) +try await player.queue.insert(song, position: .tail) // End of queue + +// Shuffle and repeat +player.state.shuffleMode = .songs // .off, .songs +player.state.repeatMode = .all // .none, .one, .all +``` + +### Observing Queue Changes + +```swift +// Current track info +if let entry = player.queue.currentEntry { + let title = entry.title + let subtitle = entry.subtitle // Artist name + let artwork = entry.artwork // Artwork for display + + // Get full Song object if needed + if case .song(let song) = entry.item { + let albumTitle = song.albumTitle + } +} +``` + +--- + +## Hybrid Apps (Own Content + Apple Music) + +If your app plays both Apple Music and your own content: + +```swift +import MusicKit + +@MainActor +class HybridPlayer { + private let musicKitPlayer = ApplicationMusicPlayer.shared + private var avPlayer: AVPlayer? + private var currentSource: ContentSource = .none + + enum ContentSource { + case none + case appleMusic // MusicKit handles Now Playing + case ownContent // We handle Now Playing + } + + func playAppleMusicSong(_ song: Song) async throws { + // Switch to MusicKit + avPlayer?.pause() + currentSource = .appleMusic + + musicKitPlayer.queue = [song] + try await musicKitPlayer.play() + // ✅ MusicKit handles Now Playing automatically + } + + func playOwnContent(_ url: URL) { + // Switch to AVPlayer + musicKitPlayer.pause() + currentSource = .ownContent + + avPlayer = AVPlayer(url: url) + avPlayer?.play() + + // ✅ Manually update Now Playing (see axiom-now-playing) + updateNowPlayingForOwnContent() + } + + private func updateNowPlayingForOwnContent() { + var nowPlayingInfo = [String: Any]() + nowPlayingInfo[MPMediaItemPropertyTitle] = "My Track" + // ... rest of manual setup + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + } +} +``` + +--- + +## Common Mistake + +```swift +// ❌ WRONG - Overwrites MusicKit's automatic Now Playing data +func playAppleMusicSong(_ song: Song) async throws { + try await ApplicationMusicPlayer.shared.play() + + // ❌ This clears MusicKit's Now Playing info! + var nowPlayingInfo = [String: Any]() + nowPlayingInfo[MPMediaItemPropertyTitle] = song.title + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo +} + +// ✅ CORRECT - Let MusicKit handle it +func playAppleMusicSong(_ song: Song) async throws { + try await ApplicationMusicPlayer.shared.play() + // That's it! MusicKit publishes Now Playing automatically. +} +``` + +## When to Use Manual Updates with MusicKit + +Only override MPNowPlayingInfoCenter if: +- You're mixing in additional metadata (e.g., podcast chapter markers) +- You're displaying custom content alongside Apple Music +- You have a specific reason to replace MusicKit's automatic behavior + +**Default**: Let MusicKit manage Now Playing automatically. + +## Resources + +**Docs**: /musickit, /musickit/applicationmusicplayer, /musickit/musicsubscription + +**Skills**: axiom-now-playing, axiom-now-playing-carplay diff --git a/.claude/skills/axiom-now-playing-musickit/agents/openai.yaml b/.claude/skills/axiom-now-playing-musickit/agents/openai.yaml new file mode 100644 index 0000000..6cf43f9 --- /dev/null +++ b/.claude/skills/axiom-now-playing-musickit/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Now Playing Musickit" + short_description: "MusicKit Now Playing integration patterns" diff --git a/.claude/skills/axiom-now-playing/.openskills.json b/.claude/skills/axiom-now-playing/.openskills.json new file mode 100644 index 0000000..450cb02 --- /dev/null +++ b/.claude/skills/axiom-now-playing/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-now-playing", + "installedAt": "2026-04-12T08:06:30.607Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-now-playing/SKILL.md b/.claude/skills/axiom-now-playing/SKILL.md new file mode 100644 index 0000000..b362d7e --- /dev/null +++ b/.claude/skills/axiom-now-playing/SKILL.md @@ -0,0 +1,1001 @@ +--- +name: axiom-now-playing +description: Use when Now Playing metadata doesn't appear on Lock Screen/Control Center, remote commands (play/pause/skip) don't respond, artwork is missing/wrong/flickering, or playback state is out of sync - provides systematic diagnosis, correct patterns, and professional push-back for audio/video apps on iOS 18+ +license: MIT +compatibility: iOS 18+, iPadOS 18+, CarPlay (iOS 14+) +metadata: + version: "1.1.0" +--- + +# Now Playing Integration Guide + +**Purpose**: Prevent the 4 most common Now Playing issues on iOS 18+: info not appearing, commands not working, artwork problems, and state sync issues + +**Swift Version**: Swift 6.0+ +**iOS Version**: iOS 18+ +**Xcode**: Xcode 16+ + +## Core Philosophy + +> "Now Playing eligibility requires THREE things working together: AVAudioSession activation, remote command handlers, and metadata publishing. Missing ANY of these silently breaks the entire system. 90% of Now Playing issues stem from incorrect activation order or missing command handlers, not API bugs." + +**Key Insight from WWDC 2022/110338**: Apps must meet two system heuristics: +1. Register handlers for at least one remote command +2. Configure AVAudioSession with a non-mixable category + +## When to Use This Skill + +✅ **Use this skill when**: +- Now Playing info doesn't appear on Lock Screen or Control Center +- Play/pause/skip buttons are grayed out or don't respond +- Album artwork is missing, wrong, or flickers between images +- Control Center shows "Playing" when app is paused, or vice versa +- Apple Music or other apps "steal" Now Playing status +- Implementing Now Playing for the first time +- Debugging Now Playing issues in existing implementation +- Integrating CarPlay Now Playing (covered in Pattern 6) +- Working with MusicKit/Apple Music content (covered in Pattern 7) + +### iOS 26 Note + +iOS 26 introduces **Liquid Glass visual design** for Lock Screen and Control Center Now Playing widgets. This is **automatic system behavior** — no code changes required. The patterns in this skill remain valid for iOS 26. + +❌ **Do NOT use this skill for**: +- Background audio configuration details (see AVFoundation skill) + +## Related Skills + +- **swift-concurrency** - For @MainActor patterns, weak self in closures, async artwork loading +- **memory-debugging** - For retain cycles in command handlers +- **avfoundation-ref** - For AVAudioSession configuration details + +--- + +## Red Flags / Anti-Patterns + +**If you see ANY of these, suspect Now Playing misconfiguration:** + +- Info appears briefly then disappears (AVAudioSession deactivated) +- Commands work in simulator but not on device (simulator has different audio stack) +- Artwork shows placeholder then updates (race condition, not necessarily wrong) +- Artwork never appears (format/size issue or MPMediaItemArtwork block returning nil) +- Play/pause state incorrect after backgrounding (not updating on playback rate changes) +- Another app "steals" Now Playing (didn't meet eligibility requirements) +- `playbackState` property doesn't update (iOS doesn't have `playbackState`, macOS only!) + +**FORBIDDEN Assumptions:** +- "Just set nowPlayingInfo and it works" - Must have AVAudioSession + command handlers +- "playbackState controls Control Center" - iOS ignores playbackState, uses playbackRate +- "Artwork just needs an image" - Needs proper MPMediaItemArtwork with size handler +- "Commands enable themselves" - Must add target AND set isEnabled = true +- "Update elapsed time every second" - System infers from rate, causes jitter + +## Mandatory First Steps (Pre-Diagnosis) + +Run this code to understand current state before debugging: + +```swift +// 1. Verify AVAudioSession configuration +let session = AVAudioSession.sharedInstance() +print("Category: \(session.category.rawValue)") +print("Mode: \(session.mode.rawValue)") +print("Options: \(session.categoryOptions)") +print("Is active: \(try? session.setActive(true))") +// Must be: .playback category, NOT .mixWithOthers option + +// 2. Verify background mode +// Info.plist must have: UIBackgroundModes = ["audio"] + +// 3. Check command handlers are registered +let commandCenter = MPRemoteCommandCenter.shared() +print("Play enabled: \(commandCenter.playCommand.isEnabled)") +print("Pause enabled: \(commandCenter.pauseCommand.isEnabled)") +// Must have at least one command with target AND isEnabled = true + +// 4. Check nowPlayingInfo dictionary +if let info = MPNowPlayingInfoCenter.default().nowPlayingInfo { + print("Title: \(info[MPMediaItemPropertyTitle] ?? "nil")") + print("Artwork: \(info[MPMediaItemPropertyArtwork] != nil)") + print("Duration: \(info[MPMediaItemPropertyPlaybackDuration] ?? "nil")") + print("Elapsed: \(info[MPNowPlayingInfoPropertyElapsedPlaybackTime] ?? "nil")") + print("Rate: \(info[MPNowPlayingInfoPropertyPlaybackRate] ?? "nil")") +} else { + print("No nowPlayingInfo set!") +} +``` + +**What this tells you:** + +| Observation | Diagnosis | Pattern | +|-------------|-----------|---------| +| Category is .ambient or has .mixWithOthers | Won't become Now Playing app | Pattern 1 | +| No commands have targets | System ignores app | Pattern 2 | +| Commands have targets but isEnabled = false | UI grayed out | Pattern 2 | +| Artwork is nil | MPMediaItemArtwork block returning nil | Pattern 3 | +| playbackRate is 0.0 when playing | Control Center shows paused | Pattern 4 | +| Background mode "audio" not in Info.plist | Info disappears on lock | Pattern 1 | + +## Decision Tree + +``` +Now Playing not working? +├─ Info never appears at all? +│ ├─ AVAudioSession category .ambient or .mixWithOthers? +│ │ └─ Pattern 1a (Wrong Category) +│ ├─ No remote command handlers registered? +│ │ └─ Pattern 2a (Missing Handlers) +│ ├─ Background mode "audio" not in Info.plist? +│ │ └─ Pattern 1b (Background Mode) +│ └─ AVAudioSession.setActive(true) never called? +│ └─ Pattern 1c (Not Activated) +│ +├─ Info appears briefly, then disappears? +│ ├─ On lock screen specifically? +│ │ ├─ AVAudioSession deactivated too early? +│ │ │ └─ Pattern 1d (Early Deactivation) +│ │ └─ App suspended (no background mode)? +│ │ └─ Pattern 1b (Background Mode) +│ └─ When switching apps? +│ └─ Another app claiming Now Playing → Pattern 5 +│ +├─ Commands not responding? +│ ├─ Buttons grayed out (disabled)? +│ │ └─ command.isEnabled = false → Pattern 2b +│ ├─ Buttons visible but no response? +│ │ ├─ Handler not returning .success? +│ │ │ └─ Pattern 2c (Handler Return) +│ │ └─ Using wrong command center (session vs shared)? +│ │ └─ Pattern 2d (Command Center) +│ └─ Skip forward/backward not showing? +│ └─ preferredIntervals not set → Pattern 2e +│ +├─ Artwork problems? +│ ├─ Never appears? +│ │ ├─ MPMediaItemArtwork block returning nil? +│ │ │ └─ Pattern 3a (Artwork Block) +│ │ └─ Image format/size invalid? +│ │ └─ Pattern 3b (Image Format) +│ ├─ Wrong artwork showing? +│ │ └─ Race condition between sources → Pattern 3c +│ └─ Artwork flickering? +│ └─ Multiple updates in rapid succession → Pattern 3d +│ +├─ State sync issues? +│ ├─ Shows "Playing" when paused? +│ │ └─ playbackRate not updated → Pattern 4a +│ ├─ Progress bar stuck or jumping? +│ │ └─ elapsedTime not updated at right moments → Pattern 4b +│ └─ Duration wrong? +│ └─ Not setting playbackDuration → Pattern 4c +│ +├─ CarPlay specific issues? +│ ├─ App doesn't appear in CarPlay at all? +│ │ └─ Missing entitlement → Pattern 6 (Add com.apple.developer.carplay-audio) +│ ├─ Now Playing blank in CarPlay but works on iOS? +│ │ └─ Same root cause as iOS → Check Patterns 1-4 +│ ├─ Custom buttons don't appear in CarPlay? +│ │ └─ Wrong configuration timing → Pattern 6 (Configure at templateApplicationScene) +│ └─ Works on device but not CarPlay simulator? +│ └─ Debugger interference → Pattern 6 (Run without debugger) +│ +└─ Using MusicKit (ApplicationMusicPlayer)? + ├─ Now Playing shows wrong info? + │ └─ Overwriting automatic data → Pattern 7 (Don't set nowPlayingInfo manually) + └─ Mixing MusicKit + own content? + └─ Hybrid approach needed → Pattern 7 (Switch between players) +``` + +--- + +## Pattern 1: AVAudioSession Configuration (Info Not Appearing) + +**Time cost**: 10-15 minutes + +### Symptom +- Now Playing info never appears on Lock Screen +- Info appears briefly then disappears on lock +- Works in foreground, disappears in background + +### BAD Code + +```swift +// ❌ WRONG — Category allows mixing, won't become Now Playing app +class PlayerService { + func setupAudioSession() throws { + try AVAudioSession.sharedInstance().setCategory( + .playback, + options: .mixWithOthers // ❌ Mixable = not eligible for Now Playing + ) + // Never called setActive() // ❌ Session not activated + } + + func play() { + player.play() + updateNowPlaying() // ❌ Won't appear - session not active + } +} +``` + +### GOOD Code + +```swift +// ✅ CORRECT — Non-mixable category, activated before playback +class PlayerService { + func setupAudioSession() throws { + try AVAudioSession.sharedInstance().setCategory( + .playback, + mode: .default, + options: [] // ✅ No .mixWithOthers = eligible for Now Playing + ) + } + + func play() async throws { + // ✅ Activate BEFORE starting playback + try AVAudioSession.sharedInstance().setActive(true) + + player.play() + updateNowPlaying() // ✅ Now appears correctly + } + + func stop() async throws { + player.pause() + + // ✅ Deactivate AFTER stopping, with notify option + try AVAudioSession.sharedInstance().setActive( + false, + options: .notifyOthersOnDeactivation + ) + } +} +``` + +### Info.plist Requirement + +```xml +UIBackgroundModes + + audio + +``` + +### Verification +- Lock screen shows Now Playing controls +- Info persists when app backgrounded +- Survives app switch (unless another app plays) + +--- + +## Pattern 2: Remote Command Registration (Commands Not Working) + +**Time cost**: 15-20 minutes + +### Symptom +- Play/pause buttons grayed out +- Buttons visible but tapping does nothing +- Skip buttons don't appear +- Commands work once then stop + +### BAD Code + +```swift +// ❌ WRONG — Missing targets and isEnabled +class PlayerService { + func setupCommands() { + let commandCenter = MPRemoteCommandCenter.shared() + + // ❌ Added target but forgot isEnabled + commandCenter.playCommand.addTarget { _ in + self.player.play() + return .success + } + // playCommand.isEnabled defaults to false! + + // ❌ Never added pause handler + + // ❌ skipForward without preferredIntervals + commandCenter.skipForwardCommand.addTarget { _ in + return .success + } + } +} +``` + +### GOOD Code + +```swift +// ✅ CORRECT — Targets registered, enabled, with proper configuration +@MainActor +class PlayerService { + private var commandTargets: [Any] = [] // Keep strong references + + func setupCommands() { + let commandCenter = MPRemoteCommandCenter.shared() + + // ✅ Play command - add target AND enable + let playTarget = commandCenter.playCommand.addTarget { [weak self] _ in + self?.player.play() + self?.updateNowPlayingPlaybackState(isPlaying: true) + return .success + } + commandCenter.playCommand.isEnabled = true + commandTargets.append(playTarget) + + // ✅ Pause command + let pauseTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in + self?.player.pause() + self?.updateNowPlayingPlaybackState(isPlaying: false) + return .success + } + commandCenter.pauseCommand.isEnabled = true + commandTargets.append(pauseTarget) + + // ✅ Skip forward - set preferredIntervals BEFORE adding target + commandCenter.skipForwardCommand.preferredIntervals = [15.0] + let skipForwardTarget = commandCenter.skipForwardCommand.addTarget { [weak self] event in + guard let skipEvent = event as? MPSkipIntervalCommandEvent else { + return .commandFailed + } + self?.skip(by: skipEvent.interval) + return .success + } + commandCenter.skipForwardCommand.isEnabled = true + commandTargets.append(skipForwardTarget) + + // ✅ Skip backward + commandCenter.skipBackwardCommand.preferredIntervals = [15.0] + let skipBackwardTarget = commandCenter.skipBackwardCommand.addTarget { [weak self] event in + guard let skipEvent = event as? MPSkipIntervalCommandEvent else { + return .commandFailed + } + self?.skip(by: -skipEvent.interval) + return .success + } + commandCenter.skipBackwardCommand.isEnabled = true + commandTargets.append(skipBackwardTarget) + } + + func teardownCommands() { + let commandCenter = MPRemoteCommandCenter.shared() + commandCenter.playCommand.removeTarget(nil) + commandCenter.pauseCommand.removeTarget(nil) + commandCenter.skipForwardCommand.removeTarget(nil) + commandCenter.skipBackwardCommand.removeTarget(nil) + commandTargets.removeAll() + } + + deinit { + teardownCommands() + } +} +``` + +### Verification +- Buttons not grayed out in Control Center +- Tapping play/pause actually plays/pauses +- Skip buttons show with correct interval (15s) + +--- + +## Pattern 3: Artwork Configuration (Artwork Problems) + +**Time cost**: 15-25 minutes + +### Symptom +- Artwork never appears (generic placeholder) +- Wrong artwork for current track +- Artwork flickers between images +- Artwork appears then disappears + +### BAD Code + +```swift +// ❌ WRONG — MPMediaItemArtwork block can return nil, no size handling +func updateNowPlaying() { + var nowPlayingInfo = [String: Any]() + nowPlayingInfo[MPMediaItemPropertyTitle] = track.title + + // ❌ Storing UIImage directly (doesn't work) + nowPlayingInfo[MPMediaItemPropertyArtwork] = image + + // ❌ Or: Block that ignores requested size + let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in + return self.cachedImage // ❌ May be nil, ignores requested size + } + + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo +} + +// ❌ WRONG — Multiple rapid updates cause flickering +func loadArtwork(from url: URL) { + // Request 1 + loadImage(url) { image in + self.updateNowPlayingArtwork(image) // Update 1 + } + // Request 2 (cached) returns faster + loadCachedImage(url) { image in + self.updateNowPlayingArtwork(image) // Update 2 - flicker! + } +} +``` + +### GOOD Code + +```swift +// ✅ CORRECT — Proper MPMediaItemArtwork with value capture (Swift 6 compliant) +@MainActor +class NowPlayingService { + private var currentArtworkURL: URL? + + func updateNowPlayingArtwork(_ image: UIImage, for trackURL: URL) { + // ✅ Prevent race conditions - only update if still current track + guard trackURL == currentArtworkURL else { return } + + // ✅ Create MPMediaItemArtwork with VALUE CAPTURE (not stored property) + // This is Swift 6 strict concurrency compliant — UIImage is immutable + // and safe to capture across isolation domains + let artwork = MPMediaItemArtwork(boundsSize: image.size) { [image] requestedSize in + // ✅ System calls this block from any thread + // Captured value avoids "Main actor-isolated property" error + return image + } + + // ✅ Update only artwork key, preserve other values + var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + } + + // ✅ Single entry point with priority: embedded > cached > remote + func loadArtwork(for track: Track) async { + currentArtworkURL = track.artworkURL + + // Priority 1: Embedded in file (immediate, no flicker) + if let embedded = await extractEmbeddedArtwork(track.fileURL) { + updateNowPlayingArtwork(embedded, for: track.artworkURL) + return + } + + // Priority 2: Already cached (fast) + if let cached = await loadFromCache(track.artworkURL) { + updateNowPlayingArtwork(cached, for: track.artworkURL) + return + } + + // Priority 3: Remote (slow, but don't flicker) + // ✅ Set placeholder first, then update once with real image + if let remote = await downloadImage(track.artworkURL) { + updateNowPlayingArtwork(remote, for: track.artworkURL) + } + } +} +``` + +**Why value capture, not `nonisolated(unsafe)`**: The closure passed to `MPMediaItemArtwork` may be called by the system from any thread. Under Swift 6 strict concurrency, accessing `@MainActor`-isolated stored properties from this closure would cause a compile error. Capturing the image value directly is cleaner than using `nonisolated(unsafe)` because UIImage is immutable and thread-safe for reads. + +### Artwork Size Guidelines +- Lock Screen: 300x300 points (600x600 @2x, 900x900 @3x) +- Control Center: Various sizes +- **Best practice**: Provide image at least 600x600 pixels + +### Verification +- Artwork appears on Lock Screen +- Correct artwork for current track +- No flickering when track changes +- Artwork persists after backgrounding + +--- + +## Pattern 4: Playback State Synchronization (State Sync Issues) + +**Time cost**: 10-20 minutes + +### Symptom +- Control Center shows "Playing" when actually paused +- Progress bar doesn't move or jumps unexpectedly +- Duration shows wrong value +- Scrubbing doesn't work correctly + +### BAD Code + +```swift +// ❌ WRONG — Using playbackState (macOS only, ignored on iOS) +func updatePlaybackState(isPlaying: Bool) { + MPNowPlayingInfoCenter.default().playbackState = isPlaying ? .playing : .paused + // ❌ iOS ignores this property! Only macOS uses it. +} + +// ❌ WRONG — Updating elapsed time on a timer (causes drift) +Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = self.player.currentTime().seconds + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + // ❌ Every second creates jitter, system already infers from timestamp +} + +// ❌ WRONG — Partial dictionary updates cause race conditions +func updateTitle() { + var info = [String: Any]() + info[MPMediaItemPropertyTitle] = track.title + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + // ❌ Cleared all other values (artwork, duration, etc.)! +} +``` + +### GOOD Code + +```swift +// ✅ CORRECT — Use playbackRate for iOS, update at key moments only +@MainActor +class NowPlayingService { + + // ✅ Update when playback STARTS + func playbackStarted(track: Track, player: AVPlayer) { + var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + + // ✅ Core metadata + nowPlayingInfo[MPMediaItemPropertyTitle] = track.title + nowPlayingInfo[MPMediaItemPropertyArtist] = track.artist + nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = track.album + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.currentItem?.duration.seconds ?? 0 + + // ✅ Playback state via RATE (not playbackState property) + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime().seconds + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 // Playing + + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + } + + // ✅ Update when playback PAUSES + func playbackPaused(player: AVPlayer) { + var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + + // ✅ Update elapsed time AND rate together + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime().seconds + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0 // Paused + + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + } + + // ✅ Update when user SEEKS + func userSeeked(to time: CMTime, player: AVPlayer) { + var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time.seconds + // ✅ Keep current rate (don't change playing/paused state) + + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + } + + // ✅ Update when track CHANGES + func trackChanged(to newTrack: Track, player: AVPlayer) { + // ✅ Full refresh of all metadata + var nowPlayingInfo = [String: Any]() + + nowPlayingInfo[MPMediaItemPropertyTitle] = newTrack.title + nowPlayingInfo[MPMediaItemPropertyArtist] = newTrack.artist + nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = newTrack.album + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.currentItem?.duration.seconds ?? 0 + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0.0 + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate + + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + + // Then load artwork asynchronously + Task { + await loadArtwork(for: newTrack) + } + } +} +``` + +### When to Update Now Playing Info + +| Event | What to Update | +|-------|---------------| +| Playback starts | All metadata + elapsed=current + rate=1.0 | +| Playback pauses | elapsed=current + rate=0.0 | +| User seeks | elapsed=newPosition (keep rate) | +| Track changes | All metadata (new track) | +| Playback rate changes (2x, 0.5x) | rate=newRate | + +### DO NOT Update +- On a timer (system infers from elapsed + rate + timestamp) +- Elapsed time continuously (causes jitter) +- Partial dictionaries (loses other values) + +--- + +## Pattern 5: MPNowPlayingSession (iOS 16+ Recommended Approach) + +**Time cost**: 20-30 minutes + +### When to Use MPNowPlayingSession +- iOS 16+ (available since iOS 16, previously tvOS only) +- Using AVPlayer for playback +- Want automatic publishing of playback state +- Multiple players (Picture-in-Picture scenarios) + +### BAD Code (Manual Approach - More Error-Prone) + +```swift +// ❌ Manual updates are error-prone, easy to miss state changes +class OldStylePlayer { + func play() { + player.play() + // Must remember to: + updateNowPlayingElapsed() + updateNowPlayingRate() + // Easy to forget one... + } +} +``` + +### GOOD Code (MPNowPlayingSession) + +```swift +// ✅ CORRECT — MPNowPlayingSession handles automatic publishing +@MainActor +class ModernPlayerService { + private var player: AVPlayer + private var session: MPNowPlayingSession? + + init() { + player = AVPlayer() + setupSession() + } + + func setupSession() { + // ✅ Create session with player + session = MPNowPlayingSession(players: [player]) + + // ✅ Enable automatic publishing of: + // - Duration + // - Elapsed time + // - Playback state (rate) + // - Playback progress + session?.automaticallyPublishNowPlayingInfo = true + + // ✅ Register commands on SESSION's command center (not shared) + session?.remoteCommandCenter.playCommand.addTarget { [weak self] _ in + self?.player.play() + return .success + } + session?.remoteCommandCenter.playCommand.isEnabled = true + + session?.remoteCommandCenter.pauseCommand.addTarget { [weak self] _ in + self?.player.pause() + return .success + } + session?.remoteCommandCenter.pauseCommand.isEnabled = true + + // ✅ Try to become active Now Playing session + session?.becomeActiveIfPossible { success in + print("Became active Now Playing: \(success)") + } + } + + func play(track: Track) async { + let item = AVPlayerItem(url: track.url) + + // ✅ Set static metadata on player item (title, artwork) + item.nowPlayingInfo = [ + MPMediaItemPropertyTitle: track.title, + MPMediaItemPropertyArtist: track.artist, + MPMediaItemPropertyArtwork: await createArtwork(for: track) + ] + + player.replaceCurrentItem(with: item) + player.play() + // ✅ No need to manually update elapsed time, rate, duration + // MPNowPlayingSession publishes automatically! + } +} +``` + +### Multiple Sessions (Picture-in-Picture) + +```swift +class MultiPlayerService { + var mainSession: MPNowPlayingSession + var pipSession: MPNowPlayingSession + + func pipDidExpand() { + // ✅ Promote PiP session when it expands to full screen + pipSession.becomeActiveIfPossible { success in + // PiP now controls Lock Screen, Control Center + } + } + + func pipDidMinimize() { + // ✅ Demote back to main session + mainSession.becomeActiveIfPossible { success in + // Main player now controls Lock Screen, Control Center + } + } +} +``` + +### Critical Gotcha + +**When using MPNowPlayingSession**: Use `session.remoteCommandCenter`, NOT `MPRemoteCommandCenter.shared()` + +```swift +// ❌ WRONG +let commandCenter = MPRemoteCommandCenter.shared() +commandCenter.playCommand.addTarget { _ in } + +// ✅ CORRECT +session.remoteCommandCenter.playCommand.addTarget { _ in } +``` + +--- + +## Pattern 6: CarPlay Integration + +For CarPlay-specific integration patterns, invoke `/skill axiom-now-playing-carplay`. + +**Key insight**: CarPlay uses the SAME MPNowPlayingInfoCenter and MPRemoteCommandCenter as iOS. If your Now Playing works on iOS, it works in CarPlay with zero additional code. + +--- + +## Pattern 7: MusicKit Integration (Apple Music) + +For MusicKit-specific integration patterns and hybrid app examples, invoke `/skill axiom-now-playing-musickit`. + +**Key insight**: MusicKit's ApplicationMusicPlayer automatically publishes to MPNowPlayingInfoCenter. You don't need to manually update Now Playing info when playing Apple Music content. + +--- + +## Pressure Scenarios + +### Scenario 1: Apple Music Keeps Taking Over (24-Hour Launch Deadline) + +#### Situation +- App launching tomorrow +- QA reports: "Now Playing works, but when user opens Apple Music then returns to our app, our controls disappear" +- Product manager: "This is a blocker, users will think our app is broken" +- You're 2 hours from code freeze + +#### Rationalization Traps (DO NOT) +1. *"Just tell users not to use Apple Music"* - Unacceptable UX, will get 1-star reviews +2. *"Force our app to always be Now Playing"* - Impossible, system controls eligibility +3. *"File a bug with Apple"* - Won't help before launch + +#### Root Cause +Your app loses eligibility because: +- Using `.mixWithOthers` option (allows other apps to play simultaneously) +- Not calling `becomeActiveIfPossible()` when returning to foreground +- AVAudioSession deactivated when backgrounded + +#### Systematic Fix (30 minutes) + +```swift +// 1. Remove mixWithOthers +try AVAudioSession.sharedInstance().setCategory(.playback, options: []) + +// 2. Reactivate when returning to foreground +NotificationCenter.default.addObserver( + forName: UIApplication.willEnterForegroundNotification, + object: nil, + queue: .main +) { [weak self] _ in + guard self?.isPlaying == true else { return } + + do { + try AVAudioSession.sharedInstance().setActive(true) + self?.session?.becomeActiveIfPossible { _ in } + } catch { + print("Failed to reactivate audio session: \(error)") + } +} + +// 3. Handle interruptions (phone call, Siri) +NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, + object: nil, + queue: .main +) { [weak self] notification in + guard let info = notification.userInfo, + let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { + return + } + + if type == .ended { + // ✅ Reactivate after interruption + try? AVAudioSession.sharedInstance().setActive(true) + self?.session?.becomeActiveIfPossible { _ in } + } +} +``` + +#### Communication Template + +``` +To PM: Found root cause - our audio session config allowed Apple Music to take over. +Fix implemented: 3 changes to audio session handling. +Testing: Verified fix with Apple Music, Spotify, phone calls. +ETA: 20 more minutes for full regression test. + +To QA: Please test this flow: +1. Play audio in our app +2. Open Apple Music, play a song +3. Return to our app, tap play +4. Lock screen should show OUR controls +``` + +#### Time Saved +- 2-3 hours of debugging speculation +- Launch delay avoided +- QA confidence restored + +--- + +### Scenario 2: Artwork Flickers Every Track Change + +#### Situation +- User feedback: "Album art keeps flashing when songs change" +- Analytics show 3-4 artwork updates per track change +- Designer: "This looks unprofessional" + +#### Root Cause +Multiple artwork sources racing: +1. Cache check (async) +2. Remote URL fetch (async) +3. Embedded artwork extraction (async) + +All three complete at different times, each updating Now Playing + +#### Fix (20 minutes) + +```swift +// ✅ Single-source-of-truth with cancellation +private var artworkTask: Task? + +func loadArtwork(for track: Track) { + // Cancel previous artwork load + artworkTask?.cancel() + + artworkTask = Task { @MainActor in + // Clear previous artwork immediately (optional) + // updateNowPlayingArtwork(nil) + + // Wait for best available artwork + let artwork = await loadBestArtwork(for: track) + + // Check if still current track + guard !Task.isCancelled else { return } + + // Single update + updateNowPlayingArtwork(artwork, for: track.artworkURL) + } +} + +private func loadBestArtwork(for track: Track) async -> UIImage? { + // Priority order: embedded > cached > remote + if let embedded = await extractEmbeddedArtwork(track) { + return embedded + } + if let cached = await loadFromCache(track.artworkURL) { + return cached + } + return await downloadImage(track.artworkURL) +} +``` + +#### Communication Template + +``` +To Designer: Fixed artwork flicker - reduced from 3-4 updates to 1 per track. +Root cause: Multiple async sources racing to update artwork. +Solution: Task cancellation + priority order (embedded > cached > remote). +Testing: Verified with 10 track changes, zero flicker. +``` + +#### Time Saved +- 1-2 hours investigating image caching +- Designer approval unblocked +- Professional UX restored + +--- + +## Common Gotchas + +| Symptom | Cause | Solution | Time to Fix | +|---------|-------|----------|-------------| +| Info never appears | Missing background mode | Add `audio` to UIBackgroundModes in Info.plist | 2 min | +| Info never appears | AVAudioSession not activated | Call `setActive(true)` before playback | 5 min | +| Info never appears | No command handlers | Add target to at least one command | 10 min | +| Info never appears | Using `.mixWithOthers` | Remove .mixWithOthers option | 5 min | +| Commands grayed out | `isEnabled = false` | Set `command.isEnabled = true` after adding target | 5 min | +| Commands don't respond | Handler returns wrong status | Return `.success` from handler | 5 min | +| Commands don't respond | Using shared command center with MPNowPlayingSession | Use `session.remoteCommandCenter` instead | 10 min | +| Skip buttons missing | No preferredIntervals | Set `skipCommand.preferredIntervals = [15.0]` | 5 min | +| Artwork never appears | MPMediaItemArtwork block returns nil | Ensure image is loaded before creating artwork | 15 min | +| Artwork flickers | Multiple rapid updates | Single source of truth with cancellation | 20 min | +| Wrong play/pause state | Using `playbackState` property | Use `playbackRate` (1.0 = playing, 0.0 = paused) | 10 min | +| Progress bar stuck | Not updating on seek | Update `elapsedPlaybackTime` after seek completes | 10 min | +| Progress bar jumps | Updating elapsed on timer | Don't update on timer; system infers from rate | 10 min | +| Loses Now Playing to other apps | Session not reactivated on foreground | Call `becomeActiveIfPossible()` on foreground | 15 min | +| `playbackState` doesn't work | iOS-only app | `playbackState` is macOS only; use `playbackRate` on iOS | 10 min | +| Siri skip ignores preferredIntervals | Hardcoded interval in handler | Use `event.interval` from MPSkipIntervalCommandEvent | 5 min | +| **CarPlay**: App doesn't appear | Missing entitlement | Add `com.apple.developer.carplay-audio` to entitlements | 5 min | +| **CarPlay**: Custom buttons don't appear | Configured at wrong time | Configure at `templateApplicationScene(_:didConnect:)` | 5 min | +| **CarPlay**: Works on device, not simulator | Debugger attached | Run without debugger for reliable testing | 1 min | +| **MusicKit**: Now Playing wrong | Overwriting automatic data | Don't set `nowPlayingInfo` when using ApplicationMusicPlayer | 5 min | + +--- + +## Expert Checklist + +### Before Implementing Now Playing +- [ ] Added `audio` to UIBackgroundModes in Info.plist +- [ ] AVAudioSession category is `.playback` without `.mixWithOthers` +- [ ] Decided: Manual (MPNowPlayingInfoCenter) or Automatic (MPNowPlayingSession)? + +### AVAudioSession Setup +- [ ] `setCategory(.playback)` called at app launch +- [ ] `setActive(true)` called before playback starts +- [ ] `setActive(false, options: .notifyOthersOnDeactivation)` on stop +- [ ] Interruption notification handled (reactivate after phone call) +- [ ] Foreground notification handled (reactivate after background) + +### Remote Commands +- [ ] At least one command has target registered +- [ ] All registered commands have `isEnabled = true` +- [ ] Skip commands have `preferredIntervals` set +- [ ] Handlers return `.success` on success +- [ ] Using correct command center (session's vs shared) +- [ ] Command targets stored to prevent deallocation +- [ ] Commands removed in deinit + +### Now Playing Info +- [ ] Title set (`MPMediaItemPropertyTitle`) +- [ ] Duration set (`MPMediaItemPropertyPlaybackDuration`) +- [ ] Elapsed time set at play/pause/seek (`MPNowPlayingInfoPropertyElapsedPlaybackTime`) +- [ ] Playback rate set (`MPNowPlayingInfoPropertyPlaybackRate`: 1.0 = playing, 0.0 = paused) +- [ ] Artwork created with `MPMediaItemArtwork(boundsSize:requestHandler:)` +- [ ] NOT using `playbackState` property (macOS only) +- [ ] NOT updating elapsed time on a timer + +### Artwork +- [ ] Image at least 600x600 pixels +- [ ] MPMediaItemArtwork block never returns nil (return placeholder if needed) +- [ ] Single source of truth prevents flickering +- [ ] Previous artwork load cancelled on track change + +### Testing +- [ ] Lock screen shows correct info +- [ ] Control Center shows correct info +- [ ] Play/pause buttons respond +- [ ] Skip buttons show and respond +- [ ] Progress bar moves correctly +- [ ] Survives app background/foreground +- [ ] Survives phone call interruption +- [ ] Survives other app playing audio +- [ ] Tested with Apple Music conflict +- [ ] Tested with Spotify conflict + +### CarPlay (if applicable) +- [ ] Added `com.apple.developer.carplay-audio` entitlement +- [ ] CPNowPlayingTemplate configured at `templateApplicationScene(_:didConnect:)` +- [ ] Custom buttons (if any) configured with CPNowPlayingButton +- [ ] Tested on CarPlay simulator (I/O → External Displays → CarPlay) +- [ ] Tested in real vehicle (if available) +- [ ] Tested both with and without debugger attached + +--- + +## Resources + +**WWDC**: 2022-110338, 2017-251, 2019-501 + +**Docs**: /mediaplayer/mpnowplayinginfocenter, /mediaplayer/mpremotecommandcenter, /mediaplayer/mpnowplayingsession + +**Skills**: axiom-avfoundation-ref, axiom-now-playing-carplay, axiom-now-playing-musickit + +--- + +**Last Updated**: 2026-01-04 +**Status**: iOS 18+ discipline skill covering Now Playing, CarPlay, and MusicKit integration +**Tested**: Based on WWDC 2019-501, WWDC 2022-110338 patterns diff --git a/.claude/skills/axiom-now-playing/agents/openai.yaml b/.claude/skills/axiom-now-playing/agents/openai.yaml new file mode 100644 index 0000000..36ff635 --- /dev/null +++ b/.claude/skills/axiom-now-playing/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Now Playing" + short_description: "Now Playing metadata doesn't appear on Lock Screen/Control Center, remote commands (play/pause/skip) don't respond, a..." diff --git a/.claude/skills/axiom-objc-block-retain-cycles/.openskills.json b/.claude/skills/axiom-objc-block-retain-cycles/.openskills.json new file mode 100644 index 0000000..0a8de6b --- /dev/null +++ b/.claude/skills/axiom-objc-block-retain-cycles/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-objc-block-retain-cycles", + "installedAt": "2026-04-12T08:06:31.185Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-objc-block-retain-cycles/SKILL.md b/.claude/skills/axiom-objc-block-retain-cycles/SKILL.md new file mode 100644 index 0000000..d379f37 --- /dev/null +++ b/.claude/skills/axiom-objc-block-retain-cycles/SKILL.md @@ -0,0 +1,601 @@ +--- +name: axiom-objc-block-retain-cycles +description: Use when debugging memory leaks from blocks, blocks assigned to self or properties, network callbacks, or crashes from deallocated objects - systematic weak-strong pattern diagnosis with mandatory diagnostic rules +license: MIT +metadata: + version: "1.0.0" +--- + +# Objective-C Block Retain Cycles + +## Overview + +Block retain cycles are the #1 cause of Objective-C memory leaks. When a block captures `self` and is stored on that same object (directly or indirectly through an operation/request), you create a circular reference: self → block → self. **Core principle** 90% of block memory leaks stem from missing or incorrectly applied weak-strong patterns, not genuine Apple framework bugs. + +## Red Flags — Suspect Block Retain Cycle + +If you see ANY of these, suspect a block retain cycle, not something else: +- Memory grows steadily over time during normal app use +- UIViewController instances not deallocating (verified in Instruments) +- Crash: "Sending message to deallocated instance" from network/async callback +- Network requests or animations prevent view controller from closing +- Weak reference becomes nil unexpectedly in a block +- NSLog, NSAssert, or string formatting hiding self references +- Completion handler fires after the view controller "should be gone" +- ❌ **FORBIDDEN** Rationalizing as "It's probably normal memory usage" + - Memory leaks are never "normal" + - Apps should return to baseline memory after user dismisses a screen + - Do not rationalize this as "good enough" or "monitor it later" + +**Critical distinction** Block retain cycles accumulate silently. A single cycle might be 100KB, but after 50 screens viewed, you have 5MB of dead memory. **MANDATORY: Test on real device (oldest supported model) after fixes, not just simulator.** + +## Mandatory First Steps + +**ALWAYS run these FIRST** (before changing code): + +```objc +// 1. Identify the leak with Allocations instrument +// In Xcode: Xcode > Open Developer Tool > Instruments +// Choose Allocations template +// Perform an action (open/close a screen with the suspected block) +// Check if memory doesn't return to baseline +// Record: "Memory baseline: X MB, after action: Y MB, still allocated: Z objects" + +// 2. Use Memory Debugger to trace the cycle +// Run app, pause at suspected code location +// Debug > Debug Memory Graph +// Search for the view controller that should be deallocated +// Right-click > Show memory graph +// Look for arrows pointing back to self (the cycle) +// Record: "ViewController retained by: [operation/block/property]" + +// 3. Check if block is assigned to self or self's properties +// Search for: setBlock:, completion:, handler:, callback: +// Check: Is the block stored in self.property? +// Check: Is the block passed to something that retains it (network operation)? +// Record: "Block assigned to: [property or operation]" + +// 4. Search for self references in the block +// Look for: [self method], self.property, self-> access +// Look for HIDDEN self references: +// - NSLog(@"Value: %@", self.property) +// - NSAssert(self.isValid, @"message") +// - Format strings: @"Name: %@", self.name +// Record: "self references found in block: [list]" + +// Example output: +// Memory not returning to baseline ✓ +// ViewController retained by: AFHTTPRequestOperation +// Operation retains: successBlock +// Block references self: [self updateUI], NSLog with self.property +// → DIAGNOSIS: Block retain cycle confirmed +``` + +#### What this tells you +- **Memory stays high** → Leak confirmed, not false alarm +- **ViewController retained by operation** → Block is the culprit +- **Block references self** → Pattern: weak-strong needed +- **Hidden self in NSLog/NSAssert** → Need to check ALL macro calls +- **No self references found** → Maybe not a block cycle, investigate elsewhere + +#### MANDATORY INTERPRETATION + +Before changing ANY code, you must confirm ONE of these: + +1. If memory doesn't return to baseline AND ViewController still allocated → Block retain cycle exists +2. If memory returns to baseline → Not a retain cycle, investigate other causes +3. If cycle exists but you can't find self references → Check for hidden references (macros, indirect property access) +4. If you find the cycle but don't understand the chain → Trace backward through retained objects in Memory Graph + +#### If diagnostics are contradictory or unclear +- STOP. Do NOT proceed to patterns yet +- Add more diagnostics: Print the object graph, list retained objects +- Ask: "If memory is low, why is the ViewController still allocated?" +- Run Instruments > Leaks instrument if memory graph is confusing + +## Decision Tree + +``` +Block memory leak suspected? +├─ Memory stays high after dismiss? +│ ├─ YES +│ │ ├─ ViewController still allocated in Memory Graph? +│ │ │ ├─ YES → Proceed to patterns +│ │ │ └─ NO → Not a block cycle, check other leaks +│ │ └─ NO → Not a leak, normal memory usage +│ │ +│ └─ Crash: "Sending message to deallocated instance"? +│ ├─ Happens in block/callback? +│ │ ├─ YES → Block captured weakSelf but it became nil +│ │ │ └─ Apply Pattern 4 (Guard condition is wrong or missing) +│ │ └─ NO → Different crash, not block-related +│ └─ Crash is timing-dependent (only on device)? +│ └─ YES → Weak reference timing issue, apply Pattern 2 +│ +├─ Block assigned to self or self.property? +│ ├─ YES → Apply Pattern 1 (weak-strong mandatory) +│ ├─ Assigned through network operation/timer/animation? +│ │ └─ YES → Apply Pattern 1 (operation retains block indirectly) +│ └─ Block called immediately (inline execution)? +│ ├─ YES → Optional to use weak-strong (no cycle possible) +│ │ └─ But recommend for consistency with other blocks +│ └─ NO → Block stored or passed to async method → Use Pattern 1 +│ +├─ Multiple nested blocks? +│ └─ YES → Apply Pattern 3 (must guard ALL nested blocks) +│ +├─ Block contains NSAssert, NSLog, or string format with self? +│ └─ YES → Apply Pattern 2 (macro hides self reference) +│ +└─ Implemented weak-strong pattern but still leaking? + ├─ Check: Is weakSelf used EVERYWHERE? + ├─ Check: No direct `self` references mixed in? + ├─ Check: Nested blocks also guarded? + └─ Check: No __unsafe_unretained used? +``` + +## Common Patterns + +### Pattern Selection Rules (MANDATORY) + +#### Apply ONE pattern at a time, in this order + +1. **Always start with Pattern 1** (Weak-Strong Basics) + - If block assigned to self or self's properties → Pattern 1 + - If block passed to operation/request that retains it → Pattern 1 + - Only proceed to Pattern 2 if pattern still leaks + +2. **Then Pattern 2** (Hidden self in Macros) + - Only if memory still leaks after applying Pattern 1 + - Check for NSAssert, NSLog, string formatting + - If found, apply Pattern 2 + +3. **Then Pattern 3** (Nested Blocks) + - Only if block has nested callbacks + - Each nested block needs its own guard + - If found, apply Pattern 3 + +4. **Then Pattern 4** (Guard Condition Edge Cases) + - Only if crash happens with weakSelf approach + - Check guard condition is correct + - Verify strongSelf used everywhere + +#### FORBIDDEN +- ❌ Applying multiple patterns at once +- ❌ Skipping Pattern 1 because "I already know weak-strong" +- ❌ Using __unsafe_unretained as workaround +- ❌ Using strong self "just this once" +- ❌ Rationalizing: "The block is too small for a leak" + +--- + +### Pattern 1: Weak-Strong Pattern (MANDATORY) + +**PRINCIPLE** Any block that captures `self` must use weak-strong pattern if block is retained by self (directly or transitively). + +#### ❌ WRONG (Creates retain cycle) +```objc +[self.networkManager GET:@"url" success:^(id response) { + self.data = response; // self is retained by block + [self updateUI]; // block is retained by operation +} failure:^(NSError *error) { + [self handleError:error]; // CYCLE! +}]; +``` + +#### ✅ CORRECT (Breaks the cycle) +```objc +__weak typeof(self) weakSelf = self; +[self.networkManager GET:@"url" success:^(id response) { + typeof(self) strongSelf = weakSelf; + if (strongSelf) { + strongSelf.data = response; + [strongSelf updateUI]; + } +} failure:^(NSError *error) { + __weak typeof(self) weakSelf2 = self; + typeof(self) strongSelf = weakSelf2; + if (strongSelf) { + [strongSelf handleError:error]; + } +}]; +``` + +#### Why this works +1. `__weak typeof(self) weakSelf = self;` creates a weak reference outside the block +2. Block captures weakSelf (weak reference), not self (strong reference) +3. When block executes, convert to strongSelf (temporary strong ref) +4. Check if strongSelf is nil (object was deallocated) +5. Use strongSelf for the duration of the block +6. strongSelf released when block exits → No cycle + +#### Important details +- Declare weakSelf OUTSIDE the block, not inside +- Use `typeof(self)` for type safety (works in both ARC and non-ARC) +- Guard condition MUST use `if (strongSelf)`, not just declare it +- Never use direct `self` inside the block once weakSelf is declared +- Apply to EVERY block that captures self +- ANY block that captures `self` must use weak-strong pattern + - This includes: `[self method]`, `self.property`, `self->ivar` + - Property access (`self.property = value`) captures self just like method calls +- Blocks passed to frameworks: + - If framework documentation says 'block is called asynchronously' → Use weak-strong pattern (framework retains the block) + - If framework documentation says 'block is called immediately' → Still safe to use weak-strong (better practice) + - If unsure about framework behavior → Always use weak-strong (doesn't hurt) + +#### Capturing variables (avoiding indirect self references) +```objc +// ✅ SAFE: Capture simple values extracted from self +__weak typeof(self) weakSelf = self; +[self.manager fetch:^(id response) { + typeof(self) strongSelf = weakSelf; + if (strongSelf) { + NSString *name = strongSelf.name; // Extract value + dispatch_async(dispatch_get_main_queue(), ^{ + NSLog(@"Name: %@", name); // Captured the STRING, not self + }); + } +}]; + +// ❌ WRONG: Capture properties directly in nested blocks +__weak typeof(self) weakSelf = self; +[self.manager fetch:^(id response) { + typeof(self) strongSelf = weakSelf; + if (strongSelf) { + dispatch_async(dispatch_get_main_queue(), ^{ + NSLog(@"Name: %@", strongSelf.name); // Captures strongSelf again! + }); + } +}]; +``` + +When nesting blocks, extract simple values first, then pass them to the inner block. This avoids creating an indirect capture of self through property access. + +**Time cost** 30 seconds per block + +--- + +### Pattern 2: Hidden self in Macros + +**PRINCIPLE** Macros like NSAssert, NSLog, and string formatting can secretly capture self. You must check them. + +#### ❌ WRONG (NSAssert captures self) +```objc +[self.button setTapAction:^{ + NSAssert(self.isValidState, @"State must be valid"); // self captured! + [self doWork]; // Another self reference +}]; +// Leak exists even though you think only [self doWork] captures self +``` + +#### ✅ CORRECT (Check for hidden captures) +```objc +__weak typeof(self) weakSelf = self; +[self.button setTapAction:^{ + typeof(self) strongSelf = weakSelf; + if (strongSelf) { + // NSAssert still references self indirectly through strongSelf + NSAssert(strongSelf.isValidState, @"State must be valid"); + [strongSelf doWork]; + } +}]; +``` + +#### Common hidden self references +- `NSAssert(self.condition, ...)` → Use strongSelf instead +- `NSLog(@"Value: %@", self.property)` → Use strongSelf.property +- `NSError *error = [NSError errorWithDomain:@"MyApp" ...]` → Safe, doesn't capture self +- String formatting: `@"Name: %@", self.name` → Use strongSelf.name +- Inline conditionals: `self.flag ? @"yes" : @"no"` → Use strongSelf.flag + +#### How to find them +1. Search block for all instances of `self.` +2. Mark them: `[self method]`, `self.property`, `self->ivar` +3. Check if any are inside macro calls (NSAssert, NSLog, etc.) +4. Replace with strongSelf + +**Time cost** 1 minute per block to audit + +--- + +### Pattern 3: Nested Blocks (Each Needs Guard) + +**PRINCIPLE** Nested blocks create a chain: outer block captures self, inner block captures outer block variable (which holds strongSelf), creating a new cycle. Each nested block needs its own weak-strong pattern. + +#### ❌ WRONG (Guarded outer block only) +```objc +__weak typeof(self) weakSelf = self; +[self.manager fetchData:^(NSArray *result) { + typeof(self) strongSelf = weakSelf; + if (strongSelf) { + // Inner block captures strongSelf! + [strongSelf.analytics trackEvent:@"Fetched" + completion:^{ + strongSelf.cachedData = result; // Still strong reference! + [strongSelf updateUI]; + }]; + } +}]; +``` + +#### ✅ CORRECT (Guard every nested block) +```objc +__weak typeof(self) weakSelf = self; +[self.manager fetchData:^(NSArray *result) { + typeof(self) strongSelf = weakSelf; + if (strongSelf) { + // Declare new weak reference for inner block + __weak typeof(strongSelf) weakSelf2 = strongSelf; + + [strongSelf.analytics trackEvent:@"Fetched" + completion:^{ + typeof(strongSelf) strongSelf2 = weakSelf2; + if (strongSelf2) { + strongSelf2.cachedData = result; + [strongSelf2 updateUI]; + } + }]; + } +}]; +``` + +#### Why this works +- Each nesting level needs its own weakSelf/strongSelf pair +- Outer block: weakSelf → strongSelf +- Inner block: weakSelf2 → strongSelf2 +- Each level is independent and safe + +#### Important details +- Don't reuse the same weakSelf variable in nested blocks +- Each nesting level gets a new pair (weakSelf2, strongSelf2) +- Guard condition MANDATORY for each level +- Use consistent naming: weakSelf, weakSelf2, weakSelf3 (for readability) + +#### Common nested block patterns that need Pattern 3 +- Completion handlers in callbacks +- `dispatch_async(queue, ^{ ... })` +- `dispatch_after(time, queue, ^{ ... })` +- `[NSTimer scheduledTimerWithTimeInterval:... block:^{ ... }]` +- `[UIView animateWithDuration:... animations:^{ ... }]` + +Each of these is a block that might capture strongSelf, requiring its own weak-strong pattern. + +#### Example with dispatch_async +```objc +__weak typeof(self) weakSelf = self; +[self.manager fetchData:^(NSArray *result) { + typeof(self) strongSelf = weakSelf; + if (strongSelf) { + __weak typeof(strongSelf) weakSelf2 = strongSelf; + + dispatch_async(dispatch_get_main_queue(), ^{ + typeof(strongSelf) strongSelf2 = weakSelf2; + if (strongSelf2) { + strongSelf2.data = result; + [strongSelf2 updateUI]; + } + }); + } +}]; +``` + +**Time cost** 1 minute per nesting level + +--- + +### Pattern 4: Guard Condition Edge Cases + +**PRINCIPLE** The guard condition `if (strongSelf)` must be correct. Common mistakes: forgetting the guard, wrong condition, or mixing self and strongSelf. + +#### ❌ WRONG (Multiple guard failures) +```objc +__weak typeof(self) weakSelf = self; +[self.button setTapAction:^{ + typeof(self) strongSelf = weakSelf; + // MISTAKE 1: Forgot guard condition + self.counter++; // CRASH! self is deallocated, accessing freed object + + // MISTAKE 2: Guard exists but used wrong variable + if (weakSelf) { + [weakSelf doWork]; // weakSelf is weak, might become nil again + } + + // MISTAKE 3: Mixed self and strongSelf + if (strongSelf) { + self.flag = YES; // Used self instead of strongSelf! + [strongSelf doWork]; + } +}]; +``` + +#### ✅ CORRECT (Proper guard and consistent usage) +```objc +__weak typeof(self) weakSelf = self; +[self.button setTapAction:^{ + typeof(self) strongSelf = weakSelf; + if (strongSelf) { + // CORRECT: Use strongSelf everywhere, never self + strongSelf.counter++; + strongSelf.flag = YES; + [strongSelf doWork]; + } + // If strongSelf is nil, entire block skips gracefully +}]; +``` + +#### Why this works +1. `if (strongSelf)` checks if object still exists +2. If it does, strongSelf is a strong reference (safe) +3. If it doesn't (object deallocated), block skips +4. Using strongSelf everywhere prevents accidental self references + +#### Critical rules (MANDATORY, no exceptions) +- ✅ ALWAYS check `if (strongSelf)` before using it +- ✅ ALWAYS use strongSelf inside the if block, NEVER direct self +- ✅ strongSelf is guaranteed valid for the entire block scope +- ❌ NEVER use `if (!strongSelf) return;` (confuses logic) +- ❌ NEVER skip the guard to "save code" +- ❌ NEVER mix weakSelf and strongSelf access +- ❌ NEVER use strongSelf without guard (GUARANTEED crash) + +#### What happens if you get it wrong +- No guard: Crashes with "Sending message to deallocated instance" +- Wrong condition: Object still deallocated, still crashes +- Mixed self/strongSelf: One accidental self defeats entire pattern +- Using strongSelf without guard: GUARANTEED crash when object is deallocated + +#### Inside the guard +```objc +if (strongSelf) { + strongSelf.data1 = value1; + [strongSelf doWork1]; + [strongSelf doWork2]; // All safe +} +// ❌ WRONG: Using strongSelf after guard ends +strongSelf.data = value2; // CRASH! Outside guard +``` + +#### What NOT to do +```objc +// ❌ FORBIDDEN: strongSelf without guard guarantees crash +typeof(self) strongSelf = weakSelf; +strongSelf.data = value; // CRASH if weakSelf is nil! + +// ✅ MANDATORY: Always guard before using strongSelf +if (strongSelf) { + strongSelf.data = value; // Safe +} +``` + +**Time cost** 10 seconds per block to verify guard is correct + +--- + +## Quick Reference Table + +| Issue | Check | Fix | +|-------|-------|-----| +| Memory not returning to baseline | Does ViewController still exist in Memory Graph? | Apply Pattern 1 (weak-strong) | +| Crash: "message to deallocated instance" | Is guard condition missing or wrong? | Apply Pattern 4 (correct guard) | +| Applied weak-strong but still leaking | Are ALL self references using strongSelf? | Check for mixed self/strongSelf | +| Block contains NSAssert or NSLog | Do they reference self? | Apply Pattern 2 (use strongSelf in macros) | +| Nested blocks | Is weak-strong applied to EACH level? | Apply Pattern 3 (guard every block) | +| Not sure if block creates cycle | Is block assigned to self or self.property? | If yes, apply Pattern 1 | + +--- + +## When You're Stuck After 30 Minutes + +If you've spent >30 minutes and the leak still exists: + +#### STOP. You either +1. Skipped a mandatory diagnostic step (most common) +2. Didn't apply weak-strong to ALL blocks (nested blocks missed) +3. Have hidden self reference (NSAssert, NSLog, string format) +4. Applied pattern but mixed in direct `self` references +5. Have a different kind of leak (not block-related) + +#### MANDATORY checklist before claiming "skill didn't work" + +- [ ] I ran all 4 diagnostic blocks (Allocations, Memory Graph, block search, self reference search) +- [ ] I confirmed memory doesn't return to baseline in Instruments +- [ ] I confirmed ViewController is still allocated (not deallocated) +- [ ] I traced the retention chain (what's holding the ViewController?) +- [ ] I found ALL blocks that capture self (global search: `[self` in the file) +- [ ] I checked for hidden self references (NSAssert, NSLog, string formatting) +- [ ] I applied weak-strong pattern to outer blocks +- [ ] I applied weak-strong pattern to nested blocks (every nesting level) +- [ ] I verified NO direct `self` references remain (only strongSelf) +- [ ] I ran Instruments again and memory returned to baseline +- [ ] I tested on real device, not just simulator +- [ ] I cleared Xcode derived data between runs + +#### If ALL boxes are checked and still leaking +- You have a non-block leak (Core Data, timer, delegate, notification) +- Use Instruments > Leaks instrument to identify the actual cycle +- Profile for 2-3 minutes: open screen, close screen, repeat 5 times +- Look at "Leaks" panel—it shows exactly what's not being released +- Time cost: 15-30 minutes to identify the real culprit + +#### If you identify it's NOT a block leak +- Do not rationalize: "Maybe blocks are fine, I'll ship anyway" +- Find the actual cycle (could be delegate, timer, property observer, notification) +- Fix the real issue, not a false positive + +#### Time cost transparency +- Pattern 1: 30 seconds per block +- Pattern 2: 1 minute per block (audit for hidden self) +- Pattern 3: 1 minute per nesting level +- Nested diagnostics if stuck: 15-30 minutes +- Total for straightforward leak: 5-10 minutes + +--- + +## Common Mistakes + +❌ **Forgetting the guard condition** +- `strongSelf.property = value;` without `if (strongSelf)` +- Crash when object is deallocated +- Fix: ALWAYS use `if (strongSelf) { ... }` + +❌ **Mixing self and strongSelf in same block** +- `self.flag = YES; [strongSelf doWork];` +- One direct `self` reference defeats the entire pattern +- Fix: ONLY use strongSelf inside the block + +❌ **Applying pattern to outer block only** +- Nested block still captures strongSelf strongly +- Still leaks +- Fix: Apply weak-strong to EVERY block + +❌ **Using __unsafe_unretained as "workaround"** +- ❌ FORBIDDEN pattern—unsafe and crashes +- Creates crashes when object is deallocated +- Not a solution, worse problem +- Fix: Use weak-strong pattern instead + +❌ **Not checking for hidden self references** +- `NSLog(@"Value: %@", self.property)` in a block +- Leak still exists even after applying weak-strong +- Fix: Audit for NSAssert, NSLog, string formatting + +❌ **Rationalizing "it's a small leak"** +- Single block leak might be 100KB +- After 50 screens, accumulates to 5MB +- Eventually app crashes from memory pressure +- Fix: Fix every block leak, don't rationalize + +❌ **Assuming blocks in system frameworks are safe** +- UIView animations, AFNetworking, dispatch, timers +- ALL can retain blocks that reference self +- Fix: Apply weak-strong pattern regardless of source + +❌ **Testing only in simulator** +- Simulator memory pressure is different +- Leak might not appear until real device under load +- Fix: Test on real device, oldest supported model + +## Real-World Impact + +**Before** Block memory leak debugging 2-3 hours per issue +- Run Allocations, not sure what to look at +- Search everywhere, no clear diagnostic path +- Try random fixes, hope one works +- Ship anyway after sunk cost fallacy +- Customer reports crashes or slowdown + +**After** 5-10 minutes with systematic diagnosis +- Run Allocations, confirm memory not returning to baseline +- Memory Graph shows exactly what's retained +- Find all blocks capturing self with global search +- Apply weak-strong pattern (30 seconds per block) +- Test in Instruments, memory returns to baseline +- Done + +**Key insight** Block retain cycles are 100% preventable with weak-strong pattern. There are no exceptions, no "special cases" where strong self is acceptable. + +--- + +**Last Updated**: 2025-11-30 +**Status**: TDD-tested with pressure scenarios +**Framework**: Objective-C, blocks (closure), ARC diff --git a/.claude/skills/axiom-objc-block-retain-cycles/agents/openai.yaml b/.claude/skills/axiom-objc-block-retain-cycles/agents/openai.yaml new file mode 100644 index 0000000..2ddf256 --- /dev/null +++ b/.claude/skills/axiom-objc-block-retain-cycles/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Obj-C Block Retain Cycles" + short_description: "Debugging memory leaks from blocks, blocks assigned to self or properties, network callbacks, or crashes from dealloc..." diff --git a/.claude/skills/axiom-optimize-build/.openskills.json b/.claude/skills/axiom-optimize-build/.openskills.json new file mode 100644 index 0000000..7971d37 --- /dev/null +++ b/.claude/skills/axiom-optimize-build/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-optimize-build", + "installedAt": "2026-04-12T08:06:31.186Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-optimize-build/SKILL.md b/.claude/skills/axiom-optimize-build/SKILL.md new file mode 100644 index 0000000..652d6a0 --- /dev/null +++ b/.claude/skills/axiom-optimize-build/SKILL.md @@ -0,0 +1,209 @@ +--- +name: axiom-optimize-build +description: Use when the user mentions slow builds, build performance, or build time optimization. +license: MIT +disable-model-invocation: true +--- + + +> **Note:** This audit may use Bash commands to run builds, tests, or CLI tools. +# Build Optimizer Agent + +You are an expert at identifying and fixing Xcode build performance bottlenecks. Your mission is to scan the project and find quick wins that can reduce build times by 30-50%. + +## Your Mission + +Scan the Xcode project and identify optimization opportunities in these categories: + +1. **Build Settings** (HIGH IMPACT) +2. **Build Phase Scripts** (MEDIUM-HIGH IMPACT) +3. **Type Checking Performance** (MEDIUM IMPACT) +4. **Compiler Flags** (LOW-MEDIUM IMPACT) + +For each finding, provide: +- Category and severity (HIGH/MEDIUM/LOW) +- Current configuration +- Recommended fix +- Expected time savings +- Implementation steps + +## What You Check + +### 1. Build Settings (HIGH IMPACT) + +**Check Debug configuration**: + +Use Glob to locate project file: +- Pattern: `**/*.xcodeproj/project.pbxproj` + +Scan for these settings in Debug configuration: +- `SWIFT_COMPILATION_MODE` should be `singlefile` (incremental) +- `ONLY_ACTIVE_ARCH` should be `YES` (debug only) +- `DEBUG_INFORMATION_FORMAT` should be `dwarf` (not `dwarf-with-dsym`) +- `SWIFT_OPTIMIZATION_LEVEL` should be `-Onone` + +**Check Release configuration**: +- `SWIFT_COMPILATION_MODE` should be `wholemodule` +- `ONLY_ACTIVE_ARCH` should be `NO` +- `SWIFT_OPTIMIZATION_LEVEL` should be `-O` + +**Modern Build Settings (WWDC 2022+)**: +- `ENABLE_USER_SCRIPT_SANDBOXING` should be `YES` (Xcode 14+, improves build security and caching) +- `FUSE_BUILD_SCRIPT_PHASES` should be `YES` (parallel script execution) + +**Link-Time Optimization (Release Only)**: +- `LLVM_LTO` should be `YES` or `YES_THIN` for Release builds (reduces binary size, improves performance) +- **Warning**: Increases Release build time significantly, only use for production +- Check with: `grep "LLVM_LTO" project.pbxproj` + +### 2. Build Phase Scripts (MEDIUM-HIGH IMPACT) + +```bash +# Find build phase scripts +grep -A 10 "shellScript" project.pbxproj +``` + +**Red flags**: +- Scripts running in ALL configurations (should skip debug when possible) +- Expensive operations without conditional checks: + - dSYM uploads + - Crashlytics uploads + - Code signing scripts + - Asset processing + +**Example fix**: +```bash +# ❌ BAD - Runs in debug AND release +firebase-crashlytics-upload-symbols + +# ✅ GOOD - Skip in debug builds +if [ "${CONFIGURATION}" = "Release" ]; then + firebase-crashlytics-upload-symbols +fi +``` + +### 3. Type Checking Performance (MEDIUM IMPACT) + +**Enable type checking warnings**: + +Check if these compiler flags are present: +```bash +grep "OTHER_SWIFT_FLAGS" project.pbxproj +``` + +Recommend adding: +- `-warn-long-function-bodies 100` (warns if function takes >100ms to type-check) +- `-warn-long-expression-type-checking 100` (warns if expression takes >100ms) + +**How to find slow files**: +```bash +# Run build with timing +xcodebuild -workspace YourApp.xcworkspace \ + -scheme YourScheme \ + clean build \ + OTHER_SWIFT_FLAGS="-Xfrontend -debug-time-function-bodies" | \ + grep ".[0-9]ms" | \ + sort -nr | \ + head -20 +``` + +### 4. Swift Package Build Plugins (LOW-MEDIUM IMPACT) + +```bash +# Check for prebuilt plugins +grep -r "prebuiltPlugins" Package.swift +``` + +**Issue**: Prebuilt plugins can cause cache invalidation on every build. + +**Fix**: Switch to regular build plugins when possible. + +### 5. Parallelization Check (INFORMATIONAL) + +```bash +# Check available cores +sysctl -n hw.ncpu +``` + +Recommend setting "Build Active Architecture Only" to YES for debug to maximize parallelization. + +### 6. Build Timeline Analysis (Xcode 14+) + +**How to access Build Timeline**: +1. Build your project in Xcode +2. Open Report Navigator (Cmd+9) +3. Select most recent build +4. Click "Editor → Assistant" or View → Navigators → Reports +5. Look for timeline view showing task duration + +**What to look for**: +- Tasks taking >10 seconds (optimization candidates) +- Sequential tasks that could be parallelized +- Script phases blocking compilation +- Redundant asset processing + +**Actionable fixes from Build Timeline**: +- Move slow scripts to background (`.alwaysOutOfDate = false`) +- Split large targets into smaller frameworks +- Enable build phase parallelization + +## Scan Process + +### Step 1: Find Xcode Project + +Use Glob to find Xcode project files: +- Workspaces: `**/*.xcworkspace` +- Projects: `**/*.xcodeproj` + +### Step 2: Locate project.pbxproj + +Use Glob to find project configuration: +- Pattern: `**/*.xcodeproj/project.pbxproj` + +### Step 3: Scan Build Settings + +Use grep to check for key build settings: + +```bash +# Check compilation mode +grep "SWIFT_COMPILATION_MODE" project.pbxproj + +# Check architecture settings +grep "ONLY_ACTIVE_ARCH" project.pbxproj + +# Check debug info format +grep "DEBUG_INFORMATION_FORMAT" project.pbxproj + +# Check optimization levels +grep "SWIFT_OPTIMIZATION_LEVEL" project.pbxproj +``` + +### Step 4: Find Build Phase Scripts + +```bash +# Extract all shell scripts from build phases +grep -A 20 "shellScript" project.pbxproj +``` + +### Step 5: Check for Compiler Flags + +```bash +# Look for existing Swift flags +grep "OTHER_SWIFT_FLAGS" project.pbxproj +``` + +## Output Format + +Generate a "Build Performance Optimization Report" with: +1. **Summary**: Potential time savings, counts by severity (HIGH/MEDIUM/LOW) +2. **Issues by severity**: HIGH first, then MEDIUM, then LOW +3. **Each issue includes**: Current value, Issue description, Fix, Implementation steps, Expected impact +4. **Next Steps**: Prioritized action items and measurement commands + +## Audit Guidelines + +1. **Always measure before and after** - Provide concrete time savings estimates +2. **Prioritize by impact** - HIGH → MEDIUM → LOW +3. **Be specific** - Exact settings names, exact values, exact steps +4. **Check configurations separately** - Debug vs Release have different optimal settings +5. **Provide commands** - Give exact bash commands for verification diff --git a/.claude/skills/axiom-optimize-build/agents/openai.yaml b/.claude/skills/axiom-optimize-build/agents/openai.yaml new file mode 100644 index 0000000..ecdccbd --- /dev/null +++ b/.claude/skills/axiom-optimize-build/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Optimize Build" + short_description: "The user mentions slow builds, build performance, or build time optimization." diff --git a/.claude/skills/axiom-ownership-conventions/.openskills.json b/.claude/skills/axiom-ownership-conventions/.openskills.json new file mode 100644 index 0000000..e770f5c --- /dev/null +++ b/.claude/skills/axiom-ownership-conventions/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-ownership-conventions", + "installedAt": "2026-04-12T08:06:31.376Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-ownership-conventions/SKILL.md b/.claude/skills/axiom-ownership-conventions/SKILL.md new file mode 100644 index 0000000..7d287d0 --- /dev/null +++ b/.claude/skills/axiom-ownership-conventions/SKILL.md @@ -0,0 +1,455 @@ +--- +name: axiom-ownership-conventions +description: Use when optimizing large value type performance, working with noncopyable types, reducing ARC traffic, or using InlineArray/Span for zero-copy memory access. Covers borrowing, consuming, inout modifiers, consume operator, ~Copyable types, InlineArray, Span, value generics. +license: MIT +metadata: + version: "1.0.0" +--- + +# borrowing & consuming — Parameter Ownership + +Explicit ownership modifiers for performance optimization and noncopyable type support. + +## When to Use + +✅ **Use when:** +- Large value types being passed read-only (avoid copies) +- Working with noncopyable types (`~Copyable`) +- Reducing ARC retain/release traffic +- Factory methods that consume builder objects +- Performance-critical code where copies show in profiling + +❌ **Don't use when:** +- Simple types (Int, Bool, small structs) +- Compiler optimization is sufficient (most cases) +- Readability matters more than micro-optimization +- You're not certain about the performance impact + +## Quick Reference + +| Modifier | Ownership | Copies | Use Case | +|----------|-----------|--------|----------| +| (default) | Compiler chooses | Implicit | Most cases | +| `borrowing` | Caller keeps | Explicit `copy` only | Read-only, large types | +| `consuming` | Caller transfers | None needed | Final use, factories | +| `inout` | Caller keeps, mutable | None | Modify in place | + +## Default Behavior by Context + +| Context | Default | Reason | +|---------|---------|--------| +| Function parameters | `borrowing` | Most params are read-only | +| Initializer parameters | `consuming` | Usually stored in properties | +| Property setters | `consuming` | Value is stored | +| Method `self` | `borrowing` | Methods read self | + +## Patterns + +### Pattern 1: Read-Only Large Struct + +```swift +struct LargeBuffer { + var data: [UInt8] // Could be megabytes +} + +// ❌ Default may copy +func process(_ buffer: LargeBuffer) -> Int { + buffer.data.count +} + +// ✅ Explicit borrow — no copy +func process(_ buffer: borrowing LargeBuffer) -> Int { + buffer.data.count +} +``` + +### Pattern 2: Consuming Factory + +```swift +struct Builder { + var config: Configuration + + // Consumes self — builder invalid after call + consuming func build() -> Product { + Product(config: config) + } +} + +let builder = Builder(config: .default) +let product = builder.build() +// builder is now invalid — compiler error if used +``` + +### Pattern 3: Explicit Copy in Borrowing + +With `borrowing`, copies must be explicit: + +```swift +func store(_ value: borrowing LargeValue) { + // ❌ Error: Cannot implicitly copy borrowing parameter + self.cached = value + + // ✅ Explicit copy + self.cached = copy value +} +``` + +### Pattern 4: Consume Operator + +Transfer ownership explicitly: + +```swift +let data = loadLargeData() +process(consume data) +// data is now invalid — compiler prevents use +``` + +### Pattern 5: Noncopyable Type + +For `~Copyable` types, ownership modifiers are **required**: + +```swift +struct FileHandle: ~Copyable { + private let fd: Int32 + + init(path: String) throws { + fd = open(path, O_RDONLY) + guard fd >= 0 else { throw POSIXError.errno } + } + + borrowing func read(count: Int) -> Data { + // Read without consuming handle + var buffer = [UInt8](repeating: 0, count: count) + _ = Darwin.read(fd, &buffer, count) + return Data(buffer) + } + + consuming func close() { + Darwin.close(fd) + // Handle consumed — can't use after close() + } + + deinit { + Darwin.close(fd) + } +} + +// Usage +let file = try FileHandle(path: "/tmp/data.txt") +let data = file.read(count: 1024) // borrowing +file.close() // consuming — file invalidated +``` + +### Pattern 6: Reducing ARC Traffic + +```swift +class ExpensiveObject { /* ... */ } + +// ❌ Default: May retain/release +func inspect(_ obj: ExpensiveObject) -> String { + obj.description +} + +// ✅ Borrowing: No ARC traffic +func inspect(_ obj: borrowing ExpensiveObject) -> String { + obj.description +} +``` + +### Pattern 7: Consuming Method on Self + +```swift +struct Transaction { + var amount: Decimal + var recipient: String + + // After commit, transaction is consumed + consuming func commit() async throws { + try await sendToServer(self) + // self consumed — can't modify or reuse + } +} +``` + +## Common Mistakes + +### Mistake 1: Over-Optimizing Small Types + +```swift +// ❌ Unnecessary — Int is trivially copyable +func add(_ a: borrowing Int, _ b: borrowing Int) -> Int { + a + b +} + +// ✅ Let compiler optimize +func add(_ a: Int, _ b: Int) -> Int { + a + b +} +``` + +### Mistake 2: Forgetting Explicit Copy + +```swift +func cache(_ value: borrowing LargeValue) { + // ❌ Compile error + self.values.append(value) + + // ✅ Explicit copy required + self.values.append(copy value) +} +``` + +### Mistake 3: Consuming When Borrowing Suffices + +```swift +// ❌ Consumes unnecessarily — caller loses access +func validate(_ data: consuming Data) -> Bool { + data.count > 0 +} + +// ✅ Borrow for read-only +func validate(_ data: borrowing Data) -> Bool { + data.count > 0 +} +``` + +## ~Copyable Limitations + +**Know the constraints before adopting ~Copyable:** + +| Limitation | Impact | Workaround | +|-----------|--------|------------| +| Can't store in `Array`, `Dictionary`, `Set` | Collections require `Copyable` | Use `Optional` wrapper or manage manually | +| Can't use with most generics | `` implicitly means `` | Use `` (requires library support) | +| Protocol conformance restricted | Most protocols require `Copyable` | Use `~Copyable` protocol definitions | +| Can't capture in closures by default | Closures copy captured values | Use `borrowing` closure parameters | +| No existential support | `any ~Copyable` doesn't work | Use generics instead | + +**Common compiler errors when adopting ownership modifiers:** + +```swift +// Error: "Cannot implicitly copy a borrowing parameter" +// Fix: Add explicit `copy` or change to consuming +func store(_ v: borrowing LargeValue) { + self.cached = copy v // ✅ Explicit copy +} + +// Error: "Noncopyable type cannot be used with generic" +// Fix: Constrain generic to ~Copyable +func use(_ value: borrowing T) { } // ✅ + +// Error: "Cannot consume a borrowing parameter" +// Fix: Change to consuming if you need ownership transfer +func takeOwnership(_ v: consuming FileHandle) { } // ✅ + +// Error: "Missing 'consuming' or 'borrowing' modifier" +// Fix: ~Copyable types require explicit ownership on all methods +struct Token: ~Copyable { + borrowing func peek() -> String { ... } // ✅ Explicit + consuming func redeem() { ... } // ✅ Explicit +} +``` + +**When NOT to use ~Copyable:** +- If you need collection storage (arrays, dictionaries) +- If you need to work with existing generic APIs +- If the type needs broad protocol conformance +- Prefer `consuming func` on regular types as a lighter alternative for "use once" semantics + +## Performance Considerations + +### When Ownership Modifiers Help + +- Large structs (arrays, dictionaries, custom value types) +- High-frequency function calls in tight loops +- Reference types where ARC traffic is measurable +- Noncopyable types (required, not optional) + +### When to Skip + +- Default behavior is almost always optimal +- Small value types (primitives, small structs) +- Code where profiling shows no benefit +- API stability concerns (modifiers affect ABI) + +## InlineArray + +Fixed-size, stack-allocated array using value generics. No heap allocation, no reference counting, no copy-on-write. + +### Declaration + +```swift +@frozen struct InlineArray where Element: ~Copyable +``` + +The `let count: Int` is a **value generic** — the size is part of the type, checked at compile time. `InlineArray<3, Int>` and `InlineArray<4, Int>` are different types. + +### When to Use InlineArray + +| Use InlineArray | Use Array | +|----------------|-----------| +| Size known at compile time | Size changes at runtime | +| Hot path needing zero heap allocation | Copy-on-write sharing is beneficial | +| Embedded in other value types | Frequently copied between variables | +| Performance-critical inner loops | General-purpose collection needs | + +### Canonical Example + +```swift +// Fixed-size, inline storage — no heap allocation +var matrix: InlineArray<9, Float> = [1, 0, 0, 0, 1, 0, 0, 0, 1] +matrix[4] = 2.0 + +// Type inference works for count, element, or both +let rgb: InlineArray = [0.2, 0.4, 0.8] // InlineArray<3, Double> + +// Eager copy on assignment (no COW) +var copy = matrix +copy[0] = 99 // matrix[0] still 1 +``` + +### Memory Layout + +Elements are stored contiguously with no overhead: + +```swift +MemoryLayout>.size // 6 (2 bytes × 3) +MemoryLayout>.alignment // 2 (same as UInt16) +``` + +### ~Copyable Integration + +InlineArray supports noncopyable elements — enables fixed-size collections of unique resources: + +```swift +struct Sensor: ~Copyable { var id: Int } +var sensors: InlineArray<4, Sensor> = ... // Valid: ~Copyable elements allowed +``` + +## Span — Safe Contiguous Memory Access + +`Span` replaces unsafe pointers with compile-time-enforced safe memory views. Zero runtime overhead. + +### The Span Family + +| Type | Access | Use Case | +|------|--------|----------| +| `Span` | Read-only elements | Safe iteration, passing to algorithms | +| `MutableSpan` | Read-write elements | In-place mutation without copies | +| `RawSpan` | Read-only bytes | Binary parsing, protocol decoding | +| `MutableRawSpan` | Read-write bytes | Binary serialization | +| `OutputSpan` | Write-only | Initializing new collection storage | +| `UTF8Span` | Read-only UTF-8 | Safe Unicode processing | + +### Accessing Spans + +Containers with contiguous storage expose `.span` and `.mutableSpan`: + +```swift +let array = [1, 2, 3, 4] +let span = array.span // Span + +var mutable = [10, 20, 30] +var ms = mutable.mutableSpan // MutableSpan +ms[0] = 99 +``` + +### Lifetime Safety — Compile-Time Enforcement + +Spans are **non-escapable** — the compiler guarantees they cannot outlive the container they borrow from: + +```swift +// ❌ Cannot return span that depends on local variable +func getSpan() -> Span { + let array: [UInt8] = Array(repeating: 0, count: 128) + return array.span // Compile error +} + +// ❌ Cannot capture span in closure +let span = array.span +let closure = { span.count } // Compile error + +// ❌ Cannot access span after mutating original +var array = [1, 2, 3] +let span = array.span +array.append(4) +// span[0] // Compile error: container was modified +``` + +These constraints prevent use-after-free, dangling pointers, and overlapping mutation at **compile time** with zero runtime cost. + +### Span vs Unsafe Pointers + +| | Span | UnsafeBufferPointer | +|---|------|---------------------| +| Memory safety | Compile-time enforced | Manual, error-prone | +| Lifetime tracking | Automatic, non-escapable | None — dangling pointers possible | +| Runtime overhead | Zero | Zero | +| Use-after-free | Impossible | Common source of crashes | + +### Canonical Example — Binary Parsing + +```swift +func parseHeader(_ data: borrowing [UInt8]) -> Header { + var raw = data.span.rawSpan // RawSpan over the array's bytes + let magic = raw.unsafeLoadUnaligned(as: UInt32.self) + raw = raw.extracting(droppingFirst: 4) + let version = raw.unsafeLoadUnaligned(as: UInt16.self) + return Header(magic: magic, version: version) +} +``` + +### When to Use Span + +- **Replace `UnsafeBufferPointer`** — same performance, compile-time safety +- **Performance-critical algorithms** — direct memory access without copying +- **Binary parsing/serialization** — `RawSpan` for byte-level access +- **Passing data between functions** — borrow the container, pass the span +- **UTF-8 processing** — `UTF8Span` for safe string byte access + +## Value Generics + +Value generics allow integer values as generic parameters, making sizes part of the type system: + +```swift +// `let count: Int` is a value generic parameter +struct InlineArray { ... } + +// Different counts = different types +let a: InlineArray<3, Int> = [1, 2, 3] +let b: InlineArray<4, Int> = [1, 2, 3, 4] +// a = b // Compile error: different types +``` + +Currently limited to `Int` parameters. Enables stack-allocated, fixed-size abstractions where the compiler verifies size compatibility at compile time. + +## Decision Tree + +``` +Need explicit ownership? +├─ Working with ~Copyable type? +│ └─ Yes → Required (borrowing/consuming) +├─ Fixed-size collection, no heap allocation? +│ └─ Yes → InlineArray +├─ Need safe pointer-like access to contiguous memory? +│ ├─ Read-only? → Span +│ ├─ Mutable? → MutableSpan +│ └─ Raw bytes? → RawSpan / MutableRawSpan +├─ Large value type passed frequently? +│ ├─ Read-only? → borrowing +│ └─ Final use? → consuming +├─ ARC traffic visible in profiler? +│ ├─ Read-only? → borrowing +│ └─ Transferring ownership? → consuming +└─ Otherwise → Let compiler choose +``` + +## Resources + +**Swift Evolution**: SE-0377, SE-0453 (Span), SE-0451 (InlineArray), SE-0452 (value generics) + +**WWDC**: 2024-10170, 2025-245, 2025-312 + +**Docs**: /swift/inlinearray, /swift/span + +**Skills**: axiom-swift-performance, axiom-swift-concurrency diff --git a/.claude/skills/axiom-ownership-conventions/agents/openai.yaml b/.claude/skills/axiom-ownership-conventions/agents/openai.yaml new file mode 100644 index 0000000..bd2d9d4 --- /dev/null +++ b/.claude/skills/axiom-ownership-conventions/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Ownership Conventions" + short_description: "Optimizing large value type performance, working with noncopyable types, reducing ARC traffic, or using InlineArray/S..." diff --git a/.claude/skills/axiom-passkeys/.openskills.json b/.claude/skills/axiom-passkeys/.openskills.json new file mode 100644 index 0000000..6d3c108 --- /dev/null +++ b/.claude/skills/axiom-passkeys/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-passkeys", + "installedAt": "2026-04-12T08:06:31.579Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-passkeys/SKILL.md b/.claude/skills/axiom-passkeys/SKILL.md new file mode 100644 index 0000000..29eb95e --- /dev/null +++ b/.claude/skills/axiom-passkeys/SKILL.md @@ -0,0 +1,461 @@ +--- +name: axiom-passkeys +description: Use when implementing passkey sign-in, replacing passwords with WebAuthn, configuring ASAuthorizationController, setting up AutoFill-assisted requests, adding automatic passkey upgrades, or migrating from password-based authentication. Covers passkey creation, assertion, cross-device sign-in, credential managers, and the Passwords app. +license: MIT +--- + +# Passkeys + +Passkey authentication for iOS apps — registration, assertion, AutoFill-assisted requests, automatic upgrades, combined credential flows, and migration from password-based auth. + +## When to Use This Skill + +Use when you need to: +- ☑ Add passkey sign-in to an iOS app +- ☑ Replace password-based authentication with passkeys +- ☑ Configure ASAuthorizationController for passkey registration or assertion +- ☑ Set up AutoFill-assisted passkey requests (QuickType bar) +- ☑ Add automatic passkey upgrades for existing password users (iOS 18+) +- ☑ Support combined credential requests (passkey + password + Sign in with Apple) +- ☑ Configure associated domains for webauthn/webcredentials +- ☑ Debug passkey assertion failures or missing QuickType suggestions + +## Example Prompts + +"How do I add passkey sign-in to my app?" +"My passkeys aren't showing in the QuickType bar" +"How do I migrate existing password users to passkeys?" +"ASAuthorizationError.canceled — what's going wrong?" +"How do I support both passkeys and passwords during migration?" +"How do I set up associated domains for passkeys?" +"What's the difference between performRequests and performAutoFillAssistedRequests?" +"How do I add automatic passkey upgrades on iOS 18?" + +## Red Flags + +Signs you're heading in the wrong direction: + +- ❌ Still using passwords as primary auth when passkeys are available — Passkeys are not "extra security." They are the replacement. Every password-only sign-in is a phishing opportunity you're leaving open. +- ❌ Not annotating the username field with `.username` textContentType — Without this, the system can't associate the field with passkey credentials. AutoFill won't suggest passkeys for unlabeled fields. +- ❌ Using `performRequests()` for the primary sign-in flow — this shows a modal sheet instead of putting passkeys in the QuickType bar. Use `performAutoFillAssistedRequests()` for the primary path. Reserve `performRequests()` for registration and explicit "Sign In" button taps. +- ❌ Setting `userVerification` to `"required"` on the server — This prevents sign-in on devices without biometrics. The platform handles verification appropriately per device. Use `"preferred"` (the default). +- ❌ Creating passkeys in an extension or non-main-app context — Passkey creation requires the main app target. Extensions can perform assertions but not registrations. +- ❌ Not supporting combined credential requests — During migration, users may have a passkey, a password, or neither. A single ASAuthorizationController handles all three. Offering only passkeys locks out users who haven't upgraded. + +## Why Passkeys + +Passkeys are not an incremental improvement over passwords. They are a replacement architecture. + +**Phishing-proof**: Each passkey is cryptographically bound to a specific domain. A fake login page on `secure-myapp.com` cannot trigger a passkey created for `myapp.com`. There is no credential to type into the wrong site. + +**No credential database to leak**: The server stores only a public key. A breach exposes nothing usable — no password hashes to crack, no shared secrets to replay. + +**Single-tap sign-in**: Face ID or Touch ID replaces typing. Registration and assertion are both one-tap flows. + +**FIDO Alliance standard**: WebAuthn/CTAP2 protocol. Works across Apple, Google, and Microsoft platforms. Passkeys created on iPhone sync via iCloud Keychain and work on Mac, iPad, and the web. + +**Adoption**: Apple ships passkeys as a first-class system feature. iCloud Keychain syncs them. The Passwords app manages them. Third-party credential managers (1Password, Dashlane) support them natively as of iOS 17. + +## Associated Domains Setup + +Passkeys require an associated domain linking your app to your server. Without this, the system won't offer passkeys for your app. + +### 1. Add the Entitlement + +In Xcode: Target > Signing & Capabilities > + Associated Domains. + +Add: +``` +webcredentials:example.com +``` + +### 2. Host the AASA File + +Serve `/.well-known/apple-app-site-association` from your domain over HTTPS with `Content-Type: application/json`: + +```json +{ + "webcredentials": { + "apps": [ + "TEAMID.com.example.myapp" + ] + } +} +``` + +**Requirements**: +- HTTPS with a valid certificate (no self-signed) +- No redirects — the file must be served directly at the path +- `TEAMID` is your Apple Developer Team ID, not the bundle ID prefix +- The file must be at the root domain, not a subdirectory + +### 3. Verify + +```bash +curl -s "https://example.com/.well-known/apple-app-site-association" | python3 -m json.tool +``` + +Apple's CDN caches the AASA file. Changes can take up to 24 hours to propagate. During development, enable Associated Domains Development in Developer Settings on the device and use the `?mode=developer` query parameter. + +## Registration Flow + +Registration creates a new passkey and stores it in the user's credential manager. + +```swift +import AuthenticationServices + +func registerPasskey(challenge: Data, userName: String, userID: Data) { + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: "example.com" + ) + + let request = provider.createCredentialRegistrationRequest( + challenge: challenge, + name: userName, + userID: userID + ) + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() +} +``` + +**Key parameters**: +- `relyingPartyIdentifier` — Must match your associated domain (no `https://` prefix) +- `challenge` — Server-generated cryptographic challenge (use at least 16 random bytes). Never reuse challenges. +- `name` — Display name shown to the user in the passkey prompt and Passwords app +- `userID` — Opaque identifier for the user account. Do not use email or username — use a random UUID or server-side user ID + +### Handling the Registration Response + +```swift +func authorizationController(controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization) { + guard let credential = authorization.credential + as? ASAuthorizationPlatformPublicKeyCredentialRegistration else { return } + + let attestationObject = credential.rawAttestationObject + let clientDataJSON = credential.rawClientDataJSON + let credentialID = credential.credentialID + + // Send attestationObject, clientDataJSON, credentialID to your server + // Server validates and stores the public key +} +``` + +Registration uses `performRequests()` (modal) because the user explicitly chose to create a passkey. This is the one place where modal presentation is correct. + +## Assertion Flow (Sign-In) + +Two paths for sign-in, each for a different UX context. + +### AutoFill-Assisted (Primary Path) + +The preferred sign-in flow. Passkeys appear in the QuickType bar when the user taps a text field with `.username` content type. Single-tap sign-in with no modal interruption. + +```swift +func signInWithAutoFill() { + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: "example.com" + ) + + let request = provider.createCredentialAssertionRequest( + challenge: serverChallenge + ) + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performAutoFillAssistedRequests() +} +``` + +**Critical details**: +- Call `performAutoFillAssistedRequests()` early — before the user focuses the username field. Call it in `viewDidAppear` or when the sign-in view appears. +- The username `UITextField` must have `.textContentType = .username` set. Without this, the QuickType bar won't show passkey suggestions. +- Do not set `allowedCredentials` on the request. AutoFill needs to show all available passkeys for the domain. +- The request stays active until the user selects a credential, navigates away, or you cancel it. + +### Modal (Fallback Path) + +Use when the user taps a "Sign In" button explicitly, or when you know the username and want to request a specific credential. + +```swift +func signInWithModal(allowedCredentials: [ASAuthorizationPlatformPublicKeyCredentialDescriptor]? = nil) { + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: "example.com" + ) + + let request = provider.createCredentialAssertionRequest( + challenge: serverChallenge + ) + + if let allowedCredentials { + request.allowedCredentials = allowedCredentials + } + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() +} +``` + +Use `allowedCredentials` when you know the user's credential IDs (e.g., the user typed their username and your server returned their registered credential IDs). This narrows the passkey selection to that account. + +### Handling the Assertion Response + +```swift +func authorizationController(controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization) { + guard let credential = authorization.credential + as? ASAuthorizationPlatformPublicKeyCredentialAssertion else { return } + + let signature = credential.signature + let clientDataJSON = credential.rawClientDataJSON + let authenticatorData = credential.rawAuthenticatorData + let credentialID = credential.credentialID + let userID = credential.userID + + // Send to server for verification +} +``` + +## Automatic Passkey Upgrades (iOS 18+) + +Silently upgrade password users to passkeys without interrupting their flow. The system shows a brief notification confirming the upgrade — no modal, no extra taps. + +### How It Works + +When a user signs in with a password, the system can automatically create a passkey for the same account. This happens when: +1. The credential manager supports automatic upgrades +2. The user just successfully authenticated with a password for the same account +3. Your app requests a conditional registration + +### Implementation + +```swift +func requestAutomaticUpgrade(challenge: Data, userName: String, userID: Data) { + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: "example.com" + ) + + let request = provider.createCredentialRegistrationRequest( + challenge: challenge, + name: userName, + userID: userID + ) + request.requestStyle = .conditional + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performAutoFillAssistedRequests() +} +``` + +**Key detail**: `.requestStyle = .conditional` makes the registration opportunistic. It will succeed silently when conditions are right and fail silently when they're not. Do not treat the failure callback as an error — it means conditions weren't met this time. + +**When to call**: After the user successfully authenticates with a password. Check first whether the user already has a passkey for this account — don't request an upgrade if they do. + +### Server Requirements + +Your server must be prepared for an asynchronous registration that arrives shortly after a password sign-in. The `userID` and `challenge` must be valid and associated with the session. + +## Combined Credential Requests + +During migration, your users may have passkeys, passwords, or Sign in with Apple credentials. A single ASAuthorizationController handles all three. + +```swift +func signInWithCombinedRequest() { + var requests: [ASAuthorizationRequest] = [] + + // Passkey assertion + let passkeyProvider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: "example.com" + ) + requests.append( + passkeyProvider.createCredentialAssertionRequest(challenge: serverChallenge) + ) + + // Password + let passwordProvider = ASAuthorizationPasswordProvider() + requests.append(passwordProvider.createRequest()) + + // Sign in with Apple + let appleIDProvider = ASAuthorizationAppleIDProvider() + requests.append(appleIDProvider.createRequest()) + + let controller = ASAuthorizationController(authorizationRequests: requests) + controller.delegate = self + controller.presentationContextProvider = self + controller.performAutoFillAssistedRequests() +} +``` + +### Handling Multiple Credential Types + +```swift +func authorizationController(controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization) { + switch authorization.credential { + case let credential as ASAuthorizationPlatformPublicKeyCredentialAssertion: + // Passkey sign-in — verify with server + handlePasskeyAssertion(credential) + + case let credential as ASPasswordCredential: + // Password sign-in — verify, then offer passkey upgrade + handlePasswordSignIn(credential) + + case let credential as ASAuthorizationAppleIDCredential: + // Apple ID sign-in + handleAppleIDSignIn(credential) + + default: + break + } +} +``` + +After a successful password sign-in, call the automatic upgrade flow to progressively migrate users to passkeys. + +## Cross-Device Sign-In + +Users can sign in on a device that doesn't have their passkey by using their phone as an authenticator. + +**How it works**: +1. Your app presents a passkey assertion request +2. The system shows a QR code on the device requesting sign-in +3. The user scans the QR code with their phone (which has the passkey) +4. Bluetooth proximity verification confirms the phone is physically nearby +5. The user authenticates with Face ID/Touch ID on their phone +6. The assertion completes on the original device + +**No app changes required**. This is a system-level feature. Any device that supports passkeys can act as a cross-device authenticator. The communication is end-to-end encrypted through an Apple relay server. + +**Bluetooth required**: Both devices must have Bluetooth enabled. This is the proximity check that prevents remote phishing — the authenticating device must be physically near the requesting device. + +## Migration Strategy + +### Phase 1 — Add Passkey Support Alongside Passwords + +Keep existing password auth. Add passkey registration and assertion. Use combined credential requests so both paths work. + +**Server changes**: Add WebAuthn endpoints for registration and assertion. Store public keys alongside password hashes. Both auth methods validate to the same user session. + +**App changes**: Implement registration flow (offer after password sign-in), assertion flow (AutoFill-assisted), and combined requests. + +### Phase 2 — Automatic Upgrades (iOS 18+) + +Add conditional registration requests after password sign-ins. Users silently migrate to passkeys over time. Track upgrade metrics to measure adoption. + +No user action required. The system handles the upgrade transparently. + +### Phase 3 — Reduce Phishable Factors + +For accounts with passkeys, consider: +- Removing password reset flows (passkeys don't need them) +- Dropping SMS 2FA (passkeys are inherently two-factor: device possession + biometric) +- Offering account recovery via passkey on another device instead of email/SMS + +Do not force-remove passwords. Let users choose to go passwordless. Some users need password access from devices that don't support passkeys. + +### Passwords App Integration (iOS 18+) + +The Passwords app displays your app's name and icon using OpenGraph metadata from your associated domain. Add to your website's ``: + +```html + + +``` + +This is how your app appears in the user's credential manager. Without it, the Passwords app shows only the domain name. + +## Anti-Rationalization + +| Rationalization | Reality | Time Cost | +|----------------|---------|-----------| +| "Passwords are fine for now" | Every password sign-in is a phishing vector. Credential stuffing attacks cost real money — the average breach costs $4.5M. Passkeys eliminate the entire attack surface. | Ongoing risk vs 2-3 days to implement | +| "We'll add passkeys later" | AutoFill-assisted passkey requests are the same amount of integration work as a custom password text field with AutoFill. You're not saving time by deferring. | Same implementation effort either way | +| "Users won't understand passkeys" | Users don't need to understand public-key cryptography. They see "Sign in with Face ID" — one tap. Apple, Google, and Microsoft are shipping passkeys as the default across all platforms. | 0 extra user education needed | +| "Our server doesn't support WebAuthn" | Server-side WebAuthn libraries exist for every major backend (Python, Node, Go, Ruby, Java, .NET). Most are well-tested and actively maintained. | 1-2 days server-side integration | +| "What about users without biometrics?" | Device passcode is a valid user verification method. Every supported device has at least a passcode. Setting `userVerification` to `"preferred"` lets the platform handle this correctly. | 0 extra work — platform handles it | +| "We need password as fallback forever" | Combined credential requests support passwords and passkeys simultaneously. Use automatic upgrades to progressively migrate. You can keep passwords indefinitely while passkeys become primary. | No forced choice — run both | + +## Pressure Scenarios + +### Scenario 1: "Our users aren't ready for passkeys" + +**Context**: Product manager pushes back on passkey adoption, citing user confusion risk. + +**Pressure**: "Our users are not technical. They won't understand what a passkey is. Let's stick with passwords and add passkeys next year." + +**Reality**: Apple ships passkeys as a built-in system feature across every platform — iPhone, iPad, Mac, Apple Watch, Windows via cross-device auth. Users see "Sign in with Face ID" in the QuickType bar. They do not see "WebAuthn CTAP2 public-key credential." The Passwords app manages passkeys alongside passwords transparently. Apple's own account system, Google accounts, and Microsoft accounts all use passkeys. Your users are already using them elsewhere. + +**Correct action**: Implement combined credential requests. Existing password users keep signing in with passwords. Passkeys appear automatically for users whose credential managers support them. Add automatic upgrades (iOS 18+) to progressively migrate without user action. + +**Push-back template**: "Users don't need to understand passkeys. They see 'Sign in with Face ID' — one tap, done. Apple, Google, and Amazon already use passkeys for their own sign-in. We add it alongside passwords, so nobody's flow changes. Users who get passkeys automatically get a better experience; everyone else continues as before." + +### Scenario 2: "Just ship password auth now, add passkeys later" + +**Context**: Deadline pressure on a new app. Developer wants to defer passkey support to a post-launch update. + +**Pressure**: "We need to ship by Friday. Password auth works. We'll add passkeys in the next sprint." + +**Reality**: Implementing AutoFill-assisted passkey requests is comparable in effort to building a polished password text field with AutoFill support, secure storage, and "forgot password" flows. You're building the ASAuthorizationController integration either way — the question is whether you wire up one provider (passwords) or three (passkeys + passwords + Apple ID). Combined requests add ~30 lines to the delegate. + +**Correct action**: Implement combined credential requests from the start. The server needs WebAuthn endpoints, but client-side the work is nearly identical. Shipping with passkey support from day one means you never have to retrofit it, and you avoid the "next sprint" that turns into "next quarter." + +**Push-back template**: "AutoFill-assisted passkeys use the same ASAuthorizationController we'd use for password AutoFill. Adding passkey support is ~30 lines in the delegate — not a sprint of work. Shipping without it means we build the password flow now and rebuild the auth flow later to add passkeys. Let's do it once." + +## Checklist + +Before shipping passkey authentication: + +**Associated Domains**: +- [ ] `webcredentials:yourdomain.com` added to Associated Domains capability +- [ ] AASA file served at `/.well-known/apple-app-site-association` over HTTPS +- [ ] AASA file contains correct Team ID and bundle identifier +- [ ] No redirects on the AASA file path +- [ ] AASA changes propagated (or developer mode enabled for testing) + +**Registration**: +- [ ] Server generates unique challenge per registration attempt +- [ ] `userID` is opaque (not email or username) +- [ ] `relyingPartyIdentifier` matches associated domain exactly +- [ ] Registration uses `performRequests()` (modal — correct for explicit creation) +- [ ] Server stores credentialID and public key after successful registration + +**Assertion (Sign-In)**: +- [ ] Username text field has `.textContentType = .username` +- [ ] AutoFill-assisted request called early (before field focus) +- [ ] AutoFill path uses `performAutoFillAssistedRequests()` +- [ ] Modal fallback available via `performRequests()` for explicit sign-in button +- [ ] `allowedCredentials` not set on AutoFill-assisted requests +- [ ] Server validates signature, authenticatorData, and clientDataJSON + +**Combined Requests** (if supporting multiple auth methods): +- [ ] Passkey, password, and Apple ID providers all included +- [ ] Delegate handles all credential types in switch statement +- [ ] Password sign-in triggers automatic passkey upgrade flow + +**Automatic Upgrades** (iOS 18+): +- [ ] Registration request uses `.requestStyle = .conditional` +- [ ] Upgrade request uses `performAutoFillAssistedRequests()` +- [ ] Failure callback treated as "conditions not met" — not an error +- [ ] Existing passkey checked before requesting upgrade + +**Error Handling**: +- [ ] `ASAuthorizationError.canceled` handled gracefully (user dismissed — not an error) +- [ ] `ASAuthorizationError.failed` logged with context for debugging +- [ ] Network failures during server verification don't leave auth in inconsistent state + +## Resources + +**WWDC**: 2022-10092, 2024-10125 + +**Docs**: /authenticationservices, /authenticationservices/public-private-key-authentication/supporting-passkeys + +**Skills**: axiom-keychain diff --git a/.claude/skills/axiom-passkeys/agents/openai.yaml b/.claude/skills/axiom-passkeys/agents/openai.yaml new file mode 100644 index 0000000..defbfbe --- /dev/null +++ b/.claude/skills/axiom-passkeys/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Passkeys" + short_description: "Implementing passkey sign-in, replacing passwords with WebAuthn, configuring ASAuthorizationController, setting up Au..." diff --git a/.claude/skills/axiom-performance-profiling/.openskills.json b/.claude/skills/axiom-performance-profiling/.openskills.json new file mode 100644 index 0000000..8a9bfb1 --- /dev/null +++ b/.claude/skills/axiom-performance-profiling/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-performance-profiling", + "installedAt": "2026-04-12T08:06:31.784Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-performance-profiling/SKILL.md b/.claude/skills/axiom-performance-profiling/SKILL.md new file mode 100644 index 0000000..20c9dd4 --- /dev/null +++ b/.claude/skills/axiom-performance-profiling/SKILL.md @@ -0,0 +1,1077 @@ +--- +name: axiom-performance-profiling +description: Use when app feels slow, memory grows over time, battery drains fast, or you want to profile proactively - decision trees to choose the right Instruments tool, deep workflows for Time Profiler/Allocations/Core Data, and pressure scenarios for misinterpreting results +license: MIT +metadata: + version: "1.2.0" + last-updated: "TDD-tested with deadline pressure, manager authority pressure, and Self Time vs Total Time misinterpretation scenarios, added 3 real-world examples" +--- + +# Performance Profiling + +## Overview + +iOS app performance problems fall into distinct categories, each with a specific diagnosis tool. This skill helps you **choose the right tool**, **use it effectively**, and **interpret results correctly** under pressure. + +**Core principle**: Measure before optimizing. Guessing about performance wastes more time than profiling. + +**Requires**: Xcode 15+, iOS 14+ +**Related skills**: `axiom-swiftui-performance` (SwiftUI-specific profiling with Instruments 26), `axiom-memory-debugging` (memory leak diagnosis) + +## When to Use Performance Profiling + +#### Use this skill when +- ✅ App feels slow (UI lags, loads take 5+ seconds) +- ✅ Memory grows over time (Xcode shows increasing memory usage) +- ✅ Battery drains fast (device gets hot, battery depletes in hours) +- ✅ You want to profile proactively (before users complain) +- ✅ You're unsure which Instruments tool to use +- ✅ Profiling results are confusing or contradictory + +#### Use `axiom-memory-debugging` instead when +- Investigating specific memory leaks with retain cycles +- Using Instruments Allocations in detail mode + +#### Use `axiom-swiftui-performance` instead when +- Analyzing SwiftUI view body updates +- Using SwiftUI Instrument specifically + +## Performance Decision Tree + +Before opening Instruments, narrow down what you're actually investigating. + +### Step 1: What's the Symptom? + +``` +App performance problem? +├─ App feels slow or lags (UI interactions stall, scrolling stutters) +│ └─ → Use Time Profiler (measure CPU usage) +├─ Memory grows over time (Xcode shows increasing memory) +│ └─ → Use Allocations (measure object creation) +├─ Data loading is slow (parsing, database queries, API calls) +│ └─ → Use Core Data instrument (if using Core Data) +│ └─ → Use Time Profiler (if it's computation) +└─ Battery drains fast (device gets hot, depletes in hours) + └─ → Use Energy Impact (measure power consumption) +``` + +### Step 2: Can You Reproduce It? + +**YES** – Use Instruments to measure it (profiling is most accurate) + +**NO** – Use profiling proactively +- Enable Core Data SQL debugging to catch N+1 queries +- Profile app during normal use (scrolling, loading, navigation) +- Establish baseline metrics before changes + +### Step 3: Which Instruments Tool? + +**Time Profiler** – Slowness, UI lag, CPU spikes +**Allocations** – Memory growth, memory pressure, object counts +**Core Data** – Query performance, fetch times, fault fires +**Energy Impact** – Battery drain, sustained power draw +**Network Link Conditioner** – Connection-related slowness +**System Trace** – Thread blocking, main thread blocking, scheduling + +--- + +## Time Profiler Deep Dive + +Use Time Profiler when your app feels slow or laggy. It measures CPU time spent in each function. + +### Workflow: Record and Analyze + +#### Step 1: Launch Instruments +```bash +open -a Instruments +``` + +Select "Time Profiler" template. + +#### Step 2: Attach to Running App +1. Start your app in simulator or device +2. In Instruments, select your app from the target dropdown +3. Click Record (red circle) +4. Interact with the slow part (scroll, tap buttons, load data) +5. Stop recording after 10-30 seconds of interaction + +#### Step 3: Read the Call Stack + +The top panel shows a timeline of CPU usage over time. Look for: +- **Tall spikes** – Brief CPU-intensive operations +- **Sustained high usage** – Continuous expensive work +- **Main thread blocking** – UI thread doing work (causes UI lag) + +#### Step 4: Drill Down to Hot Spots + +In the call tree, click "Heaviest Stack Trace" to see which functions use the most CPU: + +``` +Time Profiler Results + +MyViewController.viewDidLoad() – 500ms (40% of total) + ├─ DataParser.parse() – 350ms + │ └─ JSONDecoder.decode() – 320ms + └─ UITableView.reloadData() – 150ms +``` + +**Self Time** = Time spent IN that function (not in functions it calls) +**Total Time** = Time spent in that function + everything it calls + +### Common Mistakes & Fixes + +#### ❌ Mistake 1: Blaming the Wrong Function + +```swift +// ❌ WRONG: Profile shows DataParser.parse() is 80% CPU +// Conclusion: "DataParser is slow, let me optimize it" + +// ✅ RIGHT: Check what DataParser is calling +// If JSONDecoder.decode() is doing 99% of the work, +// optimize JSON decoding, not DataParser +``` + +**The issue**: A function with high Total Time might be calling slow code, not doing slow work itself. + +**Fix**: Look at Self Time, not Total Time. Drill down to see what each function calls. + +#### ❌ Mistake 2: Profiling the Wrong Code Path + +```swift +// ❌ WRONG: Profile app in Simulator +// Simulator CPU is different than real device +// Results don't reflect actual device performance + +// ✅ RIGHT: Profile on actual device +// Device settings: Developer Mode enabled, Xcode attached +``` + +**Fix**: Always profile on actual device for accurate CPU measurements. + +#### ❌ Mistake 3: Not Isolating the Problem + +```swift +// ❌ WRONG: Profile entire app startup +// Sees 2000ms startup time, many functions involved + +// ✅ RIGHT: Profile just the slow part +// "App feels slow when scrolling" → profile only scrolling +// Separate concerns: startup slow vs interaction slow +``` + +**Fix**: Reproduce the specific slow operation, not the entire app. + +### Pressure Scenario: "Profile Shows Function X is 80% CPU" + +**The temptation**: "I must optimize function X!" + +**The reality**: Function X might be: +- **Calling expensive code** (optimize the called function, not X) +- **Running on main thread** (move to background, it's already optimized) +- **Necessary work that looks slow** (baseline is acceptable, user won't notice) + +**What to do instead**: + +1. **Check Self Time, not Total Time** + - Self Time 80%? Function is actually doing expensive work + - Self Time 5%, Total Time 80%? Function is calling slow code + +2. **Drill down one level** + - What is this function calling? + - Is the slow code in a library you control? + +3. **Check the timeline** + - Is this 80% sustained (steady slow) or spikes (occasional stalls)? + - Sustained = optimization needed + - Spikes = caching might help + +4. **Ask: Will users notice?** + - 500ms background work = user won't notice + - 500ms on main thread = UI stall, user sees it + - 50ms on main thread per frame = smooth UI (60fps) + +**Time cost**: 5 min (read results) + 2 min (drill down) = **7 minutes to understand** + +**Cost of guessing**: 2 hours optimizing wrong function + 1 hour realizing it didn't help + back to square one = **3+ hours wasted** + +--- + +## Allocations Deep Dive + +Use Allocations when memory grows over time or you suspect memory pressure issues. + +### Workflow: Record and Analyze + +#### Step 1: Launch Instruments +```bash +open -a Instruments +``` + +Select "Allocations" template. + +#### Step 2: Attach and Record +1. Start your app +2. In Instruments, select your app +3. Click Record +4. Perform actions that use memory (load data, display images, navigate) +5. Stop recording after memory stabilizes or peaks + +#### Step 3: Find Memory Growth + +Look at the main chart: +- **Blue line** = Total allocations +- **Sharp climb** = Memory being allocated +- **Flat line** = Memory stable (good) +- **No decline after stopping actions** = Possible leak (or caching) + +#### Step 4: Identify Persistent Objects + +Under "Statistics": +- Sort by "Persistent" (objects still alive) +- Look for surprisingly large object counts: + ``` + UIImage: 500 instances (300MB) – Should be <50 for normal app + NSString: 50000 instances – Should be <1000 + CustomDataModel: 10000 instances – Should be <100 + ``` + +### Common Mistakes & Fixes + +#### ❌ Mistake 1: Confusing "Memory Grew" with "Memory Leak" + +```swift +// ❌ WRONG: Memory went from 100MB to 500MB +// Conclusion: "There's a leak, memory keeps growing!" + +// ✅ RIGHT: Check what caused the growth +// Loaded 1000 images (normal) +// Cached API responses (normal) +// User has 5000 contacts (normal) +// Memory is being used correctly +``` + +**The issue**: Growing memory ≠ leak. Apps legitimately use more memory when loading data. + +**Fix**: Check Allocations for object counts. If images/data count matches what you loaded, it's normal. If object count keeps growing without actions, that's a leak. + +#### ❌ Mistake 2: Not Accounting for Caching + +```swift +// ❌ WRONG: Allocations shows 1000 UIImages in memory +// Conclusion: "Memory leak, too many images!" + +// ✅ RIGHT: Check if this is intentional caching +// ImageCache holds up to 1000 images by design +// When memory pressure happens, cache is cleared +// Normal behavior +``` + +**Fix**: Distinguish between intended caching and actual leaks. Leaks don't release under memory pressure. + +#### ❌ Mistake 3: Profiling Too Short + +```swift +// ❌ WRONG: Record for 5 seconds, see 200MB +// Conclusion: "App uses 200MB, optimize memory" + +// ✅ RIGHT: Record for 2-3 minutes, see full lifecycle +// Load data: 200MB +// Navigate away: 180MB (20MB still cached) +// Navigate back: 190MB (cache reused) +// Real baseline: ~190MB at steady state +``` + +**Fix**: Profile long enough to see memory stabilize. Short recordings capture transient spikes. + +### Pressure Scenario: "Memory is 500MB, That's a Leak!" + +**The temptation**: "Delete caching, reduce object creation, optimize data structures" + +**The reality**: Is 500MB actually large? +- iPhone 14 Pro has 6GB RAM +- Instagram uses 400-600MB on load +- Photos app uses 500MB+ when browsing large library +- 500MB might be completely normal + +**What to do instead**: + +1. **Establish baseline on real device** + ```bash + # On device, open Memory view in Xcode + Xcode → Debug → Memory Debugger → Check "Real Memory" at app launch + ``` + +2. **Check object counts, not total memory** + - Allocations → Statistics → "Persistent" + - Are images, views, or data objects 10x expected count? + - If yes, investigate that object type + - If no, memory is probably fine + +3. **Test under memory pressure** + - Xcode → Debug → Simulate Memory Warning + - Does memory drop by 50%+? It's caching (normal) + - Does memory stay high? Investigate persistent objects + +4. **Profile real user journey** + - Load data (like user does) + - Navigate around (like user does) + - Return to app (from background) + - Check memory at each step + +**Time cost**: 5 min (launch Allocations) + 3 min (record app usage) + 2 min (analyze) = **10 minutes** + +**Cost of guessing**: Delete caching to "reduce memory" → app reloads data every screen → slower app → users complain → revert changes = **2+ hours wasted** + +--- + +## Core Data Deep Dive + +Use Core Data instrument when your app uses Core Data and data loading is slow. + +### Workflow: Enable SQL Debugging and Profile + +#### Step 1: Enable Core Data SQL Logging + +Add to your launch arguments in Xcode: + +``` +Edit Scheme → Run → Arguments Passed On Launch +Add: -com.apple.CoreData.SQLDebug 1 +``` + +Now SQLite queries print to console: + +``` +CoreData: sql: SELECT ... FROM tracks WHERE artist = ? (time: 0.015s) +CoreData: sql: SELECT ... FROM albums WHERE id = ? (time: 0.002s) +``` + +#### Step 2: Identify N+1 Query Problem + +Watch the console during a typical user action (load list, scroll, filter): + +``` +❌ BAD: Loading 100 tracks, then querying album for each +SELECT * FROM tracks (time: 0.050s) → 100 tracks +SELECT * FROM albums WHERE id = 1 (time: 0.005s) +SELECT * FROM albums WHERE id = 2 (time: 0.005s) +SELECT * FROM albums WHERE id = 3 (time: 0.005s) +... 97 more queries +Total: 0.050s + (100 × 0.005s) = 0.550s + +✅ GOOD: Fetch tracks WITH album relationship (eager loading) +SELECT tracks.*, albums.* FROM tracks +LEFT JOIN albums ON tracks.albumId = albums.id +(time: 0.050s) +Total: 0.050s +``` + +#### Step 3: Profile with Core Data Instrument + +```bash +open -a Instruments +``` + +Select "Core Data" template. + +Record while performing slow action: + +``` +Core Data Results + +Fetch Requests: 102 +Average Fetch Time: 12ms +Slow Fetch: "SELECT * FROM tracks" (180ms) + +Fault Fires: 5000 + → Object accessed, requires fetch from database + → Should use prefetching +``` + +### Common Mistakes & Fixes + +#### ❌ Mistake 1: Not Using Relationships Correctly + +```swift +// ❌ WRONG: Fetch tracks, then access album for each +let tracks = try context.fetch(Track.fetchRequest()) +for track in tracks { + print(track.album.title) // Fires individual query for each +} +// Total: 1 + N queries + +// ✅ RIGHT: Fetch with relationship prefetching +let request = Track.fetchRequest() +request.returnsObjectsAsFaults = false +request.relationshipKeyPathsForPrefetching = ["album"] +let tracks = try context.fetch(request) +for track in tracks { + print(track.album.title) // Already loaded +} +// Total: 1 query +``` + +**Fix**: Use `relationshipKeyPathsForPrefetching` to load related objects upfront. + +#### ❌ Mistake 2: Not Using Batching + +```swift +// ❌ WRONG: Fetch 50,000 records all at once +let request = Track.fetchRequest() +let allTracks = try context.fetch(request) // Huge memory spike + +// ✅ RIGHT: Batch fetch in chunks +let request = Track.fetchRequest() +request.fetchBatchSize = 500 // Fetch 500 at a time +let allTracks = try context.fetch(request) // Memory efficient +``` + +**Fix**: Use `fetchBatchSize` for large datasets. + +#### ❌ Mistake 3: Not Using Faulting to Reduce Memory + +```swift +// ❌ WRONG: Keep all objects in memory +let request = Track.fetchRequest() +request.returnsObjectsAsFaults = false // Keep all in memory +let allTracks = try context.fetch(request) // 50,000 objects +// Memory spike if you don't use all of them + +// ✅ RIGHT: Use faults (lazy loading) +let request = Track.fetchRequest() +// request.returnsObjectsAsFaults = true (default) +let allTracks = try context.fetch(request) // Just references +// Only load objects you actually access +``` + +**Fix**: Leave `returnsObjectsAsFaults` as default (true) unless you need all objects upfront. + +### Pressure Scenario: "Core Data Queries Are Slow, Redesign Schema!" + +**The temptation**: "The schema is wrong, I need to restructure everything" + +**The reality**: 99% of "slow Core Data" is due to: +- ❌ Missing indexes +- ❌ N+1 query problem +- ❌ Fetching too much data at once +- ❌ Not using batch size or prefetching + +Redesigning the schema is the LAST thing to try. + +**What to do instead**: + +1. **Enable SQL debugging** (2 min) + - Add `-com.apple.CoreData.SQLDebug 1` launch argument + - Watch what queries execute + +2. **Look for N+1 pattern** (3 min) + - Fetching 100 objects, then individual queries for related data? + - Add relationship prefetching + +3. **Add indexes if needed** (5 min) + - `@NSManaged var artist: String` with frequent filtering? + - Add `@Index` in schema + +4. **Test improvement** (2 min) + - Re-run the same action + - Compare query count and total time + - If 10x faster, you're done + - If still slow, go to step 5 + +5. **Only THEN consider schema changes** (30+ min) + - But you probably won't get here + +**Time cost**: 12 minutes to diagnose + fix = **12 minutes** + +**Cost of schema redesign**: 8 hours design + 4 hours migration + 2 hours testing + 1 hour rollback = **15 hours total** + +--- + +## Quick Reference: Other Tools + +### Energy Impact (Battery Drain) + +**When to use**: App drains battery fast, device gets hot + +**Workflow**: +1. Launch Instruments → Energy Impact template +2. Run app normally for 5+ minutes +3. Look for red/orange sustained usage (bad) +4. Drill down to see which subsystems drain battery + +**Key metrics**: +- **Sustained Power** – Ongoing energy use (should be minimal) +- **Peaks** – Brief high usage (acceptable) +- **CPU** – Process CPU time +- **GPU** – Graphics rendering +- **Network** – Cellular/WiFi radio +- **Location** – GPS usage + +**Common issues**: +- Continuous location updates with 1m accuracy (should be 100m) +- Running timers that wake the device repeatedly +- Excessive network calls (batch requests instead) +- Animating views while not visible + +### Network Link Conditioner (Connection Simulation) + +**When to use**: App seems slow on 4G, want to test without traveling + +**Setup**: +1. Download Additional Tools for Xcode +2. Install Network Link Conditioner +3. Open System Preferences → Network Link Conditioner +4. Choose profile (3G, LTE, WiFi Slow, etc.) +5. Enable and activate profile +6. Run app to test + +**Key profiles**: +- **3G** – 1.6Mbps down, 768Kbps up, 150ms latency +- **LTE** – 10Mbps down, 5Mbps up, 20ms latency +- **WiFi Slow** – 10Mbps, 100ms latency +- **Custom** – Set your own parameters + +**Note**: Also covered in ui-testing for network-dependent test scenarios. + +### System Trace (Thread Blocking, Scheduling) + +**When to use**: UI freezes or is janky, but Time Profiler shows low CPU + +**Common cause**: Main thread blocked by background task waiting on lock + +**Workflow**: +1. Launch Instruments → System Trace template +2. Record while reproducing issue +3. Look for main thread gaps (blocked, not running) +4. Drill down to see what's blocking it + +**Key metrics**: +- **Main thread gaps** – Empty spaces = main thread idle/blocked +- **Core scheduling** – Which threads run when +- **Lock contention** – Threads waiting for locks + +--- + +## OSSignposter — Custom Performance Instrumentation + +While Time Profiler shows where CPU time goes generally, OSSignposter lets you measure specific operations you define. It's the primary tool for custom performance instrumentation on Apple platforms. + +### When to Use + +- Measuring duration of specific operations (data load, image processing, sync cycle) +- Creating custom Instruments lanes for your app's operations +- Bridging to automated performance testing (XCTOSSignpostMetric) +- Measuring operations that span multiple threads or await points + +### Basic API + +```swift +import os + +let signposter = OSSignposter(subsystem: "com.app", category: "DataLoad") + +// Interval measurement (start → end) +func loadData() async throws -> [Item] { + let signpostID = signposter.makeSignpostID() + let state = signposter.beginInterval("Load Items", id: signpostID) + defer { signposter.endInterval("Load Items", state) } + + return try await fetchItems() +} + +// Point of interest (single event) +func cacheHit(for key: String) { + signposter.emitEvent("Cache Hit") +} +``` + +### Integration with Instruments + +1. Launch Instruments → add "os_signpost" or "Points of Interest" instrument +2. Record your app performing the instrumented operations +3. Signpost intervals appear as colored bars in the timeline +4. Filter by subsystem/category to focus on your operations + +### When to Use Signposts vs Time Profiler + +| Need | Tool | +|------|------| +| General CPU hotspots | Time Profiler | +| Specific operation duration | OSSignposter | +| Cross-thread operation timing | OSSignposter | +| Automated regression testing | OSSignposter + XCTOSSignpostMetric | + +--- + +## Pressure Scenarios + +### Scenario 1: "Profiling Shows Different Results Each Run" + +**The problem**: You run Time Profiler 3 times, get 200ms, 150ms, 280ms. Which is correct? + +**Red flags you might think**: +- "Results are unreliable, profiling isn't accurate" +- "Let me just average them" +- "This is too variable, I can't optimize" + +**The reality**: Variance is NORMAL. Different runs hit different: +- Cache states (cold cache = slower) +- System load (other apps running) +- CPU frequency (boost/throttle) + +**What to do instead**: + +1. **Warm up the cache** (first run always slower) + - Perform the action once (cold cache) + - Perform again (warm cache) – use this measurement + +2. **Control system load** + - Close other apps + - Don't touch device during profiling + - Profile on device (not simulator) + +3. **Look for the pattern** + - Multiple runs: 150ms, 160ms, 155ms (consistent = good) + - Multiple runs: 150ms, 280ms, 240ms (inconsistent = investigate) + - Inconsistency = intermittent problem, find it + +4. **Trust the slowest run** (worst case scenario) + - If range is 150-280ms, assume 280ms is real + - Optimize for worst case + +**Time cost**: 10 min (run profiler 3x) + 2 min (interpret) = **12 minutes** + +**Cost of ignoring variance**: Miss intermittent performance issue → users see occasional freezes → bad reviews + +--- + +### Scenario 2: "Time Profiler and Allocations Show Different Problems" + +**The problem**: Time Profiler shows JSON parsing is slow. Allocations show memory use is normal. Which to fix? + +**The answer**: Both are real, prioritize differently. + +``` +Time Profiler: JSONDecoder.decode() = 500ms +Allocations: Memory = 250MB (normal for app size) + +Result: App is slow AND memory is fine +Action: Optimize JSON decoding (not memory) +``` + +**Common conflicts**: + +| Time Profiler | Allocations | Action | +|---|---|---| +| High CPU | Normal memory | Optimize computation (reduce CPU) | +| Low CPU | Memory growing | Find leak or reduce object creation | +| Both high | Both high | Profile which is user-visible first | + +**What to do**: + +1. **Prioritize by user impact** + - Slowness (UI lag) = fix first + - Memory (background issue) = fix second + +2. **Check if they're related** + - Does JSON parsing leak memory? (No → separate issues) + - Does memory growth slow CPU? (Maybe → fix memory first) + +3. **Fix in order of impact** + - Slow JSON parsing: Affects every data load + - Normal memory: No user impact + - → Fix JSON parsing + +**Time cost**: 5 min (analyze both results) = **5 minutes** + +**Cost of fixing wrong problem**: Spend 4 hours optimizing memory that's fine → no improvement to user experience + +--- + +### Scenario 3: "Profiling Under Deadline Pressure" + +**The situation**: Manager says "We ship in 2 hours. Is performance acceptable?" + +**Red flags you might think**: +- "Profiling takes too long, let me just ask users" +- "I don't have time to profile properly, ship as-is" +- "One quick run will tell me if it's fine" + +**The reality**: Profiling takes 15-20 minutes total. That's 1% of your remaining time. + +**What to do instead**: + +1. **Profile the critical path** (3 min) + - What users do most (load list, scroll, search) + - Not the entire app, just the slow part + +2. **Record one proper run** (5 min) + - Cold cache first time + - Warm cache second time + - Use warm cache results + +3. **Interpret quickly** (5 min) + - Time Profiler: Any >100ms on main thread? (If no, fine) + - Allocations: Any memory growing? (If no, fine) + +4. **Ship with confidence** (2 min) + - If results are acceptable, ship + - If not, you have 90 minutes to fix or delay + +**Time cost**: 15 min profiling + 5 min analysis = **20 minutes** + +**Cost of not profiling**: Ship with unknown performance → Users hit slowness → Bad reviews → Emergency hotfix 2 weeks later + +**Math**: 20 minutes of profiling now << 2+ weeks of post-launch support + +--- + +## CLI Quick Checks (No Instruments) + +Xcode ships CLI profiling tools for fast checks without opening Instruments. + +### CPU Profiling + +```bash +# Quick 5-second CPU sample of running app +xcrun sample MyApp 5 + +# Sample by PID, save to file for analysis +xcrun sample 12345 5 -file output.txt +``` + +**When to use**: Quick CPU check before committing to a full xctrace session. Shows which functions are hot in 5 seconds. + +### Memory Profiling + +```bash +# Quick leak check — is there a leak at all? +xcrun leaks MyApp +``` + +For `heap`, `vmmap`, `stringdups`, and a full CLI diagnosis workflow, see `axiom-memory-debugging`. + +### Headless Instruments (xctrace) + +```bash +# CPU profile from CLI +xcrun xctrace record --instrument 'CPU Profiler' --attach 'MyApp' --time-limit 10s --output cpu.trace + +# Memory allocations from CLI +xcrun xctrace record --instrument 'Allocations' --attach 'MyApp' --time-limit 30s --output alloc.trace +``` + +See `axiom-xctrace-ref` for comprehensive xctrace reference. + +## Quick Reference + +### Common Operations + +```swift +// Time Profiler: Launch Instruments +open -a Instruments + +// Core Data: Enable SQL logging +// Edit Scheme → Run → Arguments Passed On Launch +-com.apple.CoreData.SQLDebug 1 + +// Allocations: Check persistent objects +Instruments → Allocations → Statistics → sort "Persistent" + +// Memory warning: Simulate pressure +Xcode → Debug → Simulate Memory Warning + +// Energy Impact: Profile battery drain +Instruments → Energy Impact template + +// Network Link Conditioner: Simulate 3G +System Preferences → Network Link Conditioner → 3G profile +``` + +### Decision Tree Summary + +``` +Performance problem? +├─ App feels slow/laggy? +│ └─ → Time Profiler (measure CPU) +├─ Memory grows over time? +│ └─ → Allocations (find object growth) +├─ Data loading is slow? +│ └─ → Core Data instrument (if using Core Data) +│ └─ → Time Profiler (if computation slow) +└─ Battery drains fast? + └─ → Energy Impact (measure power) +``` + +--- + +## Real-World Examples + +### Example 1: Identifying N+1 Query Problem in Core Data + +**Scenario**: Your app loads a list of albums with artist names. It's slow (5+ seconds for 100 albums). You suspect Core Data. + +**Setup**: Enable SQL logging first +```bash +# Edit Scheme → Run → Arguments Passed On Launch +-com.apple.CoreData.SQLDebug 1 +``` + +**What you see in console**: +``` +CoreData: sql: SELECT ... FROM albums WHERE ... (time: 0.050s) +CoreData: sql: SELECT ... FROM artists WHERE id = 1 (time: 0.003s) +CoreData: sql: SELECT ... FROM artists WHERE id = 2 (time: 0.003s) +... 98 more individual queries +Total: 0.050s + (100 × 0.003s) = 0.350s +``` + +**Diagnosis using the skill**: +- Fetching 100 albums, then individual query for each album's artist = **N+1 query problem** (Core Data Deep Dive, lines 302-325) + +**Fix**: +```swift +// ❌ WRONG: Each album access triggers separate artist query +let request = Album.fetchRequest() +let albums = try context.fetch(request) +for album in albums { + print(album.artist.name) // Extra query for each +} + +// ✅ RIGHT: Prefetch the relationship +let request = Album.fetchRequest() +request.returnsObjectsAsFaults = false +request.relationshipKeyPathsForPrefetching = ["artist"] +let albums = try context.fetch(request) +for album in albums { + print(album.artist.name) // Already loaded +} +``` + +**Result**: 0.350s → 0.050s (7x faster) + +--- + +### Example 2: Finding Where UI Lag Really Comes From + +**Scenario**: Your app UI stalls for 1-2 seconds when loading a view. Your co-lead says "Add background threading everywhere." You want to measure first. + +**Workflow using the skill** (Time Profiler Deep Dive, lines 82-118): + +1. **Open Instruments**: +```bash +open -a Instruments +# Select "Time Profiler" +``` + +2. **Record the stall**: +``` +App launches +Time Profiler records +View loads +Stall happens (observe the spike in Time Profiler) +Stop recording +``` + +3. **Examine results**: +``` +Call Stack shows: + +viewDidLoad() – 1500ms + ├─ loadJSON() – 1200ms (Self Time: 50ms) + │ └─ loadImages() – 1150ms (Self Time: 1150ms) ← HERE'S THE CULPRIT + ├─ parseData() – 200ms + └─ layoutUI() – 100ms +``` + +4. **Apply the skill** (lines 173-175): +``` +loadJSON() has Self Time: 50ms, Total Time: 1200ms +→ loadJSON() isn't slow, something it CALLS is slow +→ loadImages() has Self Time: 1150ms +→ loadImages() is the actual bottleneck +``` + +5. **Fix the right thing**: +```swift +// ❌ WRONG: Thread everything +DispatchQueue.global().async { loadJSON() } + +// ✅ RIGHT: Thread only the slow part +func loadJSON() { + let data = parseJSON() // 50ms, fine on main + + // Move ONLY the slow part to background + DispatchQueue.global().async { + let images = loadImages() // 1150ms, now background + DispatchQueue.main.async { + updateUI(with: images) + } + } +} +``` + +**Result**: 1500ms → 350ms (4x faster, main thread unblocked) + +**Why this matters**: You fixed the ACTUAL bottleneck (1150ms), not guessing blindly about threading. + +--- + +### Example 3: Memory Growing vs Memory Leak + +**Scenario**: Allocations shows memory growing from 150MB to 600MB over 30 minutes of app use. Your manager says "Memory leak!" You need to know if it's real. + +**Workflow using the skill** (Allocations Deep Dive, lines 199-277): + +1. **Launch Allocations in Instruments** + +2. **Record normal app usage for 3 minutes**: +``` +User loads data → memory grows to 400MB +User navigates around → memory stays at 400MB +User goes to Settings → memory at 400MB +User comes back → memory at 400MB +``` + +3. **Check Allocations Statistics**: +``` +Persistent Objects: +- UIImage: 1200 instances (300MB) ← Large count +- NSString: 5000 instances (4MB) +- CustomDataModel: 800 instances (15MB) +``` + +4. **Ask the skill questions** (lines 220-240): +- Are 1200 images legitimately loaded? (User loaded photo library with 1000 photos) → YES +- Does memory drop if you trigger memory warning? (Simulate with Xcode) → YES, drops to 180MB +- Is this caching working as designed? → YES + +**Diagnosis**: NOT a leak. This is **normal caching** (lines 235-248) +``` +Memory growing = apps using data users asked for +Memory dropping under pressure = cache working correctly +Memory staying high indefinitely = possible leak +``` + +5. **Conclusion**: +```swift +// ✅ This is working correctly +let imageCache = NSCache() +// Holds up to 1200 images by design +// Clears when system memory pressure happens +// No leak +``` + +**Result**: No action needed. The "leak" is actually the cache doing its job. + +--- + +## Regression-Proofing Pipeline + +Performance work isn't done when the fix ships. Without regression detection, optimizations quietly degrade over time. The three-stage pipeline catches regressions at every phase. + +### The Three Stages + +| Stage | Tool | When | Catches | +|-------|------|------|---------| +| Dev | OSSignposter | Writing code | Specific operation timing | +| CI | XCTest performance tests | Every PR | Regression vs baseline | +| Production | MetricKit | After release | Real-world degradation | + +### Stage 1: Instrument Your Code (OSSignposter) + +See OSSignposter section above. Add signpost intervals to performance-critical code paths. + +### Stage 2: Automate with XCTest Performance Tests + +```swift +func testDataLoadPerformance() throws { + let options = XCTMeasureOptions() + options.iterationCount = 10 + + measure(metrics: [ + XCTClockMetric(), // Wall clock time + XCTCPUMetric(), // CPU time and cycles + XCTMemoryMetric(), // Peak physical memory + ], options: options) { + loadData() + } +} +``` + +#### Available XCTMetric Types + +- **XCTClockMetric** — Wall clock duration +- **XCTCPUMetric** — CPU time, instructions retired, cycles +- **XCTMemoryMetric** — Peak physical memory during test +- **XCTStorageMetric** — Logical writes to storage +- **XCTOSSignpostMetric** — Duration of signposted intervals (bridges Stage 1 → Stage 2) +- **XCTApplicationLaunchMetric** — App launch time (cold/warm/optimized) +- **XCTHitchMetric** — Hitch time ratio (scrolling and animation hitches) + +#### Setting Baselines + +After running once, click the value in Xcode's test results → "Set Baseline". Subsequent runs compare against baseline and fail if regression exceeds tolerance (default 10%). + +#### Anti-Pattern: Baseline-Less Performance Tests + +```swift +// ❌ Test always passes — no baseline set +func testPerformance() { + measure { doWork() } +} + +// ✅ Set baseline in Xcode after first run +// Tests fail when performance regresses beyond tolerance +``` + +#### Bridging Signposts to Tests (XCTOSSignpostMetric) + +```swift +// In production code +let signposter = OSSignposter(subsystem: "com.app", category: "Sync") + +func syncData() { + let id = signposter.makeSignpostID() + let state = signposter.beginInterval("Full Sync", id: id) + defer { signposter.endInterval("Full Sync", state) } + // ... sync logic +} + +// In test +func testSyncPerformance() { + let metric = XCTOSSignpostMetric( + subsystem: "com.app", + category: "Sync", + name: "Full Sync" + ) + measure(metrics: [metric]) { + syncData() + } +} +``` + +### Stage 3: Monitor in Production (MetricKit) + +See `axiom-metrickit-ref` for comprehensive MetricKit integration. Key metrics to monitor: + +- `MXAppLaunchMetric` — Launch time regression +- `MXAppResponsivenessMetric` — Hang rate increase +- `MXCPUMetric` — CPU time per foreground session +- `MXMemoryMetric` — Peak memory growth across versions + +--- + +## Resources + +**WWDC**: 2023-10160, 2024-10217, 2025-308, 2025-312 + +**Docs**: /library/archive/documentation/cocoa/conceptual/coredataperformance, /library/archive/technotes/tn2224, /os/ossignposter, /xctest/xctestcase/measure + +**Skills**: axiom-memory-debugging, axiom-swiftui-performance, axiom-swift-concurrency, axiom-metrickit-ref + +--- + +**Targets:** iOS 14+, Swift 5.5+ +**Tools:** Instruments, Core Data +**History:** See git log for changes diff --git a/.claude/skills/axiom-performance-profiling/agents/openai.yaml b/.claude/skills/axiom-performance-profiling/agents/openai.yaml new file mode 100644 index 0000000..6a560f8 --- /dev/null +++ b/.claude/skills/axiom-performance-profiling/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Performance Profiling" + short_description: "App feels slow, memory grows over time, battery drains fast, or you want to profile proactively" diff --git a/.claude/skills/axiom-photo-library-ref/.openskills.json b/.claude/skills/axiom-photo-library-ref/.openskills.json new file mode 100644 index 0000000..33691d5 --- /dev/null +++ b/.claude/skills/axiom-photo-library-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-photo-library-ref", + "installedAt": "2026-04-12T08:06:32.217Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-photo-library-ref/SKILL.md b/.claude/skills/axiom-photo-library-ref/SKILL.md new file mode 100644 index 0000000..3b6917b --- /dev/null +++ b/.claude/skills/axiom-photo-library-ref/SKILL.md @@ -0,0 +1,815 @@ +--- +name: axiom-photo-library-ref +description: Reference — PHPickerViewController, PHPickerConfiguration, PhotosPicker, PhotosPickerItem, Transferable, PHPhotoLibrary, PHAsset, PHAssetCreationRequest, PHFetchResult, PHAuthorizationStatus, limited library APIs +license: MIT +metadata: + version: "1.0.0" +--- + +# Photo Library API Reference + +## Quick Reference + +```swift +// SWIFTUI PHOTO PICKER (iOS 16+) +import PhotosUI + +@State private var item: PhotosPickerItem? + +PhotosPicker(selection: $item, matching: .images) { + Text("Select Photo") +} +.onChange(of: item) { _, newItem in + Task { + if let data = try? await newItem?.loadTransferable(type: Data.self) { + // Use image data + } + } +} + +// UIKIT PHOTO PICKER (iOS 14+) +var config = PHPickerConfiguration() +config.selectionLimit = 1 +config.filter = .images +let picker = PHPickerViewController(configuration: config) +picker.delegate = self + +// SAVE TO CAMERA ROLL +try await PHPhotoLibrary.shared().performChanges { + PHAssetCreationRequest.creationRequestForAsset(from: image) +} + +// CHECK PERMISSION +let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) +``` + +--- + +## PHPickerViewController (iOS 14+) + +System photo picker for UIKit apps. No permission required. + +### Configuration + +```swift +import PhotosUI + +var config = PHPickerConfiguration() + +// Selection limit (0 = unlimited) +config.selectionLimit = 5 + +// Filter by asset type +config.filter = .images + +// Use photo library (enables asset identifiers) +config = PHPickerConfiguration(photoLibrary: .shared()) + +// Preferred asset representation +config.preferredAssetRepresentationMode = .automatic // default +// .current - original format +// .compatible - converted to compatible format +``` + +### Filter Options + +```swift +// Basic filters +PHPickerFilter.images +PHPickerFilter.videos +PHPickerFilter.livePhotos + +// Combined filters +PHPickerFilter.any(of: [.images, .videos]) + +// Exclusion filters (iOS 15+) +PHPickerFilter.all(of: [.images, .not(.screenshots)]) +PHPickerFilter.not(.livePhotos) + +// Playback style filters (iOS 17+) +PHPickerFilter.any(of: [.cinematicVideos, .slomoVideos]) +``` + +### Presenting + +```swift +let picker = PHPickerViewController(configuration: config) +picker.delegate = self +present(picker, animated: true) +``` + +### Delegate + +```swift +extension ViewController: PHPickerViewControllerDelegate { + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + + for result in results { + // Get asset identifier (if using PHPickerConfiguration(photoLibrary:)) + let identifier = result.assetIdentifier + + // Load as UIImage + result.itemProvider.loadObject(ofClass: UIImage.self) { object, error in + guard let image = object as? UIImage else { return } + DispatchQueue.main.async { + self.displayImage(image) + } + } + + // Load as Data + result.itemProvider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, error in + guard let data else { return } + // Use data + } + + // Load Live Photo + result.itemProvider.loadObject(ofClass: PHLivePhoto.self) { object, error in + guard let livePhoto = object as? PHLivePhoto else { return } + // Use live photo + } + } + } +} +``` + +### PHPickerResult Properties + +| Property | Type | Description | +|----------|------|-------------| +| `itemProvider` | NSItemProvider | Provides selected asset data | +| `assetIdentifier` | String? | PHAsset identifier (if using photoLibrary config) | + +--- + +## PhotosPicker (SwiftUI, iOS 16+) + +SwiftUI view for photo selection. No permission required. + +### Basic Usage + +```swift +import SwiftUI +import PhotosUI + +// Single selection +@State private var selectedItem: PhotosPickerItem? + +PhotosPicker(selection: $selectedItem, matching: .images) { + Label("Select Photo", systemImage: "photo") +} + +// Multiple selection +@State private var selectedItems: [PhotosPickerItem] = [] + +PhotosPicker( + selection: $selectedItems, + maxSelectionCount: 5, + matching: .images +) { + Text("Select Photos") +} +``` + +### Filters + +```swift +// Images only +matching: .images + +// Videos only +matching: .videos + +// Images and videos +matching: .any(of: [.images, .videos]) + +// Live Photos +matching: .livePhotos + +// Exclude screenshots (iOS 15+) +matching: .all(of: [.images, .not(.screenshots)]) +``` + +### Selection Behavior + +```swift +PhotosPicker( + selection: $items, + maxSelectionCount: 10, + selectionBehavior: .ordered, // .default, .ordered, .continuous + matching: .images +) { ... } +``` + +| Behavior | Description | +|----------|-------------| +| `.default` | Standard multi-select | +| `.ordered` | Selection order preserved | +| `.continuous` | Live updates as user selects (iOS 17+) | + +### Embedded Picker (iOS 17+) + +```swift +PhotosPicker( + selection: $items, + maxSelectionCount: 10, + selectionBehavior: .continuous, + matching: .images +) { + Text("Select") +} +.photosPickerStyle(.inline) // Embed in view hierarchy +.photosPickerDisabledCapabilities([.selectionActions]) +.photosPickerAccessoryVisibility(.hidden, edges: .all) +``` + +| Style | Description | +|-------|-------------| +| `.presentation` | Modal sheet (default) | +| `.inline` | Embedded in view | +| `.compact` | Single row | + +| Disabled Capability | Effect | +|---------------------|--------| +| `.search` | Hide search bar | +| `.collectionNavigation` | Hide albums | +| `.stagingArea` | Hide selection review | +| `.selectionActions` | Hide Add/Cancel | + +| Accessory Visibility | Description | +|----------------------|-------------| +| `.hidden`, `.automatic`, `.visible` | Per edge | + +### HDR Preservation (iOS 17+) + +```swift +PhotosPicker( + selection: $items, + matching: .images, + preferredItemEncoding: .current // Don't transcode, preserve HDR +) { ... } +``` + +| Encoding | Description | +|----------|-------------| +| `.automatic` | System decides format | +| `.current` | Original format, preserves HDR | +| `.compatible` | Force compatible format | + +### Loading Images from PhotosPickerItem + +```swift +// Load as Data (most reliable) +if let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) { + // Use image +} + +// Custom Transferable for direct UIImage +struct ImageTransferable: Transferable { + let image: UIImage + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(importedContentType: .image) { data in + guard let image = UIImage(data: data) else { + throw TransferError.importFailed + } + return ImageTransferable(image: image) + } + } +} + +// Usage +if let result = try? await item.loadTransferable(type: ImageTransferable.self) { + let image = result.image +} +``` + +### PhotosPickerItem Properties + +| Property | Type | Description | +|----------|------|-------------| +| `itemIdentifier` | String | Unique identifier | +| `supportedContentTypes` | [UTType] | Available representations | + +### PhotosPickerItem Methods + +```swift +// Load transferable +func loadTransferable(type: T.Type) async throws -> T? + +// Load with progress +func loadTransferable( + type: T.Type, + completionHandler: @escaping (Result) -> Void +) -> Progress +``` + +--- + +## PHPhotoLibrary + +Access and modify the photo library. + +### Authorization Status + +```swift +// Check current status +let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + +// Request authorization +let newStatus = await PHPhotoLibrary.requestAuthorization(for: .readWrite) +``` + +### PHAuthorizationStatus + +| Status | Description | +|--------|-------------| +| `.notDetermined` | User hasn't been asked | +| `.restricted` | Parental controls limit access | +| `.denied` | User denied access | +| `.authorized` | Full access granted | +| `.limited` | Access to user-selected photos only (iOS 14+) | + +### Access Levels + +```swift +// Read and write +PHPhotoLibrary.requestAuthorization(for: .readWrite) + +// Add only (save photos, no reading) +PHPhotoLibrary.requestAuthorization(for: .addOnly) +``` + +### Limited Library Picker + +```swift +// Present picker to expand limited selection +@MainActor +func presentLimitedLibraryPicker() { + guard let viewController = UIApplication.shared.keyWindow?.rootViewController else { return } + PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: viewController) +} + +// With completion handler +PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: viewController) { identifiers in + // identifiers: asset IDs user added +} +``` + +### Performing Changes + +```swift +// Async changes +try await PHPhotoLibrary.shared().performChanges { + // Create, update, or delete assets +} + +// With completion handler +PHPhotoLibrary.shared().performChanges({ + // Changes +}) { success, error in + // Handle result +} +``` + +### Change Observer + +```swift +class PhotoObserver: NSObject, PHPhotoLibraryChangeObserver { + + override init() { + super.init() + PHPhotoLibrary.shared().register(self) + } + + deinit { + PHPhotoLibrary.shared().unregisterChangeObserver(self) + } + + func photoLibraryDidChange(_ changeInstance: PHChange) { + // Handle changes + guard let changes = changeInstance.changeDetails(for: fetchResult) else { return } + + DispatchQueue.main.async { + // Update UI with new fetch result + let newResult = changes.fetchResultAfterChanges + } + } +} +``` + +--- + +## PHAsset + +Represents an asset in the photo library. + +### Fetching Assets + +```swift +// All photos +let allPhotos = PHAsset.fetchAssets(with: .image, options: nil) + +// With options +let options = PHFetchOptions() +options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] +options.fetchLimit = 100 +options.predicate = NSPredicate(format: "mediaType == %d", PHAssetMediaType.image.rawValue) + +let recentPhotos = PHAsset.fetchAssets(with: options) + +// By identifier +let assets = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil) +``` + +### Asset Properties + +| Property | Type | Description | +|----------|------|-------------| +| `localIdentifier` | String | Unique ID | +| `mediaType` | PHAssetMediaType | `.image`, `.video`, `.audio` | +| `mediaSubtypes` | PHAssetMediaSubtype | `.photoLive`, `.photoPanorama`, etc. | +| `pixelWidth` | Int | Width in pixels | +| `pixelHeight` | Int | Height in pixels | +| `creationDate` | Date? | When taken | +| `modificationDate` | Date? | Last modified | +| `location` | CLLocation? | GPS location | +| `duration` | TimeInterval | Video duration | +| `isFavorite` | Bool | Marked as favorite | +| `isHidden` | Bool | In hidden album | + +### PHAssetMediaType + +| Type | Value | +|------|-------| +| `.unknown` | 0 | +| `.image` | 1 | +| `.video` | 2 | +| `.audio` | 3 | + +### PHAssetMediaSubtype + +| Subtype | Description | +|---------|-------------| +| `.photoPanorama` | Panoramic photo | +| `.photoHDR` | HDR photo | +| `.photoScreenshot` | Screenshot | +| `.photoLive` | Live Photo | +| `.photoDepthEffect` | Portrait mode | +| `.videoStreamed` | Streamed video | +| `.videoHighFrameRate` | Slo-mo video | +| `.videoTimelapse` | Timelapse | +| `.videoCinematic` | Cinematic mode | + +--- + +## PHAssetCreationRequest + +Create new assets in the photo library. + +### Creating from UIImage + +```swift +try await PHPhotoLibrary.shared().performChanges { + PHAssetCreationRequest.creationRequestForAsset(from: image) +} +``` + +### Creating from File URL + +```swift +try await PHPhotoLibrary.shared().performChanges { + PHAssetCreationRequest.creationRequestForAssetFromImage(atFileURL: imageURL) +} + +// For video +try await PHPhotoLibrary.shared().performChanges { + PHAssetCreationRequest.creationRequestForAssetFromVideo(atFileURL: videoURL) +} +``` + +### Creating with Resources + +```swift +try await PHPhotoLibrary.shared().performChanges { + let request = PHAssetCreationRequest.forAsset() + + // Add photo resource + let options = PHAssetResourceCreationOptions() + options.shouldMoveFile = true // Move instead of copy + + request.addResource(with: .photo, fileURL: photoURL, options: options) + + // Set creation date + request.creationDate = Date() + + // Set location + request.location = CLLocation(latitude: 37.7749, longitude: -122.4194) +} +``` + +### Deferred Photo Proxy (iOS 17+) + +Save camera proxy photos for background processing: + +```swift +// From AVCaptureDeferredPhotoProxy callback +try await PHPhotoLibrary.shared().performChanges { + let request = PHAssetCreationRequest.forAsset() + + // Use .photoProxy to trigger deferred processing + request.addResource(with: .photoProxy, data: proxyData, options: nil) +} +``` + +| Resource Type | Description | +|---------------|-------------| +| `.photo` | Standard photo | +| `.video` | Video file | +| `.photoProxy` | Deferred processing proxy (iOS 17+) | +| `.adjustmentData` | Edit adjustments | + +### Getting Created Asset + +```swift +try await PHPhotoLibrary.shared().performChanges { + let request = PHAssetCreationRequest.forAsset() + request.addResource(with: .photo, fileURL: url, options: nil) + + // Get placeholder for later fetching + let placeholder = request.placeholderForCreatedAsset + // placeholder.localIdentifier available after changes complete +} +``` + +### Custom Albums + +```swift +// Create a custom album +func getOrCreateAlbum(named title: String) async throws -> PHAssetCollection { + // Check if album already exists + let fetchOptions = PHFetchOptions() + fetchOptions.predicate = NSPredicate(format: "title = %@", title) + let existing = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .any, options: fetchOptions) + if let album = existing.firstObject { return album } + + // Create new album + var placeholder: PHObjectPlaceholder? + try await PHPhotoLibrary.shared().performChanges { + let request = PHAssetCollectionChangeRequest.creationRequestForAssetCollection(withTitle: title) + placeholder = request.placeholderForCreatedAssetCollection + } + guard let id = placeholder?.localIdentifier, + let album = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [id], options: nil).firstObject + else { throw PhotoError.albumCreationFailed } + return album +} + +// Save photo to custom album +func saveToAlbum(_ image: UIImage, album: PHAssetCollection) async throws { + try await PHPhotoLibrary.shared().performChanges { + let assetRequest = PHAssetCreationRequest.creationRequestForAsset(from: image) + guard let placeholder = assetRequest.placeholderForCreatedAsset, + let albumRequest = PHAssetCollectionChangeRequest(for: album) else { return } + albumRequest.addAssets([placeholder] as NSFastEnumeration) + } +} +``` + +--- + +## PHFetchResult + +Ordered list of assets from a fetch. + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `count` | Int | Number of items | +| `firstObject` | T? | First item | +| `lastObject` | T? | Last item | + +### Methods + +```swift +// Access by index +let asset = fetchResult.object(at: 0) +let asset = fetchResult[0] + +// Get multiple +let assets = fetchResult.objects(at: IndexSet(0..<10)) + +// Iteration +fetchResult.enumerateObjects { asset, index, stop in + // Process asset + if shouldStop { + stop.pointee = true + } +} + +// Check contains +let contains = fetchResult.contains(asset) +let index = fetchResult.index(of: asset) +``` + +--- + +## PHImageManager + +Request images from assets. + +### Request Image + +```swift +let manager = PHImageManager.default() + +let options = PHImageRequestOptions() +options.deliveryMode = .highQualityFormat +options.resizeMode = .exact +options.isNetworkAccessAllowed = true // For iCloud photos + +let targetSize = CGSize(width: 300, height: 300) + +manager.requestImage( + for: asset, + targetSize: targetSize, + contentMode: .aspectFill, + options: options +) { image, info in + guard let image else { return } + + // Check if this is the final image + let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false + if !isDegraded { + // Final high-quality image + } +} +``` + +### PHImageRequestOptions + +| Property | Type | Description | +|----------|------|-------------| +| `deliveryMode` | PHImageRequestOptionsDeliveryMode | Quality preference | +| `resizeMode` | PHImageRequestOptionsResizeMode | Resize behavior | +| `isNetworkAccessAllowed` | Bool | Allow iCloud download | +| `isSynchronous` | Bool | Synchronous request | +| `progressHandler` | Block | Download progress | +| `allowSecondaryDegradedImage` | Bool | Extra callback during deferred processing (iOS 17+) | + +### Secondary Degraded Image (iOS 17+) + +For photos undergoing deferred processing, get an intermediate quality image: + +```swift +let options = PHImageRequestOptions() +options.allowSecondaryDegradedImage = true + +// Callback order: +// 1. Low quality (immediate, isDegraded = true) +// 2. Medium quality (new, isDegraded = true) -- while processing +// 3. Final quality (isDegraded = false) +``` + +### Delivery Modes + +| Mode | Description | +|------|-------------| +| `.opportunistic` | Fast thumbnail, then high quality | +| `.highQualityFormat` | Only high quality | +| `.fastFormat` | Only fast/degraded | + +### Request Video + +```swift +manager.requestAVAsset(forVideo: asset, options: nil) { avAsset, audioMix, info in + guard let avAsset else { return } + // Use AVAsset for playback +} + +// Or export to file +manager.requestExportSession( + forVideo: asset, + options: nil, + exportPreset: AVAssetExportPresetHighestQuality +) { session, info in + session?.outputURL = outputURL + session?.outputFileType = .mp4 + session?.exportAsynchronously { ... } +} +``` + +--- + +## PHChange + +Represents changes to the photo library. + +### Getting Change Details + +```swift +func photoLibraryDidChange(_ changeInstance: PHChange) { + guard let changes = changeInstance.changeDetails(for: fetchResult) else { return } + + // Check what changed + let hasIncrementalChanges = changes.hasIncrementalChanges + let insertedIndexes = changes.insertedIndexes + let removedIndexes = changes.removedIndexes + let changedIndexes = changes.changedIndexes + + // Get new fetch result + let newResult = changes.fetchResultAfterChanges + + // Update collection view + DispatchQueue.main.async { + if hasIncrementalChanges { + collectionView.performBatchUpdates { + if let removed = removedIndexes { + collectionView.deleteItems(at: removed.map { IndexPath(item: $0, section: 0) }) + } + if let inserted = insertedIndexes { + collectionView.insertItems(at: inserted.map { IndexPath(item: $0, section: 0) }) + } + if let changed = changedIndexes { + collectionView.reloadItems(at: changed.map { IndexPath(item: $0, section: 0) }) + } + } + } else { + collectionView.reloadData() + } + } +} +``` + +--- + +## Common Code Patterns + +### Complete Photo Gallery View + +```swift +import SwiftUI +import Photos + +@MainActor +class PhotoGalleryViewModel: ObservableObject { + @Published var assets: [PHAsset] = [] + @Published var authorizationStatus: PHAuthorizationStatus = .notDetermined + + func requestAccess() async { + authorizationStatus = await PHPhotoLibrary.requestAuthorization(for: .readWrite) + + if authorizationStatus == .authorized || authorizationStatus == .limited { + fetchAssets() + } + } + + func fetchAssets() { + let options = PHFetchOptions() + options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] + options.fetchLimit = 100 + + let result = PHAsset.fetchAssets(with: .image, options: options) + assets = result.objects(at: IndexSet(0.. +NSPhotoLibraryUsageDescription +Access your photos to share them + + +NSPhotoLibraryAddUsageDescription +Save photos to your library +``` + +## Core Patterns + +### Pattern 1: SwiftUI PhotosPicker (iOS 16+) + +**Use case**: Let users select photos in a SwiftUI app. + +```swift +import SwiftUI +import PhotosUI + +struct ContentView: View { + @State private var selectedItem: PhotosPickerItem? + @State private var selectedImage: Image? + + var body: some View { + VStack { + PhotosPicker( + selection: $selectedItem, + matching: .images // Filter to images only + ) { + Label("Select Photo", systemImage: "photo") + } + + if let image = selectedImage { + image + .resizable() + .scaledToFit() + } + } + .onChange(of: selectedItem) { _, newItem in + Task { + await loadImage(from: newItem) + } + } + } + + private func loadImage(from item: PhotosPickerItem?) async { + guard let item else { + selectedImage = nil + return + } + + // Load as Data first (more reliable than Image) + if let data = try? await item.loadTransferable(type: Data.self), + let uiImage = UIImage(data: data) { + selectedImage = Image(uiImage: uiImage) + } + } +} +``` + +**Multi-selection**: +```swift +@State private var selectedItems: [PhotosPickerItem] = [] + +PhotosPicker( + selection: $selectedItems, + maxSelectionCount: 5, + matching: .images +) { + Text("Select Photos") +} +``` + +#### Advanced Filters (iOS 15+/16+) + +```swift +// Screenshots only +matching: .screenshots + +// Screen recordings only +matching: .screenRecordings + +// Slo-mo videos +matching: .sloMoVideos + +// Cinematic videos (iOS 16+) +matching: .cinematicVideos + +// Depth effect photos +matching: .depthEffectPhotos + +// Bursts +matching: .bursts + +// Compound filters with .any, .all, .not +// Videos AND Live Photos +matching: .any(of: [.videos, .livePhotos]) + +// All images EXCEPT screenshots +matching: .all(of: [.images, .not(.screenshots)]) + +// All images EXCEPT screenshots AND panoramas +matching: .all(of: [.images, .not(.any(of: [.screenshots, .panoramas]))]) +``` + +**Cost**: 15 min implementation, no permissions required + +### Pattern 1b: Embedded PhotosPicker (iOS 17+) + +**Use case**: Embed picker inline in your UI instead of presenting as sheet. + +```swift +import SwiftUI +import PhotosUI + +struct EmbeddedPickerView: View { + @State private var selectedItems: [PhotosPickerItem] = [] + + var body: some View { + VStack { + // Your content above picker + SelectedPhotosGrid(items: selectedItems) + + // Embedded picker fills available space + PhotosPicker( + selection: $selectedItems, + maxSelectionCount: 10, + selectionBehavior: .continuous, // Live updates as user taps + matching: .images + ) { + // Label is ignored for inline style + Text("Select") + } + .photosPickerStyle(.inline) // Embed instead of present + .photosPickerDisabledCapabilities([.selectionActions]) // Hide Add/Cancel buttons + .photosPickerAccessoryVisibility(.hidden, edges: .all) // Hide nav/toolbar + .frame(height: 300) // Control picker height + .ignoresSafeArea(.container, edges: .bottom) // Extend to bottom edge + } + } +} +``` + +**Picker Styles**: + +| Style | Description | +|-------|-------------| +| `.presentation` | Default modal sheet | +| `.inline` | Embedded in your view hierarchy | +| `.compact` | Single row, minimal vertical space | + +**Customization modifiers**: + +```swift +// Hide navigation/toolbar accessories +.photosPickerAccessoryVisibility(.hidden, edges: .all) +.photosPickerAccessoryVisibility(.hidden, edges: .top) // Just navigation bar +.photosPickerAccessoryVisibility(.hidden, edges: .bottom) // Just toolbar + +// Disable capabilities (hides UI for them) +.photosPickerDisabledCapabilities([.search]) // Hide search +.photosPickerDisabledCapabilities([.collectionNavigation]) // Hide albums +.photosPickerDisabledCapabilities([.stagingArea]) // Hide selection review +.photosPickerDisabledCapabilities([.selectionActions]) // Hide Add/Cancel + +// Continuous selection for live updates +selectionBehavior: .continuous +``` + +**Privacy note**: First time an embedded picker appears, iOS shows an onboarding UI explaining your app can only access selected photos. A privacy badge indicates the picker is out-of-process. + +### Pattern 2: UIKit PHPickerViewController (iOS 14+) + +**Use case**: Photo selection in UIKit apps. + +```swift +import PhotosUI + +class PhotoPickerViewController: UIViewController, PHPickerViewControllerDelegate { + + func showPicker() { + var config = PHPickerConfiguration() + config.selectionLimit = 1 // 0 = unlimited + config.filter = .images // or .videos, .any(of: [.images, .videos]) + + let picker = PHPickerViewController(configuration: config) + picker.delegate = self + present(picker, animated: true) + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + + guard let result = results.first else { return } + + // Load image asynchronously + result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] object, error in + guard let image = object as? UIImage else { return } + + DispatchQueue.main.async { + self?.displayImage(image) + } + } + } +} +``` + +**Filter options**: +```swift +// Images only +config.filter = .images + +// Videos only +config.filter = .videos + +// Live Photos only +config.filter = .livePhotos + +// Images and videos +config.filter = .any(of: [.images, .videos]) + +// Exclude screenshots (iOS 15+) +config.filter = .all(of: [.images, .not(.screenshots)]) + +// iOS 16+ filters +config.filter = .cinematicVideos +config.filter = .depthEffectPhotos +config.filter = .bursts +``` + +#### UIKit Embedded Picker (iOS 17+) + +```swift +// Configure for embedded use +var config = PHPickerConfiguration() +config.selection = .continuous // Live updates instead of waiting for Add button +config.mode = .compact // Single row layout (optional) +config.selectionLimit = 10 + +// Hide accessories +config.edgesWithoutContentMargins = .all // No margins around picker + +// Disable capabilities +config.disabledCapabilities = [.search, .selectionActions] + +let picker = PHPickerViewController(configuration: config) +picker.delegate = self + +// Add as child view controller (required for embedded) +addChild(picker) +containerView.addSubview(picker.view) +picker.view.frame = containerView.bounds +picker.didMove(toParent: self) +``` + +**Updating picker while displayed (iOS 17+)**: +```swift +// Deselect assets by their identifiers +picker.deselectAssets(withIdentifiers: ["assetID1", "assetID2"]) + +// Reorder assets in selection +picker.moveAsset(withIdentifier: "assetID", afterAssetWithIdentifier: "otherID") +``` + +**Cost**: 20 min implementation, no permissions required + +### Pattern 2b: Options Menu & HDR Support (iOS 17+) + +The picker now shows an Options menu letting users choose to strip location metadata from photos. This works automatically with PhotosPicker and PHPicker. + +**Preserving HDR content**: + +By default, picker may transcode to JPEG, losing HDR data. To receive original format: + +```swift +// SwiftUI - Use .current encoding to preserve HDR +PhotosPicker( + selection: $selectedItems, + matching: .images, + preferredItemEncoding: .current // Don't transcode +) { ... } + +// Loading with original format preservation +struct HDRImage: Transferable { + let data: Data + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(importedContentType: .image) { data in + HDRImage(data: data) + } + } +} + +// Request .image content type (generic) not .jpeg (specific) +let result = try await item.loadTransferable(type: HDRImage.self) +``` + +**UIKit equivalent**: +```swift +var config = PHPickerConfiguration() +config.preferredAssetRepresentationMode = .current // Don't transcode +``` + +**Cinematic mode videos**: Picker returns rendered version with depth effects baked in. To get original with decision points, use PhotoKit with library access instead. + +### Pattern 3: Handling Limited Library Access + +**Use case**: User granted limited access; let them add more photos. + +**Suppressing automatic prompt** (iOS 14+): + +By default, iOS shows "Select More Photos" prompt when `.limited` is detected. To handle it yourself: + +```xml + +PHPhotoLibraryPreventAutomaticLimitedAccessAlert + +``` + +**Manual limited access handling**: + +```swift +import Photos + +class PhotoLibraryManager { + + func checkAndRequestAccess() async -> PHAuthorizationStatus { + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + + switch status { + case .notDetermined: + return await PHPhotoLibrary.requestAuthorization(for: .readWrite) + + case .limited: + // User granted limited access - show UI to expand + await presentLimitedLibraryPicker() + return .limited + + case .authorized: + return .authorized + + case .denied, .restricted: + return status + + @unknown default: + return status + } + } + + @MainActor + func presentLimitedLibraryPicker() { + guard let windowScene = UIApplication.shared.connectedScenes + .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, + let rootVC = windowScene.windows.first?.rootViewController else { + return + } + + PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: rootVC) + } +} +``` + +**Observe limited selection changes**: +```swift +// Register for changes +PHPhotoLibrary.shared().register(self) + +// In delegate +func photoLibraryDidChange(_ changeInstance: PHChange) { + // User may have modified their limited selection + // Refresh your photo grid +} +``` + +**Cost**: 30 min implementation + +### Pattern 4: Saving Photos to Camera Roll + +**Use case**: Save captured or edited photos. + +```swift +import Photos + +func saveImageToLibrary(_ image: UIImage) async throws { + // Request add-only permission (minimal access) + let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly) + + guard status == .authorized || status == .limited else { + throw PhotoError.permissionDenied + } + + try await PHPhotoLibrary.shared().performChanges { + PHAssetCreationRequest.creationRequestForAsset(from: image) + } +} + +// With metadata preservation +func savePhotoData(_ data: Data, metadata: [String: Any]? = nil) async throws { + try await PHPhotoLibrary.shared().performChanges { + let request = PHAssetCreationRequest.forAsset() + + // Write data to temp file for addResource + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("jpg") + try? data.write(to: tempURL) + + request.addResource(with: .photo, fileURL: tempURL, options: nil) + } +} +``` + +**Cost**: 15 min implementation + +### Pattern 5: Loading Images from PhotosPickerItem + +**Use case**: Properly handle async image loading with error handling. + +**The problem**: Default `Image` Transferable only supports PNG. Most photos are JPEG/HEIF. + +```swift +// Custom Transferable for any image format +struct TransferableImage: Transferable { + let image: UIImage + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(importedContentType: .image) { data in + guard let image = UIImage(data: data) else { + throw TransferError.importFailed + } + return TransferableImage(image: image) + } + } + + enum TransferError: Error { + case importFailed + } +} + +// Usage +func loadImage(from item: PhotosPickerItem) async -> UIImage? { + do { + let result = try await item.loadTransferable(type: TransferableImage.self) + return result?.image + } catch { + print("Failed to load image: \(error)") + return nil + } +} +``` + +**Loading with progress**: +```swift +func loadImageWithProgress(from item: PhotosPickerItem) async -> UIImage? { + let progress = Progress() + + return await withCheckedContinuation { continuation in + _ = item.loadTransferable(type: TransferableImage.self) { result in + switch result { + case .success(let transferable): + continuation.resume(returning: transferable?.image) + case .failure: + continuation.resume(returning: nil) + } + } + } +} +``` + +**Cost**: 20 min implementation + +### Pattern 6: Observing Photo Library Changes + +**Use case**: Keep your gallery UI in sync with Photos app. + +```swift +import Photos + +class PhotoGalleryViewModel: NSObject, ObservableObject, PHPhotoLibraryChangeObserver { + @Published var photos: [PHAsset] = [] + + private var fetchResult: PHFetchResult? + + override init() { + super.init() + PHPhotoLibrary.shared().register(self) + fetchPhotos() + } + + deinit { + PHPhotoLibrary.shared().unregisterChangeObserver(self) + } + + func fetchPhotos() { + let options = PHFetchOptions() + options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] + fetchResult = PHAsset.fetchAssets(with: .image, options: options) + + photos = fetchResult?.objects(at: IndexSet(0..<(fetchResult?.count ?? 0))) ?? [] + } + + func photoLibraryDidChange(_ changeInstance: PHChange) { + guard let fetchResult = fetchResult, + let changes = changeInstance.changeDetails(for: fetchResult) else { + return + } + + DispatchQueue.main.async { + self.fetchResult = changes.fetchResultAfterChanges + self.photos = changes.fetchResultAfterChanges.objects(at: + IndexSet(0.. + + + + NSPrivacyTracking + + NSPrivacyCollectedDataTypes + + + + NSPrivacyAccessedAPITypes + + + + + +``` + +### NSPrivacyTracking Declaration + +**Does your app track users?** + +Tracking = combining user/device data from your app with data from other apps/websites to create a profile for targeted advertising or data broker purposes. + +```xml +NSPrivacyTracking + +``` + +**If `true`**, you must also declare tracking domains: + +```xml +NSPrivacyTrackingDomains + + tracking.example.com + analytics.example.com + +``` + +**iOS 17 behavior**: Network requests to tracking domains **automatically blocked** if user hasn't granted ATT permission. + +### NSPrivacyCollectedDataTypes + +Declare all data your app collects: + +```xml +NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeName + + NSPrivacyCollectedDataTypeLinked + + + NSPrivacyCollectedDataTypeTracking + + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + NSPrivacyCollectedDataTypePurposeAnalytics + + + +``` + +**Common data types**: +- `NSPrivacyCollectedDataTypeName` - User's name +- `NSPrivacyCollectedDataTypeEmailAddress` +- `NSPrivacyCollectedDataTypePhoneNumber` +- `NSPrivacyCollectedDataTypePhysicalAddress` +- `NSPrivacyCollectedDataTypePreciseLocation` +- `NSPrivacyCollectedDataTypeCoarseLocation` +- `NSPrivacyCollectedDataTypePhotosorVideos` +- `NSPrivacyCollectedDataTypeContacts` +- `NSPrivacyCollectedDataTypeUserID` + +**Common purposes**: +- `NSPrivacyCollectedDataTypePurposeAppFunctionality` +- `NSPrivacyCollectedDataTypePurposeAnalytics` +- `NSPrivacyCollectedDataTypePurposeProductPersonalization` +- `NSPrivacyCollectedDataTypePurposeDeveloperAdvertising` +- `NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising` + +### NSPrivacyAccessedAPITypes + +Declare Required Reason APIs (see Part 5): + +```xml +NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + +``` + +--- + +## Part 2: Permission Request UX + +### Just-in-Time vs Up-Front + +**❌ Don't**: Request all permissions at launch +```swift +// BAD - overwhelming and confusing +func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + requestCameraPermission() + requestLocationPermission() + requestNotificationPermission() + requestPhotoLibraryPermission() + return true +} +``` + +**✅ Do**: Request just-in-time when user triggers feature +```swift +// GOOD - clear causality +@objc func takePhotoButtonTapped() { + // Show pre-permission education first + showCameraEducation { + // Then request permission + AVCaptureDevice.requestAccess(for: .video) { granted in + if granted { + self.openCamera() + } else { + self.showPermissionDeniedAlert() + } + } + } +} +``` + +### Pre-Permission Education Screens + +Explain **why** you need permission **before** showing system dialog: + +```swift +func showCameraEducation(completion: @escaping () -> Void) { + let alert = UIAlertController( + title: "Take Photos", + message: "FoodSnap needs camera access to let you photograph your meals and get nutrition information.", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Continue", style: .default) { _ in + completion() // Now request actual permission + }) + + alert.addAction(UIAlertAction(title: "Not Now", style: .cancel)) + + present(alert, animated: true) +} +``` + +**Why this works**: +- User understands value proposition +- System dialog rejection rate drops 60-80% +- Better App Store ratings (fewer "why does it need that?" reviews) + +### Permission Denied Handling + +**Never dead-end the user**: + +```swift +func handleCameraPermission() { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + openCamera() + + case .notDetermined: + showCameraEducation { + AVCaptureDevice.requestAccess(for: .video) { granted in + if granted { + self.openCamera() + } else { + self.showSettingsPrompt() + } + } + } + + case .denied, .restricted: + showSettingsPrompt() // Offer to open Settings + + @unknown default: + break + } +} + +func showSettingsPrompt() { + let alert = UIAlertController( + title: "Camera Access Required", + message: "Please enable camera access in Settings to use this feature.", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Open Settings", style: .default) { _ in + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }) + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + present(alert, animated: true) +} +``` + +### Settings Deep Links + +Open specific settings screens: + +```swift +// General app settings +UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + +// Notification settings (iOS 15.4+) +UIApplication.shared.open(URL(string: UIApplication.openNotificationSettingsURLString)!) +``` + +--- + +## Part 3: App Tracking Transparency + +### When ATT Is Required + +You **must** request ATT permission if you: +- Track users across apps/websites owned by other companies +- Share user data with data brokers +- Use third-party SDKs that track (Facebook SDK, Google Analytics, etc.) + +You **don't** need ATT if you **only**: +- Use first-party analytics (no sharing with other companies) +- Personalize ads based only on data from your own app +- Use fraud detection/security measures + +### ATTrackingManager.requestTrackingAuthorization + +```swift +import AppTrackingTransparency +import AdSupport + +func requestTrackingPermission() { + // Check availability (iOS 14.5+) + guard #available(iOS 14.5, *) else { return } + + // Wait until app is active + // Showing alert too early causes auto-denial + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + ATTrackingManager.requestTrackingAuthorization { status in + switch status { + case .authorized: + // User granted permission + // You can now access IDFA and track + let idfa = ASIdentifierManager.shared().advertisingIdentifier + self.initializeTrackingSDKs(idfa: idfa) + + case .denied: + // User denied permission + // Do NOT track + self.initializeNonTrackingSDKs() + + case .notDetermined: + // User closed dialog without choosing + // Treat as denied + self.initializeNonTrackingSDKs() + + case .restricted: + // Device doesn't allow tracking (parental controls) + self.initializeNonTrackingSDKs() + + @unknown default: + self.initializeNonTrackingSDKs() + } + } + } +} +``` + +### Custom ATT Prompt Message + +**Info.plist**: +```xml +NSUserTrackingUsageDescription +This allows us to show you personalized ads and improve your experience +``` + +**Best practices**: +- Be honest and specific +- Explain user benefit (not company benefit) +- Keep it concise (1-2 sentences) + +**❌ Bad examples**: +- "We value your privacy" (vague) +- "This is required for the app to work" (dishonest) +- "To monetize our app" (user doesn't care) + +**✅ Good examples**: +- "This helps us show you relevant ads for products you might like" +- "Personalized ads help keep this app free" + +### Pre-Tracking Prompt Design + +Show your own dialog before ATT system prompt: + +```swift +func showPreTrackingPrompt() { + let alert = UIAlertController( + title: "Support Free Features", + message: "We use tracking to show you personalized ads, which helps keep advanced features free. You can always change this in Settings.", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Continue", style: .default) { _ in + self.requestTrackingPermission() + }) + + alert.addAction(UIAlertAction(title: "Not Now", style: .cancel)) + + present(alert, animated: true) +} +``` + +**Why this works**: Education increases opt-in rates by 20-40%. + +### Graceful Degradation + +**Always provide value without tracking**: + +```swift +func initializeAnalytics() { + let status = ATTrackingManager.trackingAuthorizationStatus + + if status == .authorized { + // Full featured analytics + Analytics.setUserProperty(userID, forName: "user_id") + Analytics.enableCrossAppTracking() + } else { + // Limited, privacy-preserving analytics + Analytics.setUserProperty("anonymous", forName: "user_id") + Analytics.disableCrossAppTracking() + Analytics.enableOnDeviceConversionTracking() + } +} +``` + +--- + +## Part 4: Tracking Domain Management + +### Declaring Tracking Domains + +In `PrivacyInfo.xcprivacy`: + +```xml +NSPrivacyTracking + + +NSPrivacyTrackingDomains + + tracking.example.com + ads.example.com + +``` + +**iOS 17 behavior**: If user denies ATT, network requests to these domains are **automatically blocked**. + +### Domain Separation Strategy + +**Problem**: Single domain used for both tracking and non-tracking + +**Solution**: Separate functionality into different hosts + +``` +Before: +- api.example.com (mixed tracking + app functionality) + +After: +- api.example.com (app functionality only) +- tracking.example.com (tracking only) +``` + +**Update manifest**: +```xml +NSPrivacyTrackingDomains + + tracking.example.com + +``` + +Result: App functionality continues working; tracking blocked if denied. + +### Points of Interest Instrument (Xcode 15+) + +**Detecting unexpected tracking connections**: + +1. Xcode → Product → Profile +2. Choose "Points of Interest" instrument +3. Run app +4. Look for "Privacy" track showing network connections +5. Review flagged domains + +**What it shows**: Connections to domains that may be tracking users across apps/websites. + +**Action**: Declare these domains in `NSPrivacyTrackingDomains` or stop connecting to them. + +--- + +## Part 5: Required Reason APIs + +### What Are Required Reason APIs? + +APIs that **could** be misused for fingerprinting (identifying devices without permission). + +**Fingerprinting is never allowed**, even with ATT permission. + +**Required Reason APIs have approved use cases**. You must declare which approved reason applies to your usage. + +### Common Required Reason APIs + +| API Category | Examples | Approved Reason Codes | +|--------------|----------|----------------------| +| **File timestamp** | `creationDate`, `modificationDate` | `C617.1` - `DDA9.1` | +| **System boot time** | `systemUptime`, `processInfo.systemUptime` | `35F9.1`, `8FFB.1` | +| **Disk space** | `NSFileSystemFreeSize`, `volumeAvailableCapacity` | `E174.1`, `7D9E.1` | +| **Active keyboards** | `activeInputModes` | `54BD.1`, `3EC4.1` | +| **User defaults** | `UserDefaults` | `CA92.1`, `1C8F.1`, `C56D.1` | + +### Example: Disk Space API + +**API**: `NSFileSystemFreeSize` / `URLResourceKey.volumeAvailableCapacityKey` + +**Approved reasons**: +- **E174.1**: Check if there's enough space before writing files +- **7D9E.1**: Display storage information to user +- **B728.1**: Include disk space in optional analytics (only if user opted in) + +**Declaration in manifest**: +```xml +NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + + NSPrivacyAccessedAPITypeReasons + + E174.1 + + + +``` + +**Code**: +```swift +func checkDiskSpace() -> Bool { + do { + let values = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()) + + if let freeSpace = values[.systemFreeSize] as? NSNumber { + let requiredSpace: Int64 = 100 * 1024 * 1024 // 100 MB + return freeSpace.int64Value > requiredSpace + } + } catch { + print("Error checking disk space: \(error)") + } + + return false +} + +// Usage +if checkDiskSpace() { + saveFile() // Approved reason E174.1: Check before writing +} else { + showInsufficientSpaceAlert() +} +``` + +### Example: UserDefaults API + +**Approved reasons**: +- **CA92.1**: Access info stored by app (settings, preferences) +- **1C8F.1**: Access info stored by App Group +- **C56D.1**: Access info stored by App Clips +- **AC6B.1**: Third-party SDK accessing its own defaults + +**Declaration**: +```xml + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + +``` + +### Feedback for Missing Reasons + +If your use case isn't covered, use Apple's feedback form: +https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api + +--- + +## Part 6: Privacy Nutrition Labels + +### Data Types and Categories + +**Identifiers**: +- User ID +- Device ID + +**Contact Info**: +- Name +- Email address +- Phone number +- Physical address + +**Location**: +- Precise location +- Coarse location + +**User Content**: +- Photos or videos +- Audio data +- Gameplay content +- Customer support messages + +**Browsing History** +**Search History** +**Financial Info** +**Health & Fitness** +**Contacts** +**Sensitive Info** (racial/ethnic data, political opinions, religious beliefs) + +### Data Use Purposes + +- **App functionality** - Necessary for core features +- **Analytics** - Understanding app usage +- **Product personalization** - Customizing experience +- **Developer advertising** - Ads for your own products +- **Third-party advertising** - Ads from other companies + +### Linked vs Not Linked + +**Linked to user**: +- Data connected to user identity (name, email, user ID) +- Example: User profile information + +**Not linked to user**: +- Data not connected to identity (anonymous analytics) +- Example: Aggregate crash reports + +### Tracking Disclosure + +Data is used for **tracking** if: +- Combined with data from other apps/websites +- Shared with data brokers +- Used for targeted advertising based on cross-app behavior + +**Example declaration**: +``` +Data Type: Email Address +Purpose: App Functionality +Linked to User: Yes +Used for Tracking: No +``` + +--- + +## Part 7: Xcode Privacy Report + +### Generating Report + +1. Archive app: Product → Archive +2. Xcode Organizer → Select archive +3. Right-click → "Generate Privacy Report" +4. PDF created showing aggregated privacy data + +**What's included**: +- All privacy manifests (app + third-party SDKs) +- Collected data types +- Tracking declaration +- Required Reason APIs + +### Reviewing Report + +**Check for**: +- Unexpected data collection (SDK collecting data you didn't know about) +- Missing Required Reason declarations +- Tracking domain discrepancies +- Third-party SDKs without privacy manifests + +**Use for**: Completing Privacy Nutrition Labels in App Store Connect + +--- + +## Part 8: Permission Types + +### Camera + +```swift +import AVFoundation + +AVCaptureDevice.requestAccess(for: .video) { granted in + // Handle response +} + +// Info.plist +NSCameraUsageDescription +Take photos of your meals to track nutrition +``` + +### Microphone + +```swift +AVAudioSession.sharedInstance().requestRecordPermission { granted in + // Handle response +} + +NSMicrophoneUsageDescription +Record voice memos +``` + +### Location + +```swift +import CoreLocation + +class LocationManager: NSObject, CLLocationManagerDelegate { + let manager = CLLocationManager() + + func requestPermission() { + manager.delegate = self + + // Choose one: + manager.requestWhenInUseAuthorization() // Only when app is open + // OR + manager.requestAlwaysAuthorization() // Background location + } +} + +// Info.plist (iOS 14+) +NSLocationWhenInUseUsageDescription +Show nearby restaurants + +NSLocationAlwaysAndWhenInUseUsageDescription +Track your runs even when the app is in the background +``` + +### Photos + +```swift +import Photos + +PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in + switch status { + case .authorized, .limited: // .limited = selected photos only + // Access granted + case .denied, .restricted: + // Access denied + @unknown default: + break + } +} + +NSPhotoLibraryUsageDescription +Save and share your workout photos +``` + +### Contacts + +```swift +import Contacts + +CNContactStore().requestAccess(for: .contacts) { granted, error in + // Handle response +} + +NSContactsUsageDescription +Invite friends to join you +``` + +### Notifications + +```swift +import UserNotifications + +UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + // Handle response +} + +// No Info.plist entry required +``` + +--- + +## Part 9: Privacy-First Design Patterns + +### Data Minimization + +**Principle**: Only collect data you actually need + +```swift +// ❌ Bad - collecting unnecessary data +struct UserProfile { + let name: String + let email: String + let phone: String // Do you really need this? + let dateOfBirth: Date // Or this? + let socialSecurityNumber: String // Definitely not +} + +// ✅ Good - minimal data collection +struct UserProfile { + let name: String + let email: String + // That's it +} +``` + +### On-Device Processing + +**Principle**: Process data locally when possible + +```swift +// ✅ Good - on-device ML +import Vision + +func analyzePhoto(_ image: UIImage) { + let request = VNClassifyImageRequest { request, error in + // Results stay on device + let classifications = request.results as? [VNClassificationObservation] + self.displayResults(classifications) + } + + let handler = VNImageRequestHandler(cgImage: image.cgImage!) + try? handler.perform([request]) + // No network request, no data leaving device +} +``` + +### Explaining Value Exchange + +**Principle**: Be transparent about why you need data + +```swift +// ✅ Good - clear value proposition +"We use your location to show nearby restaurants and save your favorite places. Your location is never shared with third parties." +``` + +### Transparent Data Practices + +**Principle**: Make privacy information easily accessible + +```swift +// Add Privacy Policy link in Settings screen +struct SettingsView: View { + var body: some View { + List { + Section("About") { + Link("Privacy Policy", destination: URL(string: "https://example.com/privacy")!) + Link("Data We Collect", destination: URL(string: "https://example.com/data")!) + } + } + } +} +``` + +--- + +## Common Mistakes + +### Requesting permissions at launch + +```swift +// ❌ Wrong +func application(_ application: UIApplication, + didFinishLaunchingWithOptions...) -> Bool { + requestAllPermissions() // User has no context + return true +} + +// ✅ Correct +@objc func cameraButtonTapped() { + requestCameraPermission() // Just-in-time +} +``` + +### No explanation before permission dialog + +```swift +// ❌ Wrong +AVCaptureDevice.requestAccess(for: .video) { granted in } + +// ✅ Correct +showCameraEducation { + AVCaptureDevice.requestAccess(for: .video) { granted in } +} +``` + +### Not handling denial gracefully + +```swift +// ❌ Wrong - dead end +if !granted { + return // User stuck +} + +// ✅ Correct - offer alternative +if !granted { + showSettingsPrompt() // Path forward +} +``` + +### Missing tracking domains + +```swift +// ❌ Wrong - privacy manifest declares tracking but no domains +NSPrivacyTracking + + + +// ✅ Correct +NSPrivacyTrackingDomains + + tracking.example.com + +``` + +### Incomplete Required Reason declarations + +```swift +// ❌ Wrong - using UserDefaults without declaring it +UserDefaults.standard.set(value, forKey: "setting") +// Privacy manifest has no NSPrivacyAccessedAPITypes entry + +// ✅ Correct - declared in manifest with approved reason +``` + +--- + +## Timeline + +| Date | Milestone | +|------|-----------| +| **WWDC 2023** | Privacy manifests announced | +| **Fall 2023** | Informational emails begin | +| **Spring 2024** | App Review enforcement begins | +| **May 1, 2024** | Privacy manifests required for apps with privacy-impacting SDKs | + +--- + +## Resources + +**WWDC**: 2023-10060, 2023-10053 + +**Docs**: /bundleresources/privacy_manifest_files, /bundleresources/describing-use-of-required-reason-api, /app-store/app-privacy-details, /app-store/user-privacy-and-data-use + +**Skills**: axiom-app-intents-ref, axiom-cloudkit-ref, axiom-storage diff --git a/.claude/skills/axiom-privacy-ux/agents/openai.yaml b/.claude/skills/axiom-privacy-ux/agents/openai.yaml new file mode 100644 index 0000000..5aa3695 --- /dev/null +++ b/.claude/skills/axiom-privacy-ux/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Privacy UX" + short_description: "Implementing privacy manifests, requesting permissions, App Tracking Transparency UX, or preparing Privacy Nutrition ..." diff --git a/.claude/skills/axiom-profile-performance/.openskills.json b/.claude/skills/axiom-profile-performance/.openskills.json new file mode 100644 index 0000000..146f041 --- /dev/null +++ b/.claude/skills/axiom-profile-performance/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-profile-performance", + "installedAt": "2026-04-12T08:06:32.603Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-profile-performance/SKILL.md b/.claude/skills/axiom-profile-performance/SKILL.md new file mode 100644 index 0000000..19d894b --- /dev/null +++ b/.claude/skills/axiom-profile-performance/SKILL.md @@ -0,0 +1,276 @@ +--- +name: axiom-profile-performance +description: Use when the user wants automated performance profiling, headless Instruments analysis, or CLI-based trace collection. +license: MIT +disable-model-invocation: true +--- + + +> **Note:** This audit may use Bash commands to run builds, tests, or CLI tools. +# Performance Profiler Agent + +You are an expert at automated performance profiling using `xctrace` CLI. + +## Core Principle + +**Measurement before optimization.** Record actual performance data, analyze it programmatically, and provide actionable findings—all without requiring the Instruments GUI. + +## Your Mission + +When the user requests performance profiling: +1. Detect available targets (simulators, devices, running apps) +2. Help user select what to profile (if not specified) +3. Record a trace with appropriate instrument +4. Export and analyze the data +5. Report findings with severity and recommendations + +## Mandatory First Steps + +**ALWAYS run these discovery commands FIRST**: + +```bash +# 1. Check for booted simulators +echo "=== Booted Simulators ===" +xcrun simctl list devices booted -j 2>/dev/null | jq -r '.devices | to_entries[] | .value[] | "\(.name) (\(.udid))"' + +# 2. Find running apps in simulator (if any simulator is booted) +echo "" +echo "=== Running Simulator Apps ===" +BOOTED_UDID=$(xcrun simctl list devices booted -j 2>/dev/null | jq -r '.devices | to_entries[] | .value[0].udid // empty' | head -1) +if [ -n "$BOOTED_UDID" ]; then + xcrun simctl spawn "$BOOTED_UDID" launchctl list 2>/dev/null | grep UIKitApplication | head -10 +else + echo "No booted simulator found" +fi + +# 3. Check if user specified an app (look for common app processes) +echo "" +echo "=== Mac Apps (for reference) ===" +pgrep -lf "\.app" 2>/dev/null | grep -vE "com\.apple|Xcode|Simulator|Google|Chrome|Safari|Finder|Dock" | head -5 +``` + +### Interpreting Results + +**Ready to profile**: +- Booted simulator with running app → Use simulator profiling +- User specifies app name → Use that name + +**Need user input**: +- Multiple booted simulators → Ask which one +- No running app specified → Ask what to profile +- No simulators booted → Ask if they want to boot one or profile a mac app + +## Template Selection + +Choose the right instrument based on user request: + +| User Says | Instrument | Time Limit | +|-----------|------------|------------| +| "CPU", "slow", "performance", "Time Profiler" | `CPU Profiler` | 10s | +| "memory", "allocations", "RAM" | `Allocations` | 30s | +| "leaks", "retain cycle" | `Leaks` | 30s | +| "SwiftUI", "view updates", "body" | `SwiftUI` | 10s | +| "launch", "startup", "cold start" | (special workflow) | n/a | +| "concurrency", "actors", "tasks" | `Swift Tasks` + `Swift Actors` | 10s | +| (unspecified) | `CPU Profiler` | 10s | + +## Recording Workflow + +### Standard Profiling (Attach to Running App) + +```bash +# Create temp directory for traces +TRACE_DIR="/tmp/axiom-traces" +mkdir -p "$TRACE_DIR" + +# Get simulator UUID +SIMULATOR_UDID=$(xcrun simctl list devices booted -j | jq -r '.devices | to_entries[] | .value[0].udid' | head -1) + +# Record trace (replace INSTRUMENT and APP_NAME) +xcrun xctrace record \ + --instrument 'CPU Profiler' \ + --device "$SIMULATOR_UDID" \ + --attach 'APP_NAME' \ + --time-limit 10s \ + --no-prompt \ + --output "$TRACE_DIR/profile.trace" +``` + +### Launch Profiling (App Launch Time) + +```bash +# For app launch profiling, use --launch instead of --attach +# First, find the app bundle +APP_PATH=$(find ~/Library/Developer/CoreSimulator/Devices/*/data/Containers/Bundle/Application -name "*.app" -type d 2>/dev/null | grep -i "AppName" | head -1) + +# Or for Mac app +APP_PATH="/Applications/AppName.app" + +# Record launch +xcrun xctrace record \ + --instrument 'CPU Profiler' \ + --time-limit 30s \ + --no-prompt \ + --output "$TRACE_DIR/launch.trace" \ + --launch -- "$APP_PATH" +``` + +### All-Processes Profiling + +```bash +# For general system profiling (when no specific app) +xcrun xctrace record \ + --instrument 'CPU Profiler' \ + --device "$SIMULATOR_UDID" \ + --all-processes \ + --time-limit 10s \ + --no-prompt \ + --output "$TRACE_DIR/system.trace" +``` + +## Export and Analysis + +### Export Trace Data + +```bash +# First, check what data is available +echo "=== Available Tables ===" +xcrun xctrace export --input "$TRACE_DIR/profile.trace" --toc 2>&1 | grep -E ' "$TRACE_DIR/cpu-profile.xml" 2>&1 +``` + +### Analyze CPU Profile + +Look for in the exported XML: +1. **High cycle counts** - Functions with large `` values +2. **Main thread activity** - Samples on "Main Thread" (affects UI responsiveness) +3. **Hot functions** - Functions appearing frequently in backtraces + +```bash +# Quick analysis: Find processes with most samples +echo "=== Process Sample Counts ===" +grep -o 'process.*fmt="[^"]*"' "$TRACE_DIR/cpu-profile.xml" | sort | uniq -c | sort -rn | head -10 + +# Find most common function frames +echo "" +echo "=== Hot Functions ===" +grep -o 'name="[^"]*"' "$TRACE_DIR/cpu-profile.xml" | sort | uniq -c | sort -rn | head -20 +``` + +## Output Format + +Provide a clear, structured report: + +```markdown +## Performance Profile Results + +### Recording Summary +- **Instrument**: [CPU Profiler/Allocations/Leaks/SwiftUI] +- **Target**: [App name or "All Processes"] +- **Device**: [Simulator name or "Mac"] +- **Duration**: [10s/30s] +- **Trace file**: [path] + +### Key Findings + +#### CRITICAL +- [Issue with highest impact] + +#### HIGH +- [Significant issues] + +#### MEDIUM +- [Notable patterns] + +### Top Hot Functions +| Rank | Function | Samples | % of Total | +|------|----------|---------|------------| +| 1 | function_name | 150 | 15% | +| 2 | ... | ... | ... | + +### Recommendations +1. [Specific actionable recommendation] +2. [Next investigation step] + +### Next Steps +- To investigate further: [specific command or action] +- To open in Instruments GUI: `open [trace path]` +``` + +## Decision Tree + +``` +User requests profiling +↓ +Run mandatory discovery (simulators, running apps) +↓ +├─ User specified app name → Use that name +├─ Multiple options available → Ask user to choose +├─ No targets found → Help user boot simulator or specify app +↓ +Determine instrument from user request +↓ +├─ CPU/slow/performance → CPU Profiler +├─ Memory/allocations → Allocations +├─ Leaks → Leaks +├─ SwiftUI → SwiftUI +├─ Launch → Launch workflow +├─ Unspecified → CPU Profiler (default) +↓ +Record trace (10-30s depending on instrument) +↓ +Export to XML +↓ +Analyze and report findings +``` + +## Error Handling + +### Common Issues + +| Error | Cause | Fix | +|-------|-------|-----| +| "Unable to attach to process" | App not running | Ask user to launch app first | +| "No such device" | Wrong UDID | Re-run device discovery | +| "Document Missing Template Error" | Used --template | Use --instrument instead | +| Empty trace | No activity during recording | Ask user to interact with app during profile | +| Permission denied | Privacy settings | Check System Preferences > Privacy | + +### When to Stop and Report + +If you encounter: +- No simulators or apps to profile → Help user set up +- Recording fails repeatedly → Report error details +- Export produces no data → Note the issue, suggest GUI Instruments +- User needs real-time analysis → Suggest opening trace in Instruments GUI + +## Cleanup + +After analysis is complete: + +```bash +# Offer to clean up traces +echo "Traces saved in: $TRACE_DIR" +echo "To open in Instruments: open '$TRACE_DIR/profile.trace'" +echo "To clean up: rm -rf '$TRACE_DIR'" +``` + +## Tips for Better Profiles + +1. **Warm up first**: Run the slow operation once before profiling to avoid cold-cache effects +2. **Isolate the issue**: Profile just the slow operation, not the entire app +3. **Sufficient duration**: 10s minimum for CPU, 30s for memory/leaks +4. **Active usage**: Interact with the app during profiling to capture real behavior +5. **Multiple runs**: Consider profiling 2-3 times to identify consistent patterns + +## Related + +- `axiom-xctrace-ref` — Full xctrace CLI reference +- `axiom-performance-profiling` — Manual Instruments workflows +- `axiom-memory-debugging` — Memory leak diagnosis +- `axiom-swiftui-performance` — SwiftUI-specific profiling diff --git a/.claude/skills/axiom-profile-performance/agents/openai.yaml b/.claude/skills/axiom-profile-performance/agents/openai.yaml new file mode 100644 index 0000000..5a26338 --- /dev/null +++ b/.claude/skills/axiom-profile-performance/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Profile Performance" + short_description: "The user wants automated performance profiling, headless Instruments analysis, or CLI-based trace collection." diff --git a/.claude/skills/axiom-push-notifications-diag/.openskills.json b/.claude/skills/axiom-push-notifications-diag/.openskills.json new file mode 100644 index 0000000..b259766 --- /dev/null +++ b/.claude/skills/axiom-push-notifications-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-push-notifications-diag", + "installedAt": "2026-04-12T08:06:33.331Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-push-notifications-diag/SKILL.md b/.claude/skills/axiom-push-notifications-diag/SKILL.md new file mode 100644 index 0000000..9b68cd0 --- /dev/null +++ b/.claude/skills/axiom-push-notifications-diag/SKILL.md @@ -0,0 +1,532 @@ +--- +name: axiom-push-notifications-diag +description: Use when push notifications fail to arrive, token registration errors occur, notifications work in development but not production, silent push does not wake app, rich notification media is missing, or Live Activity stops updating via push. Covers APNs errors, environment mismatches, Focus mode filtering, service extension failures, FCM diagnostics. +license: MIT +--- + +# Push Notification Diagnostics + +Systematic troubleshooting for push notification failures: missing notifications, token registration errors, environment mismatches, silent push throttling, and service extension problems. + +## Overview + +**Core Principle**: When push notifications don't work, the problem is usually: +1. **Token/registration failures** (never registered, wrong format, expired) — 30% +2. **Entitlement/provisioning mismatch** (capability missing, wrong environment) — 25% +3. **Payload structure errors** (missing keys, wrong types, invalid JSON) — 15% +4. **Focus/interruption suppression** (iOS 15+ filtering, provisional auth) — 15% +5. **Service extension failures** (timeout, crash, missing mutable-content) — 10% +6. **Delivery timing/throttling** (silent push budget, APNs coalescing) — 5% + +**Always verify entitlements and token registration BEFORE debugging payload or delivery logic.** + +## Red Flags + +Symptoms that indicate push-specific issues: + +| Symptom | Likely Cause | +|---------|--------------| +| No notifications at all | Missing Push Notification capability or provisioning profile | +| Works in dev, not production | Sending to sandbox APNs with production token (or vice versa) | +| Token registration fails on Simulator | Expected — Simulator cannot register for remote notifications | +| Notifications appear without sound | Missing .sound in authorization options or payload | +| Rich notification shows plain text | Missing mutable-content: 1 in payload | +| Image not showing in notification | Service extension failed silently — check serviceExtensionTimeWillExpire | +| Silent push not waking app | System throttling (~2-3/hour), or app was force-quit by user | +| Notifications stopped after iOS update | Focus mode enabled by default in iOS 15+; check interruption level | +| Badge shows wrong number | Multiple notifications sent without explicit badge count reset | +| Actions not appearing | Category identifier mismatch between payload and registered categories | +| Notification appears twice | Both local and remote notification scheduled for same event | +| FCM works on Android, not iOS | Missing APNs auth key upload in Firebase Console | + +## Anti-Rationalization + +| Rationalization | Why It Fails | Time Cost | +|----------------|--------------|-----------| +| "It worked yesterday, so entitlements are fine" | Provisioning profiles get regenerated during signing changes. Always re-verify. | 30-60 min debugging code when the profile lost push capability | +| "The server says their payload is fine" | 55% of push failures are client-side (entitlements + tokens). Verify independently with curl. | 1-2 hours of finger-pointing before someone checks | +| "I'll skip token verification, the error is clearly in the payload" | Wrong-environment tokens are the #1 cause of "works in dev, not production." | 30+ min debugging valid payloads sent to invalid tokens | +| "Focus mode doesn't matter, we use default interruption level" | Default (`active`) is filtered by Focus. Only `time-sensitive` and `critical` break through. | Hours adding code workarounds for a payload-level fix | +| "Silent push is reliable, we use it for sync" | System throttles to ~2-3/hour and ignores force-quit apps. It's a hint, not a guarantee. | Architecture rework when silent push can't sustain real-time sync | +| "Service extension is set up, so rich notifications should work" | Extension needs correct bundle ID suffix, mutable-content in payload, AND completing within 30s. | 30+ min when any one of the three prerequisites is missing | +| "FCM handles everything, I don't need to understand APNs" | FCM wraps APNs. Token type confusion, missing p8 key upload, and swizzling conflicts are all APNs-level problems. | Hours debugging FCM when the issue is APNs configuration | +| "I'll test on Simulator first" | Simulator cannot register for remote notifications. No APNs token = no real push testing. | Wasted test cycle discovering Simulator limitations | +| "Let me rewrite the notification handler" | 80% of push failures are configuration (entitlements, tokens, environment), not code. | Hours rewriting working code while the config stays broken | +| "This worked on iOS 17, the bug must be in our code" | Each iOS version changes Focus defaults, interruption filtering, and provisional behavior. | Debugging code when the fix is a payload or Settings change | + +## Mandatory First Steps + +Before investigating code, run these diagnostics: + +### Step 1: Verify Push Notification Entitlements + +```bash +security cms -D -i path/to/embedded.mobileprovision | grep -A1 "aps-environment" +``` + +**Expected output**: +- ✅ `development` or `production` → Entitlement present +- ❌ No aps-environment key → Push Notifications capability not enabled in Xcode + +**How to find the provisioning profile**: +```bash +# For installed app on device +find ~/Library/Developer/Xcode/DerivedData -name "embedded.mobileprovision" -newer . 2>/dev/null | head -3 +``` + +### Step 2: Check Token Registration + +```swift +func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + print("✅ APNs token: \(token)") + print("✅ Token length: \(token.count) chars") +} + +func application(_ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error) { + print("❌ Registration failed: \(error.localizedDescription)") +} +``` + +**Expected output**: +- ✅ 64-character hex token → Registration successful +- ❌ "no valid aps-environment entitlement" → Capability misconfigured +- ❌ No callback fires at all → `registerForRemoteNotifications()` never called + +**Critical**: Both callbacks must be in `AppDelegate`, not `SceneDelegate`. SwiftUI apps need `@UIApplicationDelegateAdaptor`. + +### Step 3: Validate Payload with curl + +```bash +curl -v \ + --header "apns-topic: com.your.bundle.id" \ + --header "apns-push-type: alert" \ + --header "authorization: bearer $JWT_TOKEN" \ + --data '{"aps":{"alert":{"title":"Test","body":"Hello"}}}' \ + --http2 https://api.sandbox.push.apple.com/3/device/$DEVICE_TOKEN +``` + +**Expected output**: +- ✅ HTTP/2 200 → Payload accepted by APNs +- ❌ 400 BadDeviceToken → Token format wrong or expired +- ❌ 403 ExpiredProviderToken → JWT older than 1 hour +- ❌ 403 InvalidProviderToken → Wrong key ID, team ID, or key +- ❌ 410 Unregistered → App uninstalled or token invalidated +- ❌ 413 PayloadTooLarge → Exceeds 4096 bytes + +### Step 4: Check Authorization Status + +```swift +let settings = await UNUserNotificationCenter.current().notificationSettings() +print("Authorization: \(settings.authorizationStatus.rawValue)") +print("Alert: \(settings.alertSetting.rawValue)") +print("Sound: \(settings.soundSetting.rawValue)") +print("Badge: \(settings.badgeSetting.rawValue)") +``` + +**Expected output**: +- ✅ authorizationStatus = 2 → Authorized +- ⚠️ authorizationStatus = 3 → Provisional (appears silently in Notification Center) +- ❌ authorizationStatus = 1 → Denied by user +- ❌ authorizationStatus = 0 → Not determined (never requested) + +## Decision Trees + +### Tree 1: Not Receiving Any Notifications + +``` +Not receiving any notifications? +│ +├─ Check Step 1 (entitlements) +│ ├─ No aps-environment key? +│ │ └─ Enable Push Notifications in Signing & Capabilities → DONE +│ └─ aps-environment present → continue +│ +├─ Check Step 2 (token registration) +│ ├─ didFailToRegister called? +│ │ ├─ "no valid aps-environment" → Regenerate provisioning profile +│ │ └─ Other error → Check network, device (not Simulator) +│ ├─ Neither callback fires? +│ │ └─ Verify registerForRemoteNotifications() called after app launch +│ └─ Token received → continue +│ +├─ Check Step 3 (payload delivery) +│ ├─ HTTP 200 but no notification? +│ │ └─ Check Step 4 (authorization status) +│ ├─ 400 BadDeviceToken? +│ │ └─ Token expired or wrong environment → Re-register +│ └─ 403/410 error? +│ └─ Fix auth credentials or re-register device +│ +└─ Check Step 4 (user authorization) + ├─ Status: denied? + │ └─ User must enable in Settings → Show settings prompt + ├─ Status: notDetermined? + │ └─ Call requestAuthorization() → Was never requested + └─ Status: authorized but still no notifications? + └─ Check Focus mode, Do Not Disturb, notification grouping +``` + +### Tree 2: Works in Dev, Not Production + +``` +Works in development, fails in production? +│ +├─ APNs endpoint correct? +│ ├─ Dev: api.sandbox.push.apple.com +│ └─ Prod: api.push.apple.com +│ └─ Using sandbox endpoint with production build? → Switch endpoint +│ +├─ Token environment matches? +│ ├─ Dev and production tokens are DIFFERENT +│ │ └─ Server storing dev token, sending to prod APNs? → Re-register on prod build +│ └─ Server distinguishes token environments? → Add environment flag to token storage +│ +├─ Auth method correct? +│ ├─ .p8 key (token-based)? +│ │ └─ Same key works for both environments ✅ +│ └─ .p12 certificate? +│ ├─ Dev cert → Only works with sandbox +│ └─ Prod cert → Only works with production +│ └─ Wrong cert for environment? → Generate correct certificate +│ +└─ Using FCM? + ├─ APNs auth key (.p8) uploaded to Firebase Console? + │ └─ Missing? → Upload in Project Settings > Cloud Messaging + └─ Key uploaded but wrong Team ID? + └─ Verify Team ID matches Apple Developer account +``` + +### Tree 3: Silent Notifications Not Waking App + +``` +Silent push not waking app? +│ +├─ Payload correct? +│ ├─ Has "content-available": 1 in aps? +│ │ └─ Missing? → Add to aps dictionary +│ ├─ Has NO "alert", "badge", or "sound" in aps? +│ │ └─ Has alert? → Not a silent push; system treats as visible notification +│ └─ Payload valid → continue +│ +├─ Headers correct? +│ ├─ apns-push-type: background? +│ │ └─ Missing or wrong? → Must be "background" for silent push +│ └─ apns-priority: 5? +│ └─ Using 10? → Silent push MUST use priority 5 +│ +├─ Background mode enabled? +│ ├─ "Remote notifications" checked in Background Modes capability? +│ │ └─ Missing? → Enable in Signing & Capabilities +│ └─ application(_:didReceiveRemoteNotification:fetchCompletionHandler:) implemented? +│ └─ Missing? → Implement the delegate method +│ +├─ App state? +│ ├─ Force-quit by user (swiped up)? +│ │ └─ System will NOT wake force-quit apps for silent push +│ └─ Suspended or background? +│ └─ Should wake — continue debugging +│ +└─ System throttling? + ├─ Budget: ~2-3 silent pushes per hour + │ └─ Exceeding? → Reduce frequency, batch updates + └─ Device in Low Power Mode? + └─ Further reduces background execution budget +``` + +### Tree 4: Rich Notification Missing Media + +``` +Rich notification not showing image/video? +│ +├─ Payload has mutable-content: 1? +│ └─ Missing? → Required for Notification Service Extension to fire +│ +├─ Notification Service Extension target exists? +│ ├─ Missing? → File > New > Target > Notification Service Extension +│ └─ Exists → continue +│ +├─ Extension bundle ID correct? +│ ├─ Must be: {app-bundle-id}.{extension-name} +│ │ Example: com.myapp.NotificationService +│ └─ Wrong prefix? → Fix bundle ID to match parent app +│ +├─ Download completing in time? +│ ├─ Extension has ~30 seconds to modify notification +│ │ └─ Large file? → Use thumbnail URL, not full resolution +│ └─ serviceExtensionTimeWillExpire called? +│ └─ Must deliver bestAttemptContent with fallback text +│ +├─ Attachment created correctly? +│ ├─ File written to disk before creating UNNotificationAttachment? +│ │ └─ Must write to tmp directory, then create attachment from file URL +│ └─ File type supported? +│ ├─ Images: JPEG, GIF, PNG (max 10MB) +│ ├─ Audio: AIFF, WAV, MP3, M4A (max 5MB) +│ └─ Video: MPEG, MPEG-2, MP4, AVI (max 50MB) +│ +└─ App groups configured? + └─ Extension and app share data via App Groups? + └─ Missing? → Add same App Group to both targets +``` + +### Tree 5: Live Activity Not Updating via Push + +``` +Live Activity not updating from push? +│ +├─ APNs topic correct? +│ ├─ Must be: {bundleID}.push-type.liveactivity +│ │ └─ Using plain bundle ID? → Append .push-type.liveactivity +│ └─ Topic correct → continue +│ +├─ Push type header correct? +│ ├─ apns-push-type: liveactivity? +│ │ └─ Using "alert"? → Must be "liveactivity" +│ └─ Correct → continue +│ +├─ Content-state matches ActivityAttributes.ContentState? +│ ├─ JSON keys match Swift property names exactly? +│ │ └─ Mismatch? → Decoding fails silently +│ └─ Using custom CodingKeys or JSONEncoder strategies? +│ └─ Custom strategies NOT supported — use default key encoding +│ +├─ Push token being sent to server? +│ ├─ Observing pushTokenUpdates on the Activity? +│ │ └─ Missing? → Must iterate Activity.pushTokenUpdates async sequence +│ └─ Token changes when Activity restarts? +│ └─ Must handle token rotation — send updated token to server +│ +└─ Rate limiting? + ├─ Frequent updates: ~10-12 per hour per Activity + │ └─ Exceeding? → Batch updates, reduce frequency + └─ Alert updates (sound/vibration): ~3-4 per hour + └─ Exceeding? → Reserve alerts for critical state changes +``` + +### Tree 6: Notifications Stopped After iOS Update + +``` +Notifications stopped working after iOS update? +│ +├─ Focus mode auto-enabled? (iOS 15+) +│ ├─ Check Settings > Focus +│ │ └─ Focus active? → App may not be in allowed list +│ └─ No Focus active → continue +│ +├─ Interruption level filtering? +│ ├─ Default level is .active (may be filtered by Focus) +│ │ └─ Need to break through Focus? → Use .timeSensitive or .critical +│ ├─ .timeSensitive requires capability +│ │ └─ Missing? → Add Time Sensitive Notifications capability +│ └─ .critical requires Apple entitlement +│ └─ Only for health/safety/security apps — apply via Apple Developer +│ +├─ Provisional authorization behavior changed? +│ ├─ iOS 15+ provisional notifications appear in Notification Summary +│ │ └─ User may not see them → Request full authorization +│ └─ Was relying on provisional? → Prompt for explicit permission +│ +└─ Communication notifications require INSendMessageIntent? + ├─ iOS 15+ communication notifications need SiriKit intent + │ └─ Missing? → Donate INSendMessageIntent before showing notification + └─ Intent donated but still filtered? + └─ Check that sender is in user's contacts +``` + +## Push Notification Console Workflow + +Apple's Push Notification Console provides server-free testing: + +#### Navigate to Console + +1. Open https://icloud.developer.apple.com/dashboard +2. Select "Push Notifications" from sidebar +3. Choose your app's bundle ID + +#### Send Test Notification + +1. Enter device token (from Step 2 diagnostic) +2. Select environment (Sandbox/Production) +3. Compose payload or use template +4. Send and observe delivery status + +#### Check Delivery Logs + +1. Copy `apns-id` from the response header of your push request +2. Use Push Notification Console to look up delivery status by `apns-id` +3. Status shows: accepted, delivered, dropped (with reason) + +#### Common Console Findings + +| Status | Meaning | Action | +|--------|---------|--------| +| Delivered | APNs delivered to device | Problem is on-device (auth, Focus, extension) | +| Dropped: Unregistered | Token invalid | Re-register device | +| Dropped: DeviceTokenNotForTopic | Bundle ID mismatch | Fix apns-topic header | +| Stored | Device offline, will deliver later | Wait or check device connectivity | + +## Simulator Testing with simctl + +Simulators cannot register for remote notifications, but you can test notification handling: + +```bash +cat > test-push.apns << 'EOF' +{ + "Simulator Target Bundle": "com.your.bundle.id", + "aps": { + "alert": { + "title": "Test", + "body": "Hello from simctl" + }, + "sound": "default", + "mutable-content": 1 + } +} +EOF + +xcrun simctl push booted com.your.bundle.id test-push.apns +``` + +#### Simulator Limitations + +- ✅ Notification appearance and content +- ✅ Notification Service Extension processing +- ✅ Notification Content Extension (custom UI) +- ✅ Action handling and categories +- ❌ APNs token registration (always fails) +- ❌ Silent push waking app accurately +- ❌ Live Activity push updates + +#### Drag-and-Drop Alternative + +Drag a `.apns` file directly onto the Simulator window to deliver it. Requires `"Simulator Target Bundle"` key in the payload. + +## Common FCM Diagnostics + +### Swizzling Conflict + +**Symptom**: Token callback not firing with Firebase + +**Cause**: Method swizzling disabled but manual forwarding not implemented + +**Diagnostic**: +```swift +// Check Info.plist +// FirebaseAppDelegateProxyEnabled = NO means YOU must forward tokens +``` + +**Fix** (if swizzling disabled): +```swift +func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Messaging.messaging().apnsToken = deviceToken +} +``` + +### Token Mismatch + +**Symptom**: Server has FCM token, but APNs delivery fails + +**Cause**: FCM token and APNs token are different. FCM wraps APNs token. + +**Diagnostic**: +```swift +// FCM token (send this to YOUR server) +Messaging.messaging().token { token, error in + print("FCM token: \(token ?? "nil")") +} + +// APNs token (FCM handles this internally) +// Do NOT send raw APNs token to your server when using FCM +``` + +### Missing APNs Key in Firebase Console + +**Symptom**: FCM works on Android, notifications not arriving on iOS + +**Fix**: +1. Firebase Console → Project Settings → Cloud Messaging +2. Upload APNs Authentication Key (.p8) +3. Enter Key ID and Team ID +4. Verify bundle ID matches your app + +## Quick Reference Table + +| Symptom | Check | Fix | +|---------|-------|-----| +| No notifications at all | Step 1: entitlements | Enable Push Notification capability, regenerate profile | +| Token registration fails | Step 2: callbacks | Implement both delegate methods in AppDelegate | +| 400 BadDeviceToken | Token format | Re-register; check hex encoding (no spaces, no angle brackets) | +| 403 InvalidProviderToken | JWT/certificate | Regenerate JWT; verify key ID, team ID, bundle ID | +| 410 Unregistered | Device state | App uninstalled or token rotated — remove from server | +| Works dev not prod | Step 3: curl both | Switch APNs endpoint; tokens differ per environment | +| Silent push ignored | Payload + headers | content-available: 1, push-type: background, priority: 5 | +| Rich media missing | Extension | Add mutable-content: 1, check extension bundle ID and timeout | +| Live Activity stale | Topic format | Use {bundleID}.push-type.liveactivity topic | +| Focus mode filtering | Interruption level | Use .timeSensitive for important notifications | +| FCM iOS failure | Firebase Console | Upload .p8 key with correct Key ID and Team ID | +| Actions not showing | Category ID | Match category identifier in payload to registered categories | + +## Pressure Scenarios + +### Scenario 1: "Server team says the problem is on the iOS side" + +**Context**: Push notifications stopped working. The backend team says their payload is fine and the problem must be in the app. + +**Pressure**: Skip client-side diagnostics and assume the server is right. Start rewriting notification handling code. + +**Reality**: 55% of push failures are entitlement/token issues (Steps 1-2), not code bugs. The server may be sending to the wrong environment or using an expired token. + +**Correct action**: Run all 4 mandatory diagnostic steps before touching code. Share the curl test (Step 3) results with the server team — this objectively proves which side has the issue. + +**Push-back template**: "Let me verify the client-side chain first — I can share the curl results in 5 minutes so we both know exactly where the failure is." + +### Scenario 2: "Notifications stopped after iOS update, ship a fix today" + +**Context**: Users report notifications stopped working after updating to a new iOS version. Management wants a hotfix today. + +**Pressure**: Start debugging notification code immediately. Assume Apple broke something. + +**Reality**: New iOS versions often enable Focus mode by default or change interruption level filtering. 15% of push failures are Focus/interruption suppression — no code change needed on your side. + +**Correct action**: Check Tree 6 ("Notifications stopped after iOS update"). Verify Focus mode settings on test devices before changing any code. If Focus is filtering, the fix is setting the correct `interruption-level` in the payload, not rewriting notification handling. + +**Push-back template**: "iOS updates often change Focus mode defaults. Let me check interruption levels first — if that's the cause, the fix is a one-line payload change, not a code rewrite." + +### Scenario 3: "Silent push worked last week, nothing changed" + +**Context**: Background content sync via silent push stopped working. "We didn't change anything." + +**Pressure**: Deep-dive into background execution code. Assume a regression. + +**Reality**: Silent push has a system-enforced throttle budget (~2-3/hour). If usage increased, or if users force-quit the app, silent push stops working regardless of code quality. Also, the provisioning profile may have been regenerated without the push entitlement. + +**Correct action**: Follow Tree 3 ("Silent notifications not waking app"). Check throttle budget, force-quit state, and entitlements before debugging code. + +**Push-back template**: "Silent push has a system throttle budget. Let me verify we haven't exceeded it and that the app hasn't been force-quit on test devices — those are the two most common causes." + +## Checklist + +Before escalating push notification issues: + +- [ ] Push Notification capability enabled in Xcode (Step 1) +- [ ] Provisioning profile contains aps-environment (Step 1) +- [ ] Token registration callback fires with 64-char hex token (Step 2) +- [ ] curl to APNs returns HTTP/2 200 (Step 3) +- [ ] User authorized notifications, status = 2 (Step 4) +- [ ] APNs environment matches build type (sandbox/production) +- [ ] Focus mode not filtering notifications on test device +- [ ] Tested on physical device (not Simulator for token registration) +- [ ] For FCM: APNs auth key uploaded to Firebase Console +- [ ] For silent push: background mode enabled, priority 5, no alert keys + +## Resources + +**WWDC**: 2021-10091, 2023-10025, 2023-10185 + +**Docs**: /usernotifications, /usernotifications/testing-notifications-using-the-push-notification-console + +**Skills**: axiom-push-notifications, axiom-push-notifications-ref, axiom-extensions-widgets diff --git a/.claude/skills/axiom-push-notifications-diag/agents/openai.yaml b/.claude/skills/axiom-push-notifications-diag/agents/openai.yaml new file mode 100644 index 0000000..8420863 --- /dev/null +++ b/.claude/skills/axiom-push-notifications-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Push Notifications Diagnostics" + short_description: "Push notifications fail to arrive, token registration errors occur, notifications work in development but not product..." diff --git a/.claude/skills/axiom-push-notifications-ref/.openskills.json b/.claude/skills/axiom-push-notifications-ref/.openskills.json new file mode 100644 index 0000000..0863571 --- /dev/null +++ b/.claude/skills/axiom-push-notifications-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-push-notifications-ref", + "installedAt": "2026-04-12T08:06:33.708Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-push-notifications-ref/SKILL.md b/.claude/skills/axiom-push-notifications-ref/SKILL.md new file mode 100644 index 0000000..bafb210 --- /dev/null +++ b/.claude/skills/axiom-push-notifications-ref/SKILL.md @@ -0,0 +1,837 @@ +--- +name: axiom-push-notifications-ref +description: Use when needing APNs HTTP/2 transport details, JWT authentication setup, payload key reference, UNUserNotificationCenter API, notification category/action registration, service extension lifecycle, local notification triggers, Live Activity push headers, or broadcast push format. Covers complete push notification API surface. +license: MIT +--- + +# Push Notifications API Reference + +Comprehensive API reference for APNs HTTP/2 transport, UserNotifications framework, and push-driven features including Live Activities and broadcast push. + +## Quick Reference + +```swift +// AppDelegate — minimal remote notification setup +class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + UNUserNotificationCenter.current().delegate = self + return true + } + + func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + sendTokenToServer(token) + } + + func application(_ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error) { + print("Registration failed: \(error)") + } + + // Show notifications when app is in foreground + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + return [.banner, .sound, .badge] + } + + // Handle notification tap / action response + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse) async { + let userInfo = response.notification.request.content.userInfo + // Route to appropriate screen based on userInfo + } +} +``` + +--- + +## APNs Transport Reference + +### Endpoints + +| Environment | Host | Port | +|-------------|------|------| +| Development | api.sandbox.push.apple.com | 443 or 2197 | +| Production | api.push.apple.com | 443 or 2197 | + +### Request Format + +``` +POST /3/device/{device_token} +Host: api.push.apple.com +Authorization: bearer {jwt_token} +apns-topic: {bundle_id} +apns-push-type: alert +Content-Type: application/json +``` + +### APNs Headers + +| Header | Required | Values | Notes | +|--------|----------|--------|-------| +| apns-push-type | Yes | alert, background, liveactivity, voip, complication, fileprovider, mdm, location | Must match payload content | +| apns-topic | Yes | Bundle ID (or .push-type.liveactivity suffix) | Required for token-based auth | +| apns-priority | No | 10 (immediate), 5 (power-conscious), 1 (low) | Default: 10 for alert, 5 for background | +| apns-expiration | No | UNIX timestamp or 0 | 0 = deliver once, don't store | +| apns-collapse-id | No | String ≤64 bytes | Replaces matching notification on device | +| apns-id | No | UUID (lowercase) | Returned by APNs for tracking | +| authorization | Token auth | bearer {JWT} | Not needed for certificate auth | +| apns-unique-id | Response only | UUID | Use with Push Notifications Console delivery log | + +### Response Codes + +| Status | Meaning | Common Cause | +|--------|---------|--------------| +| 200 | Success | | +| 400 | Bad request | Malformed JSON, missing required header | +| 403 | Forbidden | Expired JWT, wrong team/key, topic mismatch | +| 404 | Not found | Invalid device token path | +| 405 | Method not allowed | Not using POST | +| 410 | Unregistered | Device token no longer active (app uninstalled) | +| 413 | Payload too large | Exceeds 4KB (5KB for VoIP) | +| 429 | Too many requests | Rate limited by APNs | +| 500 | Internal server error | APNs issue, retry | +| 503 | Service unavailable | APNs overloaded, retry with backoff | + +--- + +## JWT Authentication Reference + +### JWT Header + +```json +{ "alg": "ES256", "kid": "{10-char Key ID}" } +``` + +### JWT Claims + +```json +{ "iss": "{10-char Team ID}", "iat": {unix_timestamp} } +``` + +### Rules + +| Rule | Detail | +|------|--------| +| Algorithm | ES256 (P-256 curve) | +| Signing key | APNs auth key (.p8 from developer portal) | +| Token lifetime | Max 1 hour (403 ExpiredProviderToken if older) | +| Refresh interval | Between 20 and 60 minutes | +| Scope | One key works for all apps in team, both environments | + +### Authorization Header Format + +``` +authorization: bearer eyAia2lkIjog... +``` + +--- + +## Payload Reference + +### `aps` Dictionary Keys + +| Key | Type | Purpose | Since | +|-----|------|---------|-------| +| alert | Dict/String | Alert content | iOS 10 | +| badge | Number | App icon badge (0 removes) | iOS 10 | +| sound | String/Dict | Audio playback | iOS 10 | +| thread-id | String | Notification grouping | iOS 10 | +| category | String | Actionable notification type | iOS 10 | +| content-available | Number (1) | Silent background push | iOS 10 | +| mutable-content | Number (1) | Triggers service extension | iOS 10 | +| target-content-id | String | Window/content identifier | iOS 13 | +| interruption-level | String | passive/active/time-sensitive/critical | iOS 15 | +| relevance-score | Number 0-1 | Notification summary sorting | iOS 15 | +| filter-criteria | String | Focus filter matching | iOS 15 | +| stale-date | Number | UNIX timestamp (Live Activity) | iOS 16.1 | +| content-state | Dict | Live Activity content update | iOS 16.1 | +| timestamp | Number | UNIX timestamp (Live Activity) | iOS 16.1 | +| event | String | start/update/end (Live Activity) | iOS 16.1 | +| dismissal-date | Number | UNIX timestamp (Live Activity) | iOS 16.1 | +| attributes-type | String | Live Activity struct name | iOS 17 | +| attributes | Dict | Live Activity init data | iOS 17 | + +### Alert Dictionary Keys + +| Key | Type | Purpose | +|-----|------|---------| +| title | String | Short title | +| subtitle | String | Secondary description | +| body | String | Full message | +| launch-image | String | Launch screen filename | +| title-loc-key | String | Localization key for title | +| title-loc-args | [String] | Title format arguments | +| subtitle-loc-key | String | Localization key for subtitle | +| subtitle-loc-args | [String] | Subtitle format arguments | +| loc-key | String | Localization key for body | +| loc-args | [String] | Body format arguments | + +### Sound Dictionary (Critical Alerts) + +```json +{ "critical": 1, "name": "alarm.aiff", "volume": 0.8 } +``` + +### Interruption Level Values + +| Value | Behavior | Requires | +|-------|----------|----------| +| passive | No sound/wake. Notification summary only. | Nothing | +| active | Default. Sound + banner. | Nothing | +| time-sensitive | Breaks scheduled delivery. Banner persists. | Time Sensitive capability | +| critical | Overrides DND and ringer switch. | Apple approval + entitlement | + +### Example Payloads + +#### Basic Alert + +```json +{ + "aps": { + "alert": { + "title": "New Message", + "subtitle": "From Alice", + "body": "Hey, are you free for lunch?" + }, + "badge": 3, + "sound": "default" + } +} +``` + +#### Localized with loc-key/loc-args + +```json +{ + "aps": { + "alert": { + "title-loc-key": "MESSAGE_TITLE", + "title-loc-args": ["Alice"], + "loc-key": "MESSAGE_BODY", + "loc-args": ["Alice", "lunch"] + }, + "sound": "default" + } +} +``` + +#### Silent Background Push + +```json +{ + "aps": { + "content-available": 1 + }, + "custom-key": "sync-update" +} +``` + +#### Rich Notification (Service Extension) + +```json +{ + "aps": { + "alert": { + "title": "Photo shared", + "body": "Alice shared a photo with you" + }, + "mutable-content": 1, + "sound": "default" + }, + "image-url": "https://example.com/photo.jpg" +} +``` + +#### Critical Alert + +```json +{ + "aps": { + "alert": { + "title": "Server Down", + "body": "Production database is unreachable" + }, + "sound": { "critical": 1, "name": "default", "volume": 1.0 }, + "interruption-level": "critical" + } +} +``` + +#### Time-Sensitive with Category + +```json +{ + "aps": { + "alert": { + "title": "Package Delivered", + "body": "Your order has been delivered to the front door" + }, + "interruption-level": "time-sensitive", + "category": "DELIVERY", + "sound": "default" + }, + "order-id": "12345" +} +``` + +--- + +## UNUserNotificationCenter API Reference + +### Key Methods + +| Method | Purpose | +|--------|---------| +| requestAuthorization(options:) | Request permission | +| notificationSettings() | Check current status | +| add(_:) | Schedule notification request | +| getPendingNotificationRequests() | List scheduled | +| removePendingNotificationRequests(withIdentifiers:) | Cancel scheduled | +| getDeliveredNotifications() | List in notification center | +| removeDeliveredNotifications(withIdentifiers:) | Remove from center | +| setNotificationCategories(_:) | Register actionable types | +| setBadgeCount(_:) | Update badge (iOS 16+) | +| supportsContentExtensions | Check content extension support | + +### UNAuthorizationOptions + +| Option | Purpose | +|--------|---------| +| .alert | Display alerts | +| .badge | Update badge count | +| .sound | Play sounds | +| .carPlay | Show in CarPlay | +| .criticalAlert | Critical alerts (requires entitlement) | +| .provisional | Trial delivery without prompting | +| .providesAppNotificationSettings | "Configure in App" button in Settings | +| .announcement | Siri announcement (deprecated iOS 15+) | + +### UNAuthorizationStatus + +| Value | Meaning | +|-------|---------| +| .notDetermined | No prompt shown yet | +| .denied | User denied or disabled in Settings | +| .authorized | User explicitly granted | +| .provisional | Provisional trial delivery | +| .ephemeral | App Clip temporary | + +### Request Authorization + +```swift +let center = UNUserNotificationCenter.current() + +let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) +if granted { + await MainActor.run { + UIApplication.shared.registerForRemoteNotifications() + } +} +``` + +### Check Settings + +```swift +let settings = await center.notificationSettings() + +switch settings.authorizationStatus { +case .authorized: break +case .denied: + // Direct user to Settings +case .provisional: + // Upgrade to full authorization +case .notDetermined: + // Request authorization +case .ephemeral: + // App Clip — temporary +@unknown default: break +} +``` + +### Delegate Methods + +```swift +// Foreground presentation — called when notification arrives while app is active +func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification) async + -> UNNotificationPresentationOptions { + return [.banner, .sound, .badge] +} + +// Action response — called when user taps notification or action button +func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse) async { + let actionIdentifier = response.actionIdentifier + let userInfo = response.notification.request.content.userInfo + + switch actionIdentifier { + case UNNotificationDefaultActionIdentifier: + // User tapped notification body + break + case UNNotificationDismissActionIdentifier: + // User dismissed (requires .customDismissAction on category) + break + default: + // Custom action + break + } +} + +// Settings — called when user taps "Configure in App" from notification settings +func userNotificationCenter(_ center: UNUserNotificationCenter, + openSettingsFor notification: UNNotification?) { + // Navigate to in-app notification settings +} +``` + +--- + +## UNNotificationCategory and UNNotificationAction API + +### Category Registration + +```swift +let likeAction = UNNotificationAction( + identifier: "LIKE", + title: "Like", + options: [] +) + +let replyAction = UNTextInputNotificationAction( + identifier: "REPLY", + title: "Reply", + options: [], + textInputButtonTitle: "Send", + textInputPlaceholder: "Type a message..." +) + +let deleteAction = UNNotificationAction( + identifier: "DELETE", + title: "Delete", + options: [.destructive, .authenticationRequired] +) + +let messageCategory = UNNotificationCategory( + identifier: "MESSAGE", + actions: [likeAction, replyAction, deleteAction], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: "New message", + categorySummaryFormat: "%u more messages", + options: [.customDismissAction] +) + +UNUserNotificationCenter.current().setNotificationCategories([messageCategory]) +``` + +### Action Options + +| Option | Effect | +|--------|--------| +| .authenticationRequired | Requires device unlock | +| .destructive | Red text display | +| .foreground | Launches app to foreground | + +### Category Options + +| Option | Effect | +|--------|--------| +| .customDismissAction | Fires delegate on dismiss | +| .allowInCarPlay | Show actions in CarPlay | +| .hiddenPreviewsShowTitle | Show title when previews hidden | +| .hiddenPreviewsShowSubtitle | Show subtitle when previews hidden | +| .allowAnnouncement | Siri can announce (deprecated iOS 15+) | + +### UNNotificationActionIcon (iOS 15+) + +```swift +let icon = UNNotificationActionIcon(systemImageName: "hand.thumbsup") +let action = UNNotificationAction( + identifier: "LIKE", + title: "Like", + options: [], + icon: icon +) +``` + +--- + +## UNNotificationServiceExtension API + +Modifies notification content before display. Runs in a separate extension process. + +### Lifecycle + +| Method | Window | Purpose | +|--------|--------|---------| +| didReceive(_:withContentHandler:) | ~30 seconds | Modify notification content | +| serviceExtensionTimeWillExpire() | Called at deadline | Deliver best attempt immediately | + +### Implementation + +```swift +class NotificationService: UNNotificationServiceExtension { + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + + guard let content = bestAttemptContent, + let imageURLString = content.userInfo["image-url"] as? String, + let imageURL = URL(string: imageURLString) else { + contentHandler(request.content) + return + } + + // Download and attach image + let task = URLSession.shared.downloadTask(with: imageURL) { url, _, error in + defer { contentHandler(content) } + guard let url = url, error == nil else { return } + + let attachment = try? UNNotificationAttachment( + identifier: "image", + url: url, + options: [UNNotificationAttachmentOptionsTypeHintKey: "public.jpeg"] + ) + if let attachment = attachment { + content.attachments = [attachment] + } + } + task.resume() + } + + override func serviceExtensionTimeWillExpire() { + if let content = bestAttemptContent { + contentHandler?(content) + } + } +} +``` + +### Supported Attachment Types + +| Type | Extensions | Max Size | +|------|-----------|----------| +| Image | .jpg, .gif, .png | 10 MB | +| Audio | .aif, .wav, .mp3 | 5 MB | +| Video | .mp4, .mpeg | 50 MB | + +### Payload Requirement + +The notification payload must include `"mutable-content": 1` in the `aps` dictionary for the service extension to fire. + +--- + +## Local Notifications API + +### Trigger Types + +| Trigger | Use Case | Repeating | +|---------|----------|-----------| +| UNTimeIntervalNotificationTrigger | After N seconds | Yes (≥60s) | +| UNCalendarNotificationTrigger | Specific date/time | Yes | +| UNLocationNotificationTrigger | Enter/exit region | Yes | + +### Time Interval Trigger + +```swift +let content = UNMutableNotificationContent() +content.title = "Reminder" +content.body = "Time to take a break" +content.sound = .default + +let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 300, repeats: false) + +let request = UNNotificationRequest( + identifier: "break-reminder", + content: content, + trigger: trigger +) + +try await UNUserNotificationCenter.current().add(request) +``` + +### Calendar Trigger + +```swift +var dateComponents = DateComponents() +dateComponents.hour = 9 +dateComponents.minute = 0 + +let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + +let request = UNNotificationRequest( + identifier: "daily-9am", + content: content, + trigger: trigger +) + +try await UNUserNotificationCenter.current().add(request) +``` + +### Location Trigger + +```swift +import CoreLocation + +let center = CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.0090) +let region = CLCircularRegion(center: center, radius: 100, identifier: "apple-park") +region.notifyOnEntry = true +region.notifyOnExit = false + +let trigger = UNLocationNotificationTrigger(region: region, repeats: false) + +let request = UNNotificationRequest( + identifier: "arrived-at-office", + content: content, + trigger: trigger +) + +try await UNUserNotificationCenter.current().add(request) +``` + +### Limitations + +| Limitation | Detail | +|-----------|--------| +| Minimum repeat interval | 60 seconds for UNTimeIntervalNotificationTrigger | +| Location authorization | Location trigger requires When In Use or Always authorization | +| No service extensions | Local notifications do not trigger UNNotificationServiceExtension | +| No background wake | Local notifications cannot use content-available for background processing | +| App extensions | Local notifications cannot be scheduled from app extensions (use app group + main app) | +| Pending limit | 64 pending notification requests per app | + +--- + +## Live Activity Push Headers + +### Required Headers + +| Header | Value | +|--------|-------| +| apns-push-type | liveactivity | +| apns-topic | {bundleID}.push-type.liveactivity | +| apns-priority | 5 (routine) or 10 (time-sensitive) | + +### Event Types + +| Event | Purpose | Required Fields | +|-------|---------|----------------| +| start | Start Live Activity remotely | attributes-type, attributes, content-state, timestamp | +| update | Update content | content-state, timestamp | +| end | End Live Activity | timestamp (content-state optional) | + +### Update Payload + +```json +{ + "aps": { + "timestamp": 1709913600, + "event": "update", + "content-state": { + "homeScore": 2, + "awayScore": 1, + "inning": "Top 7" + } + } +} +``` + +### Start Payload (Push-to-Start Token) + +```json +{ + "aps": { + "timestamp": 1709913600, + "event": "start", + "content-state": { + "homeScore": 0, + "awayScore": 0, + "inning": "Top 1" + }, + "attributes-type": "GameAttributes", + "attributes": { + "homeTeam": "Giants", + "awayTeam": "Dodgers" + }, + "alert": { + "title": "Game Starting", + "body": "Giants vs Dodgers is about to begin" + } + } +} +``` + +### Start Payload (Channel-Based) + +```json +{ + "aps": { + "timestamp": 1709913600, + "event": "start", + "content-state": { + "homeScore": 0, + "awayScore": 0, + "inning": "Top 1" + }, + "attributes-type": "GameAttributes", + "attributes": { + "homeTeam": "Giants", + "awayTeam": "Dodgers" + } + } +} +``` + +### End Payload + +```json +{ + "aps": { + "timestamp": 1709913600, + "event": "end", + "dismissal-date": 1709917200, + "content-state": { + "homeScore": 5, + "awayScore": 3, + "inning": "Final" + } + } +} +``` + +### Push-to-Start Token + +```swift +// Observe push-to-start tokens (iOS 17.2+) +for await token in Activity.pushToStartTokenUpdates { + let tokenString = token.map { String(format: "%02x", $0) }.joined() + sendPushToStartTokenToServer(tokenString) +} +``` + +### Activity Push Token + +```swift +// Observe activity-specific push tokens +for await tokenData in activity.pushTokenUpdates { + let token = tokenData.map { String(format: "%02x", $0) }.joined() + sendActivityTokenToServer(token, activityId: activity.id) +} +``` + +Content-state encoding rule: the system always uses default JSONDecoder — do not use custom encoding strategies in your ActivityAttributes.ContentState. + +--- + +## Broadcast Push API (iOS 18+) + +Server-to-many push for Live Activities without tracking individual device tokens. + +### Endpoint + +``` +POST /4/broadcasts/apps/{TOPIC} +``` + +### Headers + +| Header | Value | +|--------|-------| +| apns-push-type | liveactivity | +| apns-channel-id | {channelID} | +| authorization | bearer {JWT} | + +### Subscribe via Channel + +```swift +try Activity.request( + attributes: attributes, + content: .init(state: initialState, staleDate: nil), + pushType: .channel(channelId) +) +``` + +### Channel Storage Policies + +| Policy | Behavior | Budget | +|--------|----------|--------| +| No Storage | Deliver only to connected devices | Higher | +| Most Recent Message | Store latest for offline devices | Lower | + +--- + +## Command-Line Testing + +### JWT Generation + +```bash +JWT_ISSUE_TIME=$(date +%s) +JWT_HEADER=$(printf '{ "alg": "ES256", "kid": "%s" }' "${AUTH_KEY_ID}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =) +JWT_CLAIMS=$(printf '{ "iss": "%s", "iat": %d }' "${TEAM_ID}" "${JWT_ISSUE_TIME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =) +JWT_HEADER_CLAIMS="${JWT_HEADER}.${JWT_CLAIMS}" +JWT_SIGNED_HEADER_CLAIMS=$(printf "${JWT_HEADER_CLAIMS}" | openssl dgst -binary -sha256 -sign "${TOKEN_KEY_FILE_NAME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =) +AUTHENTICATION_TOKEN="${JWT_HEADER}.${JWT_CLAIMS}.${JWT_SIGNED_HEADER_CLAIMS}" +``` + +### Send Alert Push + +```bash +curl -v \ + --header "apns-topic: $TOPIC" \ + --header "apns-push-type: alert" \ + --header "authorization: bearer $AUTHENTICATION_TOKEN" \ + --data '{"aps":{"alert":"test"}}' \ + --http2 https://${APNS_HOST_NAME}/3/device/${DEVICE_TOKEN} +``` + +### Send Live Activity Push + +```bash +curl \ + --header "apns-topic: com.example.app.push-type.liveactivity" \ + --header "apns-push-type: liveactivity" \ + --header "apns-priority: 10" \ + --header "authorization: bearer $AUTHENTICATION_TOKEN" \ + --data '{ + "aps": { + "timestamp": '$(date +%s)', + "event": "update", + "content-state": { "score": "2-1" } + } + }' \ + --http2 https://api.sandbox.push.apple.com/3/device/$ACTIVITY_PUSH_TOKEN +``` + +### Simulator Push + +```bash +xcrun simctl push booted com.example.app payload.json +``` + +### Simulator Payload File + +```json +{ + "Simulator Target Bundle": "com.example.app", + "aps": { + "alert": { "title": "Test", "body": "Hello" }, + "sound": "default" + } +} +``` + +--- + +## Resources + +**WWDC**: 2021-10091, 2023-10025, 2023-10185, 2024-10069 + +**Docs**: /usernotifications, /usernotifications/sending-notification-requests-to-apns, /usernotifications/generating-a-remote-notification, /activitykit + +**Skills**: axiom-push-notifications, axiom-push-notifications-diag, axiom-extensions-widgets diff --git a/.claude/skills/axiom-push-notifications-ref/agents/openai.yaml b/.claude/skills/axiom-push-notifications-ref/agents/openai.yaml new file mode 100644 index 0000000..06a2a48 --- /dev/null +++ b/.claude/skills/axiom-push-notifications-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Push Notifications Reference" + short_description: "Needing APNs HTTP/2 transport details, JWT authentication setup, payload key reference, UNUserNotificationCenter API,..." diff --git a/.claude/skills/axiom-push-notifications/.openskills.json b/.claude/skills/axiom-push-notifications/.openskills.json new file mode 100644 index 0000000..ae21c89 --- /dev/null +++ b/.claude/skills/axiom-push-notifications/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-push-notifications", + "installedAt": "2026-04-12T08:06:32.964Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-push-notifications/SKILL.md b/.claude/skills/axiom-push-notifications/SKILL.md new file mode 100644 index 0000000..47aaba0 --- /dev/null +++ b/.claude/skills/axiom-push-notifications/SKILL.md @@ -0,0 +1,918 @@ +--- +name: axiom-push-notifications +description: Use when implementing remote or local push notifications, requesting notification permission, managing APNs device tokens, adding notification actions/categories, building service extensions, or debugging push delivery failures. Covers APNs, FCM, Live Activity push transport, broadcast push, communication notifications, Focus interaction. +license: MIT +--- + +# Push Notifications + +Remote and local notification patterns for iOS. Covers permission flow, APNs registration, token management, payload design, actionable notifications, rich notifications with service extensions, communication notifications, Focus interaction, and Live Activity push transport. + +## When to Use This Skill + +Use when you need to: +- ☑ Implementing remote (APNs) push notifications +- ☑ Requesting notification permissions +- ☑ Managing device tokens and server sync +- ☑ Adding actionable notifications with categories/actions +- ☑ Building rich notifications with service extensions +- ☑ Communication notifications with avatars (iOS 15+) +- ☑ Time Sensitive or Critical alerts +- ☑ Updating Live Activities via push (transport layer) +- ☑ Broadcast push for large audiences (iOS 18+) +- ☑ Local notification scheduling + +## Example Prompts + +"How do I set up push notifications?" +"When should I ask for notification permission?" +"My push notifications aren't arriving" +"How do I add buttons to notifications?" +"How do I show images in push notifications?" +"How do I send push updates to a Live Activity?" +"What's the difference between APNs token and FCM token?" +"How do I make notifications break through Focus mode?" +"My notifications work in development but not production" +"How do I handle notification taps to open a specific screen?" + +## Red Flags + +Signs you're making this harder than it needs to be: + +- ❌ Requesting permission on first launch before user understands value +- ❌ Caching device tokens locally instead of requesting fresh each launch +- ❌ Sending the same token to sandbox AND production APNs +- ❌ Using `content-available: 1` without understanding silent push throttling (~2-3/hour) +- ❌ Not implementing `serviceExtensionTimeWillExpire` fallback +- ❌ Force-unwrapping device token or assuming registration always succeeds +- ❌ Hardcoding APNs host instead of switching sandbox/production by environment +- ❌ Setting `apns-priority: 10` for all notifications (drains battery, gets throttled) +- ❌ Exceeding 4KB payload without realizing APNs silently rejects it +- ❌ Using FCM without disabling method swizzling when you have custom delegate handling +- ❌ Not handling foreground notification presentation (notifications silently dropped) +- ❌ Overusing Time Sensitive interruption level (erodes user trust, they'll disable all notifications) + +## Mandatory First Steps + +Before implementing any push notification feature: + +### 1. Enable Push Notification Capability + +- Xcode: Target → Signing & Capabilities → + Push Notifications +- Adds `aps-environment` entitlement +- Verify in Apple Developer Portal that the App ID has Push Notifications enabled + +### 2. Register for Remote Notifications + +```swift +// AppDelegate +func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + UNUserNotificationCenter.current().delegate = self + UIApplication.shared.registerForRemoteNotifications() + return true +} + +func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + sendTokenToServer(token) // Never cache locally — tokens change +} + +func application(_ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error) { + // Simulator cannot register. Log, don't crash. +} +``` + +### 3. Request Authorization (in context, not at launch) + +```swift +let center = UNUserNotificationCenter.current() +let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) +if granted { + await MainActor.run { UIApplication.shared.registerForRemoteNotifications() } +} +``` + +Request when user action makes notification value obvious (e.g., after scheduling a reminder, subscribing to updates). The system prompts only once — bad timing means permanent denial. + +## Permission Flow + +### Pattern 1: Standard Authorization + +Request in context after user understands the value: + +```swift +func subscribeToUpdates() async { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + + switch settings.authorizationStatus { + case .notDetermined: + let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) + if granted == true { + await MainActor.run { UIApplication.shared.registerForRemoteNotifications() } + } + case .authorized, .provisional: + // Already have permission + break + case .denied: + // Redirect to Settings + promptToOpenSettings() + case .ephemeral: + break + @unknown default: + break + } +} +``` + +### Pattern 2: Provisional Authorization (iOS 12+) + +Notifications appear quietly in Notification Center with Keep/Turn Off buttons. No permission dialog shown to user. + +```swift +let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge, .provisional]) +``` + +Good for apps where users haven't yet discovered notification value. They see notifications quietly and choose to promote them. + +### Pattern 3: Authorization Status Check + +Always check before scheduling or assuming permission: + +```swift +let settings = await UNUserNotificationCenter.current().notificationSettings() +guard settings.authorizationStatus == .authorized else { + // Handle missing permission + return +} +``` + +### Pattern 4: Handling Denial + +Redirect to Settings when user has denied: + +```swift +func promptToOpenSettings() { + // iOS 16+ + if let url = URL(string: UIApplication.openNotificationSettingsURLString) { + UIApplication.shared.open(url) + } else { + // Fallback: general app settings + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } +} +``` + +## Token Management + +### Token Format + +```swift +func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + sendTokenToServer(token) +} +``` + +### Key Rules + +- **Never cache locally** — request fresh at every app launch via `registerForRemoteNotifications()` +- **Sandbox ≠ production** — tokens are different per APNs environment, endpoints are different (`api.sandbox.push.apple.com` vs `api.push.apple.com`) +- **Server sync** — send token + bundle ID + user ID + environment (sandbox/production) to your server +- **Tokens change** after backup restore, device migration, reinstall, or OS updates + +## Notification Types Decision Tree + +``` +What type of notification? +│ +├─ Alert (user-visible) +│ ├─ Passive — informational, no sound, appears in history +│ │ interruption-level: "passive" +│ │ +│ ├─ Active — default, sound + banner +│ │ interruption-level: "active" (or omit, it's default) +│ │ +│ ├─ Time Sensitive — breaks through scheduled summary, not Focus +│ │ interruption-level: "time-sensitive" +│ │ Requires: Time Sensitive Notifications capability +│ │ +│ └─ Critical — breaks through Do Not Disturb and mute switch +│ interruption-level: "critical" +│ Requires: Apple entitlement approval (medical, safety, security) +│ +├─ Communication (iOS 15+) +│ Shows sender avatar, name, breaks Focus for allowed contacts +│ Requires: INSendMessageIntent + Communication Notifications capability +│ Configured in service extension via content.updating(from: intent) +│ +├─ Silent / Background +│ content-available: 1, no alert/sound/badge +│ Throttled to ~2-3 per hour +│ apns-priority: 5 (MUST be 5, not 10) +│ App gets ~30 seconds background execution +│ +└─ Live Activity + apns-push-type: liveactivity + apns-topic: {bundleID}.push-type.liveactivity + Updates/starts/ends Live Activities remotely +``` + +## Payload Design Patterns + +### Basic Alert + +```json +{ + "aps": { + "alert": { + "title": "New Message", + "subtitle": "From Alice", + "body": "Hey, are you free for lunch?" + }, + "sound": "default", + "badge": 3 + } +} +``` + +### Sound Options + +```json +{ + "aps": { + "alert": { "title": "Notification", "body": "With custom sound" }, + "sound": "custom-sound.aiff" + } +} +``` + +Critical alert (requires Apple entitlement): +```json +{ + "aps": { + "alert": { "title": "Emergency", "body": "Critical alert" }, + "sound": { + "critical": 1, + "name": "alarm.aiff", + "volume": 0.8 + } + } +} +``` + +### Badge + +```json +{ + "aps": { + "badge": 5 + } +} +``` + +Set to `0` to remove badge. + +### Localized Content + +```json +{ + "aps": { + "alert": { + "loc-key": "NEW_MESSAGE_FORMAT", + "loc-args": ["Alice", "lunch"] + } + } +} +``` + +### Custom Data + +Place custom data outside the `aps` dictionary: + +```json +{ + "aps": { + "alert": { "title": "Order Update", "body": "Your order shipped" } + }, + "orderId": "12345", + "deepLink": "/orders/12345" +} +``` + +### Relevance Score and Thread ID + +```json +{ + "aps": { + "alert": { "title": "Breaking News", "body": "..." }, + "relevance-score": 0.8, + "thread-id": "news-breaking", + "interruption-level": "time-sensitive" + } +} +``` + +- `relevance-score` (0.0–1.0): ranking for notification summary (iOS 15+) +- `thread-id`: groups notifications into conversations in Notification Center + +### Payload Size Limits + +| Type | Max Size | +|------|----------| +| Standard push | 4KB | +| VoIP push | 5KB | +| Live Activity | 4KB | + +APNs silently rejects oversized payloads. No error returned to sender. + +## Categories and Actions + +### Register Categories at Launch + +```swift +func registerNotificationCategories() { + let replyAction = UNTextInputNotificationAction( + identifier: "REPLY_ACTION", + title: "Reply", + options: []) + + // iOS 15+: actions with icons + let likeIcon = UNNotificationActionIcon(systemImageName: "hand.thumbsup") + let likeAction = UNNotificationAction( + identifier: "LIKE_ACTION", + title: "Like", + options: [], + icon: likeIcon) + + let messageCategory = UNNotificationCategory( + identifier: "MESSAGE", + actions: [replyAction, likeAction], + intentIdentifiers: [], + options: [.customDismissAction]) + + UNUserNotificationCenter.current().setNotificationCategories([messageCategory]) +} +``` + +### Set Category in Payload + +```json +{ + "aps": { + "alert": { "title": "Alice", "body": "Are you free?" }, + "category": "MESSAGE" + } +} +``` + +### Handle Action Response + +```swift +extension AppDelegate: UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + let userInfo = response.notification.request.content.userInfo + + switch response.actionIdentifier { + case "REPLY_ACTION": + if let textResponse = response as? UNTextInputNotificationResponse { + handleReply(text: textResponse.userText, userInfo: userInfo) + } + case "LIKE_ACTION": + handleLike(userInfo: userInfo) + case UNNotificationDefaultActionIdentifier: + // User tapped the notification itself + handleNotificationTap(userInfo: userInfo) + case UNNotificationDismissActionIdentifier: + // User dismissed (requires .customDismissAction on category) + handleDismiss(userInfo: userInfo) + default: + break + } + + completionHandler() + } +} +``` + +## Service Extension Patterns + +### Pattern 1: Media Enrichment + +Download and attach images, audio, or video to notifications. + +**Payload requirement**: Must include `"mutable-content": 1`: + +```json +{ + "aps": { + "alert": { "title": "Photo", "body": "Alice sent a photo" }, + "mutable-content": 1 + }, + "imageURL": "https://example.com/photo.jpg" +} +``` + +**Service extension**: + +```swift +class NotificationService: UNNotificationServiceExtension { + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, + withContentHandler contentHandler: + @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent + + guard let bestAttemptContent, + let imageURLString = bestAttemptContent.userInfo["imageURL"] as? String, + let imageURL = URL(string: imageURLString) else { + contentHandler(request.content) + return + } + + // Download image + let task = URLSession.shared.downloadTask(with: imageURL) { [weak self] url, _, error in + guard let self, let url, error == nil else { + contentHandler(self?.bestAttemptContent ?? request.content) + return + } + + // Move to tmp with proper extension + let tmpURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("jpg") + try? FileManager.default.moveItem(at: url, to: tmpURL) + + if let attachment = try? UNNotificationAttachment(identifier: "image", + url: tmpURL, + options: nil) { + bestAttemptContent.attachments = [attachment] + } + + contentHandler(bestAttemptContent) + } + task.resume() + } + + override func serviceExtensionTimeWillExpire() { + // 30-second window exceeded — deliver what we have + if let contentHandler, let bestAttemptContent { + contentHandler(bestAttemptContent) + } + } +} +``` + +### Pattern 2: End-to-End Decryption + +Decrypt payload in service extension before display: + +```swift +override func didReceive(_ request: UNNotificationRequest, + withContentHandler contentHandler: + @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent + + guard let bestAttemptContent, + let encryptedBody = bestAttemptContent.userInfo["encryptedBody"] as? String else { + contentHandler(request.content) + return + } + + if let decrypted = decrypt(encryptedBody) { + bestAttemptContent.body = decrypted + } else { + bestAttemptContent.body = "(Encrypted message)" + } + + contentHandler(bestAttemptContent) +} + +override func serviceExtensionTimeWillExpire() { + if let contentHandler, let bestAttemptContent { + bestAttemptContent.body = "(Encrypted message)" + contentHandler(bestAttemptContent) + } +} +``` + +**30-second processing window**: If `didReceive` doesn't call `contentHandler` within ~30 seconds, `serviceExtensionTimeWillExpire` is called. Always deliver `bestAttemptContent` as fallback — if neither method calls the handler, the notification vanishes entirely. + +## Communication Notifications (iOS 15+) + +Show sender avatar and name. Can break through Focus for allowed contacts. + +```swift +// In your Notification Service Extension +import Intents + +override func didReceive(_ request: UNNotificationRequest, + withContentHandler contentHandler: + @escaping (UNNotificationContent) -> Void) { + guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else { + contentHandler(request.content) + return + } + + // 1. Create sender persona + let senderImage = INImage(url: avatarURL) // or INImage(imageData:) + let sender = INPerson( + personHandle: INPersonHandle(value: "alice@example.com", type: .emailAddress), + nameComponents: nil, + displayName: "Alice", + image: senderImage, + contactIdentifier: nil, + customIdentifier: "user-alice-123" + ) + + // 2. Create message intent + let intent = INSendMessageIntent( + recipients: nil, // nil for 1:1, set for group + outgoingMessageType: .outgoingMessageText, + content: bestAttemptContent.body, + speakableGroupName: nil, // set for group conversations + conversationIdentifier: "conversation-123", + serviceName: nil, + sender: sender, + attachments: nil + ) + + // 3. Donate interaction + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + interaction.donate(completion: nil) + + // 4. Update content with intent + do { + let updatedContent = try bestAttemptContent.updating(from: intent) + contentHandler(updatedContent) + } catch { + contentHandler(bestAttemptContent) + } +} +``` + +**Requirements**: +- Communication Notifications capability in Xcode +- Notification Service Extension target +- `mutable-content: 1` in payload + +**Focus breakthrough**: Communication notifications from contacts the user has allowed in Focus settings will break through. Use sparingly — overuse erodes trust. + +## Foreground Notification Handling + +Without this delegate method, notifications received while the app is in foreground are **silently dropped**: + +```swift +extension AppDelegate: UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: + @escaping (UNNotificationPresentationOptions) -> Void) { + // Show notification even when app is in foreground + completionHandler([.banner, .sound, .badge]) + } +} +``` + +Set `UNUserNotificationCenter.current().delegate = self` in `didFinishLaunchingWithOptions` — before the app finishes launching. + +## Live Activity Push Transport + +Push updates to Live Activities remotely via APNs. + +### Observe Push Token + +```swift +let activity = try Activity.request( + attributes: attributes, + content: initialContent, + pushType: .token +) + +Task { + for await pushToken in activity.pushTokenUpdates { + let pushTokenString = pushToken.reduce("") { + $0 + String(format: "%02x", $1) + } + try await sendPushToken(pushTokenString: pushTokenString) + } +} +``` + +### APNs Headers for Live Activity + +| Header | Value | +|--------|-------| +| apns-push-type | `liveactivity` | +| apns-topic | `{bundleID}.push-type.liveactivity` | +| apns-priority | `5` (routine) or `10` (time-sensitive) | + +### Update Payload + +```json +{ + "aps": { + "timestamp": 1234567890, + "event": "update", + "content-state": { + "currentStep": "outForDelivery", + "estimatedArrival": "2:30 PM" + }, + "stale-date": 1234571490, + "dismissal-date": 1234575090, + "relevance-score": 75.0 + } +} +``` + +### Event Types + +| Event | Purpose | +|-------|---------| +| `update` | Update content-state | +| `start` | Start a new Live Activity remotely (iOS 17.2+) | +| `end` | End the activity | + +### Key Rules + +- `content-state` **must match** `ActivityAttributes.ContentState` exactly — no custom JSON encoding strategies, property names must be identical +- `timestamp` is required — APNs uses it to discard stale updates +- `stale-date` shows a visual indicator that data is outdated +- `dismissal-date` controls when an ended activity disappears from Lock Screen +- `relevance-score` orders multiple active Live Activities +- Priority budget enforced — excessive `apns-priority: 10` gets throttled +- Add `NSSupportsLiveActivitiesFrequentUpdates` to Info.plist for high-frequency apps (sports, navigation) + +For ActivityKit UI, attributes, and Dynamic Island layout, see axiom-extensions-widgets. + +## Broadcast Push (iOS 18+) + +Channel-based delivery for large audiences (sports scores, flight status, breaking news). + +### Setup + +1. Enable Broadcast Push Notifications capability in Apple Developer Portal +2. Create channels via Apple's Broadcast Push API + +### Subscribe to Channel + +```swift +let activity = try Activity.request( + attributes: attributes, + content: initialContent, + pushType: .channel(channelId) +) +``` + +### Server Sends to Channel + +``` +POST /4/broadcasts/apps/{TOPIC} +``` + +### Key Rules + +- Only available for Live Activities (not regular push) +- Channel storage policies: **No Storage** (higher budget) vs **Most Recent Message** (stores last update) +- Manage channel lifecycle — delete unused channels (total active channels are limited) +- Channels are identified by opaque IDs, not user-facing names +- More efficient than per-device token delivery for 1-to-many scenarios + +## FCM as Provider + +If using Firebase Cloud Messaging as your push provider, watch for these gotchas: + +### 1. Swizzling Trap + +FCM swizzles `UNUserNotificationCenterDelegate` methods and `didRegisterForRemoteNotifications` by default. If you have custom delegate handling, they conflict. + +**Fix**: Set in Info.plist: +```xml +FirebaseAppDelegateProxyEnabled + +``` + +Then manually pass the APNs token to FCM: +```swift +func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Messaging.messaging().apnsToken = deviceToken +} +``` + +### 2. Dual Token Confusion + +FCM token ≠ APNs device token. They are completely different. Send the correct one to the correct backend. + +- FCM token → your server (for sending via FCM) +- APNs token → only needed if sending directly via APNs + +### 3. APNs Auth Key Upload + +Upload your .p8 APNs authentication key to Firebase Console → Project Settings → Cloud Messaging. Without this, development builds work (FCM uses sandbox automatically) but production builds silently fail. + +### 4. Silent Push Payload Size + +FCM's `content_available` maps to APNs `content-available`, but FCM may add extra fields to the payload. Monitor total size to avoid exceeding the 4KB limit. + +## Anti-Patterns + +### Anti-Pattern 1: Requesting Permission at App Launch + +**Wrong**: +```swift +func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } + return true +} +``` + +**Right**: +```swift +// After user taps "Subscribe to updates" or completes onboarding +func subscribeButtonTapped() async { + let granted = try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound, .badge]) + if granted == true { + await MainActor.run { UIApplication.shared.registerForRemoteNotifications() } + } +} +``` + +**Why it matters**: The system only shows the permission dialog once. If the user hasn't seen value yet, they tap "Don't Allow" reflexively. ~60% of users who deny never re-enable in Settings. You get one shot. + +### Anti-Pattern 2: Caching Device Tokens + +**Wrong**: +```swift +func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) { + let tokenString = token.map { String(format: "%02x", $0) }.joined() + UserDefaults.standard.set(tokenString, forKey: "pushToken") // Stale after restore +} +``` + +**Right**: +```swift +func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) { + let tokenString = token.map { String(format: "%02x", $0) }.joined() + sendTokenToServer(tokenString) // Fresh every launch +} +``` + +**Why it matters**: Tokens change after backup restore, device migration, reinstall, and sometimes after OS updates. A stale cached token means your server sends to a token APNs no longer recognizes — notifications silently vanish. + +### Anti-Pattern 3: Ignoring Service Extension Timeout + +**Wrong**: +```swift +override func didReceive(_ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + // Download large image, no timeout handling + downloadImage(from: url) { image in + // If this takes >30 seconds, notification vanishes entirely + contentHandler(modifiedContent) + } +} +// serviceExtensionTimeWillExpire not implemented +``` + +**Right**: +```swift +override func serviceExtensionTimeWillExpire() { + if let contentHandler, let bestAttemptContent { + // Deliver whatever we have — text without image is better than nothing + contentHandler(bestAttemptContent) + } +} +``` + +**Why it matters**: The service extension has a ~30 second window. If neither `didReceive` nor `serviceExtensionTimeWillExpire` calls the content handler, the notification disappears completely. Users never see it. + +### Anti-Pattern 4: Using Time Sensitive for Everything + +**Wrong**: +```json +{ + "aps": { + "alert": { "title": "Weekly Newsletter", "body": "Check out this week's articles" }, + "interruption-level": "time-sensitive" + } +} +``` + +**Right**: +```json +{ + "aps": { + "alert": { "title": "Weekly Newsletter", "body": "Check out this week's articles" }, + "interruption-level": "passive" + } +} +``` + +**Why it matters**: iOS shows users which apps overuse Time Sensitive. Users who feel interrupted will disable ALL notifications from your app — not just Time Sensitive ones. Reserve it for genuinely time-bound events (delivery arriving, meeting starting, security alerts). Apple can also revoke the capability. + +## Pressure Scenarios + +### Scenario 1: "Ship Push Notifications by Friday" + +**Context**: PM needs push notifications working for a demo. + +**Pressure**: "Just ask for permission at launch, we'll fix it later." + +**Reality**: The system only prompts once. If the user denies, you need them to manually enable in Settings. ~60% of users never do. "Fix it later" means permanently lower opt-in rates. + +**Correct action**: +1. Implement push registration and delivery without the permission prompt first +2. Add contextual permission request after a user action that makes notification value obvious +3. Test both grant and deny flows end-to-end + +**Push-back template**: "Permission timing directly affects our opt-in rate. A 2-hour investment now prevents a 30% lower notification reach permanently. Let me implement the contextual prompt — it's the same amount of code, just in the right place." + +### Scenario 2: "Notifications Work in Dev but Not Production" + +**Context**: App Store build doesn't receive push notifications. + +**Pressure**: "Something is wrong with APNs, let's file a radar." + +**Reality**: 95% of the time it's a sandbox/production token mismatch. Dev builds use `api.sandbox.push.apple.com`, production uses `api.push.apple.com`. Tokens are different per environment. The same token sent to the wrong endpoint silently fails. + +**Correct action**: +1. Verify server is using the correct APNs endpoint for the build type +2. Check that the token was obtained from a production build (not a dev/TestFlight token sent to production endpoint) +3. If using FCM, verify the APNs authentication key (.p8) is uploaded to Firebase Console + +**Push-back template**: "Before filing a radar, let me verify our token/environment configuration. This is the number one cause of 'works in dev, not production' and takes 5 minutes to check." + +### Scenario 3: "Just Send Everything as Time Sensitive" + +**Context**: Product wants maximum notification visibility. + +**Pressure**: "Users need to see our notifications immediately." + +**Reality**: iOS shows users which apps overuse Time Sensitive. Users who feel interrupted will disable ALL notifications from your app — not just Time Sensitive. Apple can also revoke the entitlement for abuse. + +**Correct action**: +1. Classify notifications by genuine urgency +2. Use `passive` for informational, `active` (default) for normal engagement, `time-sensitive` only for truly time-bound events +3. Document the classification for the team so backend engineers apply the right level + +**Push-back template**: "Overusing Time Sensitive will cause users to disable our notifications entirely. Let's classify by urgency — most notifications should be active, with time-sensitive reserved for genuinely time-bound events like delivery arrivals or expiring offers." + +## Checklist + +Before shipping push notifications: + +**Entitlements**: +- ☑ Push Notifications capability added in Xcode +- ☑ Provisioning profile includes aps-environment +- ☑ Communication Notifications capability (if using communication type) + +**Permissions**: +- ☑ Authorization requested in context (not at launch) +- ☑ Denial handled gracefully (Settings redirect or degraded experience) +- ☑ Authorization status checked before scheduling +- ☑ Provisional authorization considered for trial period + +**Token Management**: +- ☑ Token sent to server on every launch (never cached) +- ☑ Server stores token per environment (sandbox/production) +- ☑ Token refresh handled (pushTokenUpdates for Live Activities) + +**Payload**: +- ☑ Payload under 4KB (5KB for VoIP) +- ☑ Category identifier matches registered categories +- ☑ Interruption level appropriate for content urgency +- ☑ Custom data placed outside aps dictionary + +**Service Extension** (if applicable): +- ☑ mutable-content: 1 set in payload +- ☑ serviceExtensionTimeWillExpire delivers fallback content +- ☑ App group configured for shared data access + +**Testing**: +- ☑ Tested with Push Notifications Console or curl +- ☑ Tested both foreground and background delivery +- ☑ Tested on physical device (Simulator has no APNs token) + +## Resources + +**WWDC**: 2021-10091, 2023-10025, 2023-10185, 2024-10069 + +**Docs**: /usernotifications, /usernotifications/unusernotificationcenter, /activitykit + +**Skills**: axiom-push-notifications-ref, axiom-push-notifications-diag, axiom-extensions-widgets, axiom-background-processing diff --git a/.claude/skills/axiom-push-notifications/agents/openai.yaml b/.claude/skills/axiom-push-notifications/agents/openai.yaml new file mode 100644 index 0000000..bc9e849 --- /dev/null +++ b/.claude/skills/axiom-push-notifications/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Push Notifications" + short_description: "Implementing remote or local push notifications, requesting notification permission, managing APNs device tokens, add..." diff --git a/.claude/skills/axiom-realitykit-diag/.openskills.json b/.claude/skills/axiom-realitykit-diag/.openskills.json new file mode 100644 index 0000000..eaf7d16 --- /dev/null +++ b/.claude/skills/axiom-realitykit-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-realitykit-diag", + "installedAt": "2026-04-12T08:06:34.441Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-realitykit-diag/SKILL.md b/.claude/skills/axiom-realitykit-diag/SKILL.md new file mode 100644 index 0000000..c5e7c7c --- /dev/null +++ b/.claude/skills/axiom-realitykit-diag/SKILL.md @@ -0,0 +1,455 @@ +--- +name: axiom-realitykit-diag +description: Use when RealityKit entities not visible, anchors not tracking, gestures not responding, performance drops, materials wrong, or multiplayer sync fails +license: MIT +metadata: + version: "1.0.0" +--- + +# RealityKit Diagnostics + +Systematic diagnosis for common RealityKit issues with time-cost annotations. + +## When to Use This Diagnostic Skill + +Use this skill when: +- Entity added but not visible in the scene +- AR anchor not tracking or content floating +- Tap/drag gestures not responding on 3D entities +- Frame rate dropping or stuttering +- Material looks wrong (too dark, too bright, incorrect colors) +- Multiplayer entities not syncing across devices +- Physics bodies not colliding or passing through each other + +For RealityKit architecture patterns and best practices, see `axiom-realitykit`. For API reference, see `axiom-realitykit-ref`. + +--- + +## Mandatory First Step: Enable Debug Visualization + +**Time cost**: 10 seconds vs hours of blind debugging + +```swift +// In your RealityView or ARView setup +#if DEBUG +// Xcode: Debug → Attach to Process → Show RealityKit Statistics +// Or enable in code: +arView.debugOptions = [ + .showStatistics, // Entity count, draw calls, FPS + .showPhysics, // Collision shapes + .showAnchorOrigins, // Anchor positions + .showAnchorGeometry // Detected plane geometry +] +#endif +``` + +If you can't see collision shapes with `.showPhysics`, your `CollisionComponent` is missing or misconfigured. **Fix collision before debugging gestures or physics.** + +--- + +## Symptom 1: Entity Not Visible + +**Time saved**: 30-60 min → 2-5 min + +``` +Entity added but nothing appears +│ +├─ Is the entity added to the scene? +│ └─ NO → Add to RealityView content: +│ content.add(entity) +│ ✓ Entities must be in the scene graph to render +│ +├─ Does the entity have a ModelComponent? +│ └─ NO → Add mesh and material: +│ entity.components[ModelComponent.self] = ModelComponent( +│ mesh: .generateBox(size: 0.1), +│ materials: [SimpleMaterial(color: .red, isMetallic: false)] +│ ) +│ ✓ Bare Entity is invisible — it's just a container +│ +├─ Is the entity's scale zero or nearly zero? +│ └─ CHECK → Print: entity.scale +│ USD models may import with unexpected scale. +│ Try: entity.scale = SIMD3(repeating: 0.01) for meter-scale models +│ +├─ Is the entity behind the camera? +│ └─ CHECK → Print: entity.position(relativeTo: nil) +│ In RealityKit, -Z is forward (toward screen). +│ Try: entity.position = SIMD3(0, 0, -0.5) (half meter in front) +│ +├─ Is the entity inside another object? +│ └─ CHECK → Move to a known visible position: +│ entity.position = SIMD3(0, 0, -1) +│ +├─ Is the entity's isEnabled set to false? +│ └─ CHECK → entity.isEnabled = true +│ Also check parent: entity.isEnabledInHierarchy +│ +├─ Is the entity on an untracked anchor? +│ └─ CHECK → Verify anchor is tracking: +│ entity.isAnchored (should be true) +│ If using plane anchor, ensure surface is detected first +│ +└─ Is the material transparent or OcclusionMaterial? + └─ CHECK → Inspect material: + If using PhysicallyBasedMaterial, check baseColor is not black + If using blending = .transparent, check opacity > 0 +``` + +### Quick Diagnostic + +```swift +func diagnoseVisibility(_ entity: Entity) { + print("Name: \(entity.name)") + print("Is enabled: \(entity.isEnabled)") + print("In hierarchy: \(entity.isEnabledInHierarchy)") + print("Is anchored: \(entity.isAnchored)") + print("Position (world): \(entity.position(relativeTo: nil))") + print("Scale: \(entity.scale)") + print("Has model: \(entity.components[ModelComponent.self] != nil)") + print("Children: \(entity.children.count)") +} +``` + +--- + +## Symptom 2: Anchor Not Tracking + +**Time saved**: 20-45 min → 3-5 min + +``` +AR content not appearing or floating +│ +├─ Is the AR session running? +│ └─ For RealityView on iOS 18+, AR runs automatically +│ For ARView, check: arView.session.isRunning +│ +├─ Is SpatialTrackingSession configured? (iOS 18+) +│ └─ CHECK → Ensure tracking modes requested: +│ let config = SpatialTrackingSession.Configuration( +│ tracking: [.plane, .object]) +│ let result = await session.run(config) +│ if let notSupported = result { +│ // Handle unsupported modes +│ } +│ +├─ Is the anchor type appropriate for the environment? +│ ├─ .plane(.horizontal) → Need a flat surface visible to camera +│ ├─ .plane(.vertical) → Need a wall visible to camera +│ ├─ .image → Image must be in "AR Resources" asset catalog +│ ├─ .face → Front camera required (not rear) +│ └─ .body → Full body must be visible +│ +├─ Is minimumBounds too large? +│ └─ CHECK → Reduce minimum bounds: +│ AnchorEntity(.plane(.horizontal, classification: .any, +│ minimumBounds: SIMD2(0.1, 0.1))) // Smaller = detects sooner +│ +├─ Is the device supported? +│ └─ CHECK → Plane detection requires A12+ chip +│ Face tracking requires TrueDepth camera +│ Body tracking requires A12+ chip +│ +└─ Is the environment adequate? + └─ CHECK → AR needs: + - Adequate lighting (not too dark) + - Textured surfaces (not blank walls) + - Stable device position during initial detection +``` + +--- + +## Symptom 3: Gesture Not Responding + +**Time saved**: 15-30 min → 2-3 min + +``` +Tap/drag on entity does nothing +│ +├─ Does the entity have a CollisionComponent? +│ └─ NO → Add collision shapes: +│ entity.generateCollisionShapes(recursive: true) +│ // or manual: +│ entity.components[CollisionComponent.self] = CollisionComponent( +│ shapes: [.generateBox(size: SIMD3(0.1, 0.1, 0.1))]) +│ ✓ Collision shapes are REQUIRED for gesture hit testing +│ +├─ [visionOS] Does the entity have InputTargetComponent? +│ └─ NO → Add it: +│ entity.components[InputTargetComponent.self] = InputTargetComponent() +│ ✓ Required on visionOS for gesture input +│ +├─ Is the gesture attached to the RealityView? +│ └─ CHECK → Gesture must be on the view, not the entity: +│ RealityView { content in ... } +│ .gesture(TapGesture().targetedToAnyEntity().onEnded { ... }) +│ +├─ Is the collision shape large enough to hit? +│ └─ CHECK → Enable .showPhysics to see shapes +│ Shapes too small = hard to tap. +│ Try: .generateBox(size: SIMD3(repeating: 0.1)) minimum +│ +├─ Is the entity behind another entity? +│ └─ CHECK → Front entities may block gestures on back entities +│ Ensure collision is on the intended target +│ +└─ Is the entity enabled? + └─ CHECK → entity.isEnabled must be true + Disabled entities don't receive input +``` + +### Quick Diagnostic + +```swift +func diagnoseGesture(_ entity: Entity) { + print("Has collision: \(entity.components[CollisionComponent.self] != nil)") + print("Has input target: \(entity.components[InputTargetComponent.self] != nil)") + print("Is enabled: \(entity.isEnabled)") + print("Is anchored: \(entity.isAnchored)") + + if let collision = entity.components[CollisionComponent.self] { + print("Collision shapes: \(collision.shapes.count)") + } +} +``` + +--- + +## Symptom 4: Performance Problems + +**Time saved**: 1-3 hours → 10-20 min + +``` +Frame rate dropping or stuttering +│ +├─ How many entities are in the scene? +│ └─ CHECK → Print entity count: +│ var count = 0 +│ func countEntities(_ entity: Entity) { +│ count += 1 +│ for child in entity.children { countEntities(child) } +│ } +│ Under 100: unlikely to be entity count +│ 100-500: review for optimization +│ 500+: definitely needs optimization +│ +├─ Are mesh/material resources shared? +│ └─ NO → Share resources across identical entities: +│ let sharedMesh = MeshResource.generateBox(size: 0.05) +│ let sharedMaterial = SimpleMaterial(color: .white, isMetallic: false) +│ // Reuse for all instances +│ ✓ RealityKit batches entities with identical resources +│ +├─ Is a System creating components every frame? +│ └─ CHECK → Look for allocations in update(): +│ Creating ModelComponent, CollisionComponent, or materials +│ every frame causes GC pressure. +│ Cache resources, only update when values change. +│ +├─ Are collision shapes mesh-based? +│ └─ CHECK → Replace generateCollisionShapes(recursive: true) +│ with simple shapes (box, sphere, capsule) for dynamic entities +│ +├─ Is generateCollisionShapes called repeatedly? +│ └─ CHECK → Call once during setup, not every frame +│ +├─ Are there too many physics bodies? +│ └─ CHECK → Dynamic bodies are most expensive. +│ Convert distant/static objects to .static mode. +│ Remove physics from non-interactive entities. +│ +└─ Is the model polygon count too high? + └─ CHECK → Decimate models for real-time use. + Target: <100K triangles total for mobile AR. + Use LOD (Level of Detail) for distant objects. +``` + +--- + +## Symptom 5: Material Looks Wrong + +**Time saved**: 15-45 min → 5-10 min + +``` +Colors, lighting, or textures look incorrect +│ +├─ Is the scene too dark? +│ └─ CHECK → Missing environment lighting: +│ Add DirectionalLightComponent or EnvironmentResource +│ In AR, RealityKit uses real-world lighting automatically +│ In non-AR, you must provide lighting explicitly +│ +├─ Is the baseColor set? +│ └─ CHECK → PhysicallyBasedMaterial defaults to white +│ material.baseColor = .init(tint: .red) +│ If using a texture, verify it loaded: +│ try TextureResource(named: "albedo") +│ +├─ Is metallic set incorrectly? +│ └─ CHECK → metallic = 1.0 makes surfaces mirror-like +│ Most real objects: metallic = 0.0 +│ Only metals (gold, silver, chrome): metallic = 1.0 +│ +├─ Is the texture semantic wrong? +│ └─ CHECK → Use correct semantic: +│ .color for albedo/baseColor textures +│ .raw for data textures (metallic, roughness) +│ .normal for normal maps +│ .hdrColor for HDR textures +│ +├─ Is the model upside down or inside out? +│ └─ CHECK → Try: +│ material.faceCulling = .none (shows both sides) +│ If that fixes it, the model normals are flipped +│ +└─ Is blending/transparency unexpected? + └─ CHECK → material.blending + Default is .opaque + For transparency: .transparent(opacity: ...) +``` + +--- + +## Symptom 6: Physics Not Working + +**Time saved**: 20-40 min → 5-10 min + +``` +Objects pass through each other or don't collide +│ +├─ Do both entities have CollisionComponent? +│ └─ NO → Both sides of a collision need CollisionComponent +│ +├─ Does the moving entity have PhysicsBodyComponent? +│ └─ NO → Add physics body: +│ entity.components[PhysicsBodyComponent.self] = PhysicsBodyComponent( +│ mode: .dynamic) +│ +├─ Are collision groups/filters configured correctly? +│ └─ CHECK → Entities must be in compatible groups: +│ Default: group = .default, mask = .all +│ If using custom groups, verify mask includes the other group +│ +├─ Is the physics mode correct? +│ ├─ Two .static bodies → Never collide (both immovable) +│ ├─ .dynamic + .static → Correct (common setup) +│ ├─ .dynamic + .dynamic → Both move on collision +│ └─ .kinematic + .dynamic → Kinematic pushes dynamic +│ +├─ Is the collision shape appropriate? +│ └─ CHECK → .showPhysics debug option +│ Shape may be too small, offset, or wrong type +│ +└─ Are entities on different anchors? + └─ CHECK → "Physics bodies and colliders affect only + entities that share the same anchor" (Apple docs) + Move entities under the same anchor for physics interaction +``` + +--- + +## Symptom 7: Multiplayer Sync Issues + +**Time saved**: 30-60 min → 10-15 min + +``` +Entities not appearing on other devices +│ +├─ Does the entity have SynchronizationComponent? +│ └─ NO → Add it: +│ entity.components[SynchronizationComponent.self] = +│ SynchronizationComponent() +│ +├─ Is the MultipeerConnectivityService set up? +│ └─ CHECK → Verify MCSession is connected before syncing +│ +├─ Are custom components Codable? +│ └─ NO → Non-Codable components don't sync +│ struct MyComponent: Component, Codable { ... } +│ +├─ Does the entity have an owner? +│ └─ CHECK → Only the owner can modify synced properties +│ Request ownership before modifying: +│ entity.requestOwnership { result in ... } +│ +└─ Is the entity anchored? + └─ CHECK → Unanchored entities may not sync position correctly + Use a shared world anchor for reliable positioning +``` + +--- + +## Common Mistakes + +| Mistake | Time Cost | Fix | +|---------|-----------|-----| +| No CollisionComponent on interactive entity | 15-30 min | `entity.generateCollisionShapes(recursive: true)` | +| Missing InputTargetComponent on visionOS | 10-20 min | Add `InputTargetComponent()` | +| Gesture on wrong view (not RealityView) | 10-15 min | Attach `.gesture()` to `RealityView` | +| Entity scale wrong for USD model | 15-30 min | Check units: meters vs centimeters | +| No lighting in non-AR scene | 10-20 min | Add `DirectionalLightComponent` | +| Storing entity refs in System | 30-60 min crash debugging | Query with `EntityQuery` each frame | +| Components not registered | 10-15 min | Call `registerComponent()` in app init | +| Systems not registered | 10-15 min | Call `registerSystem()` before scene load | +| Physics across different anchors | 20-40 min | Put interacting entities under same anchor | +| Calling generateCollisionShapes every frame | Performance degradation | Call once during setup | + +--- + +## Diagnostic Quick Reference + +| Symptom | First Check | Time Saved | +|---------|-------------|------------| +| Not visible | Has ModelComponent? Scale > 0? | 30-60 min | +| No gesture response | Has CollisionComponent? | 15-30 min | +| Not tracking | Anchor type matches environment? | 20-45 min | +| Frame drops | Entity count? Resource sharing? | 1-3 hours | +| Wrong colors | Has lighting? Metallic value? | 15-45 min | +| No collision | Both have CollisionComponent? Same anchor? | 20-40 min | +| No sync | SynchronizationComponent? Codable? | 30-60 min | +| Sim OK, device crash | Metal features? Texture format? | 15-30 min | + +--- + +## Symptom 8: Works in Simulator, Crashes on Device + +**Time cost**: 15-30 min (often misdiagnosed as model issue) + +``` +Q1: Is the crash a Metal error (MTLCommandBuffer, shader compilation)? +├─ YES → Simulator uses software rendering, device uses real GPU +│ Common causes: +│ - Custom Metal shaders with unsupported features +│ - Texture formats not supported on device GPU +│ - Exceeding device texture size limits (max 8192x8192 on older) +│ Fix: Check device GPU family, use supported formats +│ +└─ NO → Check next + +Q2: Is it an out-of-memory crash? +├─ YES → Simulator has more RAM available +│ Common: Large USDZ files with uncompressed textures +│ Fix: Compress textures, reduce polygon count, use LOD +│ Check: USDZ file size (keep < 50MB for reliable loading) +│ +└─ NO → Check next + +Q3: Is it an AR-related crash (camera, tracking)? +├─ YES → Simulator has no real camera/sensors +│ Fix: Test AR features on device only, use simulator for UI/layout +│ +└─ NO → Check device capabilities + - A12+ required for RealityKit + - LiDAR for scene reconstruction + - TrueDepth for face tracking +``` + +--- + +## Resources + +**WWDC**: 2019-603, 2019-605, 2023-10080, 2024-10103 + +**Docs**: /realitykit, /realitykit/entity, /realitykit/collisioncomponent, /realitykit/physicsbodycomponent + +**Skills**: axiom-realitykit, axiom-realitykit-ref diff --git a/.claude/skills/axiom-realitykit-diag/agents/openai.yaml b/.claude/skills/axiom-realitykit-diag/agents/openai.yaml new file mode 100644 index 0000000..d02c68e --- /dev/null +++ b/.claude/skills/axiom-realitykit-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "RealityKit Diagnostics" + short_description: "RealityKit entities not visible, anchors not tracking, gestures not responding, performance drops, materials wrong, o..." diff --git a/.claude/skills/axiom-realitykit-ref/.openskills.json b/.claude/skills/axiom-realitykit-ref/.openskills.json new file mode 100644 index 0000000..46dab1c --- /dev/null +++ b/.claude/skills/axiom-realitykit-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-realitykit-ref", + "installedAt": "2026-04-12T08:06:34.831Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-realitykit-ref/SKILL.md b/.claude/skills/axiom-realitykit-ref/SKILL.md new file mode 100644 index 0000000..1a5e85d --- /dev/null +++ b/.claude/skills/axiom-realitykit-ref/SKILL.md @@ -0,0 +1,652 @@ +--- +name: axiom-realitykit-ref +description: RealityKit API reference — Entity, Component, System, RealityView, Model3D, anchor types, material system, physics, collision, animation, audio, accessibility +license: MIT +compatibility: [iOS 13+, macOS 10.15+, visionOS 1.0+, tvOS 26+] +metadata: + version: "1.0.0" +--- + +# RealityKit API Reference + +Complete API reference for RealityKit organized by category. + +## When to Use This Reference + +Use this reference when: +- Looking up specific RealityKit API signatures or properties +- Checking which component types are available +- Finding the right anchor type for an AR experience +- Browsing material properties and options +- Setting up physics body parameters +- Looking up animation or audio API details +- Checking platform availability for specific APIs + +--- + +## Part 1: Entity API + +### Entity + +```swift +// Creation +let entity = Entity() +let entity = Entity(components: [TransformComponent(), ModelComponent(...)]) + +// Async loading +let entity = try await Entity(named: "scene", in: .main) +let entity = try await Entity(contentsOf: url) + +// Clone +let clone = entity.clone(recursive: true) +``` + +### Entity Properties + +| Property | Type | Description | +|----------|------|-------------| +| `name` | `String` | Identifier for lookup | +| `id` | `ObjectIdentifier` | Unique identity | +| `isEnabled` | `Bool` | Local enabled state | +| `isEnabledInHierarchy` | `Bool` | Effective enabled (considers parents) | +| `isActive` | `Bool` | Entity is in an active scene | +| `isAnchored` | `Bool` | Has anchoring or anchored ancestor | +| `scene` | `RealityKit.Scene?` | Owning scene | +| `parent` | `Entity?` | Parent entity | +| `children` | `Entity.ChildCollection` | Child entities | +| `components` | `Entity.ComponentSet` | All attached components | +| `anchor` | `HasAnchoring?` | Nearest anchoring ancestor | + +### Entity Hierarchy Methods + +```swift +entity.addChild(child) +entity.addChild(child, preservingWorldTransform: true) +entity.removeChild(child) +entity.removeFromParent() +entity.findEntity(named: "name") // Recursive search +``` + +### Entity Subclasses + +| Class | Purpose | Key Component | +|-------|---------|---------------| +| `Entity` | Base container | Transform only | +| `ModelEntity` | Renderable object | ModelComponent | +| `AnchorEntity` | AR anchor point | AnchoringComponent | +| `PerspectiveCamera` | Virtual camera | PerspectiveCameraComponent | +| `DirectionalLight` | Sun/directional | DirectionalLightComponent | +| `PointLight` | Point light | PointLightComponent | +| `SpotLight` | Spot light | SpotLightComponent | +| `TriggerVolume` | Invisible collision zone | CollisionComponent | +| `ViewAttachmentEntity` | SwiftUI view in 3D | visionOS | +| `BodyTrackedEntity` | Body-tracked entity | BodyTrackingComponent | + +--- + +## Part 2: Component Catalog + +### Transform + +```swift +// Properties +entity.position // SIMD3, local +entity.orientation // simd_quatf +entity.scale // SIMD3 +entity.transform // Transform struct + +// World-space +entity.position(relativeTo: nil) +entity.orientation(relativeTo: nil) +entity.setPosition(pos, relativeTo: nil) + +// Utilities +entity.look(at: target, from: position, relativeTo: nil) +``` + +### ModelComponent + +```swift +let component = ModelComponent( + mesh: MeshResource.generateBox(size: 0.1), + materials: [SimpleMaterial(color: .red, isMetallic: true)] +) +entity.components[ModelComponent.self] = component +``` + +### MeshResource Built-in Generators + +| Method | Parameters | +|--------|-----------| +| `.generateBox(size:)` | `SIMD3` or single `Float` | +| `.generateBox(size:cornerRadius:)` | Rounded box | +| `.generateSphere(radius:)` | `Float` | +| `.generatePlane(width:depth:)` | `Float`, `Float` | +| `.generatePlane(width:height:)` | Vertical plane | +| `.generateCylinder(height:radius:)` | `Float`, `Float` | +| `.generateCone(height:radius:)` | `Float`, `Float` | +| `.generateText(_:)` | `String`, with options | + +### CollisionComponent + +```swift +let component = CollisionComponent( + shapes: [ + .generateBox(size: SIMD3(0.1, 0.2, 0.1)), + .generateSphere(radius: 0.05), + .generateCapsule(height: 0.3, radius: 0.05), + .generateConvex(from: meshResource) + ], + mode: .default, // .default or .trigger + filter: CollisionFilter( + group: CollisionGroup(rawValue: 1), + mask: .all + ) +) +``` + +### ShapeResource Types + +| Method | Description | Performance | +|--------|-------------|-------------| +| `.generateBox(size:)` | Axis-aligned box | Fastest | +| `.generateSphere(radius:)` | Sphere | Fast | +| `.generateCapsule(height:radius:)` | Capsule | Fast | +| `.generateConvex(from:)` | Convex hull from mesh | Moderate | +| `.generateStaticMesh(from:)` | Exact mesh | Slowest (static only) | + +### PhysicsBodyComponent + +```swift +let component = PhysicsBodyComponent( + massProperties: .init( + mass: 1.0, + inertia: SIMD3(repeating: 0.1), + centerOfMass: .zero + ), + material: .generate( + staticFriction: 0.5, + dynamicFriction: 0.3, + restitution: 0.4 + ), + mode: .dynamic // .dynamic, .static, .kinematic +) +``` + +| Mode | Behavior | +|------|----------| +| `.dynamic` | Physics simulation controls position | +| `.static` | Immovable, participates in collisions | +| `.kinematic` | Code-controlled, affects dynamic bodies | + +### PhysicsMotionComponent + +```swift +var motion = PhysicsMotionComponent() +motion.linearVelocity = SIMD3(0, 5, 0) +motion.angularVelocity = SIMD3(0, .pi, 0) +entity.components[PhysicsMotionComponent.self] = motion +``` + +### CharacterControllerComponent + +```swift +entity.components[CharacterControllerComponent.self] = CharacterControllerComponent( + radius: 0.3, + height: 1.8, + slopeLimit: .pi / 4, + stepLimit: 0.3 +) + +// Move character with gravity +entity.moveCharacter( + by: SIMD3(0.1, -0.01, 0), + deltaTime: Float(context.deltaTime), + relativeTo: nil +) +``` + +### AnchoringComponent + +```swift +// Plane detection +AnchoringComponent(.plane(.horizontal, classification: .table, + minimumBounds: SIMD2(0.2, 0.2))) +AnchoringComponent(.plane(.vertical, classification: .wall, + minimumBounds: SIMD2(0.5, 0.5))) + +// World position +AnchoringComponent(.world(transform: float4x4(...))) + +// Image anchor +AnchoringComponent(.image(group: "AR Resources", name: "poster")) + +// Face tracking +AnchoringComponent(.face) + +// Body tracking +AnchoringComponent(.body) +``` + +### Plane Classification + +| Classification | Description | +|----------------|-------------| +| `.table` | Horizontal table surface | +| `.floor` | Floor surface | +| `.ceiling` | Ceiling surface | +| `.wall` | Vertical wall | +| `.door` | Door | +| `.window` | Window | +| `.seat` | Chair/couch | + +### Light Components + +```swift +// Directional +let light = DirectionalLightComponent( + color: .white, + intensity: 1000, + isRealWorldProxy: false +) +light.shadow = DirectionalLightComponent.Shadow( + maximumDistance: 10, + depthBias: 0.01 +) + +// Point +PointLightComponent( + color: .white, + intensity: 1000, + attenuationRadius: 5 +) + +// Spot +SpotLightComponent( + color: .white, + intensity: 1000, + innerAngleInDegrees: 30, + outerAngleInDegrees: 60, + attenuationRadius: 10 +) +``` + +### Accessibility + +```swift +var accessibility = AccessibilityComponent() +accessibility.label = "Red cube" +accessibility.value = "Interactive 3D object" +accessibility.traits = .button +accessibility.isAccessibilityElement = true +entity.components[AccessibilityComponent.self] = accessibility +``` + +### Additional Components + +| Component | Purpose | Platform | +|-----------|---------|----------| +| `OpacityComponent` | Fade entity in/out | All | +| `GroundingShadowComponent` | Contact shadow beneath entity | All | +| `InputTargetComponent` | Enable gesture input | visionOS | +| `HoverEffectComponent` | Highlight on gaze/hover | visionOS | +| `SynchronizationComponent` | Multiplayer entity sync | All | +| `ImageBasedLightComponent` | Custom environment lighting | All | +| `ImageBasedLightReceiverComponent` | Receive IBL from source | All | + +--- + +## Part 3: System API + +### System Protocol + +```swift +protocol System { + init(scene: RealityKit.Scene) + func update(context: SceneUpdateContext) +} +``` + +### SceneUpdateContext + +| Property | Type | Description | +|----------|------|-------------| +| `deltaTime` | `TimeInterval` | Time since last update | +| `scene` | `RealityKit.Scene` | The scene | + +```swift +// Query entities +context.entities(matching: query, updatingSystemWhen: .rendering) +``` + +### EntityQuery + +```swift +// Has specific component +EntityQuery(where: .has(HealthComponent.self)) + +// Has multiple components +EntityQuery(where: .has(HealthComponent.self) && .has(ModelComponent.self)) + +// Does not have component +EntityQuery(where: .has(EnemyComponent.self) && !.has(DeadComponent.self)) +``` + +### Scene Events + +| Event | Trigger | +|-------|---------| +| `SceneEvents.Update` | Every frame | +| `SceneEvents.DidAddEntity` | Entity added to scene | +| `SceneEvents.DidRemoveEntity` | Entity removed from scene | +| `SceneEvents.AnchoredStateChanged` | Anchor tracking changes | +| `CollisionEvents.Began` | Two entities start colliding | +| `CollisionEvents.Updated` | Collision continues | +| `CollisionEvents.Ended` | Collision ends | +| `AnimationEvents.PlaybackCompleted` | Animation finishes | + +```swift +scene.subscribe(to: CollisionEvents.Began.self, on: entity) { event in + // event.entityA, event.entityB, event.impulse +} +``` + +--- + +## Part 4: RealityView API + +### Initializers + +```swift +// Basic (iOS 18+, visionOS 1.0+) +RealityView { content in + // make: Add entities to content +} + +// With update +RealityView { content in + // make +} update: { content in + // update: Called when SwiftUI state changes +} + +// With placeholder +RealityView { content in + // make (async loading) +} placeholder: { + ProgressView() +} + +// With attachments (visionOS) +RealityView { content, attachments in + // make +} update: { content, attachments in + // update +} attachments: { + Attachment(id: "label") { Text("Hello") } +} +``` + +### RealityViewContent + +```swift +content.add(entity) +content.remove(entity) +content.entities // EntityCollection + +// iOS/macOS — camera content +content.camera // RealityViewCameraContent (non-visionOS) +``` + +### Gestures on RealityView + +```swift +RealityView { content in ... } + .gesture(TapGesture().targetedToAnyEntity().onEnded { value in + let entity = value.entity + }) + .gesture(DragGesture().targetedToAnyEntity().onChanged { value in + value.entity.position = value.convert(value.location3D, + from: .local, to: .scene) + }) + .gesture(RotateGesture().targetedToAnyEntity().onChanged { value in + // Handle rotation + }) + .gesture(MagnifyGesture().targetedToAnyEntity().onChanged { value in + // Handle scale + }) +``` + +--- + +## Part 5: Model3D API + +```swift +// Simple display +Model3D(named: "robot") + +// With phases +Model3D(named: "robot") { phase in + switch phase { + case .empty: + ProgressView() + case .success(let model): + model.resizable().scaledToFit() + case .failure(let error): + Text("Failed: \(error.localizedDescription)") + @unknown default: + EmptyView() + } +} + +// From URL +Model3D(url: modelURL) +``` + +--- + +## Part 6: Material System + +### SimpleMaterial + +```swift +var material = SimpleMaterial() +material.color = .init(tint: .blue) +material.metallic = .init(floatLiteral: 1.0) +material.roughness = .init(floatLiteral: 0.3) +``` + +### PhysicallyBasedMaterial + +```swift +var material = PhysicallyBasedMaterial() +material.baseColor = .init(tint: .white, + texture: .init(try .load(named: "albedo"))) +material.metallic = .init(floatLiteral: 0.0) +material.roughness = .init(floatLiteral: 0.5) +material.normal = .init(texture: .init(try .load(named: "normal"))) +material.ambientOcclusion = .init(texture: .init(try .load(named: "ao"))) +material.emissiveColor = .init(color: .blue) +material.emissiveIntensity = 2.0 +material.clearcoat = .init(floatLiteral: 0.8) +material.clearcoatRoughness = .init(floatLiteral: 0.1) +material.specular = .init(floatLiteral: 0.5) +material.sheen = .init(color: .white) +material.anisotropyLevel = .init(floatLiteral: 0.5) +material.blending = .transparent(opacity: .init(floatLiteral: 0.5)) +material.faceCulling = .back // .none, .front, .back +``` + +### UnlitMaterial + +```swift +var material = UnlitMaterial() +material.color = .init(tint: .red, + texture: .init(try .load(named: "texture"))) +material.blending = .transparent(opacity: .init(floatLiteral: 0.8)) +``` + +### Special Materials + +```swift +// Occlusion — invisible but hides content behind it +let occlusionMaterial = OcclusionMaterial() + +// Video +let videoMaterial = VideoMaterial(avPlayer: avPlayer) +``` + +### TextureResource Loading + +```swift +// From bundle +let texture = try await TextureResource(named: "texture") + +// From URL +let texture = try await TextureResource(contentsOf: url) + +// With options +let texture = try await TextureResource(named: "texture", + options: .init(semantic: .color)) // .color, .raw, .normal, .hdrColor +``` + +--- + +## Part 7: Animation + +### Transform Animation + +```swift +entity.move( + to: Transform( + scale: .one, + rotation: targetRotation, + translation: targetPosition + ), + relativeTo: entity.parent, + duration: 1.5, + timingFunction: .easeInOut +) +``` + +### Timing Functions + +| Function | Curve | +|----------|-------| +| `.default` | System default | +| `.linear` | Constant speed | +| `.easeIn` | Slow start | +| `.easeOut` | Slow end | +| `.easeInOut` | Slow start and end | + +### Playing Loaded Animations + +```swift +// All animations from USD +for animation in entity.availableAnimations { + let controller = entity.playAnimation(animation) +} + +// With options +let controller = entity.playAnimation( + animation.repeat(count: 3), + transitionDuration: 0.3, + startsPaused: false +) +``` + +### AnimationPlaybackController + +```swift +let controller = entity.playAnimation(animation) +controller.pause() +controller.resume() +controller.stop() +controller.speed = 0.5 // Half speed +controller.blendFactor = 1.0 // Full blend +controller.isComplete // Check completion +``` + +--- + +## Part 8: Audio + +### AudioFileResource + +```swift +// Load +let resource = try AudioFileResource.load( + named: "sound.wav", + configuration: .init( + shouldLoop: true, + shouldRandomizeStartTime: false, + mixGroupName: "effects" + ) +) +``` + +### Audio Components + +```swift +// Spatial (3D positional) +entity.components[SpatialAudioComponent.self] = SpatialAudioComponent( + directivity: .beam(focus: 0.5), + distanceAttenuation: .rolloff(factor: 1.0), + gain: 0 // dB +) + +// Ambient (non-positional, uniform) +entity.components[AmbientAudioComponent.self] = AmbientAudioComponent( + gain: -6 +) + +// Channel (multi-channel output) +entity.components[ChannelAudioComponent.self] = ChannelAudioComponent( + gain: 0 +) +``` + +### Playback + +```swift +let controller = entity.playAudio(resource) +controller.pause() +controller.stop() +controller.gain = -3 // Adjust volume (dB) +controller.speed = 1.5 // Pitch shift + +entity.stopAllAudio() +``` + +--- + +## Part 9: RealityRenderer (Metal Integration) + +```swift +// Low-level Metal rendering of RealityKit content +let renderer = try RealityRenderer() +renderer.entities.append(entity) + +// Render to Metal texture +let descriptor = RealityRenderer.CameraOutput.Descriptor( + colorFormat: .bgra8Unorm, + depthFormat: .depth32Float +) +try renderer.render( + viewMatrix: viewMatrix, + projectionMatrix: projectionMatrix, + size: size, + colorTexture: colorTexture, + depthTexture: depthTexture +) +``` + +--- + +## Resources + +**WWDC**: 2019-603, 2019-605, 2021-10074, 2022-10074, 2023-10080, 2024-10103, 2024-10153 + +**Docs**: /realitykit, /realitykit/entity, /realitykit/component, /realitykit/system, /realitykit/realityview, /realitykit/model3d, /realitykit/modelentity, /realitykit/anchorentity, /realitykit/physicallybasedmaterial + +**Skills**: axiom-realitykit, axiom-realitykit-diag, axiom-scenekit-ref diff --git a/.claude/skills/axiom-realitykit-ref/agents/openai.yaml b/.claude/skills/axiom-realitykit-ref/agents/openai.yaml new file mode 100644 index 0000000..5e678ce --- /dev/null +++ b/.claude/skills/axiom-realitykit-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "RealityKit Reference" + short_description: "RealityKit API reference" diff --git a/.claude/skills/axiom-realitykit/.openskills.json b/.claude/skills/axiom-realitykit/.openskills.json new file mode 100644 index 0000000..06cf62d --- /dev/null +++ b/.claude/skills/axiom-realitykit/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-realitykit", + "installedAt": "2026-04-12T08:06:34.076Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-realitykit/SKILL.md b/.claude/skills/axiom-realitykit/SKILL.md new file mode 100644 index 0000000..617dd15 --- /dev/null +++ b/.claude/skills/axiom-realitykit/SKILL.md @@ -0,0 +1,929 @@ +--- +name: axiom-realitykit +description: Use when building 3D content, AR experiences, or spatial computing with RealityKit. Covers ECS architecture, SwiftUI integration, RealityView, AR anchors, materials, physics, interaction, multiplayer, performance. +license: MIT +metadata: + version: "1.0.0" +--- + +# RealityKit Development Guide + +**Purpose**: Build 3D content, AR experiences, and spatial computing apps using RealityKit's Entity-Component-System architecture +**iOS Version**: iOS 13+ (base), iOS 18+ (RealityView on iOS), visionOS 1.0+ +**Xcode**: Xcode 15+ + +## When to Use This Skill + +Use this skill when: +- Building any 3D experience (AR, games, visualization, spatial computing) +- Creating SwiftUI apps with 3D content (RealityView, Model3D) +- Implementing AR with anchors (world, image, face, body tracking) +- Working with Entity-Component-System (ECS) architecture +- Setting up physics, collisions, or spatial interactions +- Building multiplayer or shared AR experiences +- Migrating from SceneKit to RealityKit +- Targeting visionOS + +Do NOT use this skill for: +- SceneKit maintenance (use `axiom-scenekit`) +- 2D games (use `axiom-spritekit`) +- Metal shader programming (use `axiom-metal-migration-ref`) +- Pure GPU compute (use Metal directly) + +--- + +## 1. Mental Model: ECS vs Scene Graph + +### Scene Graph (SceneKit) + +In SceneKit, nodes own their properties. A node IS a renderable, collidable, animated thing. + +### Entity-Component-System (RealityKit) + +In RealityKit, entities are **empty containers**. Components add data. Systems process that data. + +``` +Entity (identity + hierarchy) + ├── TransformComponent (position, rotation, scale) + ├── ModelComponent (mesh + materials) + ├── CollisionComponent (collision shapes) + ├── PhysicsBodyComponent (mass, mode) + └── [YourCustomComponent] (game-specific data) + +System (processes entities with specific components each frame) +``` + +**Why ECS matters**: +- **Composition over inheritance**: Combine any components on any entity +- **Data-oriented**: Systems process arrays of components efficiently +- **Decoupled logic**: Systems don't know about each other +- **Testable**: Components are pure data, Systems are pure logic + +### The ECS Mental Shift + +| Scene Graph Thinking | ECS Thinking | +|---------------------|--------------| +| "The player node moves" | "The movement system processes entities with MovementComponent" | +| "Add a method to the node subclass" | "Add a component, create a system" | +| "Override `update(_:)` in the node" | "Register a System that queries for components" | +| "The node knows its health" | "HealthComponent holds data, DamageSystem processes it" | + +--- + +## 2. Entity Hierarchy + +### Creating Entities + +```swift +// Empty entity +let entity = Entity() +entity.name = "player" + +// Entity with components +let entity = Entity() +entity.components[ModelComponent.self] = ModelComponent( + mesh: .generateBox(size: 0.1), + materials: [SimpleMaterial(color: .blue, isMetallic: false)] +) + +// ModelEntity convenience (has ModelComponent built in) +let box = ModelEntity( + mesh: .generateBox(size: 0.1), + materials: [SimpleMaterial(color: .red, isMetallic: true)] +) +``` + +### Hierarchy Management + +```swift +// Parent-child +parent.addChild(child) +child.removeFromParent() + +// Find entities +let found = root.findEntity(named: "player") + +// Enumerate +for child in entity.children { + // Process children +} + +// Clone +let clone = entity.clone(recursive: true) +``` + +### Transform + +```swift +// Local transform (relative to parent) +entity.position = SIMD3(0, 1, 0) +entity.orientation = simd_quatf(angle: .pi / 4, axis: SIMD3(0, 1, 0)) +entity.scale = SIMD3(repeating: 2.0) + +// World-space queries +let worldPos = entity.position(relativeTo: nil) +let worldTransform = entity.transform(relativeTo: nil) + +// Set world-space transform +entity.setPosition(SIMD3(1, 0, 0), relativeTo: nil) + +// Look at a point +entity.look(at: targetPosition, from: entity.position, relativeTo: nil) +``` + +--- + +## 3. Components + +### Built-in Components + +| Component | Purpose | +|-----------|---------| +| `Transform` | Position, rotation, scale | +| `ModelComponent` | Mesh geometry + materials | +| `CollisionComponent` | Collision shapes for physics and interaction | +| `PhysicsBodyComponent` | Mass, physics mode (dynamic/static/kinematic) | +| `PhysicsMotionComponent` | Linear and angular velocity | +| `AnchoringComponent` | AR anchor attachment | +| `SynchronizationComponent` | Multiplayer sync | +| `PerspectiveCameraComponent` | Camera settings | +| `DirectionalLightComponent` | Directional light | +| `PointLightComponent` | Point light | +| `SpotLightComponent` | Spot light | +| `CharacterControllerComponent` | Character physics controller | +| `AudioMixGroupsComponent` | Audio mixing | +| `SpatialAudioComponent` | 3D positional audio | +| `AmbientAudioComponent` | Non-positional audio | +| `ChannelAudioComponent` | Multi-channel audio | +| `OpacityComponent` | Entity transparency | +| `GroundingShadowComponent` | Contact shadow | +| `InputTargetComponent` | Gesture input (visionOS) | +| `HoverEffectComponent` | Hover highlight (visionOS) | +| `AccessibilityComponent` | VoiceOver support | + +### Custom Components + +```swift +struct HealthComponent: Component { + var current: Int + var maximum: Int + + var percentage: Float { + Float(current) / Float(maximum) + } +} + +// Register before use (typically in app init) +HealthComponent.registerComponent() + +// Attach to entity +entity.components[HealthComponent.self] = HealthComponent(current: 100, maximum: 100) + +// Read +if let health = entity.components[HealthComponent.self] { + print(health.current) +} + +// Modify +entity.components[HealthComponent.self]?.current -= 10 +``` + +### Component Lifecycle + +Components are value types (structs). When you read a component, modify it, and write it back, you're replacing the entire component: + +```swift +// Read-modify-write pattern +var health = entity.components[HealthComponent.self]! +health.current -= damage +entity.components[HealthComponent.self] = health +``` + +**Anti-pattern**: Holding a reference to a component and expecting mutations to propagate. Components are copied on read. + +--- + +## 4. Systems + +### System Protocol + +```swift +struct DamageSystem: System { + // Define which components this system needs + static let query = EntityQuery(where: .has(HealthComponent.self)) + + init(scene: RealityKit.Scene) { + // One-time setup + } + + func update(context: SceneUpdateContext) { + for entity in context.entities(matching: Self.query, + updatingSystemWhen: .rendering) { + var health = entity.components[HealthComponent.self]! + if health.current <= 0 { + entity.removeFromParent() + } + } + } +} + +// Register system +DamageSystem.registerSystem() +``` + +### System Best Practices + +- **One responsibility per system**: MovementSystem, DamageSystem, RenderingSystem — not GameLogicSystem +- **Query filtering**: Use precise queries to avoid processing irrelevant entities +- **Order matters**: Systems run in registration order. Register dependencies first. +- **Avoid storing entity references**: Query each frame instead. Entity references can become stale. + +### Event Handling + +```swift +// Subscribe to collision events +scene.subscribe(to: CollisionEvents.Began.self) { event in + let entityA = event.entityA + let entityB = event.entityB + // Handle collision +} + +// Subscribe to scene update +scene.subscribe(to: SceneEvents.Update.self) { event in + let deltaTime = event.deltaTime + // Per-frame logic +} +``` + +--- + +## 5. SwiftUI Integration + +### RealityView (iOS 18+, visionOS 1.0+) + +```swift +struct ContentView: View { + var body: some View { + RealityView { content in + // make closure — called once + let box = ModelEntity( + mesh: .generateBox(size: 0.1), + materials: [SimpleMaterial(color: .blue, isMetallic: false)] + ) + content.add(box) + + } update: { content in + // update closure — called when SwiftUI state changes + } + } +} +``` + +### RealityView with Camera (iOS) + +On iOS, `RealityView` provides a camera content parameter for configuring the AR or virtual camera: + +```swift +RealityView { content, attachments in + // Load 3D content + if let model = try? await ModelEntity(named: "scene") { + content.add(model) + } +} +``` + +### Loading Content Asynchronously + +```swift +RealityView { content in + // Load from bundle + if let entity = try? await Entity(named: "MyScene", in: .main) { + content.add(entity) + } + + // Load from URL + if let entity = try? await Entity(contentsOf: modelURL) { + content.add(entity) + } +} +``` + +### Model3D (Simple Display) + +```swift +// Simple 3D model display (no interaction) +Model3D(named: "toy_robot") { model in + model + .resizable() + .scaledToFit() +} placeholder: { + ProgressView() +} +``` + +### SwiftUI Attachments (visionOS) + +```swift +RealityView { content, attachments in + let entity = ModelEntity(mesh: .generateSphere(radius: 0.1)) + content.add(entity) + + if let label = attachments.entity(for: "priceTag") { + label.position = SIMD3(0, 0.15, 0) + entity.addChild(label) + } +} attachments: { + Attachment(id: "priceTag") { + Text("$9.99") + .padding() + .glassBackgroundEffect() + } +} +``` + +### State Binding Pattern + +```swift +struct GameView: View { + @State private var score = 0 + + var body: some View { + VStack { + Text("Score: \(score)") + + RealityView { content in + let scene = try! await Entity(named: "GameScene") + content.add(scene) + } update: { content in + // React to state changes + // Note: update is called when SwiftUI state changes, + // not every frame. Use Systems for per-frame logic. + } + } + } +} +``` + +--- + +## 6. AR on iOS + +### AnchorEntity + +```swift +// Horizontal plane +let anchor = AnchorEntity(.plane(.horizontal, classification: .table, + minimumBounds: SIMD2(0.2, 0.2))) + +// Vertical plane +let anchor = AnchorEntity(.plane(.vertical, classification: .wall, + minimumBounds: SIMD2(0.5, 0.5))) + +// World position +let anchor = AnchorEntity(world: SIMD3(0, 0, -1)) + +// Image anchor +let anchor = AnchorEntity(.image(group: "AR Resources", name: "poster")) + +// Face anchor (front camera) +let anchor = AnchorEntity(.face) + +// Body anchor +let anchor = AnchorEntity(.body) +``` + +### SpatialTrackingSession (iOS 18+) + +```swift +let session = SpatialTrackingSession() +let configuration = SpatialTrackingSession.Configuration(tracking: [.plane, .object]) +let result = await session.run(configuration) + +if let notSupported = result { + // Handle unsupported tracking on this device + for denied in notSupported.deniedTrackingModes { + print("Not supported: \(denied)") + } +} +``` + +### AR Best Practices + +- Anchor entities to detected surfaces rather than world positions for stability +- Use plane classification (`.table`, `.floor`, `.wall`) to place content appropriately +- Start with horizontal plane detection — it's the most reliable +- Test on real devices; simulator AR is limited +- Provide visual feedback during surface detection (coaching overlay) + +--- + +## 7. Interaction + +### ManipulationComponent (iOS, visionOS) + +```swift +// Enable drag, rotate, scale gestures +entity.components[ManipulationComponent.self] = ManipulationComponent( + allowedModes: .all // .translate, .rotate, .scale +) + +// Also requires CollisionComponent for hit testing +entity.generateCollisionShapes(recursive: true) +``` + +### InputTargetComponent (visionOS) + +```swift +// Required for visionOS gesture input +entity.components[InputTargetComponent.self] = InputTargetComponent() +entity.components[CollisionComponent.self] = CollisionComponent( + shapes: [.generateBox(size: SIMD3(0.1, 0.1, 0.1))] +) +``` + +### Gesture Integration with SwiftUI + +```swift +RealityView { content in + let entity = ModelEntity(mesh: .generateBox(size: 0.1)) + entity.generateCollisionShapes(recursive: true) + entity.components.set(InputTargetComponent()) + content.add(entity) +} +.gesture( + TapGesture() + .targetedToAnyEntity() + .onEnded { value in + let tappedEntity = value.entity + // Handle tap + } +) +.gesture( + DragGesture() + .targetedToAnyEntity() + .onChanged { value in + value.entity.position = value.convert(value.location3D, + from: .local, to: .scene) + } +) +``` + +### Hit Testing + +```swift +// Ray-cast from screen point +if let result = arView.raycast(from: screenPoint, + allowing: .estimatedPlane, + alignment: .horizontal).first { + let worldPosition = result.worldTransform.columns.3 + // Place entity at worldPosition +} +``` + +--- + +## 8. Materials and Rendering + +### Material Types + +| Material | Purpose | Customization | +|----------|---------|---------------| +| `SimpleMaterial` | Solid color or texture | Color, metallic, roughness | +| `PhysicallyBasedMaterial` | Full PBR | All PBR maps (base color, normal, metallic, roughness, AO, emissive) | +| `UnlitMaterial` | No lighting response | Color or texture, always fully lit | +| `OcclusionMaterial` | Invisible but occludes | AR content hiding behind real objects | +| `VideoMaterial` | Video playback on surface | AVPlayer-driven | +| `ShaderGraphMaterial` | Custom shader graph | Reality Composer Pro | +| `CustomMaterial` | Metal shader functions | Full Metal control | + +### PhysicallyBasedMaterial + +```swift +var material = PhysicallyBasedMaterial() +material.baseColor = .init(tint: .white, + texture: .init(try! .load(named: "albedo"))) +material.metallic = .init(floatLiteral: 0.0) +material.roughness = .init(floatLiteral: 0.5) +material.normal = .init(texture: .init(try! .load(named: "normal"))) +material.ambientOcclusion = .init(texture: .init(try! .load(named: "ao"))) +material.emissiveColor = .init(color: .blue) +material.emissiveIntensity = 2.0 + +let entity = ModelEntity( + mesh: .generateSphere(radius: 0.1), + materials: [material] +) +``` + +### OcclusionMaterial (AR) + +```swift +// Invisible plane that hides 3D content behind it +let occluder = ModelEntity( + mesh: .generatePlane(width: 1, depth: 1), + materials: [OcclusionMaterial()] +) +occluder.position = SIMD3(0, 0, 0) +anchor.addChild(occluder) +``` + +### Environment Lighting + +```swift +// Image-based lighting +if let resource = try? await EnvironmentResource(named: "studio_lighting") { + // Apply via RealityView content +} +``` + +--- + +## 9. Physics and Collision + +### Collision Shapes + +```swift +// Generate from mesh (accurate but expensive) +entity.generateCollisionShapes(recursive: true) + +// Manual shapes (prefer for performance) +entity.components[CollisionComponent.self] = CollisionComponent( + shapes: [ + .generateBox(size: SIMD3(0.1, 0.2, 0.1)), // Box + .generateSphere(radius: 0.1), // Sphere + .generateCapsule(height: 0.3, radius: 0.05) // Capsule + ] +) +``` + +### Physics Body + +```swift +// Dynamic — physics simulation controls movement +entity.components[PhysicsBodyComponent.self] = PhysicsBodyComponent( + massProperties: .init(mass: 1.0), + material: .generate(staticFriction: 0.5, + dynamicFriction: 0.3, + restitution: 0.4), + mode: .dynamic +) + +// Static — immovable collision surface +ground.components[PhysicsBodyComponent.self] = PhysicsBodyComponent( + mode: .static +) + +// Kinematic — code-controlled, participates in collisions +platform.components[PhysicsBodyComponent.self] = PhysicsBodyComponent( + mode: .kinematic +) +``` + +### Collision Groups and Filters + +```swift +// Define groups +let playerGroup = CollisionGroup(rawValue: 1 << 0) +let enemyGroup = CollisionGroup(rawValue: 1 << 1) +let bulletGroup = CollisionGroup(rawValue: 1 << 2) + +// Filter: player collides with enemies and bullets +entity.components[CollisionComponent.self] = CollisionComponent( + shapes: [.generateSphere(radius: 0.1)], + filter: CollisionFilter( + group: playerGroup, + mask: enemyGroup | bulletGroup + ) +) +``` + +### Collision Events + +```swift +// Subscribe in RealityView make closure or System +scene.subscribe(to: CollisionEvents.Began.self, on: playerEntity) { event in + let otherEntity = event.entityA == playerEntity ? event.entityB : event.entityA + handleCollision(with: otherEntity) +} +``` + +### Applying Forces + +```swift +if var motion = entity.components[PhysicsMotionComponent.self] { + motion.linearVelocity = SIMD3(0, 5, 0) // Impulse up + entity.components[PhysicsMotionComponent.self] = motion +} +``` + +--- + +## 10. Animation + +### Transform Animation + +```swift +// Animate to position over duration +entity.move( + to: Transform( + scale: SIMD3(repeating: 1.5), + rotation: simd_quatf(angle: .pi, axis: SIMD3(0, 1, 0)), + translation: SIMD3(0, 2, 0) + ), + relativeTo: entity.parent, + duration: 2.0, + timingFunction: .easeInOut +) +``` + +### Playing USD Animations + +```swift +if let entity = try? await Entity(named: "character") { + // Play all available animations + for animation in entity.availableAnimations { + entity.playAnimation(animation.repeat()) + } +} +``` + +### Animation Playback Control + +```swift +let controller = entity.playAnimation(animation) +controller.pause() +controller.resume() +controller.speed = 2.0 // 2x playback speed +controller.blendFactor = 0.5 // Blend with current state +``` + +--- + +## 11. Audio + +### Spatial Audio + +```swift +// Load audio resource +let resource = try! AudioFileResource.load(named: "engine.wav", + configuration: .init(shouldLoop: true)) + +// Create entity with spatial audio +let audioEntity = Entity() +audioEntity.components[SpatialAudioComponent.self] = SpatialAudioComponent() +let controller = audioEntity.playAudio(resource) + +// Position the audio source in 3D space +audioEntity.position = SIMD3(2, 0, -1) +``` + +### Ambient Audio + +```swift +entity.components[AmbientAudioComponent.self] = AmbientAudioComponent() +entity.playAudio(backgroundMusic) +``` + +--- + +## 12. Performance + +### Entity Count + +- **Under 100 entities**: No concerns +- **100-1000 entities**: Monitor with RealityKit debugger +- **1000+ entities**: Use instancing and LOD strategies + +### Instancing + +```swift +// Share mesh and material across many entities +let sharedMesh = MeshResource.generateSphere(radius: 0.01) +let sharedMaterial = SimpleMaterial(color: .white, isMetallic: false) + +for i in 0..<1000 { + let entity = ModelEntity(mesh: sharedMesh, materials: [sharedMaterial]) + entity.position = randomPosition() + parent.addChild(entity) +} +``` + +RealityKit automatically batches entities with identical mesh and material resources. + +### Component Churn + +**Anti-pattern**: Creating and replacing components every frame. + +```swift +// BAD — component allocation every frame +func update(context: SceneUpdateContext) { + for entity in context.entities(matching: query, updatingSystemWhen: .rendering) { + entity.components[ModelComponent.self] = ModelComponent( + mesh: .generateBox(size: 0.1), + materials: [newMaterial] // New allocation every frame + ) + } +} + +// GOOD — modify existing component +func update(context: SceneUpdateContext) { + for entity in context.entities(matching: query, updatingSystemWhen: .rendering) { + // Only update when actually needed + if needsUpdate { + var model = entity.components[ModelComponent.self]! + model.materials = [cachedMaterial] + entity.components[ModelComponent.self] = model + } + } +} +``` + +### Collision Shape Optimization + +- Use simple shapes (box, sphere, capsule) instead of mesh-based collision +- `generateCollisionShapes(recursive: true)` is convenient but expensive +- For static geometry, generate shapes once during setup + +### Profiling + +Use Xcode's RealityKit debugger: +- **Entity Inspector**: View entity hierarchy and components +- **Statistics Overlay**: Entity count, draw calls, triangle count +- **Physics Visualization**: Show collision shapes + +--- + +## 13. Multiplayer + +### Synchronization Basics + +```swift +// Components sync automatically if they conform to Codable +struct ScoreComponent: Component, Codable { + var points: Int +} + +// SynchronizationComponent controls what syncs +entity.components[SynchronizationComponent.self] = SynchronizationComponent() +``` + +### MultipeerConnectivityService + +```swift +let service = try MultipeerConnectivityService(session: mcSession) +// Entities with SynchronizationComponent auto-sync across peers +``` + +### Ownership + +- Only the **owner** of an entity can modify it +- Request ownership before modifying shared entities +- Non-Codable component data does not sync + +--- + +## 14. Anti-Patterns + +### Anti-Pattern 1: UIKit-Style Thinking in ECS + +**Time cost**: Hours of frustration from fighting the architecture + +```swift +// BAD — subclassing Entity for behavior +class PlayerEntity: Entity { + func takeDamage(_ amount: Int) { /* logic in entity */ } +} + +// GOOD — component holds data, system has logic +struct HealthComponent: Component { var hp: Int } +struct DamageSystem: System { + static let query = EntityQuery(where: .has(HealthComponent.self)) + func update(context: SceneUpdateContext) { + // Process damage here + } +} +``` + +### Anti-Pattern 2: Monolithic Entities + +**Time cost**: Untestable, inflexible architecture + +Don't put all game logic in one entity type. Split into components that can be mixed and matched. + +### Anti-Pattern 3: Frame-Based Updates Without Systems + +**Time cost**: Missed frame updates, inconsistent behavior + +```swift +// BAD — timer-based updates +Timer.scheduledTimer(withTimeInterval: 1/60, repeats: true) { _ in + entity.position.x += 0.01 +} + +// GOOD — System update +struct MovementSystem: System { + static let query = EntityQuery(where: .has(VelocityComponent.self)) + func update(context: SceneUpdateContext) { + for entity in context.entities(matching: Self.query, + updatingSystemWhen: .rendering) { + let velocity = entity.components[VelocityComponent.self]! + entity.position += velocity.value * Float(context.deltaTime) + } + } +} +``` + +### Anti-Pattern 4: Not Generating Collision Shapes for Interactive Entities + +**Time cost**: 15-30 min debugging "why taps don't work" + +Gestures require `CollisionComponent`. If an entity has `InputTargetComponent` (visionOS) or `ManipulationComponent` but no `CollisionComponent`, gestures will never fire. + +### Anti-Pattern 5: Storing Entity References in Systems + +**Time cost**: Crashes from stale references + +```swift +// BAD — entity might be removed between frames +struct BadSystem: System { + var playerEntity: Entity? // Stale reference risk + + func update(context: SceneUpdateContext) { + playerEntity?.position.x += 0.1 // May crash + } +} + +// GOOD — query each frame +struct GoodSystem: System { + static let query = EntityQuery(where: .has(PlayerComponent.self)) + + func update(context: SceneUpdateContext) { + for entity in context.entities(matching: Self.query, + updatingSystemWhen: .rendering) { + entity.position.x += Float(context.deltaTime) + } + } +} +``` + +--- + +## 15. Code Review Checklist + +- [ ] Custom components registered via `registerComponent()` before use +- [ ] Systems registered via `registerSystem()` before scene loads +- [ ] Components are value types (structs), not classes +- [ ] Read-modify-write pattern used for component updates +- [ ] Interactive entities have `CollisionComponent` +- [ ] visionOS interactive entities have `InputTargetComponent` +- [ ] Collision shapes are simple (box/sphere/capsule) where possible +- [ ] No entity references stored across frames in Systems +- [ ] Mesh and material resources shared across identical entities +- [ ] Component updates only occur when values actually change +- [ ] USD/USDZ format used for 3D assets (not .scn) +- [ ] Async loading used for all model/scene loading +- [ ] `[weak self]` in closure-based subscriptions if retaining view/controller + +--- + +## 16. Pressure Scenarios + +### Scenario 1: "ECS Is Overkill for Our Simple App" + +**Pressure**: Team wants to avoid learning ECS, just needs one 3D model displayed + +**Wrong approach**: Skip ECS, jam all logic into RealityView closures. + +**Correct approach**: Even simple apps benefit from ECS. A single `ModelEntity` in a `RealityView` is already using ECS — you're just not adding custom components yet. Start simple, add components as complexity grows. + +**Push-back template**: "We're already using ECS — Entity and ModelComponent. The pattern scales. Adding a custom component when we need behavior is one struct definition, not an architecture change." + +### Scenario 2: "Just Use SceneKit, We Know It" + +**Pressure**: Team has SceneKit experience, RealityKit is unfamiliar + +**Wrong approach**: Build new features in SceneKit. + +**Correct approach**: SceneKit is soft-deprecated. New features won't be added. Invest in RealityKit now — the ECS concepts transfer to other game engines (Unity, Unreal, Bevy) if needed. + +**Push-back template**: "SceneKit is in maintenance mode — no new features, only security patches. Every line of SceneKit we write is migration debt. RealityKit's concepts (Entity, Component, System) are industry-standard ECS." + +### Scenario 3: "Make It Work Without Collision Shapes" + +**Pressure**: Deadline, collision shape setup seems complex + +**Wrong approach**: Skip collision shapes, use position-based proximity detection. + +**Correct approach**: `entity.generateCollisionShapes(recursive: true)` takes one line. Without it, gestures won't work and physics won't collide. The "shortcut" creates more debugging time than it saves. + +**Push-back template**: "Collision shapes are required for gestures and physics. It's one line: `entity.generateCollisionShapes(recursive: true)`. Skipping it means gestures silently fail — a harder bug to diagnose." + +--- + +## Resources + +**WWDC**: 2019-603, 2019-605, 2021-10074, 2022-10074, 2023-10080, 2023-10081, 2024-10103, 2024-10153 + +**Docs**: /realitykit, /realitykit/entity, /realitykit/realityview, /realitykit/modelentity, /realitykit/anchorentity, /realitykit/component + +**Skills**: axiom-realitykit-ref, axiom-realitykit-diag, axiom-scenekit, axiom-scenekit-ref diff --git a/.claude/skills/axiom-realitykit/agents/openai.yaml b/.claude/skills/axiom-realitykit/agents/openai.yaml new file mode 100644 index 0000000..d0a103b --- /dev/null +++ b/.claude/skills/axiom-realitykit/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "RealityKit" + short_description: "Building 3D content, AR experiences, or spatial computing with RealityKit" diff --git a/.claude/skills/axiom-realm-migration-ref/.openskills.json b/.claude/skills/axiom-realm-migration-ref/.openskills.json new file mode 100644 index 0000000..3a6ddeb --- /dev/null +++ b/.claude/skills/axiom-realm-migration-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-realm-migration-ref", + "installedAt": "2026-04-12T08:06:35.274Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-realm-migration-ref/SKILL.md b/.claude/skills/axiom-realm-migration-ref/SKILL.md new file mode 100644 index 0000000..4b99a38 --- /dev/null +++ b/.claude/skills/axiom-realm-migration-ref/SKILL.md @@ -0,0 +1,793 @@ +--- +name: axiom-realm-migration-ref +description: Use when migrating from Realm to SwiftData - comprehensive migration guide covering pattern equivalents, threading model conversion, schema migration strategies, CloudKit sync transition, and real-world scenarios +license: MIT +metadata: + version: "1.0.0" +--- + +# Realm to SwiftData Migration — Reference Guide + +**Purpose**: Complete migration path from Realm to SwiftData +**Swift Version**: Swift 5.9+ (Swift 6 with strict concurrency recommended) +**iOS Version**: iOS 17+ (iOS 26+ recommended) +**Context**: Realm Device Sync sunset Sept 30, 2025. This guide is essential for Realm users migrating before deadline. + +--- + +## Critical Timeline + +**Realm Device Sync** DEPRECATION DEADLINE = September 30, 2025 + +If your app uses Realm Sync: +- ⚠️ You MUST migrate by September 30, 2025 +- ✅ SwiftData is the recommended replacement +- ⏰ Time remaining: Depends on current date, but migrations take 2-8 weeks for production apps + +**This guide** provides everything needed for successful migration. + +--- + +## Migration Strategy Overview + +``` +Phase 1 (Week 1-2): Preparation & Planning +├─ Audit current Realm usage +├─ Understand model relationships +├─ Plan data migration path +└─ Set up test environment + +Phase 2 (Week 2-3): Development +├─ Create SwiftData models from Realm schemas +├─ Implement data migration logic +├─ Convert threading model to async/await +└─ Test with real data + +Phase 3 (Week 3-4): Migration +├─ Migrate existing app users' data +├─ Run in parallel (Realm + SwiftData) +├─ Verify CloudKit sync works +└─ Monitor for issues + +Phase 4 (Week 4+): Production +├─ Deploy update with parallel persistence +├─ Gradual cutover from Realm to SwiftData +├─ Deprecate Realm code +└─ Monitor CloudKit sync health +``` + +--- + +## Part 1: Pattern Equivalents + +### Model Definition Conversion + +#### Realm → SwiftData: Basic Model + +```swift +// REALM +class RealmTrack: Object { + @Persisted(primaryKey: true) var id: String + @Persisted var title: String + @Persisted var artist: String + @Persisted var duration: TimeInterval + @Persisted var genre: String? +} + +// SWIFTDATA +@Model +final class Track { + @Attribute(.unique) var id: String // remove if using CloudKit sync + var title: String + var artist: String + var duration: TimeInterval + var genre: String? + + init(id: String, title: String, artist: String, duration: TimeInterval, genre: String? = nil) { + self.id = id + self.title = title + self.artist = artist + self.duration = duration + self.genre = genre + } +} +``` + +**Key differences**: +- Realm: `@Persisted(primaryKey: true)` → SwiftData: `@Attribute(.unique)` (not supported with CloudKit sync — remove if using CloudKit) +- Realm: Implicit init → SwiftData: Explicit init required +- Realm: `Object` base class → SwiftData: `@Model` macro on `final class` + +#### Realm → SwiftData: Relationships + +```swift +// REALM: One-to-Many +class RealmAlbum: Object { + @Persisted(primaryKey: true) var id: String + @Persisted var title: String + @Persisted var tracks: RealmSwiftCollection +} + +// SWIFTDATA: One-to-Many +@Model +final class Album { + @Attribute(.unique) var id: String + var title: String + + @Relationship(deleteRule: .cascade, inverse: \Track.album) + var tracks: [Track] = [] +} + +@Model +final class Track { + @Attribute(.unique) var id: String + var title: String + var album: Album? // Inverse automatically maintained +} +``` + +**Key differences**: +- Realm: Explicit `RealmSwiftCollection` type → SwiftData: Native `[Track]` array +- Realm: Manual relationship management → SwiftData: Inverse relationships automatic +- Realm: No delete rules → SwiftData: `deleteRule: .cascade / .nullify / .deny` + +#### Realm → SwiftData: Indexes + +```swift +// REALM +class RealmTrack: Object { + @Persisted(primaryKey: true) var id: String + @Persisted(indexed: true) var genre: String + @Persisted(indexed: true) var releaseDate: Date +} + +// SWIFTDATA +@Model +final class Track { + @Attribute(.unique) var id: String + @Attribute(.indexed) var genre: String = "" + @Attribute(.indexed) var releaseDate: Date = Date() +} +``` + +--- + +## Part 2: Threading Model Conversion + +### Realm Threading → Swift Concurrency + +#### Realm: Manual Thread Handling + +```swift +class RealmDataManager { + func fetchTracksOnBackground() { + DispatchQueue.global().async { + let realm = try! Realm() // Must get Realm on each thread + let tracks = realm.objects(RealmTrack.self) + + DispatchQueue.main.async { + self.updateUI(tracks: Array(tracks)) + } + } + } + + func saveTrackOnBackground(_ track: RealmTrack) { + DispatchQueue.global().async { + let realm = try! Realm() + try! realm.write { + realm.add(track) + } + } + } +} +``` + +**Problems**: +- Manual DispatchQueue threading error-prone +- Easy to access objects on wrong thread +- No compile-time guarantees + +#### SwiftData: Actor-Based Concurrency + +```swift +actor SwiftDataManager { + let modelContainer: ModelContainer + + func fetchTracks() async -> [Track] { + let context = ModelContext(modelContainer) + let descriptor = FetchDescriptor() + return (try? context.fetch(descriptor)) ?? [] + } + + func saveTrack(_ track: Track) async { + let context = ModelContext(modelContainer) + context.insert(track) + try? context.save() + } +} + +// Usage (automatic thread handling) +@MainActor +class ViewController: UIViewController { + @State private var tracks: [Track] = [] + private let manager: SwiftDataManager + + func loadTracks() async { + tracks = await manager.fetchTracks() + } +} +``` + +**Advantages**: +- No manual DispatchQueue +- Compile-time thread safety +- Automatic actor isolation +- Swift 6 strict concurrency compatible + +#### Common Threading Patterns + +| Realm Pattern | SwiftData Pattern | +|--------------|------------------| +| `DispatchQueue.global().async` | `async/await` in background actor | +| `realm.write { }` | `context.insert()` + `context.save()` | +| Manual thread-local Realm instances | Shared `ModelContainer` + background `ModelContext` | +| `Thread.isMainThread` checks | `@MainActor` annotations | + +--- + +## Part 3: Schema Migration Strategies + +### Simple Schema Migration (Direct Conversion) + +For apps with simple schemas (< 5 tables, < 10 fields), direct migration is straightforward: + +```swift +actor SchemaImporter { + let realmPath: String + let modelContainer: ModelContainer + + func migrateFromRealm() async throws { + // 1. Open Realm database + let realmConfig = Realm.Configuration(fileURL: URL(fileURLWithPath: realmPath)) + let realm = try await Realm(configuration: realmConfig) + + // 2. Create SwiftData context + let context = ModelContext(modelContainer) + + // 3. Migrate each model type + try migrateAllTracks(from: realm, to: context) + try migrateAllAlbums(from: realm, to: context) + try migrateAllPlaylists(from: realm, to: context) + + // 4. Save all at once + try context.save() + + print("Migration complete!") + } + + private func migrateAllTracks(from realm: Realm, to context: ModelContext) throws { + let realmTracks = realm.objects(RealmTrack.self) + + for realmTrack in realmTracks { + let sdTrack = Track( + id: realmTrack.id, + title: realmTrack.title, + artist: realmTrack.artist, + duration: realmTrack.duration, + genre: realmTrack.genre + ) + context.insert(sdTrack) + } + } + + private func migrateAllAlbums(from realm: Realm, to context: ModelContext) throws { + let realmAlbums = realm.objects(RealmAlbum.self) + + for realmAlbum in realmAlbums { + let sdAlbum = Album( + id: realmAlbum.id, + title: realmAlbum.title + ) + context.insert(sdAlbum) + + // Connect relationships after creating all records + for realmTrack in realmAlbum.tracks { + if let sdTrack = findTrack(id: realmTrack.id, in: context) { + sdAlbum.tracks.append(sdTrack) + } + } + } + } + + private func findTrack(id: String, in context: ModelContext) -> Track? { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + return try? context.fetch(descriptor).first + } +} +``` + +### Complex Schema Migration (Transformation Layer) + +For apps with complex schemas, many computed properties, or data transformations: + +```swift +// Step 1: Define transformation layer +struct TrackDTO { + let realmTrack: RealmTrack + + var id: String { realmTrack.id } + var title: String { realmTrack.title } + var cleanTitle: String { realmTrack.title.trimmingCharacters(in: .whitespaces) } + var durationFormatted: String { + let minutes = Int(realmTrack.duration) / 60 + let seconds = Int(realmTrack.duration) % 60 + return String(format: "%d:%02d", minutes, seconds) + } +} + +// Step 2: Migrate through transformation layer +actor ComplexMigrator { + let modelContainer: ModelContainer + + func migrateWithTransformation(from realm: Realm) throws { + let context = ModelContext(modelContainer) + + let realmTracks = realm.objects(RealmTrack.self) + for realmTrack in realmTracks { + let dto = TrackDTO(realmTrack: realmTrack) + + // Transform data during migration + let sdTrack = Track( + id: dto.id, + title: dto.cleanTitle, // Cleaned version + artist: realmTrack.artist, + duration: realmTrack.duration + ) + context.insert(sdTrack) + } + + try context.save() + } +} +``` + +--- + +## Part 4: CloudKit Sync Transition + +### Realm Sync → SwiftData CloudKit + +Realm Sync (now deprecated) provided automatic sync. SwiftData uses CloudKit directly: + +```swift +// REALM SYNC: Automatic but deprecated +let config = Realm.Configuration( + syncConfiguration: SyncConfiguration(user: app.currentUser!) +) + +// SWIFTDATA: CloudKit (recommended replacement) +let schema = Schema([Track.self, Album.self]) +let config = ModelConfiguration( + schema: schema, + cloudKitDatabase: .private("iCloud.com.example.MusicApp") +) + +let container = try ModelContainer(for: schema, configurations: config) +``` + +### Sync Status Monitoring + +```swift +@MainActor +class CloudKitSyncMonitor: ObservableObject { + @Published var isSyncing = false + @Published var lastSyncDate: Date? + @Published var syncError: Error? + + let modelContainer: ModelContainer + + func startMonitoring() { + // Monitor CloudKit sync notifications + NotificationCenter.default.addObserver( + forName: NSNotification.Name("CloudKitSyncDidComplete"), + object: nil, + queue: .main + ) { [weak self] _ in + self?.isSyncing = false + self?.lastSyncDate = Date() + } + } + + func syncNow() async { + isSyncing = true + + do { + let context = ModelContext(modelContainer) + // SwiftData sync happens automatically + // Manually fetch to trigger sync + let descriptor = FetchDescriptor() + _ = try context.fetch(descriptor) + } catch { + syncError = error + } + + isSyncing = false + } +} +``` + +### Migration Timing: Realm Sync → CloudKit + +``` +Timeline: +Week 1-2: Development & Testing +├─ Create SwiftData models +├─ Test migrations in non-CloudKit mode +└─ Prepare CloudKit configuration + +Week 3: CloudKit Sync Testing +├─ Enable CloudKit in test build +├─ Verify sync works with small datasets +├─ Test multi-device sync +└─ Test conflict resolution + +Week 4+: Production Rollout +├─ Deploy app with SwiftData + CloudKit +├─ Initially run parallel (Realm Sync + SwiftData CloudKit) +├─ Monitor both sync mechanisms +├─ Gradually deprecate Realm Sync +└─ Final cutoff before Sept 30, 2025 +``` + +--- + +## Part 5: Real-World Migration Scenarios + +### Scenario A: Small App (< 10,000 Records) + +**Timeline**: 1-2 weeks +**Data Size**: < 10 MB + +```swift +// 1. Export Realm data +let realmPath = Realm.Configuration.defaultConfiguration.fileURL! + +// 2. Migrate in background task +actor SmallAppMigration { + let modelContainer: ModelContainer + + func migrateSmallApp() async throws { + let realmConfig = Realm.Configuration(fileURL: realmPath) + let realm = try await Realm(configuration: realmConfig) + + let context = ModelContext(modelContainer) + + // All-at-once migration (safe for < 10k records) + let allTracks = realm.objects(RealmTrack.self) + for realmTrack in allTracks { + let track = Track(from: realmTrack) + context.insert(track) + } + + try context.save() + print("✅ Migrated \(allTracks.count) tracks") + } +} + +// 3. Deploy +// Option 1: Migrate on first launch (offline) +// Option 2: Provide manual "Migrate Data" button +// Option 3: Automatic migration in background +``` + +### Scenario B: Medium App (100,000 - 1,000,000 Records) + +**Timeline**: 3-4 weeks +**Data Size**: 100 MB - 1 GB +**Challenge**: Progress reporting, memory management + +```swift +actor MediumAppMigration { + let modelContainer: ModelContainer + let realmPath: String + + typealias ProgressCallback = (Int, Int) -> Void + + func migrateMediumApp(onProgress: @MainActor ProgressCallback) async throws { + let realmConfig = Realm.Configuration(fileURL: URL(fileURLWithPath: realmPath)) + let realm = try await Realm(configuration: realmConfig) + + let context = ModelContext(modelContainer) + let allTracks = realm.objects(RealmTrack.self) + let totalCount = allTracks.count + + // Chunk-based migration for memory efficiency + var count = 0 + for chunk in Array(allTracks).chunked(into: 5000) { + for realmTrack in chunk { + let track = Track(from: realmTrack) + context.insert(track) + } + + // Save periodically + try context.save() + + count += chunk.count + await onProgress(count, totalCount) + + // Check for cancellation + if Task.isCancelled { + throw CancellationError() + } + } + } +} + +// 4. Show progress UI +@MainActor +class MigrationViewController: UIViewController { + @IBOutlet weak var progressView: UIProgressView! + @IBOutlet weak var statusLabel: UILabel! + + func startMigration() { + Task { + do { + try await migrator.migrateMediumApp { current, total in + self.progressView.progress = Float(current) / Float(total) + self.statusLabel.text = "Migrated \(current) of \(total)..." + } + + self.statusLabel.text = "✅ Migration complete!" + } catch { + self.statusLabel.text = "❌ Migration failed: \(error)" + } + } + } +} +``` + +### Scenario C: Large App (Enterprise, > 1 Million Records) + +**Timeline**: 6-8 weeks +**Data Size**: > 1 GB +**Challenge**: Minimal downtime, data integrity, rollback plan + +```swift +class EnterpriseGradualMigration { + let coreDataStack: CoreDataStack // Existing Realm + let modelContainer: ModelContainer + let batchSize = 10000 + + // Phase 1: Parallel migration + func startGradualMigration() async { + var offset = 0 + let totalRecords = countAllRecords() + + while offset < totalRecords { + let batch = fetchRealmBatch(limit: batchSize, offset: offset) + try? await migrateBatch(batch) + + offset += batchSize + await reportProgress(offset, totalRecords) + } + } + + private func migrateBatch(_ batch: [RealmTrack]) async throws { + let context = ModelContext(modelContainer) + + for realmTrack in batch { + let track = Track(from: realmTrack) + context.insert(track) + track.migrationStatus = .completedPhase1 + } + + try context.save() + + // Give main thread time to breathe + try await Task.sleep(nanoseconds: 100_000_000) // 100ms + } + + // Phase 2: Verify all migrated + func verifyMigrationComplete() async throws { + let sdContext = ModelContext(modelContainer) + let sdCount = try sdContext.fetch(FetchDescriptor()) + + let realmCount = countAllRealmRecords() + + guard sdCount.count == realmCount else { + throw MigrationError.countMismatch(sd: sdCount.count, realm: realmCount) + } + + print("✅ Verified: \(sdCount.count) records migrated") + } + + // Phase 3: Rollback plan + func rollbackToRealm() { + // Keep Realm database intact until 100% confident + // Only delete Realm after running stable on SwiftData for 2+ weeks + } +} +``` + +--- + +## Part 6: Testing & Verification + +### Data Integrity Checklist + +Before going live with SwiftData: + +```swift +@MainActor +class MigrationVerifier { + func verifyMigration() async throws { + print("🔍 Running migration verification...") + + // 1. Count verification + let sdCount = try await countSwiftDataRecords() + let realmCount = countRealmRecords() + print("✓ Record count: SD=\(sdCount), Realm=\(realmCount)") + + guard sdCount == realmCount else { + throw VerificationError.countMismatch + } + + // 2. Data integrity sampling (spot checks) + try await verifySampleRecords(count: min(100, sdCount / 10)) + print("✓ Spot checked 100 records - all valid") + + // 3. Relationship integrity + try await verifyRelationships() + print("✓ All relationships intact") + + // 4. CloudKit sync test + try await verifyCloudKitSync() + print("✓ CloudKit sync working") + + // 5. Performance test + try await verifyPerformance() + print("✓ Query performance acceptable") + + print("✅ All verifications passed!") + } + + private func verifySampleRecords(count: Int) async throws { + let sdContext = ModelContext(modelContainer) + let descriptor = FetchDescriptor() + + let tracks = try sdContext.fetch(descriptor) + let sample = Array(tracks.prefix(count)) + + for track in sample { + // Verify fields populated + assert(!track.id.isEmpty, "Track has empty ID") + assert(!track.title.isEmpty, "Track has empty title") + assert(track.duration > 0, "Track has invalid duration") + } + } + + private func verifyRelationships() async throws { + let sdContext = ModelContext(modelContainer) + + let albumDescriptor = FetchDescriptor() + let albums = try sdContext.fetch(albumDescriptor) + + for album in albums { + // Verify inverse relationships + for track in album.tracks { + assert(track.album?.id == album.id, "Relationship broken") + } + } + } + + private func verifyCloudKitSync() async throws { + let sdContext = ModelContext(modelContainer) + + // Insert test record + let testTrack = Track( + id: "test-" + UUID().uuidString, + title: "Test Track", + artist: "Test Artist", + duration: 240 + ) + sdContext.insert(testTrack) + try sdContext.save() + + // Verify CloudKit sync initiated + // (Check iCloud → iPhone → Settings → iCloud for sync status) + print("ℹ️ Check iCloud app to verify sync initiated") + } + + private func verifyPerformance() async throws { + let sdContext = ModelContext(modelContainer) + + let start = Date() + + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.title)] + ) + _ = try sdContext.fetch(descriptor) + + let elapsed = Date().timeIntervalSince(start) + print("Fetch time: \(String(format: "%.2f", elapsed))s") + + guard elapsed < 2.0 else { + throw VerificationError.performanceIssue + } + } +} +``` + +--- + +## Part 7: Troubleshooting + +### Common Migration Issues + +| Issue | Cause | Solution | +|-------|-------|----------| +| "Property must have default" | CloudKit constraint | Add defaults: `var title: String = ""` | +| Relationships not synced | Missing inverse | Add `inverse: \Track.album` | +| Sync stuck | CloudKit auth issue | Check Settings → iCloud → CloudKit | +| Memory bloat during import | No chunking | Implement batch import (1000 at a time) | +| Data loss | No backup | Keep Realm copy for 2 weeks post-migration | + +--- + +## Part 8: Success Criteria + +Your migration is successful when: + +- [ ] All data migrated correctly (count matches) +- [ ] Sample record verification passes (spot checks 100+ records) +- [ ] Relationships intact (inverse relationships work) +- [ ] CloudKit sync enabled and working +- [ ] Performance acceptable (queries < 1 second) +- [ ] No data races (Swift 6 strict concurrency) +- [ ] Tested on real device (not just simulator) +- [ ] Rollback plan documented and tested +- [ ] Realm database kept as backup for 2 weeks +- [ ] Zero crashes in production after 1 week + +--- + +## Quick Reference: Command Checklist + +```bash +# 1. Audit Realm usage +grep -r "RealmTrack\|RealmAlbum" . --include="*.swift" + +# 2. Count Realm records (in app) +let realm = try! Realm() +let count = realm.objects(RealmTrack.self).count + +# 3. Export Realm database +cp ~/Library/Developer/Realm/my_realm.realm ~/Downloads/backup.realm + +# 4. Test SwiftData models +// Create in-memory test container +let config = ModelConfiguration(isStoredInMemoryOnly: true) +let container = try ModelContainer(for: Track.self, configurations: config) + +# 5. Verify CloudKit +Settings → [Your Name] → iCloud → Check CloudKit status +``` + +--- + +## Resources + +**WWDC**: 2024-10137 + +**Docs**: /swiftdata + +**Skills**: axiom-swiftdata, axiom-swift-concurrency, axiom-database-migration + +--- + +**Created**: 2025-11-30 +**Status**: Production-ready migration guide +**Urgency**: Realm Device Sync sunset September 30, 2025 +**Estimated Migration Time**: 2-8 weeks depending on app complexity diff --git a/.claude/skills/axiom-realm-migration-ref/agents/openai.yaml b/.claude/skills/axiom-realm-migration-ref/agents/openai.yaml new file mode 100644 index 0000000..ea47ecb --- /dev/null +++ b/.claude/skills/axiom-realm-migration-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Realm Migration Reference" + short_description: "Migrating from Realm to SwiftData" diff --git a/.claude/skills/axiom-resolve-spm/.openskills.json b/.claude/skills/axiom-resolve-spm/.openskills.json new file mode 100644 index 0000000..360b70d --- /dev/null +++ b/.claude/skills/axiom-resolve-spm/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-resolve-spm", + "installedAt": "2026-04-12T08:06:35.275Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-resolve-spm/SKILL.md b/.claude/skills/axiom-resolve-spm/SKILL.md new file mode 100644 index 0000000..b64c092 --- /dev/null +++ b/.claude/skills/axiom-resolve-spm/SKILL.md @@ -0,0 +1,450 @@ +--- +name: axiom-resolve-spm +description: Use when the user mentions SPM resolution failures, "no such module" errors, duplicate symbol linker errors, version conflicts between packages, or Swift 6 package compatibility issues. +license: MIT +disable-model-invocation: true +--- +# SPM Conflict Resolver Agent + +You are an expert at diagnosing and resolving Swift Package Manager dependency conflicts. + +## Your Mission + +Analyze Package.swift and Package.resolved to: +- Identify version conflicts between packages +- Detect duplicate symbol issues +- Find Swift version mismatches +- Resolve transitive dependency problems +- Fix platform compatibility issues + +Report findings with: +- Specific conflict details +- Resolution options (ranked by preference) +- Exact commands to run +- Package.swift edits if needed + +## Files to Analyze + +**Required**: +- `Package.swift` - Package manifest +- `Package.resolved` - Resolved versions (if exists) + +**Also check**: +- `*.xcodeproj/project.pbxproj` - Xcode project packages +- `.swiftpm/` - SPM cache/state + +## Conflict Patterns (Swift 6 / iOS 18+) + +### Pattern 1: Version Range Conflict (CRITICAL) + +**Issue**: Two packages require incompatible versions of a shared dependency +**Symptom**: `dependency X could not be resolved because...` + +**Detection**: +```bash +swift package show-dependencies --format json 2>&1 | grep -i "could not be resolved" +swift package diagnose-api-breaking-changes +``` + +**Resolution Strategy**: +1. Check if newer versions of conflicting packages exist +2. Widen version range constraints if safe +3. Fork and patch the stricter package +4. Use package trait/platform conditions + +```swift +// ❌ Conflict +.package(url: "https://github.com/A/PackageA", from: "1.0.0"), // Requires Alamofire 5.8+ +.package(url: "https://github.com/B/PackageB", from: "2.0.0"), // Requires Alamofire < 5.5 + +// ✅ Resolution: Find compatible versions or update PackageB +.package(url: "https://github.com/A/PackageA", from: "1.0.0"), +.package(url: "https://github.com/B/PackageB", from: "3.0.0"), // Updated to support Alamofire 5.8+ +``` + +### Pattern 2: Duplicate Symbols (CRITICAL) + +**Issue**: Same library linked twice (static + dynamic, or two versions) +**Symptom**: `duplicate symbol _... in: ... and ...` + +**Detection**: +```bash +# Check for duplicate framework linking +grep -r "frameworks" *.xcodeproj/project.pbxproj | grep -i "duplicate" + +# Check Package.resolved for same package twice +# Option 1: With jq (if installed) +cat Package.resolved | jq '.pins[] | .identity' | sort | uniq -d + +# Option 2: Without jq +swift package show-dependencies --format json 2>/dev/null | grep -o '"identity"[^,]*' | sort | uniq -d +``` + +**Resolution Strategy**: +1. Ensure package is listed only once in Package.swift +2. Check for packages that bundle the same dependency +3. Use `package` vs `target` linking appropriately + +```swift +// ❌ Problem: PackageA bundles Alamofire, you also depend on it directly +.package(url: "https://github.com/A/PackageA", from: "1.0.0"), // Has Alamofire inside +.package(url: "https://github.com/Alamofire/Alamofire", from: "5.8.0"), // Duplicate! + +// ✅ Resolution: Remove direct Alamofire dependency +.package(url: "https://github.com/A/PackageA", from: "1.0.0"), +// Use Alamofire transitively through PackageA +``` + +### Pattern 3: Swift 6 Language Mode Mismatch (HIGH) + +**Issue**: Package requires different Swift language mode +**Symptom**: `module was compiled with Swift 5 mode but client is using Swift 6` + +**Detection**: +```bash +grep -r "swiftLanguageMode" Package.swift +grep -r "swift-tools-version" Package.swift +``` + +**Resolution Strategy**: +1. Update package to Swift 6 compatible version +2. Set explicit language mode for problematic targets +3. Use `.enableExperimentalFeature("StrictConcurrency")` as bridge + +```swift +// Package.swift +let package = Package( + name: "MyApp", + platforms: [.iOS(.v18)], + products: [...], + dependencies: [...], + targets: [ + .target( + name: "MyApp", + dependencies: [...], + swiftSettings: [ + .swiftLanguageMode(.v6), // Set explicit mode + // Or for gradual migration: + .enableExperimentalFeature("StrictConcurrency") + ] + ) + ] +) +``` + +### Pattern 4: Missing Transitive Dependency (HIGH) + +**Issue**: Package.resolved is stale or corrupted +**Symptom**: `No such module 'X'` for a dependency of a dependency + +**Detection**: +```bash +# Check if Package.resolved is in sync +swift package resolve 2>&1 + +# Verify all pins are valid +swift package show-dependencies +``` + +**Resolution Strategy**: +```bash +# Full reset +rm -rf .build +rm Package.resolved +swift package resolve +``` + +### Pattern 5: Macro Target Build Failure (MEDIUM) + +**Issue**: Swift macro packages need special permissions +**Symptom**: `macro target requires Xcode 15+` or sandbox errors + +**Detection**: +```bash +grep -r "macro" Package.swift +grep -r ".macro(" Package.swift +``` + +**Resolution Strategy**: +1. Ensure Xcode 15+ for macro support +2. Trust the macro package in Xcode +3. Add `--disable-sandbox` for command-line builds if needed + +```bash +# Trust macro in Xcode +# Product → Swift Packages → Trust & Enable Package Plugin + +# Command line (last resort) +swift build --disable-sandbox +``` + +### Pattern 6: Platform Version Mismatch (MEDIUM) + +**Issue**: Package requires higher platform version +**Symptom**: `package requires minimum iOS 17 but target is iOS 16` + +**Detection**: +```bash +grep -r "platforms:" Package.swift +grep -r ".iOS\|.macOS\|.watchOS" Package.swift +``` + +**Resolution Strategy**: +1. Update your minimum deployment target +2. Use older package version compatible with your target +3. Conditionally include package with platform checks + +```swift +// Package.swift +let package = Package( + name: "MyApp", + platforms: [ + .iOS(.v18), // Must meet or exceed dependency requirements + .macOS(.v15) + ], + ... +) +``` + +## Audit Process + +### Step 1: Gather Package Information + +```bash +# Read Package.swift +cat Package.swift + +# Check resolved versions +cat Package.resolved + +# Show dependency tree +swift package show-dependencies --format text + +# Check for issues +swift package diagnose-api-breaking-changes 2>&1 || true +``` + +### Step 2: Identify Conflicts + +**Version conflicts**: +```bash +swift package resolve 2>&1 | grep -i "could not be resolved\|conflict\|incompatible" +``` + +**Build failures**: +```bash +swift build 2>&1 | head -50 +``` + +### Step 3: Analyze Dependency Graph + +```bash +# JSON format for programmatic analysis +swift package show-dependencies --format json > deps.json + +# Check for shared dependencies +cat deps.json | jq '.dependencies[].dependencies[] | .name' | sort | uniq -c | sort -rn +``` + +## Output Format + +```markdown +# SPM Dependency Analysis + +## Summary +- **CRITICAL Conflicts**: [count] +- **HIGH Issues**: [count] +- **MEDIUM Issues**: [count] + +## Package Information +- **Swift Tools Version**: 6.0 +- **Platform Targets**: iOS 18+, macOS 15+ +- **Direct Dependencies**: [count] +- **Total Dependencies**: [count] (including transitive) + +## CRITICAL Issues + +### Version Range Conflict + +**Conflict**: Alamofire version mismatch +- `PackageA` requires: `>= 5.8.0` +- `PackageB` requires: `< 5.5.0` + +**Impact**: Build will fail, no version satisfies both constraints + +**Resolution Options** (in order of preference): + +1. **Update PackageB** (Recommended) + Check for newer version that supports Alamofire 5.8+: + ```bash + # Check latest versions + git ls-remote --tags https://github.com/Example/PackageB + ``` + Then update Package.swift: + ```swift + .package(url: "https://github.com/Example/PackageB", from: "3.0.0") + ``` + +2. **Fork and Patch** + If no compatible version exists: + ```bash + git clone https://github.com/Example/PackageB + # Update its Package.swift to allow Alamofire 5.8+ + # Push to your fork + ``` + ```swift + .package(url: "https://github.com/YourFork/PackageB", branch: "alamofire-5.8") + ``` + +3. **Pin to Specific Versions** + Force specific version of shared dependency: + ```swift + .package(url: "https://github.com/Alamofire/Alamofire", exact: "5.4.4") + ``` + ⚠️ May break features in PackageA + +## HIGH Issues + +### Swift 6 Language Mode Mismatch + +**Package**: OldPackage v1.2.3 +**Issue**: Compiled with Swift 5 mode, your target uses Swift 6 + +**Resolution**: +```swift +// Add to target's swiftSettings +.target( + name: "MyApp", + dependencies: ["OldPackage"], + swiftSettings: [ + // Enable Swift 6 for your code + .swiftLanguageMode(.v6), + // OldPackage will use its own mode + ] +) +``` + +Or use gradual migration: +```swift +.enableExperimentalFeature("StrictConcurrency") +``` + +## Resolution Commands + +```bash +# Step 1: Clean SPM cache +rm -rf .build +rm -rf ~/Library/Caches/org.swift.swiftpm + +# Step 2: Reset Package.resolved +rm Package.resolved + +# Step 3: Resolve fresh +swift package resolve + +# Step 4: Verify +swift build + +# If in Xcode project +# File → Packages → Reset Package Caches +# File → Packages → Resolve Package Versions +``` + +## Dependency Graph + +``` +MyApp +├── Alamofire 5.8.0 +├── PackageA 2.0.0 +│ └── Alamofire 5.8.0 ✓ (matches) +└── PackageB 3.1.0 + └── Alamofire 5.8.0 ✓ (matches) +``` + +## Verification + +After resolution: +```bash +# Clean build +rm -rf .build && swift build + +# Run tests +swift test + +# Check for warnings +swift build 2>&1 | grep -i warning +``` +``` + +## When No Issues Found + +```markdown +# SPM Dependency Analysis + +## Summary +No conflicts detected. + +## Package Health +- ✅ All version constraints satisfied +- ✅ No duplicate dependencies +- ✅ Swift tools version compatible +- ✅ Platform requirements met + +## Dependency Graph +[Show clean dependency tree] + +## Recommendations +- Consider updating packages: + ```bash + swift package update + ``` +- Check for security advisories: + ```bash + swift package diagnose-api-breaking-changes + ``` +``` + +## Common SPM Commands Reference + +```bash +# Resolve dependencies +swift package resolve + +# Update all packages +swift package update + +# Update specific package +swift package update PackageName + +# Show dependencies +swift package show-dependencies +swift package show-dependencies --format json + +# Clean build +rm -rf .build + +# Reset SPM cache (nuclear option) +rm -rf ~/Library/Caches/org.swift.swiftpm +rm -rf .build +rm Package.resolved +swift package resolve + +# Diagnose issues +swift package diagnose-api-breaking-changes + +# Edit package locally (for debugging) +swift package edit PackageName +swift package unedit PackageName +``` + +## Xcode-Specific Commands + +``` +# In Xcode: +File → Packages → Reset Package Caches +File → Packages → Resolve Package Versions +File → Packages → Update to Latest Package Versions + +# Trust macro package: +Product → Swift Packages → Trust & Enable Package Plugin +``` diff --git a/.claude/skills/axiom-resolve-spm/agents/openai.yaml b/.claude/skills/axiom-resolve-spm/agents/openai.yaml new file mode 100644 index 0000000..18cb143 --- /dev/null +++ b/.claude/skills/axiom-resolve-spm/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Resolve Spm" + short_description: "The user mentions SPM resolution failures, \"no such module\" errors, duplicate symbol linker errors, version conflicts..." diff --git a/.claude/skills/axiom-run-tests/.openskills.json b/.claude/skills/axiom-run-tests/.openskills.json new file mode 100644 index 0000000..f32dbcb --- /dev/null +++ b/.claude/skills/axiom-run-tests/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-run-tests", + "installedAt": "2026-04-12T08:06:35.277Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-run-tests/SKILL.md b/.claude/skills/axiom-run-tests/SKILL.md new file mode 100644 index 0000000..7ad3ba4 --- /dev/null +++ b/.claude/skills/axiom-run-tests/SKILL.md @@ -0,0 +1,329 @@ +--- +name: axiom-run-tests +description: Use when the user wants to run XCUITests, parse test results, view test failures, or export test attachments. +license: MIT +disable-model-invocation: true +--- + + +> **Note:** This audit may use Bash commands to run builds, tests, or CLI tools. +# Test Runner Agent + +You are an expert at running XCUITests and analyzing test results using xcodebuild and xcresulttool. + +## Your Mission + +1. Discover available test schemes and targets +2. Run tests with proper result bundle configuration +3. Parse test results for failures +4. Export failure attachments (screenshots, videos) +5. Provide actionable analysis + +## Mandatory First Steps + +**ALWAYS run these checks FIRST** to understand the project: + +```bash +# 1. Verify project directory +ls -la | grep -E "\.xcodeproj|\.xcworkspace" + +# 2. Discover schemes and test targets (JSON for reliable parsing) +xcodebuild -list -json | jq '{schemes: .project.schemes, targets: .project.targets}' + +# 3. Check for booted simulator +BOOTED_UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid' | head -1) +if [ -z "$BOOTED_UDID" ]; then + echo "No simulator booted. Boot one first:" + xcrun simctl list devices -j | jq '.devices | to_entries[] | .value[] | select(.isAvailable == true) | {name, udid}' | head -20 +else + echo "Using booted simulator: $BOOTED_UDID" +fi +``` + +## Running Tests + +### Basic Test Execution + +```bash +# Get the booted simulator UDID +BOOTED_UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid' | head -1) + +# Create timestamped result bundle path +RESULT_PATH="/tmp/test-$(date +%s).xcresult" + +# Run tests with result bundle +xcodebuild test \ + -scheme "UITests" \ + -destination "platform=iOS Simulator,id=$BOOTED_UDID" \ + -resultBundlePath "$RESULT_PATH" \ + -enableCodeCoverage YES \ + 2>&1 | tee /tmp/xcodebuild-test.log + +echo "Results saved to: $RESULT_PATH" +``` + +### Running Specific Tests + +```bash +# Run a single test class +xcodebuild test \ + -scheme "UITests" \ + -destination "platform=iOS Simulator,id=$BOOTED_UDID" \ + -resultBundlePath "$RESULT_PATH" \ + -only-testing:"/LoginTests" + +# Run a single test method +xcodebuild test \ + -scheme "UITests" \ + -destination "platform=iOS Simulator,id=$BOOTED_UDID" \ + -resultBundlePath "$RESULT_PATH" \ + -only-testing:"/LoginTests/testLoginWithValidCredentials" + +# Skip specific tests +xcodebuild test \ + -scheme "UITests" \ + -destination "platform=iOS Simulator,id=$BOOTED_UDID" \ + -resultBundlePath "$RESULT_PATH" \ + -skip-testing:"/SlowTests" +``` + +## Parsing Test Results with xcresulttool + +### Get Test Summary + +```bash +# Overall summary (pass/fail counts, duration) +xcrun xcresulttool get test-results summary --path "$RESULT_PATH" +``` + +Output format: +``` +Test Results Summary: + Start Time: 2026-01-11 10:30:00 + End Time: 2026-01-11 10:35:00 + Tests: 42 + Passed: 39 + Failed: 3 + Skipped: 0 +``` + +### Get All Test Details + +```bash +# Detailed test information (all tests with status) +xcrun xcresulttool get test-results tests --path "$RESULT_PATH" +``` + +### Get Specific Test Details + +```bash +# First, get test IDs from the tests list +xcrun xcresulttool get test-results tests --path "$RESULT_PATH" | grep -E "testId|name" + +# Then get details for a specific test +xcrun xcresulttool get test-results test-details \ + --test-id "" \ + --path "$RESULT_PATH" +``` + +### Export Failure Attachments + +```bash +# Create output directory +ATTACHMENTS_DIR="/tmp/test-failures-$(date +%s)" +mkdir -p "$ATTACHMENTS_DIR" + +# Export only failure attachments (screenshots, videos) +xcrun xcresulttool export attachments \ + --path "$RESULT_PATH" \ + --output-path "$ATTACHMENTS_DIR" \ + --only-failures + +# Read the manifest to understand what was exported +cat "$ATTACHMENTS_DIR/manifest.json" | jq '.attachments[] | {name, testName, uniformTypeIdentifier}' + +echo "Failure attachments exported to: $ATTACHMENTS_DIR" +``` + +### Export All Attachments + +```bash +# Export all attachments (not just failures) +xcrun xcresulttool export attachments \ + --path "$RESULT_PATH" \ + --output-path "$ATTACHMENTS_DIR" +``` + +### Export Code Coverage + +```bash +COVERAGE_DIR="/tmp/coverage-$(date +%s)" +mkdir -p "$COVERAGE_DIR" + +xcrun xcresulttool export coverage \ + --path "$RESULT_PATH" \ + --output-path "$COVERAGE_DIR" + +echo "Coverage data exported to: $COVERAGE_DIR" +``` + +### Get Console Logs + +```bash +# Get console output from tests +xcrun xcresulttool get log --path "$RESULT_PATH" --type console +``` + +## Common Failure Patterns + +### Element Not Found + +**Symptom**: `Failed to find element: Button with identifier 'loginButton'` + +**Diagnosis**: +1. Missing accessibilityIdentifier +2. Element not visible (off-screen, hidden) +3. Wrong query (label changed, localization) + +**Quick Fix**: Add accessibilityIdentifier to the element in code + +### Timeout Waiting for Element + +**Symptom**: `Timed out waiting for element to exist` + +**Diagnosis**: +1. App is slow (network, animation) +2. Element appears conditionally +3. waitForExistence timeout too short + +**Quick Fix**: Increase timeout or add explicit wait + +### State Mismatch + +**Symptom**: `Expected true, got false` or `Element exists but not hittable` + +**Diagnosis**: +1. Race condition (UI updated between check and action) +2. Element behind another element +3. Keyboard covering element + +**Quick Fix**: Wait for UI to stabilize, dismiss keyboard + +## Output Format + +Provide structured test results: + +```markdown +## Test Run Results + +### Configuration +- **Scheme**: [scheme name] +- **Destination**: [simulator name] ([iOS version]) +- **Result Bundle**: [path] +- **Duration**: [time] + +### Summary +- **Total**: [count] +- **Passed**: [count] ✅ +- **Failed**: [count] ❌ +- **Skipped**: [count] ⏭️ + +### Failures + +#### 1. [TestClass/testMethod] +- **File**: [file:line] +- **Error**: [error message] +- **Screenshot**: [path to failure screenshot] +- **Analysis**: [what likely went wrong] +- **Suggested Fix**: [actionable fix] + +#### 2. [TestClass/testMethod] +... + +### Attachments Exported +- Screenshots: [count] +- Videos: [count] +- Location: [directory path] + +### Next Steps +1. [Specific action to fix first failure] +2. [How to rerun just the failing tests] +``` + +## Decision Tree + +``` +User wants to run tests +↓ +├─ No scheme specified → Discover schemes with xcodebuild -list -json +├─ No simulator booted → List available simulators, suggest boot command +├─ Scheme found + simulator ready → Run xcodebuild test +↓ +Tests complete +↓ +├─ All passed → Report success summary +├─ Failures detected: +│ ├─ Export failure attachments +│ ├─ Analyze each failure +│ ├─ Categorize by pattern (element not found, timeout, state) +│ └─ Provide specific fix suggestions +└─ Build failed before tests → Delegate to build-fixer agent +``` + +## Guidelines + +1. **ALWAYS use JSON output** for xcodebuild -list and simctl commands +2. **ALWAYS create timestamped result bundles** to preserve history +3. **Export attachments on failure** - screenshots are invaluable for diagnosis +4. **Read failure screenshots** - you're multimodal, analyze them +5. **Provide actionable fixes** - don't just report failures +6. **Suggest rerun commands** - make it easy to verify fixes + +**Never**: +- Skip the mandatory first steps (scheme discovery, simulator check) +- Delete xcresult bundles without user permission +- Report "tests failed" without analyzing WHY +- Assume the scheme name - always discover it first + +## Integration with Other Agents + +- **build-fixer**: If tests fail to build, delegate to build-fixer +- **simulator-tester**: For visual verification and manual testing scenarios +- **test-debugger**: For closed-loop debugging of persistent failures + +## Error Quick Reference + +| Error | Cause | Fix | +|-------|-------|-----| +| `xcodebuild: error: Could not find scheme` | Wrong scheme name | Run `xcodebuild -list -json` | +| `Unable to boot simulator` | Simulator stuck | Shutdown all, try again | +| `Test target not found` | Missing test target | Check scheme has test action | +| `Code signing error` | Provisioning issue | Use automatic signing | +| `xcresulttool: error: Invalid result bundle` | Corrupt or incomplete | Rerun tests | + +## Example Interaction + +**User**: "Run the UI tests and tell me what failed" + +**Your response**: +1. Discover schemes: `xcodebuild -list -json` +2. Check for booted simulator +3. Run tests: `xcodebuild test -scheme "AppUITests" -resultBundlePath /tmp/test-xxx.xcresult` +4. Parse results: `xcrun xcresulttool get test-results summary` +5. Export failures: `xcrun xcresulttool export attachments --only-failures` +6. Read and analyze failure screenshots +7. Report structured results with fixes + +## Resources + +**WWDC**: 2019-413 (Testing in Xcode) + +**Docs**: /xcode/xcresulttool + +**Skills**: axiom-ios-testing, axiom-xctest-automation + +## Related + +For build issues: `build-fixer` agent +For visual verification: `simulator-tester` agent +For closed-loop debugging: `test-debugger` agent diff --git a/.claude/skills/axiom-run-tests/agents/openai.yaml b/.claude/skills/axiom-run-tests/agents/openai.yaml new file mode 100644 index 0000000..2b3cf80 --- /dev/null +++ b/.claude/skills/axiom-run-tests/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Run Tests" + short_description: "The user wants to run XCUITests, parse test results, view test failures, or export test attachments." diff --git a/.claude/skills/axiom-scan-security-privacy/.openskills.json b/.claude/skills/axiom-scan-security-privacy/.openskills.json new file mode 100644 index 0000000..c0b4571 --- /dev/null +++ b/.claude/skills/axiom-scan-security-privacy/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-scan-security-privacy", + "installedAt": "2026-04-12T08:06:35.278Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-scan-security-privacy/SKILL.md b/.claude/skills/axiom-scan-security-privacy/SKILL.md new file mode 100644 index 0000000..460f14a --- /dev/null +++ b/.claude/skills/axiom-scan-security-privacy/SKILL.md @@ -0,0 +1,462 @@ +--- +name: axiom-scan-security-privacy +description: Use when the user mentions security review, App Store submission prep, Privacy Manifest requirements, hardcoded credentials, or sensitive data storage. +license: MIT +disable-model-invocation: true +--- +# Security & Privacy Scanner Agent + +You are an expert at detecting security vulnerabilities and privacy compliance issues in iOS apps. + +## Your Mission + +Scan the codebase for: +- Hardcoded credentials and API keys +- Insecure data storage (tokens in @AppStorage/UserDefaults) +- Missing Privacy Manifests (required for App Store) +- Required Reason API usage without declarations +- Sensitive data in logs +- ATS (App Transport Security) violations + +Report findings with: +- File:line references +- Severity ratings (CRITICAL/HIGH/MEDIUM) +- App Store rejection risk +- Fix recommendations with code examples + +## Files to Scan + +Include: `**/*.swift`, `**/Info.plist`, `**/PrivacyInfo.xcprivacy` +Skip: `*Tests.swift`, `*Previews.swift`, `*Mock*`, `*Fixture*`, `*Stub*`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*` + +## Security Patterns (iOS 18+) + +### Pattern 1: Hardcoded API Keys (CRITICAL) + +**Issue**: API keys, secrets, or tokens in source code +**App Store Risk**: May be flagged in security review +**Impact**: Keys extractable from binary + +**Detection**: +``` +# Credential assignments (ripgrep-compatible patterns) +Grep: apiKey.*=.*"[^"]+" +Grep: api_key.*=.*"[^"]+" +Grep: secret.*=.*"[^"]+" +Grep: token.*=.*"[^"]+" +Grep: password.*=.*"[^"]+" + +# Known API key formats +Grep: AKIA[0-9A-Z]{16} # AWS keys +Grep: -----BEGIN.*PRIVATE KEY----- # PEM keys +Grep: sk-[a-zA-Z0-9]{24,} # OpenAI keys +Grep: ghp_[a-zA-Z0-9]{36} # GitHub tokens +``` + +```swift +// ❌ CRITICAL - Exposed in binary +let apiKey = "sk-1234567890abcdef" +let awsKey = "AKIAIOSFODNN7EXAMPLE" + +// ✅ SECURE - Environment or Keychain +let apiKey = ProcessInfo.processInfo.environment["API_KEY"] ?? "" + +// ✅ BEST - Server-side proxy (key never in app) +// App calls your server, server calls API with key +``` + +### Pattern 2: Missing Privacy Manifest (CRITICAL) + +**Issue**: App uses Required Reason APIs without PrivacyInfo.xcprivacy +**App Store Risk**: Required since May 2024 — submissions rejected without valid manifest +**Impact**: App Store Connect blocks submission + +**Detection**: +``` +# Check if Privacy Manifest exists +Glob: **/PrivacyInfo.xcprivacy + +# Required Reason APIs that need declaration +Grep: NSUserDefaults|UserDefaults +Grep: FileManager.*contentsOfDirectory +Grep: systemUptime|ProcessInfo.*systemUptime +Grep: mach_absolute_time +Grep: fstat|stat\( +Grep: activeInputModes +Grep: UIDevice.*identifierForVendor +``` + +**Required Reason API Categories**: +| API | Category | Common Reason | +|-----|----------|---------------| +| UserDefaults | `NSPrivacyAccessedAPICategoryUserDefaults` | `CA92.1` (app functionality) | +| File timestamp | `NSPrivacyAccessedAPICategoryFileTimestamp` | `C617.1` (access/modify dates) | +| System boot time | `NSPrivacyAccessedAPICategorySystemBootTime` | `35F9.1` (elapsed time) | +| Disk space | `NSPrivacyAccessedAPICategoryDiskSpace` | `E174.1` (space available) | + +```xml + + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + +``` + +### Pattern 3: Insecure Token Storage (HIGH) + +**Issue**: Auth tokens or sensitive data in @AppStorage/UserDefaults +**App Store Risk**: Security review flag +**Impact**: Accessible on jailbroken devices, backup extraction + +**Detection**: +``` +Grep: @AppStorage.*token|@AppStorage.*key|@AppStorage.*secret +Grep: UserDefaults.*token|UserDefaults.*apiKey|UserDefaults.*password +Grep: UserDefaults\.standard\.set.*token +``` + +```swift +// ❌ HIGH RISK - UserDefaults is not encrypted +@AppStorage("authToken") var token: String = "" +UserDefaults.standard.set(token, forKey: "auth_token") + +// ✅ SECURE - Keychain with proper access +import Security + +func storeToken(_ token: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "auth_token", + kSecValueData as String: token.data(using: .utf8)!, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + + SecItemDelete(query as CFDictionary) // Remove old + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainError.saveFailed(status) + } +} +``` + +### Pattern 4: HTTP URLs (ATS Violation) (HIGH) + +**Issue**: Using `http://` instead of `https://` +**App Store Risk**: Requires ATS exception justification +**Impact**: Data transmitted in cleartext + +**Detection**: +``` +# Find HTTP URLs (exclude localhost manually when reviewing) +Grep: http://[a-zA-Z] +Grep: NSAllowsArbitraryLoads.*true +Grep: NSExceptionAllowsInsecureHTTPLoads +``` + +**Note**: Filter out `http://localhost` and `http://127.0.0.1` matches — these are acceptable for local development. + +```swift +// ❌ INSECURE - Cleartext transmission +let url = URL(string: "http://api.example.com/data") + +// ✅ SECURE - TLS encryption +let url = URL(string: "https://api.example.com/data") +``` + +### Pattern 5: Sensitive Data in Logs (MEDIUM) + +**Issue**: Passwords, tokens, or PII in Logger/print statements +**App Store Risk**: Privacy concern +**Impact**: Data visible in device logs + +**Detection**: +``` +Grep: print.*password|print.*token|print.*apiKey +Grep: Logger.*password|Logger.*token +Grep: os_log.*password|os_log.*token +Grep: NSLog.*password|NSLog.*token +``` + +```swift +// ❌ LOGGED - Visible in Console.app +print("User token: \(authToken)") +logger.info("Password: \(password)") + +// ✅ REDACTED - Safe logging +logger.info("User authenticated: \(userId, privacy: .public)") +logger.debug("Token received: [REDACTED]") +``` + +### Pattern 6: Missing ATT Usage Description (HIGH) + +**Issue**: App uses ATTrackingManager but missing NSUserTrackingUsageDescription in Info.plist +**App Store Risk**: Automatic rejection — ATT prompt cannot display without the description string +**Impact**: App crashes or silently fails to show tracking prompt + +**Detection**: +``` +# Check for ATT usage +Grep: ATTrackingManager|requestTrackingAuthorization|trackingAuthorizationStatus + +# If ATT found, check for the plist key +Grep: NSUserTrackingUsageDescription +# Also check Info.plist directly +``` + +```swift +// ❌ MISSING - ATT prompt will fail +ATTrackingManager.requestTrackingAuthorization { status in ... } +// But no NSUserTrackingUsageDescription in Info.plist + +// ✅ CORRECT - Info.plist has: +// NSUserTrackingUsageDescription +// We use this to show you relevant ads. +``` + +### Pattern 7: Missing SSL Pinning (MEDIUM) + +**Issue**: No certificate/public key pinning for sensitive APIs +**App Store Risk**: Usually not flagged, but security best practice +**Impact**: Vulnerable to MITM attacks + +**Detection**: +``` +# Look for URLSession without custom trust evaluation +Grep: URLSession\.shared +Grep: URLSessionConfiguration\.default + +# Check for TrustKit or custom pinning +Grep: SecTrust|TrustKit|alamofire.*pinnedCertificates +``` + +## Audit Process + +### Step 1: Find All Swift Files + +``` +Glob: **/*.swift +``` + +Exclude test files and third-party code. + +### Step 2: Check for Privacy Manifest + +``` +Glob: **/PrivacyInfo.xcprivacy + +# If not found, check for Required Reason API usage +Grep: UserDefaults|NSUserDefaults +Grep: fileSystemAttributes|contentsOfDirectory +Grep: systemUptime|mach_absolute_time +``` + +### Step 3: Scan for Credentials + +``` +Grep: (api[_-]?key|apikey|secret)\s*[:=]\s*["'] +Grep: password\s*[:=]\s*["'] +Grep: AKIA[0-9A-Z]{16} +Grep: sk-[a-zA-Z0-9]{24,} +``` + +### Step 4: Check Data Storage + +``` +Grep: @AppStorage.*token|@AppStorage.*password +Grep: UserDefaults.*set.*token +Grep: UserDefaults.*set.*password +``` + +### Step 5: Check ATT Compliance + +``` +# Check for ATT usage +Grep: ATTrackingManager|requestTrackingAuthorization + +# If found, verify NSUserTrackingUsageDescription exists in Info.plist +Glob: **/Info.plist +# Read each Info.plist and check for NSUserTrackingUsageDescription +``` + +### Step 6: Check Network Security + +``` +Grep: http:// +# Read Info.plist for ATS settings +Read: Info.plist (check NSAppTransportSecurity) +``` + +### Step 7: Check Logging + +``` +Grep: print\(.*password\|print\(.*token +Grep: Logger.*password|Logger.*token +``` + +## Output Format + +```markdown +# Security & Privacy Scan Results + +## Summary +- **CRITICAL Issues**: [count] (App Store rejection risk) +- **HIGH Issues**: [count] (Security vulnerabilities) +- **MEDIUM Issues**: [count] (Best practice violations) + +## App Store Readiness: ❌ NOT READY / ✅ READY + +## CRITICAL Issues + +### Missing Privacy Manifest +- **Status**: PrivacyInfo.xcprivacy NOT FOUND +- **Required Reason APIs detected**: + - `UserDefaults` in `AppConfig.swift:23` + - `FileManager.contentsOfDirectory` in `FileService.swift:45` +- **App Store Impact**: Will be rejected starting Spring 2024 +- **Fix**: Create PrivacyInfo.xcprivacy with required declarations + +```xml + + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + +``` + +### Hardcoded API Keys +- `NetworkManager.swift:23` + ```swift + let apiKey = "sk-1234567890abcdef" // EXPOSED + ``` + - **Impact**: Key extractable from IPA, can be revoked + - **Fix**: Use Keychain or environment variables + ```swift + let apiKey = try KeychainHelper.get("api_key") + ``` + +## HIGH Issues + +### Insecure Token Storage +- `AuthService.swift:45` + ```swift + @AppStorage("authToken") var token: String = "" + ``` + - **Impact**: Accessible via backup extraction, jailbreak + - **Fix**: Use Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly + +### HTTP URLs (ATS Violation) +- `APIEndpoints.swift:12` - `http://api.example.com` + - **Impact**: Data transmitted in cleartext + - **Fix**: Use HTTPS or add ATS exception with justification + +## MEDIUM Issues + +### Sensitive Data in Logs +- `LoginViewModel.swift:34` + ```swift + print("Login with password: \(password)") + ``` + - **Fix**: Remove or redact sensitive values + +## Privacy Manifest Checklist + +| API Category | Found | Declared | Status | +|--------------|-------|----------|--------| +| UserDefaults | ✅ Yes | ❌ No | ⚠️ MISSING | +| File Timestamp | ❌ No | - | ✅ OK | +| System Boot Time | ❌ No | - | ✅ OK | +| Disk Space | ❌ No | - | ✅ OK | + +## Next Steps + +1. **Create PrivacyInfo.xcprivacy** with required API declarations +2. **Move secrets to Keychain** or server-side +3. **Replace HTTP with HTTPS** or add justified exceptions +4. **Remove sensitive data from logs** + +## Verification + +After fixes: +1. Submit test build to App Store Connect +2. Check Processing status for privacy warnings +3. Run `xcodebuild -showBuildSettings | grep PRIVACY` +``` + +## When No Issues Found + +```markdown +# Security & Privacy Scan Results + +## Summary +No significant security issues detected. + +## Verified +- ✅ Privacy Manifest present with required declarations +- ✅ No hardcoded credentials detected +- ✅ Tokens stored in Keychain (or not stored locally) +- ✅ All URLs use HTTPS +- ✅ No sensitive data in logs + +## Recommendations +- Review third-party SDKs for privacy manifest requirements +- Consider adding SSL pinning for sensitive APIs +- Run `Privacy Report` in Xcode for full analysis: + Product → Build Report → Privacy +``` + +## Privacy Manifest Required Reason Codes + +### UserDefaults (NSPrivacyAccessedAPICategoryUserDefaults) +- `CA92.1` - Access for app functionality (most common) +- `1C8F.1` - Third-party SDK wrapper + +### File Timestamp (NSPrivacyAccessedAPICategoryFileTimestamp) +- `C617.1` - Access creation/modification dates +- `3B52.1` - Display to user + +### System Boot Time (NSPrivacyAccessedAPICategorySystemBootTime) +- `35F9.1` - Measure elapsed time (most common) + +### Disk Space (NSPrivacyAccessedAPICategoryDiskSpace) +- `E174.1` - Check available space +- `85F4.1` - User-initiated download size check + +## False Positives to Avoid + +**Not issues**: +- Secrets in `.gitignore`d config files +- Environment variables in build scripts +- Mock data in test files +- Comments mentioning "key" or "token" +- Generic variable names that happen to match patterns + +**Verify before reporting**: +- Read surrounding context +- Check if it's actual credential vs variable name +- Confirm file is included in build target diff --git a/.claude/skills/axiom-scan-security-privacy/agents/openai.yaml b/.claude/skills/axiom-scan-security-privacy/agents/openai.yaml new file mode 100644 index 0000000..47bacea --- /dev/null +++ b/.claude/skills/axiom-scan-security-privacy/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Scan Security Privacy" + short_description: "The user mentions security review, App Store submission prep, Privacy Manifest requirements, hardcoded credentials, o..." diff --git a/.claude/skills/axiom-scenekit-ref/.openskills.json b/.claude/skills/axiom-scenekit-ref/.openskills.json new file mode 100644 index 0000000..5c5cceb --- /dev/null +++ b/.claude/skills/axiom-scenekit-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-scenekit-ref", + "installedAt": "2026-04-12T08:06:36.400Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-scenekit-ref/SKILL.md b/.claude/skills/axiom-scenekit-ref/SKILL.md new file mode 100644 index 0000000..f5ac967 --- /dev/null +++ b/.claude/skills/axiom-scenekit-ref/SKILL.md @@ -0,0 +1,345 @@ +--- +name: axiom-scenekit-ref +description: SceneKit → RealityKit concept mapping, complete API cross-reference for migration, scene graph API, materials, lighting, camera, physics, animation, constraints +license: MIT +compatibility: [iOS 8+, macOS 10.8+, tvOS 9+] +metadata: + version: "1.0.0" +--- + +# SceneKit API Reference & Migration Mapping + +Complete API reference for SceneKit with RealityKit equivalents for every major concept. + +## When to Use This Reference + +Use this reference when: +- Looking up SceneKit → RealityKit API equivalents during migration +- Checking specific SceneKit class properties or methods +- Planning which SceneKit features have direct RealityKit counterparts +- Understanding architectural differences between scene graph and ECS + +--- + +## Part 1: SceneKit → RealityKit Concept Mapping + +### Core Architecture + +| SceneKit | RealityKit | Notes | +|----------|-----------|-------| +| `SCNScene` | `RealityViewContent` / `Entity` (root) | RealityKit scenes are entity hierarchies | +| `SCNNode` | `Entity` | Lightweight container in both | +| `SCNView` | `RealityView` (SwiftUI) | `ARView` for UIKit on iOS | +| `SceneView` (SwiftUI) | `RealityView` | SceneView deprecated iOS 26 | +| `SCNRenderer` | `RealityRenderer` | Low-level Metal rendering | +| Node properties | Components | ECS separates data from hierarchy | +| `SCNSceneRendererDelegate` | `System` / `SceneEvents.Update` | Frame-level updates | +| `.scn` files | `.usdz` / `.usda` files | Convert with `xcrun scntool` | + +### Geometry & Rendering + +| SceneKit | RealityKit | Notes | +|----------|-----------|-------| +| `SCNGeometry` | `MeshResource` | RealityKit generates from code or loads USD | +| `SCNBox`, `SCNSphere`, etc. | `MeshResource.generateBox()`, `.generateSphere()` | Similar built-in shapes | +| `SCNMaterial` | `SimpleMaterial`, `PhysicallyBasedMaterial` | PBR-first in RealityKit | +| `SCNMaterial.lightingModel = .physicallyBased` | `PhysicallyBasedMaterial` | Default in RealityKit | +| `SCNMaterial.diffuse` | `PhysicallyBasedMaterial.baseColor` | Different property name | +| `SCNMaterial.metalness` | `PhysicallyBasedMaterial.metallic` | Different property name | +| `SCNMaterial.roughness` | `PhysicallyBasedMaterial.roughness` | Same concept | +| `SCNMaterial.normal` | `PhysicallyBasedMaterial.normal` | Same concept | +| Shader modifiers | `ShaderGraphMaterial` / `CustomMaterial` | No direct port — must rewrite | +| `SCNProgram` (custom shaders) | `CustomMaterial` with Metal functions | Different API surface | +| `SCNGeometrySource` | `MeshResource.Contents` | Low-level mesh data | + +### Transforms & Hierarchy + +| SceneKit | RealityKit | Notes | +|----------|-----------|-------| +| `node.position` | `entity.position` | Both SCNVector3 / SIMD3 | +| `node.eulerAngles` | `entity.orientation` (quaternion) | RealityKit prefers quaternions | +| `node.scale` | `entity.scale` | Both SIMD3 | +| `node.transform` | `entity.transform` | 4×4 matrix | +| `node.worldTransform` | `entity.transform(relativeTo: nil)` | World-space transform | +| `node.addChildNode(_:)` | `entity.addChild(_:)` | Same hierarchy concept | +| `node.removeFromParentNode()` | `entity.removeFromParent()` | Same concept | +| `node.childNodes` | `entity.children` | Children collection | +| `node.parent` | `entity.parent` | Parent reference | +| `node.childNode(withName:recursively:)` | `entity.findEntity(named:)` | Named lookup | + +### Lighting + +| SceneKit | RealityKit | Notes | +|----------|-----------|-------| +| `SCNLight` (`.omni`) | `PointLightComponent` | Point light | +| `SCNLight` (`.directional`) | `DirectionalLightComponent` | Sun/directional light | +| `SCNLight` (`.spot`) | `SpotLightComponent` | Cone light | +| `SCNLight` (`.area`) | No direct equivalent | Use multiple point lights | +| `SCNLight` (`.ambient`) | `EnvironmentResource` (IBL) | Image-based lighting preferred | +| `SCNLight` (`.probe`) | `EnvironmentResource` | Environment probes | +| `SCNLight` (`.IES`) | No direct equivalent | Use light intensity profiles | + +### Camera + +| SceneKit | RealityKit | Notes | +|----------|-----------|-------| +| `SCNCamera` | `PerspectiveCamera` entity | Entity with camera component | +| `camera.fieldOfView` | `PerspectiveCameraComponent.fieldOfViewInDegrees` | Same concept | +| `camera.zNear` / `camera.zFar` | `PerspectiveCameraComponent.near` / `.far` | Clipping planes | +| `camera.wantsDepthOfField` | Post-processing effects | Different mechanism | +| `camera.motionBlurIntensity` | Post-processing effects | Different mechanism | +| `allowsCameraControl` | Custom gesture handling | No built-in orbit camera | + +### Physics + +| SceneKit | RealityKit | Notes | +|----------|-----------|-------| +| `SCNPhysicsBody` | `PhysicsBodyComponent` | Component-based | +| `.dynamic` | `.dynamic` | Same mode | +| `.static` | `.static` | Same mode | +| `.kinematic` | `.kinematic` | Same mode | +| `SCNPhysicsShape` | `CollisionComponent` / `ShapeResource` | Separate from body in RealityKit | +| `categoryBitMask` | `CollisionGroup` | Named groups vs raw bitmasks | +| `collisionBitMask` | `CollisionFilter` | Filter-based | +| `contactTestBitMask` | `CollisionEvents.Began` subscription | Event-based contacts | +| `SCNPhysicsContactDelegate` | `scene.subscribe(to: CollisionEvents.Began.self)` | Combine-style events | +| `SCNPhysicsField` | `PhysicsBodyComponent` forces | Apply forces directly | +| `SCNPhysicsJoint` | `PhysicsJoint` | Similar joint types | + +### Animation + +| SceneKit | RealityKit | Notes | +|----------|-----------|-------| +| `SCNAction` | `entity.move(to:relativeTo:duration:)` | Transform animation | +| `SCNAction.sequence` | Animation chaining | Less declarative in RealityKit | +| `SCNAction.group` | Parallel animations | Apply to different entities | +| `SCNAction.repeatForever` | `AnimationPlaybackController` repeat | Different API | +| `SCNTransaction` (implicit) | No direct equivalent | Explicit animations only | +| `CAAnimation` bridge | `entity.playAnimation()` | Load from USD | +| `SCNAnimationPlayer` | `AnimationPlaybackController` | Playback control | +| Morph targets | Blend shapes in USD | Load via USD files | + +### Interaction + +| SceneKit | RealityKit | Notes | +|----------|-----------|-------| +| `hitTest(_:options:)` | `RealityViewContent.entities(at:)` | Different API | +| Gesture recognizers on SCNView | `ManipulationComponent` | Built-in drag/rotate/scale | +| `allowsCameraControl` | Custom implementation | No built-in orbit | + +### AR Integration + +| SceneKit | RealityKit | Notes | +|----------|-----------|-------| +| `ARSCNView` | `RealityView` + `AnchorEntity` | Legacy → modern | +| `ARSCNViewDelegate` | `AnchorEntity` auto-tracking | Event-driven | +| `renderer(_:didAdd:for:)` | `AnchorEntity(.plane)` | Declarative anchoring | +| `ARWorldTrackingConfiguration` | `SpatialTrackingSession` | iOS 18+ | + +--- + +## Part 2: Scene Graph API + +### SCNScene + +```swift +// Loading +let scene = SCNScene(named: "scene.usdz")! +let scene = try SCNScene(url: url, options: [ + .checkConsistency: true, + .convertToYUp: true +]) + +// Properties +scene.rootNode // Root of node hierarchy +scene.background.contents // Skybox (UIImage, UIColor, MDLSkyCubeTexture) +scene.lightingEnvironment.contents // IBL environment map +scene.fogStartDistance // Fog near +scene.fogEndDistance // Fog far +scene.fogColor // Fog color +scene.isPaused // Pause simulation +``` + +### SCNNode + +```swift +// Creation +let node = SCNNode() +let node = SCNNode(geometry: SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0)) + +// Transform +node.position = SCNVector3(x, y, z) +node.eulerAngles = SCNVector3(pitch, yaw, roll) +node.scale = SCNVector3(1, 1, 1) +node.simdPosition = SIMD3(x, y, z) // SIMD variants available +node.pivot = SCNMatrix4MakeTranslation(0, -0.5, 0) // Offset pivot point + +// Visibility +node.isHidden = false +node.opacity = 1.0 +node.castsShadow = true +node.renderingOrder = 0 // Lower = rendered first + +// Hierarchy +node.addChildNode(child) +node.removeFromParentNode() +node.childNodes +node.childNode(withName: "name", recursively: true) +node.enumerateChildNodes { child, stop in } +``` + +--- + +## Part 3: Materials + +### Lighting Models + +| Model | Description | Use Case | +|-------|-------------|----------| +| `.physicallyBased` | PBR metallic-roughness | Realistic rendering (recommended) | +| `.blinn` | Blinn-Phong specular | Simple shiny surfaces | +| `.phong` | Phong specular | Classic specular highlight | +| `.lambert` | Diffuse only, no specular | Matte surfaces | +| `.constant` | Unlit, flat color | UI elements, debug visualization | +| `.shadowOnly` | Invisible, receives shadows | AR ground plane | + +### Material Properties + +```swift +let mat = SCNMaterial() +mat.lightingModel = .physicallyBased + +// Textures or scalar values +mat.diffuse.contents = UIImage(named: "albedo") // Base color +mat.metalness.contents = 0.0 // 0 = dielectric, 1 = metal +mat.roughness.contents = 0.5 // 0 = mirror, 1 = rough +mat.normal.contents = UIImage(named: "normal") // Normal map +mat.ambientOcclusion.contents = UIImage(named: "ao") // AO map +mat.emission.contents = UIColor.blue // Glow +mat.displacement.contents = UIImage(named: "height") // Height map + +// Options +mat.isDoubleSided = false // Render both sides +mat.writesToDepthBuffer = true +mat.readsFromDepthBuffer = true +mat.blendMode = .alpha // .add, .subtract, .multiply, .screen +mat.transparencyMode = .aOne // .rgbZero for pre-multiplied alpha +``` + +--- + +## Part 4: Physics + +### Body Types and Properties + +```swift +// Dynamic body with custom shape +let shape = SCNPhysicsShape(geometry: SCNSphere(radius: 0.5), options: nil) +let body = SCNPhysicsBody(type: .dynamic, shape: shape) +body.mass = 1.0 +body.friction = 0.5 +body.restitution = 0.3 // Bounciness +body.damping = 0.1 // Linear damping +body.angularDamping = 0.1 // Angular damping +body.isAffectedByGravity = true +body.allowsResting = true // Sleep optimization +node.physicsBody = body + +// Compound shapes +let compound = SCNPhysicsShape(shapes: [shape1, shape2], + transforms: [transform1, transform2]) + +// Concave (static only) +let concave = SCNPhysicsShape(geometry: mesh, options: [ + .type: SCNPhysicsShape.ShapeType.concavePolyhedron +]) +``` + +### Joint Types + +| Joint | Description | +|-------|-------------| +| `SCNPhysicsHingeJoint` | Single-axis rotation (door) | +| `SCNPhysicsBallSocketJoint` | Free rotation around point (pendulum) | +| `SCNPhysicsSliderJoint` | Linear movement along axis (drawer) | +| `SCNPhysicsConeTwistJoint` | Limited rotation (ragdoll limb) | + +--- + +## Part 5: Animation API + +### SCNAction Catalog + +| Category | Actions | +|----------|---------| +| Movement | `move(by:duration:)`, `move(to:duration:)` | +| Rotation | `rotate(by:around:duration:)`, `rotateTo(x:y:z:duration:)` | +| Scale | `scale(by:duration:)`, `scale(to:duration:)` | +| Fade | `fadeIn(duration:)`, `fadeOut(duration:)`, `fadeOpacity(to:duration:)` | +| Visibility | `hide()`, `unhide()` | +| Audio | `playAudio(source:waitForCompletion:)` | +| Custom | `run { node in }`, `customAction(duration:action:)` | +| Composition | `sequence([])`, `group([])`, `repeat(_:count:)`, `repeatForever(_:)` | +| Control | `wait(duration:)`, `removeFromParentNode()` | + +### Timing Functions + +```swift +action.timingMode = .linear // Default +action.timingMode = .easeIn // Slow start +action.timingMode = .easeOut // Slow end +action.timingMode = .easeInEaseOut // Slow start and end +action.timingFunction = { t in // Custom curve + return t * t // Quadratic ease-in +} +``` + +--- + +## Part 6: Constraints + +| Constraint | Purpose | +|------------|---------| +| `SCNLookAtConstraint` | Node always faces target | +| `SCNBillboardConstraint` | Node always faces camera | +| `SCNDistanceConstraint` | Maintains min/max distance | +| `SCNReplicatorConstraint` | Copies transform of target | +| `SCNAccelerationConstraint` | Smooths transform changes | +| `SCNSliderConstraint` | Locks to axis | +| `SCNIKConstraint` | Inverse kinematics chain | + +```swift +let lookAt = SCNLookAtConstraint(target: targetNode) +lookAt.isGimbalLockEnabled = true // Prevent roll +lookAt.influenceFactor = 0.8 // Partial constraint +node.constraints = [lookAt] +``` + +**In RealityKit**: No direct constraint system. Implement with `System` update logic or `entity.look(at:from:relativeTo:)`. + +--- + +## Part 7: Scene Configuration + +### SCNView Configuration + +| Property | Default | Description | +|----------|---------|-------------| +| `antialiasingMode` | `.multisampling4X` | MSAA level | +| `preferredFramesPerSecond` | 60 | Target frame rate | +| `allowsCameraControl` | `false` | Built-in orbit/pan/zoom | +| `autoenablesDefaultLighting` | `false` | Add default light if none | +| `showsStatistics` | `false` | FPS/node/draw count overlay | +| `isTemporalAntialiasingEnabled` | `false` | TAA smoothing | +| `isJitteringEnabled` | `false` | Temporal jitter for TAA | +| `debugOptions` | `[]` | `.showPhysicsShapes`, `.showBoundingBoxes`, `.renderAsWireframe` | + +--- + +## Resources + +**WWDC**: 2014-609, 2014-610, 2017-604, 2019-612 + +**Docs**: /scenekit, /scenekit/scnscene, /scenekit/scnnode, /scenekit/scnmaterial, /scenekit/scnphysicsbody, /scenekit/scnaction + +**Skills**: axiom-scenekit, axiom-realitykit, axiom-realitykit-ref diff --git a/.claude/skills/axiom-scenekit-ref/agents/openai.yaml b/.claude/skills/axiom-scenekit-ref/agents/openai.yaml new file mode 100644 index 0000000..af10eac --- /dev/null +++ b/.claude/skills/axiom-scenekit-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SceneKit Reference" + short_description: "SceneKit → RealityKit concept mapping, complete API cross-reference for migration, scene graph API, materials, lighti..." diff --git a/.claude/skills/axiom-scenekit/.openskills.json b/.claude/skills/axiom-scenekit/.openskills.json new file mode 100644 index 0000000..b9d8962 --- /dev/null +++ b/.claude/skills/axiom-scenekit/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-scenekit", + "installedAt": "2026-04-12T08:06:35.738Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-scenekit/SKILL.md b/.claude/skills/axiom-scenekit/SKILL.md new file mode 100644 index 0000000..d30e540 --- /dev/null +++ b/.claude/skills/axiom-scenekit/SKILL.md @@ -0,0 +1,550 @@ +--- +name: axiom-scenekit +description: Use when working with SceneKit 3D scenes, migrating SceneKit to RealityKit, or maintaining legacy SceneKit code. Covers scene graph, materials, physics, animation, SwiftUI bridge, migration decision tree. +license: MIT +metadata: + version: "1.0.0" +--- + +# SceneKit Development Guide + +**Purpose**: Maintain existing SceneKit code safely and plan migration to RealityKit +**iOS Version**: iOS 8+ (SceneKit), deprecated iOS 26+ +**Xcode**: Xcode 15+ + +## When to Use This Skill + +Use this skill when: +- Maintaining existing SceneKit code +- Building a SceneKit prototype (with awareness of deprecation) +- Planning migration from SceneKit to RealityKit +- Debugging SceneKit rendering, physics, or animation issues +- Integrating SceneKit content with SwiftUI +- Loading 3D models via Model I/O or SCNSceneSource + +Do NOT use this skill for: +- New 3D projects (use `axiom-realitykit`) +- AR experiences (use `axiom-realitykit`) +- visionOS development (use `axiom-realitykit`) +- SpriteKit 2D games (`axiom-spritekit`) +- Metal shader programming (`axiom-metal-migration-ref`) + +--- + +## Deprecation Context + +SceneKit is **soft-deprecated as of iOS 26** (WWDC 2025). This means: +- Existing apps continue to work +- No new features or general bug fixes +- Only critical security patches +- `SceneView` (SwiftUI) is formally deprecated in iOS 26 + +**Apple's forward path is RealityKit.** All new 3D projects should use RealityKit. SceneKit knowledge remains valuable for maintaining legacy code and understanding concepts during migration. + +**In RealityKit**: ECS architecture replaces scene graph. See `axiom-scenekit-ref` for the complete concept mapping table. + +--- + +## 1. Mental Model + +### Scene Graph Architecture + +SceneKit uses a **tree of nodes** (SCNNode) attached to a root node in an SCNScene. Each node has a transform (position, rotation, scale) relative to its parent. + +``` +SCNScene +└── rootNode + ├── cameraNode (SCNCamera) + ├── lightNode (SCNLight) + ├── playerNode (SCNGeometry + SCNPhysicsBody) + │ ├── weaponNode + │ └── particleNode (SCNParticleSystem) + └── environmentNode + ├── groundNode + └── wallNodes +``` + +**In RealityKit**: Entities replace nodes. Components replace node properties. The hierarchy concept persists, but behavior is driven by Systems rather than node callbacks. + +### Coordinate System + +SceneKit uses a **right-handed Y-up** coordinate system: + +``` + +Y (up) + | + | + +──── +X (right) + / + / + +Z (toward viewer) +``` + +This matches RealityKit's coordinate system, so spatial concepts transfer directly during migration. + +### Transform Hierarchy + +Transforms cascade parent → child. A child's world transform = parent's world transform × child's local transform. + +```swift +let parent = SCNNode() +parent.position = SCNVector3(10, 0, 0) + +let child = SCNNode() +child.position = SCNVector3(0, 5, 0) +parent.addChildNode(child) + +// child.worldPosition = (10, 5, 0) +// child.position (local) = (0, 5, 0) +``` + +**In RealityKit**: Same concept. `entity.position` is local, `entity.position(relativeTo: nil)` gives world position. + +--- + +## 2. Scene Setup and Rendering + +### SCNView (UIKit) + +```swift +let sceneView = SCNView(frame: view.bounds) +sceneView.scene = SCNScene(named: "scene.scn") +sceneView.allowsCameraControl = true +sceneView.showsStatistics = true +sceneView.backgroundColor = .black +view.addSubview(sceneView) +``` + +### SceneView (SwiftUI) — Deprecated iOS 26 + +```swift +// Still works but deprecated. Use SCNViewRepresentable for new code. +import SceneKit + +SceneView( + scene: scene, + pointOfView: cameraNode, + options: [.allowsCameraControl, .autoenablesDefaultLighting] +) +``` + +### SCNViewRepresentable (SwiftUI replacement) + +```swift +struct SceneKitView: UIViewRepresentable { + let scene: SCNScene + + func makeUIView(context: Context) -> SCNView { + let view = SCNView() + view.scene = scene + view.allowsCameraControl = true + view.autoenablesDefaultLighting = true + return view + } + + func updateUIView(_ view: SCNView, context: Context) {} +} +``` + +**In RealityKit**: Use `RealityView` in SwiftUI — no UIViewRepresentable needed. + +--- + +## 3. Geometry and Materials + +### Built-in Geometries + +```swift +let box = SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0.1) +let sphere = SCNSphere(radius: 0.5) +let cylinder = SCNCylinder(radius: 0.3, height: 1) +let plane = SCNPlane(width: 2, height: 2) +let torus = SCNTorus(ringRadius: 1, pipeRadius: 0.3) +let capsule = SCNCapsule(capRadius: 0.3, height: 1) +let cone = SCNCone(topRadius: 0, bottomRadius: 0.5, height: 1) +let tube = SCNTube(innerRadius: 0.3, outerRadius: 0.5, height: 1) +let text = SCNText(string: "Hello", extrusionDepth: 0.2) +``` + +### PBR Materials + +```swift +let material = SCNMaterial() +material.lightingModel = .physicallyBased +material.diffuse.contents = UIColor.red // or UIImage +material.metalness.contents = 0.8 +material.roughness.contents = 0.2 +material.normal.contents = UIImage(named: "normal_map") +material.ambientOcclusion.contents = UIImage(named: "ao_map") + +let node = SCNNode(geometry: sphere) +node.geometry?.firstMaterial = material +``` + +**In RealityKit**: Use `PhysicallyBasedMaterial` with similar properties but different API surface. See `axiom-scenekit-ref` Part 1 for the mapping. + +### Shader Modifiers + +SceneKit supports GLSL/Metal shader snippets injected at specific entry points: + +```swift +// Fragment modifier — custom effect on surface +material.shaderModifiers = [ + .fragment: """ + float stripe = sin(_surface.position.x * 20.0); + _output.color.rgb *= step(0.0, stripe); + """ +] +``` + +Entry points: `.geometry`, `.surface`, `.lightingModel`, `.fragment` + +**In RealityKit**: Use `ShaderGraphMaterial` with Reality Composer Pro, or `CustomMaterial` with Metal functions. + +--- + +## 4. Lighting + +### Light Types + +| Type | Description | Shadows | +|------|-------------|---------| +| `.omni` | Point light, radiates in all directions | No | +| `.directional` | Parallel rays (sun) | Yes | +| `.spot` | Cone-shaped beam | Yes | +| `.area` | Rectangle emitter (soft shadows) | Yes | +| `.IES` | Real-world light profile | Yes | +| `.ambient` | Uniform, no direction | No | +| `.probe` | Environment lighting from cubemap | No | + +```swift +let light = SCNLight() +light.type = .directional +light.intensity = 1000 +light.castsShadow = true +light.shadowRadius = 3 +light.shadowSampleCount = 8 + +let lightNode = SCNNode() +lightNode.light = light +lightNode.eulerAngles = SCNVector3(-Float.pi / 4, 0, 0) +scene.rootNode.addChildNode(lightNode) +``` + +**In RealityKit**: Use `DirectionalLightComponent`, `PointLightComponent`, `SpotLightComponent` as components on entities. Image-based lighting via `EnvironmentResource`. + +--- + +## 5. Animation + +### SCNAction (Declarative) + +```swift +let moveUp = SCNAction.moveBy(x: 0, y: 2, z: 0, duration: 1) +let fadeOut = SCNAction.fadeOut(duration: 0.5) +let sequence = SCNAction.sequence([moveUp, fadeOut]) +let forever = SCNAction.repeatForever(moveUp.reversed()) +node.runAction(sequence) +``` + +### Implicit Animation (SCNTransaction) + +```swift +SCNTransaction.begin() +SCNTransaction.animationDuration = 0.5 +node.position = SCNVector3(0, 5, 0) +node.opacity = 0.5 +SCNTransaction.commit() +``` + +### Explicit Animation (CAAnimation bridge) + +```swift +let animation = CABasicAnimation(keyPath: "rotation") +animation.toValue = NSValue(scnVector4: SCNVector4(0, 1, 0, Float.pi * 2)) +animation.duration = 2 +animation.repeatCount = .infinity +node.addAnimation(animation, forKey: "spin") +``` + +### Loading Animations from Files + +```swift +let scene = SCNScene(named: "character.dae")! +let animationPlayer = scene.rootNode + .childNode(withName: "mixamorig:Hips", recursively: true)! + .animationPlayer(forKey: nil)! + +characterNode.addAnimationPlayer(animationPlayer, forKey: "walk") +animationPlayer.play() +``` + +**In RealityKit**: Use `entity.playAnimation()` with animations loaded from USD files. Transform animations via `entity.move(to:relativeTo:duration:)`. + +--- + +## 6. Physics + +### Physics Bodies + +```swift +// Dynamic — simulation controls position +node.physicsBody = SCNPhysicsBody(type: .dynamic, + shape: SCNPhysicsShape(geometry: node.geometry!, options: nil)) + +// Static — immovable collision surface +ground.physicsBody = SCNPhysicsBody(type: .static, shape: nil) + +// Kinematic — code controls position, participates in collisions +platform.physicsBody = SCNPhysicsBody(type: .kinematic, shape: nil) +``` + +### Collision Categories + +```swift +struct PhysicsCategory { + static let player: Int = 1 << 0 // 1 + static let enemy: Int = 1 << 1 // 2 + static let projectile: Int = 1 << 2 // 4 + static let wall: Int = 1 << 3 // 8 +} + +playerNode.physicsBody?.categoryBitMask = PhysicsCategory.player +playerNode.physicsBody?.collisionBitMask = PhysicsCategory.wall | PhysicsCategory.enemy +playerNode.physicsBody?.contactTestBitMask = PhysicsCategory.enemy | PhysicsCategory.projectile +``` + +### Contact Delegate + +```swift +class GameScene: SCNScene, SCNPhysicsContactDelegate { + func setupPhysics() { + physicsWorld.contactDelegate = self + } + + func physicsWorld(_ world: SCNPhysicsWorld, + didBegin contact: SCNPhysicsContact) { + let nodeA = contact.nodeA + let nodeB = contact.nodeB + // Handle collision + } +} +``` + +**In RealityKit**: Use `PhysicsBodyComponent`, `CollisionComponent`, and collision event subscriptions via `scene.subscribe(to: CollisionEvents.Began.self)`. + +--- + +## 7. Hit Testing and Interaction + +```swift +// In SCNView tap handler +let results = sceneView.hitTest(tapLocation, options: [ + .searchMode: SCNHitTestSearchMode.closest.rawValue, + .boundingBoxOnly: false +]) + +if let hit = results.first { + let tappedNode = hit.node + let worldPosition = hit.worldCoordinates +} +``` + +**In RealityKit**: Use `ManipulationComponent` for drag/rotate/scale gestures, or collision-based hit testing. + +--- + +## 8. Asset Pipeline + +### Supported Formats + +| Format | Extension | Notes | +|--------|-----------|-------| +| USD/USDZ | `.usdz`, `.usda`, `.usdc` | Preferred format, works in both SceneKit and RealityKit | +| Collada | `.dae` | Legacy, still supported | +| SceneKit Archive | `.scn` | Xcode-specific, not portable to RealityKit | +| Wavefront OBJ | `.obj` | Geometry only, no animations | +| Alembic | `.abc` | Animation baking | + +### Loading Models + +```swift +// From bundle +let scene = SCNScene(named: "model.usdz")! + +// From URL +let scene = try SCNScene(url: modelURL, options: nil) + +// Via Model I/O (for format conversion) +let asset = MDLAsset(url: modelURL) +let scene = SCNScene(mdlAsset: asset) +``` + +**Migration tip**: Convert `.scn` files to `.usdz` using `xcrun scntool --convert file.scn --format usdz` before migrating to RealityKit. + +--- + +## 9. ARKit Integration (Legacy) + +```swift +// ARSCNView — SceneKit + ARKit (legacy approach) +let arView = ARSCNView(frame: view.bounds) +arView.delegate = self +arView.session.run(ARWorldTrackingConfiguration()) + +// Adding virtual content at anchors +func renderer(_ renderer: SCNSceneRenderer, + didAdd node: SCNNode, for anchor: ARAnchor) { + let box = SCNBox(width: 0.1, height: 0.1, length: 0.1, chamferRadius: 0) + node.addChildNode(SCNNode(geometry: box)) +} +``` + +**In RealityKit**: Use `RealityView` with `AnchorEntity` types. ARSCNView is legacy — all new AR development should use RealityKit. + +--- + +## 10. Anti-Patterns + +### Anti-Pattern 1: Starting New Projects in SceneKit + +**Time cost**: Weeks of rework when you eventually must migrate + +SceneKit is deprecated. New projects should use RealityKit from the start, even if the learning curve is steeper initially. + +### Anti-Pattern 2: Using .scn Files Without USDZ Conversion + +**Time cost**: Hours when migration begins + +`.scn` files are SceneKit-specific and cannot be loaded in RealityKit. Convert early: +```bash +xcrun scntool --convert model.scn --format usdz --output model.usdz +``` + +### Anti-Pattern 3: Deep Shader Modifier Customization + +**Time cost**: Complete rewrite during migration + +SceneKit shader modifiers use a proprietary entry-point system. Heavy investment here has zero portability to RealityKit's `ShaderGraphMaterial`. + +### Anti-Pattern 4: Relying on SCNRenderer for Custom Pipelines + +**Time cost**: Architecture redesign during migration + +If you need custom render pipelines, build on Metal directly or use `RealityRenderer` (RealityKit's Metal-level API). + +### Anti-Pattern 5: Ignoring Deprecation Warnings + +**Time cost**: Surprise breakage when Apple removes APIs + +Track `SceneView` deprecation warnings and plan UIViewRepresentable fallback or RealityKit migration. + +### Anti-Pattern 6: Creating Hundreds of Nodes in a Loop + +**Time cost**: 2-4 hours debugging frame drops, often misdiagnosed as GPU issue + +```swift +// ❌ WRONG: Each SCNNode has overhead (transform, bounding box, hit test) +for i in 0..<500 { + let node = SCNNode(geometry: SCNSphere(radius: 0.05)) + node.position = randomPosition() + scene.rootNode.addChildNode(node) // 500 nodes = terrible frame rate +} + +// ✅ RIGHT: Use SCNParticleSystem for particle-like effects +let particles = SCNParticleSystem() +particles.birthRate = 500 +particles.particleSize = 0.05 +particles.emitterShape = SCNBox(width: 5, height: 5, length: 5, chamferRadius: 0) +particleNode.addParticleSystem(particles) + +// ✅ RIGHT: Use geometry instancing for identical objects +let source = SCNGeometrySource(/* instance transforms */) +geometry.levelsOfDetail = [SCNLevelOfDetail(geometry: lowPoly, screenSpaceRadius: 20)] +``` + +**Rule**: If >50 identical objects, use SCNParticleSystem or flatten geometry. If different objects, use `SCNNode.flattenedClone()` to reduce draw calls. + +--- + +## 11. Migration Decision Tree + +``` +Should you migrate to RealityKit? +│ +├─ Is this a new project? +│ └─ YES → Use RealityKit from the start. No question. +│ +├─ Does the app need AR features? +│ └─ YES → Migrate. ARSCNView is legacy, RealityKit is the only forward path. +│ +├─ Does the app target visionOS? +│ └─ YES → Must migrate. SceneKit doesn't support visionOS spatial features. +│ +├─ Is the codebase heavily invested in SceneKit? +│ ├─ YES, and app is stable → Maintain in SceneKit for now, plan phased migration. +│ └─ YES, but needs new features → Migrate incrementally (new features in RealityKit). +│ +├─ Is performance a concern? +│ └─ YES → RealityKit is optimized for Apple Silicon with Metal-first rendering. +│ +└─ Is the app in maintenance mode? + └─ YES → Keep SceneKit until critical. Security patches will continue. +``` + +--- + +## 12. Pressure Scenarios + +### Scenario 1: "Just Use SceneKit, It Works Fine" + +**Pressure**: Team familiarity with SceneKit, deadline to ship + +**Wrong approach**: Start new project in SceneKit because the team knows it. + +**Correct approach**: Invest in RealityKit learning. SceneKit will receive no new features. The longer you wait, the larger the migration debt. + +**Push-back template**: "SceneKit is deprecated as of iOS 26. Starting new work in it creates migration debt that grows with every feature we add. RealityKit's ECS model is different but learnable — let's invest the time now." + +### Scenario 2: "We Don't Have Time to Learn RealityKit" + +**Pressure**: Tight deadline, team unfamiliar with ECS + +**Wrong approach**: Build everything in SceneKit to meet the deadline. + +**Correct approach**: Build the prototype in SceneKit if necessary, but document every SceneKit dependency and plan the migration. Use USDZ assets from the start so they're portable. + +**Push-back template**: "Let's use USDZ assets and keep the SceneKit layer thin. When we migrate, the assets transfer directly and only the code layer changes." + +### Scenario 3: "Port Everything At Once" + +**Pressure**: Desire for a clean migration + +**Wrong approach**: Attempt to rewrite the entire SceneKit codebase in RealityKit at once. + +**Correct approach**: Migrate incrementally. New features in RealityKit. Existing SceneKit code stays until it needs changes. Modularize with Swift packages (per Apple's migration guide). + +**Push-back template**: "Apple's own migration guide recommends modularizing into Swift packages and migrating system by system. A big-bang rewrite risks introducing new bugs across the entire app." + +--- + +## Code Review Checklist + +- [ ] No new SceneKit code in projects targeting iOS 26+ without migration plan +- [ ] Assets in USDZ format (not .scn) for portability +- [ ] No deep shader modifier customization without RealityKit equivalent identified +- [ ] SCNTransaction used for implicit animations (not direct property changes without animation context) +- [ ] Physics categoryBitMask explicitly set (not relying on defaults) +- [ ] Contact delegate set and protocol conformance added +- [ ] `[weak self]` in completion handlers and closures +- [ ] Debug overlays enabled during development (`showsStatistics = true`) + +--- + +## Resources + +**WWDC**: 2014-609, 2014-610, 2017-604, 2019-612 + +**Docs**: /scenekit, /scenekit/scnscene, /scenekit/scnnode, /scenekit/scnmaterial, /scenekit/scnphysicsbody + +**Skills**: axiom-scenekit-ref, axiom-realitykit, axiom-realitykit-ref diff --git a/.claude/skills/axiom-scenekit/agents/openai.yaml b/.claude/skills/axiom-scenekit/agents/openai.yaml new file mode 100644 index 0000000..5d0b361 --- /dev/null +++ b/.claude/skills/axiom-scenekit/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SceneKit" + short_description: "Working with SceneKit 3D scenes, migrating SceneKit to RealityKit, or maintaining legacy SceneKit code" diff --git a/.claude/skills/axiom-sf-symbols-ref/.openskills.json b/.claude/skills/axiom-sf-symbols-ref/.openskills.json new file mode 100644 index 0000000..d435f51 --- /dev/null +++ b/.claude/skills/axiom-sf-symbols-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-sf-symbols-ref", + "installedAt": "2026-04-12T08:06:37.365Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-sf-symbols-ref/SKILL.md b/.claude/skills/axiom-sf-symbols-ref/SKILL.md new file mode 100644 index 0000000..ea1ca7d --- /dev/null +++ b/.claude/skills/axiom-sf-symbols-ref/SKILL.md @@ -0,0 +1,976 @@ +--- +name: axiom-sf-symbols-ref +description: Use when you need complete SF Symbols API reference including every rendering mode, symbol effect, configuration option, UIKit equivalent, and platform availability - comprehensive code examples for iOS 17 through iOS 26 +license: MIT +compatibility: iOS 17+, iOS 18+, iOS 26+ +metadata: + version: "1.0.0" +--- + +# SF Symbols — API Reference + +## When to Use This Skill + +Use when: +- You need exact API signatures for rendering modes or symbol effects +- You need UIKit/AppKit equivalents for SwiftUI symbol APIs +- You need to check platform availability for a specific effect +- You need configuration options (weight, scale, variable values) +- You need to create custom symbols with proper template structure + +#### Related Skills +- Use `axiom-sf-symbols` for decision trees, anti-patterns, troubleshooting, and when to use which effect +- Use `axiom-swiftui-animation-ref` for general SwiftUI animation (non-symbol) + +--- + +## Part 1: Symbol Display + +### SwiftUI + +```swift +// Basic display +Image(systemName: "star.fill") + +// With Label (icon + text) +Label("Favorites", systemImage: "star.fill") + +// Font sizing — symbol scales with text +Image(systemName: "star.fill") + .font(.title) + +// Image scale — relative sizing without changing font +Image(systemName: "star.fill") + .imageScale(.large) // .small, .medium, .large + +// Explicit point size +Image(systemName: "star.fill") + .font(.system(size: 24)) + +// Weight — matches SF Pro font weights +Image(systemName: "star.fill") + .fontWeight(.bold) // .ultraLight through .black + +// Symbol variant — programmatic .fill, .circle, .square, .slash +Image(systemName: "person") + .symbolVariant(.circle.fill) // Renders person.circle.fill + +// Variable value — 0.0 to 1.0, controls symbol fill level +Image(systemName: "speaker.wave.3.fill", variableValue: 0.5) +``` + +### UIKit + +```swift +// Basic display +let image = UIImage(systemName: "star.fill") +imageView.image = image + +// Configuration — point size and weight +let config = UIImage.SymbolConfiguration(pointSize: 24, weight: .bold) +let image = UIImage(systemName: "star.fill", withConfiguration: config) + +// Configuration — text style (scales with Dynamic Type) +let config = UIImage.SymbolConfiguration(textStyle: .title1) +let image = UIImage(systemName: "star.fill", withConfiguration: config) + +// Configuration — scale +let config = UIImage.SymbolConfiguration(scale: .large) // .small, .medium, .large + +// Combine configurations +let sizeConfig = UIImage.SymbolConfiguration(pointSize: 24, weight: .bold, scale: .large) + +// Variable value +let image = UIImage(systemName: "speaker.wave.3.fill", variableValue: 0.5) +``` + +### AppKit + +```swift +// Basic display +let image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "Favorite") + +// Configuration +let config = NSImage.SymbolConfiguration(pointSize: 24, weight: .bold) +let configured = image?.withSymbolConfiguration(config) +``` + +--- + +## Part 2: Rendering Modes + +### SwiftUI + +```swift +// Monochrome (default) +Image(systemName: "cloud.rain.fill") + .foregroundStyle(.blue) + +// Hierarchical — depth from single color +Image(systemName: "cloud.rain.fill") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.blue) + +// Palette — explicit color per layer +Image(systemName: "cloud.rain.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .blue) +// For 3-layer symbols: + .foregroundStyle(.red, .white, .blue) + +// Multicolor — Apple's curated colors +Image(systemName: "cloud.rain.fill") + .symbolRenderingMode(.multicolor) + +// Preferred rendering mode — uses symbol's preferred mode +// Falls back gracefully if the symbol doesn't support it +Image(systemName: "cloud.rain.fill") + .symbolRenderingMode(.monochrome) // explicit monochrome +``` + +#### SymbolRenderingMode Enum + +| Value | Description | +|-------|-------------| +| `.monochrome` | Single color for all layers (default) | +| `.hierarchical` | Single color with automatic opacity per layer | +| `.palette` | Explicit color per layer via `.foregroundStyle()` | +| `.multicolor` | Apple's fixed curated colors | + +### UIKit + +```swift +// Hierarchical +let config = UIImage.SymbolConfiguration(hierarchicalColor: .systemBlue) +imageView.preferredSymbolConfiguration = config + +// Palette +let config = UIImage.SymbolConfiguration(paletteColors: [.white, .systemBlue]) +imageView.preferredSymbolConfiguration = config + +// Multicolor +let config = UIImage.SymbolConfiguration.preferringMulticolor() +imageView.preferredSymbolConfiguration = config + +// Monochrome — just set tintColor +imageView.tintColor = .systemBlue +``` + +### Combining Configurations (UIKit) + +```swift +let sizeConfig = UIImage.SymbolConfiguration(pointSize: 24, weight: .bold) +let colorConfig = UIImage.SymbolConfiguration(paletteColors: [.white, .blue, .gray]) +let combined = sizeConfig.applying(colorConfig) +imageView.preferredSymbolConfiguration = combined +``` + +--- + +## Part 3: Symbol Effects — Complete API + +### Effect Protocol Hierarchy + +All symbol effects conform to `SymbolEffect`. Sub-protocols define behavior: + +| Protocol | Trigger | Modifier | Loop | +|----------|---------|----------|------| +| `DiscreteSymbolEffect` | `value:` (Equatable) | `.symbolEffect(_:options:value:)` | No | +| `IndefiniteSymbolEffect` | `isActive:` (Bool) | `.symbolEffect(_:options:isActive:)` | Yes | +| `TransitionSymbolEffect` | View lifecycle | `.transition(.symbolEffect(_:))` | No | +| `ContentTransitionSymbolEffect` | Symbol change | `.contentTransition(.symbolEffect(_:))` | No | + +### Remove All Effects (SwiftUI) + +```swift +// Strip all symbol effects from a view hierarchy +Image(systemName: "star.fill") + .symbolEffectsRemoved() // Removes all effects + .symbolEffectsRemoved(false) // Re-enables effects +``` + +### SymbolEffectOptions + +```swift +// Speed multiplier +.symbolEffect(.bounce, options: .speed(2.0), value: count) + +// Repeat count +.symbolEffect(.bounce, options: .repeat(3), value: count) + +// Continuous repeat +.symbolEffect(.pulse, options: .repeat(.continuous), isActive: true) + +// Non-repeating (for indefinite effects, run once then hold) +.symbolEffect(.breathe, options: .nonRepeating, isActive: true) + +// Combined +.symbolEffect(.wiggle, options: .repeat(5).speed(1.5), value: count) +``` + +--- + +### Bounce + +**Protocols**: `DiscreteSymbolEffect` + +```swift +// Discrete — triggers on value change +Image(systemName: "arrow.down.circle") + .symbolEffect(.bounce, value: downloadCount) + +// Directional + .symbolEffect(.bounce.up, value: count) + .symbolEffect(.bounce.down, value: count) + +// By Layer — different layers bounce at different times + .symbolEffect(.bounce.byLayer, value: count) + +// Whole Symbol — entire symbol bounces together + .symbolEffect(.bounce.wholeSymbol, value: count) +``` + +**UIKit**: +```swift +imageView.addSymbolEffect(.bounce) +// With options: +imageView.addSymbolEffect(.bounce, options: .repeat(3)) +``` + +--- + +### Pulse + +**Protocols**: `DiscreteSymbolEffect`, `IndefiniteSymbolEffect` + +```swift +// Indefinite — continuous while active +Image(systemName: "network") + .symbolEffect(.pulse, isActive: isConnecting) + +// Discrete — triggers once on value change + .symbolEffect(.pulse, value: errorCount) + +// By Layer + .symbolEffect(.pulse.byLayer, isActive: true) + +// Whole Symbol + .symbolEffect(.pulse.wholeSymbol, isActive: true) +``` + +**UIKit**: +```swift +imageView.addSymbolEffect(.pulse) +imageView.removeSymbolEffect(ofType: PulseSymbolEffect.self) +``` + +--- + +### Variable Color + +**Protocols**: `DiscreteSymbolEffect`, `IndefiniteSymbolEffect` + +```swift +// Iterative — highlights one layer at a time +Image(systemName: "wifi") + .symbolEffect(.variableColor.iterative, isActive: isSearching) + +// Cumulative — progressively fills layers + .symbolEffect(.variableColor.cumulative, isActive: true) + +// Reversing — cycles back and forth + .symbolEffect(.variableColor.iterative.reversing, isActive: true) + +// Hide inactive layers (dims non-highlighted layers) + .symbolEffect(.variableColor.iterative.hideInactiveLayers, isActive: true) + +// Dim inactive layers (slightly reduces opacity of non-highlighted) + .symbolEffect(.variableColor.iterative.dimInactiveLayers, isActive: true) +``` + +**UIKit**: +```swift +imageView.addSymbolEffect(.variableColor.iterative) +imageView.removeSymbolEffect(ofType: VariableColorSymbolEffect.self) +``` + +--- + +### Scale + +**Protocols**: `IndefiniteSymbolEffect` + +```swift +// Scale up +Image(systemName: "mic.fill") + .symbolEffect(.scale.up, isActive: isRecording) + +// Scale down + .symbolEffect(.scale.down, isActive: isMuted) + +// By Layer + .symbolEffect(.scale.up.byLayer, isActive: true) + +// Whole Symbol + .symbolEffect(.scale.up.wholeSymbol, isActive: true) +``` + +**UIKit**: +```swift +imageView.addSymbolEffect(.scale.up) +imageView.removeSymbolEffect(ofType: ScaleSymbolEffect.self) +``` + +--- + +### Wiggle (iOS 18+) + +**Protocols**: `DiscreteSymbolEffect`, `IndefiniteSymbolEffect` + +```swift +// Discrete +Image(systemName: "bell.fill") + .symbolEffect(.wiggle, value: notificationCount) + +// Directional + .symbolEffect(.wiggle.left, value: count) + .symbolEffect(.wiggle.right, value: count) + .symbolEffect(.wiggle.forward, value: count) // RTL-aware + .symbolEffect(.wiggle.backward, value: count) // RTL-aware + .symbolEffect(.wiggle.up, value: count) + .symbolEffect(.wiggle.down, value: count) + .symbolEffect(.wiggle.clockwise, value: count) + .symbolEffect(.wiggle.counterClockwise, value: count) + +// Custom angle + .symbolEffect(.wiggle.custom(angle: .degrees(15)), value: count) + +// By Layer + .symbolEffect(.wiggle.byLayer, value: count) +``` + +**UIKit**: +```swift +imageView.addSymbolEffect(.wiggle) +``` + +--- + +### Rotate (iOS 18+) + +**Protocols**: `DiscreteSymbolEffect`, `IndefiniteSymbolEffect` + +```swift +// Indefinite rotation +Image(systemName: "gear") + .symbolEffect(.rotate, isActive: isProcessing) + +// Direction + .symbolEffect(.rotate.clockwise, isActive: true) + .symbolEffect(.rotate.counterClockwise, isActive: true) + +// By Layer — only specific layers rotate (e.g., fan blades) + .symbolEffect(.rotate.byLayer, isActive: true) +``` + +**UIKit**: +```swift +imageView.addSymbolEffect(.rotate) +imageView.removeSymbolEffect(ofType: RotateSymbolEffect.self) +``` + +--- + +### Breathe (iOS 18+) + +**Protocols**: `DiscreteSymbolEffect`, `IndefiniteSymbolEffect` + +```swift +// Basic breathe +Image(systemName: "heart.fill") + .symbolEffect(.breathe, isActive: isMonitoring) + +// Plain — scale only + .symbolEffect(.breathe.plain, isActive: true) + +// Pulse — scale + opacity variation + .symbolEffect(.breathe.pulse, isActive: true) + +// By Layer + .symbolEffect(.breathe.byLayer, isActive: true) +``` + +**UIKit**: +```swift +imageView.addSymbolEffect(.breathe) +imageView.removeSymbolEffect(ofType: BreatheSymbolEffect.self) +``` + +--- + +### Appear and Disappear + +**Protocols**: `TransitionSymbolEffect` + +```swift +// SwiftUI transition +if showSymbol { + Image(systemName: "checkmark.circle.fill") + .transition(.symbolEffect(.appear)) +} + +if showSymbol { + Image(systemName: "xmark.circle.fill") + .transition(.symbolEffect(.disappear)) +} + +// Directional + .transition(.symbolEffect(.appear.up)) + .transition(.symbolEffect(.appear.down)) + .transition(.symbolEffect(.disappear.up)) + .transition(.symbolEffect(.disappear.down)) + +// By Layer + .transition(.symbolEffect(.appear.byLayer)) + +// Whole Symbol + .transition(.symbolEffect(.appear.wholeSymbol)) +``` + +**UIKit** (as effect, not transition): +```swift +// Make symbol appear +imageView.addSymbolEffect(.appear) + +// Make symbol disappear +imageView.addSymbolEffect(.disappear) + +// Appear after disappear +imageView.addSymbolEffect(.appear) // re-shows hidden symbol +``` + +--- + +### Replace + +**Protocols**: `ContentTransitionSymbolEffect` + +```swift +// SwiftUI content transition +Image(systemName: isFavorite ? "star.fill" : "star") + .contentTransition(.symbolEffect(.replace)) + +// Directional variants + .contentTransition(.symbolEffect(.replace.downUp)) + .contentTransition(.symbolEffect(.replace.upUp)) + .contentTransition(.symbolEffect(.replace.offUp)) + +// By Layer + .contentTransition(.symbolEffect(.replace.byLayer)) + +// Whole Symbol + .contentTransition(.symbolEffect(.replace.wholeSymbol)) + +// Magic Replace — default in iOS 18+, morphs shared elements +// Automatic for structurally related pairs: star ↔ star.fill, pause.fill ↔ play.fill + .contentTransition(.symbolEffect(.replace)) + +// Explicit Magic Replace with fallback for unrelated symbols + .contentTransition(.symbolEffect(.replace.magic(fallback: .replace.downUp))) +``` + +**UIKit**: +```swift +// Change symbol with Replace transition +let newImage = UIImage(systemName: "star.fill") +imageView.setSymbolImage(newImage!, contentTransition: .replace) + +// Directional +imageView.setSymbolImage(newImage!, contentTransition: .replace.downUp) +``` + +--- + +## Part 4: Draw Effects (iOS 26+) + +### Draw On + +```swift +// Indefinite — draws in while active +Image(systemName: "checkmark.circle") + .symbolEffect(.drawOn, isActive: isComplete) + +// Playback modes + .symbolEffect(.drawOn.byLayer, isActive: isActive) + .symbolEffect(.drawOn.wholeSymbol, isActive: isActive) + .symbolEffect(.drawOn.individually, isActive: isActive) + +// With options + .symbolEffect(.drawOn, options: .speed(2.0), isActive: isActive) + .symbolEffect(.drawOn, options: .nonRepeating, isActive: isActive) +``` + +### Draw Off + +```swift +// Indefinite — draws out while active +Image(systemName: "star.fill") + .symbolEffect(.drawOff, isActive: isHidden) + +// Playback modes + .symbolEffect(.drawOff.byLayer, isActive: isActive) + .symbolEffect(.drawOff.wholeSymbol, isActive: isActive) + .symbolEffect(.drawOff.individually, isActive: isActive) + +// Direction control + .symbolEffect(.drawOff.nonReversed, isActive: isActive) // follows draw path forward + .symbolEffect(.drawOff.reversed, isActive: isActive) // erases in reverse order +``` + +### UIKit Draw Effects + +```swift +// Draw On +imageView.addSymbolEffect(.drawOn) + +// Draw Off +imageView.addSymbolEffect(.drawOff) + +// Remove +imageView.removeSymbolEffect(ofType: DrawOnSymbolEffect.self) +``` + +### Variable Draw + +Uses `SymbolVariableValueMode` to control how variable values are rendered. + +```swift +// Variable Draw — draws stroke proportional to value (iOS 26+) +Image(systemName: "thermometer.high", variableValue: temperature) + .symbolVariableValueMode(.draw) + +// Variable Color — sets layer opacity based on threshold (iOS 17+, default) +Image(systemName: "wifi", variableValue: signalStrength) + .symbolVariableValueMode(.color) +``` + +#### SymbolVariableValueMode Enum (iOS 26+) + +| Case | Description | +|------|-------------| +| `.color` | Sets opacity of each variable layer on/off based on threshold (existing behavior) | +| `.draw` | Changes drawn length of each variable layer based on range | + +**Constraint**: Some symbols support only one mode. Setting an unsupported mode has no visible effect. A symbol cannot use both Variable Color and Variable Draw simultaneously. + +### Gradient Rendering (iOS 26+) + +Uses `SymbolColorRenderingMode` for automatic gradient generation from a single color. + +```swift +// Gradient fill — system generates axial gradient from source color +Image(systemName: "heart.fill") + .symbolColorRenderingMode(.gradient) + .foregroundStyle(.red) + +// Works with any rendering mode +Image(systemName: "cloud.rain.fill") + .symbolRenderingMode(.hierarchical) + .symbolColorRenderingMode(.gradient) + .foregroundStyle(.blue) +``` + +#### SymbolColorRenderingMode Enum (iOS 26+) + +| Case | Description | +|------|-------------| +| `.flat` | Solid color fill (default) | +| `.gradient` | Axial gradient generated from source color | + +Gradients are most effective at larger symbol sizes and work across all rendering modes. + +--- + +## Part 5: Content Transition Patterns + +### Symbol Swap with Replace + +```swift +struct PlayPauseButton: View { + @State private var isPlaying = false + + var body: some View { + Button { + isPlaying.toggle() + } label: { + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + .contentTransition(.symbolEffect(.replace)) + } + .accessibilityLabel(isPlaying ? "Pause" : "Play") + } +} +``` + +### Download Progress Pattern + +```swift +struct DownloadButton: View { + @State private var state: DownloadState = .idle + + var symbolName: String { + switch state { + case .idle: "arrow.down.circle" + case .downloading: "stop.circle" + case .complete: "checkmark.circle.fill" + } + } + + var body: some View { + Button { + advanceState() + } label: { + Image(systemName: symbolName) + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.pulse, isActive: state == .downloading) + } + } +} +``` + +### Toggle with Effect Feedback + +```swift +struct FavoriteButton: View { + @Binding var isFavorite: Bool + @State private var bounceValue = 0 + + var body: some View { + Button { + isFavorite.toggle() + bounceValue += 1 + } label: { + Image(systemName: isFavorite ? "star.fill" : "star") + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.bounce, value: bounceValue) + .foregroundStyle(isFavorite ? .yellow : .gray) + } + } +} +``` + +--- + +## Part 6: Custom Symbols + +### Template Structure + +Custom symbols are SVG files with specific layer annotations: + +1. **Export from design tool** as SVG +2. **Import into SF Symbols app** (File > Import) +3. **Set template type**: Monochrome, Hierarchical, Multicolor, or Variable Color +4. **Annotate layers** for rendering modes: + - **Primary** layer: Full opacity in Hierarchical + - **Secondary** layer: Reduced opacity in Hierarchical + - **Tertiary** layer: Most reduced opacity in Hierarchical +5. **Set Palette colors** per layer if supporting Palette mode +6. **Export** as `.svg` template for Xcode + +### Draw Annotation (SF Symbols 7) + +To enable Draw animations on custom symbols: + +1. Select a path in SF Symbols 7 app +2. Open the Draw annotation panel +3. Place guide points on the path: + +| Point Type | Visual | Purpose | +|------------|--------|---------| +| Start | Open circle | Where drawing begins | +| End | Closed circle | Where drawing ends | +| Corner | Diamond | Sharp direction change | +| Bidirectional | Double arrow | Center-outward drawing | +| Attachment | Link icon | Non-drawing decorative connection | + +4. **Minimum**: 2 guide points per path (start + end) +5. **Option-drag** for precise placement +6. Test in Preview panel across all weights + +### Weight Interpolation + +Custom symbols should include designs for at least 3 weight variants: +- **Ultralight** (thinnest) +- **Regular** (middle) +- **Black** (thickest) + +The system interpolates between these for intermediate weights (Thin, Light, Medium, Semibold, Bold, Heavy). + +### Importing to Xcode + +1. In Xcode, open Asset Catalog +2. Click **+** > **Symbol Image Set** +3. Drag exported `.svg` from SF Symbols app +4. Asset catalog symbols: `Image("custom.symbol.name")`. For symbols loaded from a bundle: `Image(systemName: "custom.symbol.name", bundle: .module)` + +--- + +## Part 7: Platform Availability Matrix + +### Rendering Modes + +| Feature | iOS | macOS | watchOS | tvOS | visionOS | +|---------|-----|-------|---------|------|----------| +| Monochrome | 13+ | 11+ | 6+ | 13+ | 1+ | +| Hierarchical | 15+ | 12+ | 8+ | 15+ | 1+ | +| Palette | 15+ | 12+ | 8+ | 15+ | 1+ | +| Multicolor | 15+ | 12+ | 8+ | 15+ | 1+ | +| Variable Value | 16+ | 13+ | 9+ | 16+ | 1+ | + +### Symbol Effects + +| Effect | Category | iOS | macOS | watchOS | tvOS | visionOS | +|--------|----------|-----|-------|---------|------|----------| +| Bounce | Discrete | 17+ | 14+ | 10+ | 17+ | 1+ | +| Pulse | Discrete/Indefinite | 17+ | 14+ | 10+ | 17+ | 1+ | +| Variable Color | Discrete/Indefinite | 17+ | 14+ | 10+ | 17+ | 1+ | +| Scale | Indefinite | 17+ | 14+ | 10+ | 17+ | 1+ | +| Appear | Transition | 17+ | 14+ | 10+ | 17+ | 1+ | +| Disappear | Transition | 17+ | 14+ | 10+ | 17+ | 1+ | +| Replace | Content Transition | 17+ | 14+ | 10+ | 17+ | 1+ | +| Wiggle | Discrete/Indefinite | 18+ | 15+ | 11+ | 18+ | 2+ | +| Rotate | Discrete/Indefinite | 18+ | 15+ | 11+ | 18+ | 2+ | +| Breathe | Discrete/Indefinite | 18+ | 15+ | 11+ | 18+ | 2+ | +| Draw On | Indefinite | 26+ | Tahoe+ | 26+ | 26+ | 26+ | +| Draw Off | Indefinite | 26+ | Tahoe+ | 26+ | 26+ | 26+ | +| Variable Draw | Value-based | 26+ | Tahoe+ | 26+ | 26+ | 26+ | +| Gradient Fill | Rendering | 26+ | Tahoe+ | 26+ | 26+ | 26+ | + +### Effect Behavior Categories + +| Category | What It Does | How to Trigger | +|----------|-------------|----------------| +| Discrete | One-shot animation, returns to rest | `.symbolEffect(_:value:)` — fires when value changes | +| Indefinite | Loops while active | `.symbolEffect(_:isActive:)` — loops while `true` | +| Transition | Plays on view insert/remove | `.transition(.symbolEffect(_:))` | +| Content Transition | Plays when symbol changes | `.contentTransition(.symbolEffect(_:))` | + +--- + +## Part 8: UIKit Complete Reference + +### Adding Effects + +```swift +// Add indefinite effect +imageView.addSymbolEffect(.pulse) +imageView.addSymbolEffect(.breathe) +imageView.addSymbolEffect(.rotate) +imageView.addSymbolEffect(.variableColor.iterative) +imageView.addSymbolEffect(.scale.up) + +// Add with options +imageView.addSymbolEffect(.bounce, options: .repeat(3)) +imageView.addSymbolEffect(.pulse, options: .speed(2.0)) + +// Add with completion handler +imageView.addSymbolEffect(.bounce, options: .default) { context in + // Called when effect finishes + print("Bounce complete") +} +``` + +### Removing Effects + +```swift +// Remove specific effect type +imageView.removeSymbolEffect(ofType: PulseSymbolEffect.self) +imageView.removeSymbolEffect(ofType: ScaleSymbolEffect.self) +imageView.removeSymbolEffect(ofType: RotateSymbolEffect.self) + +// Remove all effects +imageView.removeAllSymbolEffects() + +// Remove with options +imageView.removeSymbolEffect(ofType: PulseSymbolEffect.self, options: .default) + +// Remove with completion +imageView.removeSymbolEffect(ofType: PulseSymbolEffect.self) { context in + print("Pulse removed") +} +``` + +### Setting Symbol Images with Transitions + +```swift +// Replace with content transition +let newImage = UIImage(systemName: "pause.fill")! +imageView.setSymbolImage(newImage, contentTransition: .replace) + +// Directional replace +imageView.setSymbolImage(newImage, contentTransition: .replace.downUp) +imageView.setSymbolImage(newImage, contentTransition: .replace.upUp) +imageView.setSymbolImage(newImage, contentTransition: .replace.offUp) + +// With options +imageView.setSymbolImage(newImage, contentTransition: .replace, options: .speed(2.0)) +``` + +### UIBarButtonItem Effects + +```swift +// Effects also work on UIBarButtonItem +barButtonItem.addSymbolEffect(.bounce) +barButtonItem.addSymbolEffect(.pulse, isActive: isLoading) +barButtonItem.removeSymbolEffect(ofType: PulseSymbolEffect.self) +``` + +--- + +## Part 9: Accessibility + +### Labels + +```swift +// SwiftUI +Image(systemName: "star.fill") + .accessibilityLabel("Favorite") + +// UIKit +let image = UIImage(systemName: "star.fill") +imageView.accessibilityLabel = "Favorite" +imageView.isAccessibilityElement = true + +// Label automatically provides accessibility +Label("Settings", systemImage: "gear") +// VoiceOver reads: "Settings" +``` + +### Reduce Motion + +Symbol effects automatically respect `UIAccessibility.isReduceMotionEnabled`. When Reduce Motion is on: +- Most effects are simplified or suppressed +- Replace transitions use crossfade instead of directional movement +- Indefinite effects may be simplified to static appearance changes + +**Do not** attempt to override or check this yourself for effects. The system handles it. Only intervene if effects carry semantic meaning: + +```swift +// If the pulsing conveys connection status, provide a text label +Image(systemName: "wifi") + .symbolEffect(.pulse, isActive: isConnecting) + .accessibilityLabel(isConnecting ? "Connecting to WiFi" : "WiFi connected") +``` + +### Bold Text + +SF Symbols automatically adapt when Bold Text is enabled in Accessibility settings. Custom symbols need weight variants to support this properly. + +### Dynamic Type + +Symbols sized with `.font()` scale automatically with Dynamic Type. Symbols sized with explicit point sizes (`.font(.system(size: 24))`) do **not** scale. + +```swift +// ✅ Scales with Dynamic Type +Image(systemName: "star.fill") + .font(.title) + +// ❌ Fixed size, does not scale +Image(systemName: "star.fill") + .font(.system(size: 24)) +``` + +--- + +## Part 10: Common Patterns + +### Notification Badge with Effect + +```swift +struct NotificationBell: View { + let count: Int + + var body: some View { + Image(systemName: count > 0 ? "bell.badge.fill" : "bell.fill") + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.wiggle, value: count) + .symbolRenderingMode(.palette) + .foregroundStyle(count > 0 ? .red : .primary, .primary) + } +} +``` + +### WiFi Strength Indicator + +```swift +struct WiFiIndicator: View { + let strength: Double // 0.0 to 1.0 + let isSearching: Bool + + var body: some View { + Image(systemName: "wifi", variableValue: strength) + .symbolEffect(.variableColor.iterative, isActive: isSearching) + .symbolRenderingMode(.hierarchical) + .accessibilityLabel( + isSearching ? "Searching for WiFi" : + "WiFi strength: \(Int(strength * 100))%" + ) + } +} +``` + +### Animated Toggle + +```swift +struct RecordButton: View { + @State private var isRecording = false + + var body: some View { + Button { + isRecording.toggle() + } label: { + Image(systemName: isRecording ? "stop.circle.fill" : "record.circle") + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.breathe.pulse, isActive: isRecording) + .font(.largeTitle) + .foregroundStyle(isRecording ? .red : .primary) + } + .accessibilityLabel(isRecording ? "Stop recording" : "Start recording") + } +} +``` + +### Multi-State Symbol with Draw (iOS 26+) + +```swift +struct TaskCheckbox: View { + @State private var isComplete = false + + var body: some View { + Button { + isComplete.toggle() + } label: { + Image(systemName: isComplete ? "checkmark.circle.fill" : "circle") + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.drawOn, isActive: isComplete) + .font(.title2) + .foregroundStyle(isComplete ? .green : .secondary) + } + .accessibilityLabel(isComplete ? "Completed" : "Not completed") + } +} +``` + +--- + +## Resources + +**WWDC**: 2023-10257, 2023-10258, 2024-10188, 2025-337 + +**Docs**: /symbols, /symbols/symboleffect, /symbols/bouncesymboleffect, /symbols/pulsesymboleffect, /symbols/variablecolorsymboleffect, /symbols/scalesymboleffect, /symbols/wigglesymboleffect, /symbols/rotatesymboleffect, /symbols/breathesymboleffect, /symbols/appearsymboleffect, /symbols/disappearsymboleffect, /symbols/replacesymboleffect, /symbols/drawonsymboleffect, /symbols/drawoffsymboleffect, /swiftui/image/symbolrenderingmode(_:), /uikit/uiimage/symbolconfiguration + +**Skills**: axiom-sf-symbols, axiom-hig-ref, axiom-swiftui-animation-ref + +--- + +**Last Updated** Based on WWDC 2023/10257-10258, WWDC 2024/10188, WWDC 2025/337 +**Version** iOS 13+ (display), iOS 15+ (rendering modes), iOS 17+ (effects), iOS 18+ (Wiggle/Rotate/Breathe), iOS 26+ (Draw, Gradients) diff --git a/.claude/skills/axiom-sf-symbols-ref/agents/openai.yaml b/.claude/skills/axiom-sf-symbols-ref/agents/openai.yaml new file mode 100644 index 0000000..69857b9 --- /dev/null +++ b/.claude/skills/axiom-sf-symbols-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SF Symbols Reference" + short_description: "You need complete SF Symbols API reference including every rendering mode, symbol effect, configuration option, UIKit..." diff --git a/.claude/skills/axiom-sf-symbols/.openskills.json b/.claude/skills/axiom-sf-symbols/.openskills.json new file mode 100644 index 0000000..1b8e108 --- /dev/null +++ b/.claude/skills/axiom-sf-symbols/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-sf-symbols", + "installedAt": "2026-04-12T08:06:36.899Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-sf-symbols/SKILL.md b/.claude/skills/axiom-sf-symbols/SKILL.md new file mode 100644 index 0000000..98a521b --- /dev/null +++ b/.claude/skills/axiom-sf-symbols/SKILL.md @@ -0,0 +1,593 @@ +--- +name: axiom-sf-symbols +description: Use when implementing SF Symbols rendering modes, symbol effects, animations, custom symbols, or troubleshooting symbol appearance - covers the full symbol effects system from iOS 17 through SF Symbols 7 Draw animations in iOS 26 +license: MIT +compatibility: iOS 17+, iOS 18+ (Wiggle/Rotate/Breathe), iOS 26+ (Draw animations) +metadata: + version: "1.0.0" +--- + +# SF Symbols — Effects, Rendering, and Custom Symbols + +## When to Use This Skill + +Use when: +- Choosing between rendering modes (Monochrome, Hierarchical, Palette, Multicolor) +- Implementing symbol effects or animations (Bounce, Pulse, Scale, Wiggle, Rotate, Breathe, Draw) +- Working with SF Symbols 7 Draw On/Off animations +- Creating custom symbols in the SF Symbols app +- Troubleshooting symbol colors, effects not playing, or weight mismatches +- Deciding which effect matches a specific UX purpose +- Handling accessibility with symbol animations (Reduce Motion) + +#### Related Skills +- Use `axiom-sf-symbols-ref` for complete API reference with all modifiers, UIKit equivalents, and platform availability matrix +- Use `axiom-swiftui-animation-ref` for general SwiftUI animation (not symbol-specific) +- Use `axiom-hig-ref` for broader icon design guidelines + +## Example Prompts + +#### 1. "My SF Symbol shows as a single flat color but I want it to have depth with multiple shades. How do I fix this?" +> The skill covers rendering mode selection — Hierarchical for depth from a single color, Palette for explicit per-layer colors + +#### 2. "I want my download button to animate when tapped, then show a spinning indicator while downloading, and animate to a checkmark when done." +> The skill covers effect selection: Bounce for tap feedback, Breathe/Pulse for in-progress, Replace with content transition for completion + +#### 3. "I'm trying to use the new Draw animations from SF Symbols 7 but the effect isn't playing." +> The skill covers Draw On/Off implementation, playback modes, iOS 26 requirements, and common troubleshooting + +#### 4. "How do I create a custom symbol that supports all rendering modes and the new Draw animation?" +> The skill covers custom symbol authoring workflow, template layers, Draw annotation with guide points + +--- + +## Part 1: Rendering Mode Decision Tree + +SF Symbols support 4 rendering modes. The right choice depends on your design intent. + +### Quick Decision + +``` +Need depth from ONE color? → Hierarchical +Need specific colors per layer? → Palette +Want Apple's curated colors? → Multicolor +Just need a tinted icon? → Monochrome (default) +``` + +### Monochrome + +The default mode. Every layer renders in the same color (your `foregroundStyle`). + +```swift +Image(systemName: "cloud.rain.fill") + .foregroundStyle(.blue) +// All layers are blue +``` + +**When to use**: Simple tinted icons, matching text color, toolbar items, tab bar items. + +### Hierarchical + +Renders layers at different opacities derived from a **single** color. Primary layers are fully opaque; secondary and tertiary layers get progressively more transparent. Creates depth without specifying multiple colors. + +```swift +Image(systemName: "cloud.rain.fill") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.blue) +// Cloud is full blue, rain drops are lighter blue +``` + +**When to use**: When you want visual depth but still want the icon to feel cohesive with a single hue. Most common choice for polished UI. + +### Palette + +Each layer gets an **explicit** color. Unlike Hierarchical, no automatic opacity derivation — you control each layer's color directly. + +```swift +Image(systemName: "cloud.rain.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(.blue, .cyan) +// Cloud is blue, rain drops are cyan +``` + +**When to use**: Branded icons, status indicators where specific colors carry meaning, designs requiring exact color control. + +**Gotcha**: If you provide fewer colors than layers, extra layers reuse the last color. If the symbol has 3 layers and you provide 2 colors, the third layer uses the second color. + +### Multicolor + +Uses Apple's predefined color scheme for each symbol. Colors are fixed — you cannot customize them. + +```swift +Image(systemName: "cloud.rain.fill") + .symbolRenderingMode(.multicolor) +// Cloud is white, rain drops are blue (Apple's design) +``` + +**When to use**: Weather indicators, file type icons, or anywhere Apple's curated design intent matches your needs. Not all symbols support Multicolor — unsupported symbols fall back to Monochrome. + +### Common Mistakes + +| Mistake | Impact | Fix | +|---------|--------|-----| +| Using `.foregroundColor()` with Multicolor | Overrides Apple's colors | Remove foreground color modifier | +| Setting Palette with only 1 color | Looks like Monochrome | Provide colors for each layer | +| Assuming all symbols support Multicolor | Fallback to Monochrome | Check in SF Symbols app first | +| Using Hierarchical when layers need distinct meanings | Colors don't carry semantic intent | Use Palette instead | + +--- + +## Part 2: Symbol Effects System + +Symbol effects bring SF Symbols to life with motion. Every effect falls into one of four behavioral categories. + +### Effect Categories + +| Category | Trigger | Duration | Use Case | +|----------|---------|----------|----------| +| **Discrete** | Value change | One-shot | Tap feedback, event notification | +| **Indefinite** | `isActive` bool | Continuous until stopped | Loading states, ongoing processes | +| **Transition** | View insert/remove | One-shot | Appear/disappear with style | +| **Content Transition** | Symbol swap | One-shot | Replacing one symbol with another | + +### Which Effect for Which UX Purpose + +``` +User tapped something → Bounce (discrete) +Something changed, draw attention → Wiggle (discrete, iOS 18+) +Ongoing process/loading → Pulse, Breathe, or Variable Color (indefinite) +Rotation indicates progress → Rotate (indefinite, iOS 18+) +Show/hide symbol → Appear/Disappear (transition) +Swap between two symbols → Replace (content transition) +Symbol enters with hand-drawn style → Draw On (iOS 26+) +Symbol exits with hand-drawn style → Draw Off (iOS 26+) +Progress indicator along path → Variable Draw (iOS 26+) +Scale up/down for emphasis → Scale (indefinite) +``` + +### Discrete Effects + +Fire once when a value changes. The symbol performs the animation and returns to its resting state. + +#### Bounce + +The most common discrete effect. A brief, springy animation. + +```swift +@State private var downloadCount = 0 + +Image(systemName: "arrow.down.circle") + .symbolEffect(.bounce, value: downloadCount) +``` + +The animation triggers each time `downloadCount` changes. + +**Directional options**: `.bounce.up`, `.bounce.down` + +#### Wiggle (iOS 18+) + +A horizontal shake that draws attention to the symbol. + +```swift +Image(systemName: "bell.fill") + .symbolEffect(.wiggle, value: notificationCount) +``` + +**Directional options**: `.wiggle.left`, `.wiggle.right`, `.wiggle.forward`, `.wiggle.backward` + +`.forward` and `.backward` respect reading direction — use these for RTL support. + +#### Rotate (as Discrete, iOS 18+) + +A single rotation when triggered by value change. + +```swift +Image(systemName: "arrow.trianglehead.2.clockwise") + .symbolEffect(.rotate, value: refreshCount) +``` + +**Options**: `.rotate.clockwise`, `.rotate.counterClockwise` + +**By Layer**: Some symbols rotate only specific layers (e.g., fan blades spin but the housing stays fixed). Use `.rotate.byLayer` to activate this. + +### Indefinite Effects + +Run continuously while `isActive` is `true`. Stop when `isActive` becomes `false`. + +#### Pulse + +A subtle opacity pulse. Good for "waiting" states. + +```swift +Image(systemName: "network") + .symbolEffect(.pulse, isActive: isConnecting) +``` + +#### Variable Color + +Iterates through the symbol's layers, highlighting each in sequence. Creates a "filling up" or "cycling" look. + +```swift +Image(systemName: "wifi") + .symbolEffect(.variableColor.iterative, isActive: isSearching) +``` + +**Variants**: +- `.variableColor.iterative` — highlights one layer at a time +- `.variableColor.cumulative` — progressively fills layers +- `.variableColor.reversing` — cycles back and forth +- Combine: `.variableColor.iterative.reversing` + +#### Scale + +Scales the symbol up or down. + +```swift +Image(systemName: "mic.fill") + .symbolEffect(.scale.up, isActive: isRecording) +``` + +#### Breathe (iOS 18+) + +A smooth, rhythmic scale animation — like the symbol is breathing. + +```swift +Image(systemName: "heart.fill") + .symbolEffect(.breathe, isActive: isMonitoring) +``` + +**Variants**: `.breathe.plain` (scale only), `.breathe.pulse` (scale + opacity) + +#### Rotate (as Indefinite, iOS 18+) + +Continuous rotation for processing indicators. + +```swift +Image(systemName: "gear") + .symbolEffect(.rotate, isActive: isProcessing) +``` + +### Effect Options + +All effects accept `SymbolEffectOptions` via the `options` parameter. + +```swift +// Repeat 3 times +.symbolEffect(.bounce, options: .repeat(3), value: count) + +// Double speed +.symbolEffect(.pulse, options: .speed(2.0), isActive: true) + +// Repeat continuously +.symbolEffect(.variableColor, options: .repeat(.continuous), isActive: true) + +// Non-repeating (run once) +.symbolEffect(.breathe, options: .nonRepeating, isActive: true) + +// Combine options +.symbolEffect(.bounce, options: .repeat(5).speed(1.5), value: count) +``` + +### Transition Effects + +Used when a symbol-based view appears or disappears from the view hierarchy. + +```swift +if showSymbol { + Image(systemName: "checkmark.circle.fill") + .transition(.symbolEffect(.appear)) +} +``` + +**Available transitions**: `.appear`, `.disappear` + +**Variants**: `.appear.up`, `.appear.down`, `.disappear.up`, `.disappear.down` + +### Content Transitions + +Used to animate from one symbol to another. Applied to the container, not the symbol. + +```swift +@State private var isFavorite = false + +Button { + isFavorite.toggle() +} label: { + Image(systemName: isFavorite ? "star.fill" : "star") + .contentTransition(.symbolEffect(.replace)) +} +``` + +**Replace variants**: +- `.replace.downUp` — old symbol moves down, new moves up +- `.replace.upUp` — both move up +- `.replace.offUp` — old fades off, new moves up + +#### Magic Replace + +When two symbols share a common structure (like `star` and `star.fill`, or `pause.fill` and `play.fill`), Replace automatically performs a **Magic Replace** — morphing shared elements while transitioning differing parts. Magic Replace is the default behavior for `.replace` in iOS 18+. For explicit control: + +```swift +// Explicit Magic Replace with fallback +.contentTransition(.symbolEffect(.replace.magic(fallback: .replace.downUp))) +``` + +--- + +## Part 3: SF Symbols 7 — Draw Animations (iOS 26+) + +Draw animations simulate the natural flow of drawing a symbol with a pen. This is the signature new feature in SF Symbols 7. + +### Draw On and Draw Off + +**Draw On** animates a symbol appearing by "drawing" it stroke by stroke. +**Draw Off** animates a symbol disappearing by "erasing" it. + +```swift +// Draw On — symbol draws in when isComplete becomes true +Image(systemName: "checkmark.circle") + .symbolEffect(.drawOn, isActive: isComplete) + +// Draw Off — symbol draws out when isHidden becomes true +Image(systemName: "star.fill") + .symbolEffect(.drawOff, isActive: isHidden) +``` + +### Playback Modes + +Control how multi-layer symbols animate their draw: + +```swift +// By Layer (default) — staggered timing, layers overlap +Image(systemName: "square.and.arrow.up") + .symbolEffect(.drawOn.byLayer, isActive: showIcon) + +// Whole Symbol — all layers draw simultaneously +Image(systemName: "square.and.arrow.up") + .symbolEffect(.drawOn.wholeSymbol, isActive: showIcon) + +// Individually — sequential, each layer completes before next starts +Image(systemName: "square.and.arrow.up") + .symbolEffect(.drawOn.individually, isActive: showIcon) +``` + +**When to use each mode**: +- **By Layer** (default): Most natural feel, good for most symbols +- **Whole Symbol**: When the symbol should appear as one unit, not in parts +- **Individually**: When you want to emphasize each layer separately (storytelling, onboarding) + +### Draw Off Direction + +Draw Off supports controlling whether the animation plays forward or in reverse: + +```swift +// Forward (default) — follows the draw path +.symbolEffect(.drawOff.nonReversed, isActive: isHidden) + +// Reversed — erases in reverse order of how it was drawn +.symbolEffect(.drawOff.reversed, isActive: isErasing) +``` + +### Variable Draw + +Variable Draw uses `SymbolVariableValueMode.draw` to partially draw a symbol's stroke path based on a 0.0 to 1.0 value — perfect for progress indicators. + +```swift +Image(systemName: "thermometer.high", variableValue: temperature) + .symbolVariableValueMode(.draw) // iOS 26+ +``` + +Compare with traditional Variable Color (which sets opacity per layer): + +```swift +Image(systemName: "wifi", variableValue: signalStrength) + .symbolVariableValueMode(.color) // iOS 17+ (default behavior) +``` + +**Constraint**: A symbol can support both Variable Color and Variable Draw, but only one mode can be active at render time. Setting an unsupported mode has no visible effect. + +### Gradient Rendering + +SF Symbols 7 introduces `SymbolColorRenderingMode` for gradient fills generated from a single source color. + +```swift +Image(systemName: "star.fill") + .symbolColorRenderingMode(.gradient) // iOS 26+ + .foregroundStyle(.red) +``` + +| Mode | Description | +|------|-------------| +| `.flat` | Solid color fill (default) | +| `.gradient` | Axial gradient from source color | + +Gradients work with all rendering modes and are most effective at larger sizes. + +### Magic Replace with Draw + +When using `.contentTransition(.symbolEffect(.replace))` between certain symbol pairs, the system now combines Draw Off on the outgoing symbol with Draw On for the incoming symbol. The enclosure (if shared, like a circle outline) is preserved while inner elements transition with draw animations. + +```swift +// Automatic Draw-enhanced Magic Replace +Image(systemName: isComplete ? "checkmark.circle.fill" : "circle") + .contentTransition(.symbolEffect(.replace)) +``` + +### Custom Symbol Draw Annotation + +To enable Draw animations on custom symbols, annotate paths in the SF Symbols app: + +1. **Open** your custom symbol in SF Symbols 7 +2. **Select** a path layer +3. **Add guide points** to define draw direction: + - **Start point** (open circle): Where drawing begins + - **End point** (closed circle): Where drawing ends + - **Corner point** (diamond): Sharp direction changes + - **Bidirectional point**: Enables center-outward drawing + - **Attachment point**: Connects non-drawing decorative elements +4. **Minimum**: Two guide points per path (start and end) +5. **Test** using the Preview panel in SF Symbols app + +**Option-drag** guide points for precise placement. Use context menus to configure direction and end caps. + +--- + +## Part 4: Anti-Patterns + +### Wrong Rendering Mode + +| Pattern | Problem | Fix | +|---------|---------|-----| +| Palette with 1 color | Equivalent to Monochrome, wasted API call | Use Monochrome or provide multiple colors | +| Multicolor for branded icons | Can't customize Apple's fixed colors | Use Palette with brand colors | +| Hardcoded `.foregroundColor(.blue)` | Ignores Dark Mode, Dynamic Type, accessibility | Use `.foregroundStyle()` with semantic colors | +| Hierarchical for status indicators | Layers don't carry distinct meaning | Use Palette with semantic colors | + +### Wrong Effect Choice + +| Pattern | Problem | Fix | +|---------|---------|-----| +| Bounce for loading state | One-shot, doesn't convey "ongoing" | Use Pulse, Breathe, or Variable Color | +| Pulse for tap feedback | Too subtle for confirming action | Use Bounce | +| Continuous Rotate for non-mechanical symbols | Looks unnatural for organic shapes | Use Breathe for organic symbols | +| Draw On for transient state changes | Too dramatic for frequent toggles | Use Replace or Scale | + +### Missing iOS Version Checks + +```swift +// ❌ Crashes on iOS 17 +Image(systemName: "bell") + .symbolEffect(.wiggle, value: count) // Wiggle requires iOS 18+ + +// ✅ Safe version check +Image(systemName: "bell") + .modifier(BellEffectModifier(count: count)) + +struct BellEffectModifier: ViewModifier { + let count: Int + func body(content: Content) -> some View { + if #available(iOS 18, *) { + content.symbolEffect(.wiggle, value: count) + } else { + content.symbolEffect(.bounce, value: count) + } + } +} +``` + +### Ignoring Reduce Motion + +Symbol effects **automatically** respect the Reduce Motion accessibility setting — most effects are suppressed or simplified. However, if you're using effects to convey essential information (not just decoration), provide an alternative: + +```swift +// Variable Color conveys WiFi strength — provide text fallback +Image(systemName: "wifi") + .symbolEffect(.variableColor, isActive: isSearching) + .accessibilityLabel("Searching for WiFi networks") +``` + +**Do not** disable Reduce Motion or try to force-play effects. The system handles this correctly. + +### Missing Accessibility Labels + +```swift +// ❌ VoiceOver says "star.fill" +Image(systemName: "star.fill") + +// ✅ VoiceOver says "Favorite" +Image(systemName: "star.fill") + .accessibilityLabel("Favorite") +``` + +When using `.contentTransition(.symbolEffect(.replace))` to swap symbols, update the accessibility label to match the current state: + +```swift +Image(systemName: isFavorite ? "star.fill" : "star") + .contentTransition(.symbolEffect(.replace)) + .accessibilityLabel(isFavorite ? "Remove from favorites" : "Add to favorites") +``` + +--- + +## Part 5: Troubleshooting + +### Effect Not Playing + +**Symptom**: `.symbolEffect()` modifier applied but no animation visible. + +1. **Check iOS version** — Bounce/Pulse/Scale require iOS 17+, Wiggle/Rotate/Breathe require iOS 18+, Draw requires iOS 26+ +2. **Check Reduce Motion** — Settings > Accessibility > Motion > Reduce Motion. If on, most effects are suppressed +3. **Check trigger type** — Discrete effects need `value:` that changes. Indefinite effects need `isActive: true`. Transition effects need the view to actually enter/leave the hierarchy +4. **Check symbol compatibility** — Not all symbols support all effects. Open the SF Symbols app, select the symbol, and check the Animation inspector +5. **Check for conflicting effects** — Multiple `.symbolEffect()` modifiers on the same view can conflict. Use a single effect or combine with options + +### Wrong Colors in Rendering Mode + +**Symptom**: Symbol colors don't match expected appearance. + +1. **Check rendering mode** — If you set `.foregroundStyle` but see only one color, you may need `.symbolRenderingMode(.palette)` or `.hierarchical` +2. **Check `.tint` vs `.foregroundStyle`** — In UIKit, `tintColor` affects Monochrome and Hierarchical. For Palette, use `UIImage.SymbolConfiguration(paletteColors:)` +3. **Check Multicolor support** — Not all symbols have Multicolor variants. Unsupported symbols fall back to Monochrome +4. **Check environment** — `.foregroundStyle` from a parent view may override your rendering mode. Apply `.symbolRenderingMode()` directly on the Image + +### Custom Symbol Weight Mismatch + +**Symptom**: Custom symbol looks too thin or too thick next to text or other symbols. + +1. **Check template weight** — Custom symbols need weight variants matching the 9 SF Pro weights. Export from SF Symbols app handles this +2. **Check `.font()` alignment** — The symbol's weight follows the applied font weight. If using `.font(.title)`, ensure your custom symbol has appropriate weight variants +3. **Check scale** — `.imageScale(.small/.medium/.large)` affects overall size. Use `.font()` for weight matching + +### Draw Animation Not Working on Custom Symbol + +**Symptom**: `.symbolEffect(.drawOn)` applied to custom symbol but no draw animation occurs. + +1. **Check guide points** — Custom symbols need Draw annotation with at least 2 guide points per path (start + end) +2. **Check SF Symbols app version** — Draw annotation requires SF Symbols 7+ +3. **Check path structure** — Guide points must be placed on stroked paths, not fills. Convert fills to strokes where draw animation is desired +4. **Check layer structure** — Each annotatable layer needs its own guide points + +--- + +## Part 6: Pressure Scenarios + +### Scenario 1: "Just use a static image, symbols are overkill" + +**Setup**: Designer provides PNG icons. Developer considers using them instead of SF Symbols. + +**Why this matters**: Static PNGs don't adapt to Dynamic Type, Bold Text, Dark Mode, or accessibility settings. They also don't support symbol effects. + +**Professional response**: "SF Symbols scale with text, support 9 weights, adapt to Dark Mode and Bold Text automatically, and enable animations without custom code. A PNG requires @1x/@2x/@3x variants, manual Dark Mode handling, manual Dynamic Type scaling, and custom animation code. The 10 minutes to find the right SF Symbol saves hours of asset management." + +**Time cost of skipping**: 2-4 hours managing assets + ongoing maintenance vs 10 minutes finding the right symbol. + +### Scenario 2: "We'll add animations later" + +**Setup**: Sprint deadline. PM says animations are polish and can wait. + +**Why this matters**: Retrofitting symbol effects requires restructuring state management. Effects triggered by `value:` changes need the right state architecture from the start. + +**Professional response**: "Adding `.symbolEffect(.bounce, value: count)` takes one line. Retrofitting the state to support it later takes a refactor. Let me add the effect now — it's literally one modifier." + +### Scenario 3: "Draw animations look janky on our custom symbols" + +**Setup**: Custom symbols have Draw animations that look wrong — paths draw in unexpected order or direction. + +**Why this matters**: Draw annotation requires intentional guide point placement. Without it, the system guesses and often gets it wrong. + +**Fix**: Open custom symbols in SF Symbols 7 app, add guide points explicitly to each path defining start/end/direction. Test each weight variant. See Custom Symbol Draw Annotation section above. + +--- + +## Resources + +**WWDC**: 2023-10257, 2023-10258, 2024-10188, 2025-337 + +**Docs**: /symbols, /symbols/symboleffect, /symbols/symbolrenderingmode, /swiftui/image/symboleffect(_:options:value:), /swiftui/image/symbolrenderingmode(_:) + +**Skills**: axiom-sf-symbols-ref, axiom-hig-ref, axiom-swiftui-animation-ref + +--- + +**Last Updated** Based on WWDC 2023/10257-10258, WWDC 2024/10188, WWDC 2025/337 +**Version** iOS 17+ (effects), iOS 18+ (Wiggle/Rotate/Breathe), iOS 26+ (Draw On/Off, Variable Draw, Gradients) diff --git a/.claude/skills/axiom-sf-symbols/agents/openai.yaml b/.claude/skills/axiom-sf-symbols/agents/openai.yaml new file mode 100644 index 0000000..113fb07 --- /dev/null +++ b/.claude/skills/axiom-sf-symbols/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SF Symbols" + short_description: "Implementing SF Symbols rendering modes, symbol effects, animations, custom symbols, or troubleshooting symbol appear..." diff --git a/.claude/skills/axiom-shazamkit-ref/.openskills.json b/.claude/skills/axiom-shazamkit-ref/.openskills.json new file mode 100644 index 0000000..92838c0 --- /dev/null +++ b/.claude/skills/axiom-shazamkit-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-shazamkit-ref", + "installedAt": "2026-04-12T08:06:38.213Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-shazamkit-ref/SKILL.md b/.claude/skills/axiom-shazamkit-ref/SKILL.md new file mode 100644 index 0000000..94dba56 --- /dev/null +++ b/.claude/skills/axiom-shazamkit-ref/SKILL.md @@ -0,0 +1,553 @@ +--- +name: axiom-shazamkit-ref +description: Use when needing ShazamKit API details — SHManagedSession, SHSession, SHCustomCatalog, SHSignatureGenerator, SHMediaItem, SHMatchedMediaItem, SHLibrary, SHMediaLibrary, SHSignature, SHMatch, SHError, SHSessionDelegate, and related types +license: MIT +metadata: + version: "1.0" + last-updated: "2026-03-30" +--- + +# ShazamKit API Reference + +## Overview + +ShazamKit provides audio recognition against Shazam's music catalog and custom audio catalogs. The framework covers matching, signature generation, catalog management, and library integration. + +For decision trees, setup checklist, and best practices, see the **shazamkit** discipline skill. + +**Platform**: iOS 15+, iPadOS 15+, macOS 12+, tvOS 15+, watchOS 8+, visionOS 1+ + +--- + +# Part 1: SHManagedSession (iOS 17+) + +A managed session that handles recording and matching captured sound automatically. This is the modern, recommended path for microphone-based recognition. + +## Initialization + +```swift +init() // Matches against Shazam catalog +init(catalog: SHCatalog) // Matches against custom catalog +``` + +## Matching + +```swift +func result() async -> SHSession.Result // Single match attempt +var results: SHManagedSession.Results // AsyncSequence for continuous matching +``` + +## Lifecycle + +```swift +func prepare() async // Preallocate resources + start prerecording +func cancel() // Stop recording + cancel current match +``` + +## State (Observable) + +```swift +var state: SHManagedSession.State // Current session state +``` + +`SHManagedSession` conforms to `Observable` (iOS 17+). SwiftUI views refresh automatically on state changes. + +Conforms to `Sendable` as of iOS 18. + +--- + +# Part 2: SHManagedSession.State + +```swift +@frozen enum State +``` + +| Case | Meaning | +|------|---------| +| `.idle` | Not recording or matching | +| `.prerecording` | Prepared, recording in anticipation of match | +| `.matching` | Actively making match attempts | + +--- + +# Part 3: SHSession (iOS 15+) + +Lower-level session for matching audio buffers or signatures against catalogs. + +## Initialization + +```swift +init() // Matches against Shazam catalog +init(catalog: SHCatalog) // Matches against custom catalog +``` + +## Matching Methods + +```swift +func match(_ signature: SHSignature) // Match a complete signature +func matchStreamingBuffer(_ buffer: AVAudioPCMBuffer, at time: AVAudioTime?) // Match streaming audio +``` + +When using `matchStreamingBuffer`, include the `time` parameter when available — the session validates contiguous audio. + +## Delegate + +```swift +var delegate: (any SHSessionDelegate)? +``` + +## AsyncSequence (iOS 16+) + +```swift +var results: SHSession.Results // AsyncSequence of SHSession.Result +``` + +## Audio Format Support + +- iOS 15-16: Specific PCM formats and sample rates required +- iOS 17+: Most PCM format settings accepted; automatic conversion + +## Multiple Matches (iOS 17+) + +When a query matches multiple reference signatures in a custom catalog, all matches are returned sorted by quality. Use metadata annotation to distinguish between them. + +--- + +# Part 4: SHSession.Result (iOS 16+) + +```swift +@frozen enum Result: Sendable +``` + +| Case | Associated Value | +|------|-----------------| +| `.match(SHMatch)` | Matched media items found | +| `.noMatch(SHSignature)` | No match for this signature | +| `.error(any Error, SHSignature)` | Error during matching | + +--- + +# Part 5: SHSessionDelegate (iOS 15+) + +```swift +protocol SHSessionDelegate: NSObjectProtocol +``` + +### Methods + +```swift +optional func session(_ session: SHSession, didFind match: SHMatch) +optional func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature, error: (any Error)?) +``` + +--- + +# Part 6: SHMatch (iOS 15+) + +Contains the results of a successful match. + +## Properties + +```swift +var mediaItems: [SHMatchedMediaItem] // Matched items (multiple possible) +var querySignature: SHSignature // The query that produced this match +``` + +--- + +# Part 7: SHMediaItem (iOS 15+) + +Metadata associated with a reference signature. + +## Initialization + +```swift +init(properties: [SHMediaItemProperty : any NSSecureCoding & NSObjectProtocol]) +``` + +## Predefined Properties + +| Property | Type | Description | +|----------|------|-------------| +| `.title` | String | Song/content title | +| `.subtitle` | String | Subtitle | +| `.artist` | String | Artist name | +| `.artworkURL` | URL | Album art URL | +| `.videoURL` | URL | Video URL | +| `.genres` | [String] | Genre list | +| `.explicitContent` | Bool | Explicit content flag | +| `.isrc` | String | International Standard Recording Code | +| `.appleMusicID` | String | Apple Music identifier | +| `.appleMusicURL` | URL | Apple Music URL | +| `.webURL` | URL | Web URL for sharing | +| `.shazamID` | String | Shazam catalog identifier | +| `.creationDate` | Date | When item was created | + +## Timed Content Properties (iOS 16+) + +| Property | Type | Description | +|----------|------|-------------| +| `.timeRanges` | [Range\] | When this item is active in the reference | +| `.frequencySkewRanges` | [Range\] | Frequency skew ranges for differentiation | + +## Custom Properties + +Add custom metadata using `SHMediaItemProperty` extensions: + +```swift +extension SHMediaItemProperty { + static let episodeNumber = SHMediaItemProperty("episodeNumber") + static let teacher = SHMediaItemProperty("teacher") +} + +let item = SHMediaItem(properties: [ + .title: "Episode 3", + .episodeNumber: 3, + .teacher: "Neil" +]) +``` + +Custom property values must be valid property list types. + +## Fetching by Shazam ID + +```swift +class func fetch(shazamID: String, completionHandler: @escaping (SHMediaItem?, (any Error)?) -> Void) +``` + +Requests a media item from the Shazam catalog by its Shazam ID. + +## Subscript Access + +```swift +subscript(key: SHMediaItemProperty) -> Any { get } +``` + +## Protocols + +NSSecureCoding, NSCopying, NSObjectProtocol, Identifiable (iOS 17+), Sendable + +--- + +# Part 8: SHMatchedMediaItem (iOS 15+) + +Subclass of `SHMediaItem` with match-specific information. Only created by the framework from successful matches. + +## Additional Properties + +| Property | Type | Description | +|----------|------|-------------| +| `.matchOffset` | TimeInterval | Where in the reference the match occurred | +| `.predictedCurrentMatchOffset` | TimeInterval | Auto-updating position in reference (seconds) | +| `.frequencySkew` | Float | Frequency difference between matched and reference | +| `.confidence` | Float | Match confidence (0.0 to 1.0, where 1.0 is highest) | + +`predictedCurrentMatchOffset` updates continuously during streaming matches — use it to sync UI to audio position. + +--- + +# Part 9: SHMediaItemProperty (iOS 15+) + +```swift +struct SHMediaItemProperty: RawRepresentable, Hashable, Sendable +``` + +Predefined property keys for `SHMediaItem`. Extend with custom keys using `init(rawValue:)`. + +### All Predefined Keys + +`.title`, `.subtitle`, `.artist`, `.artworkURL`, `.videoURL`, `.genres`, `.explicitContent`, `.isrc`, `.appleMusicID`, `.appleMusicURL`, `.webURL`, `.shazamID`, `.creationDate`, `.matchOffset`, `.frequencySkew`, `.confidence`, `.timeRanges`, `.frequencySkewRanges` + +--- + +# Part 10: SHSignature (iOS 15+) + +Contains opaque audio fingerprint data. + +## Properties + +```swift +var duration: TimeInterval // Duration of audio represented +var dataRepresentation: Data // Serializable data for storage/transmission +``` + +## Initialization + +```swift +init(dataRepresentation: Data) throws +``` + +## Slicing + +```swift +func slices(from start: TimeInterval, duration: TimeInterval, stride: TimeInterval) -> SHSignature.Slices +``` + +Returns a sequence of signature segments of the specified duration, stepping by stride from the start offset. + +## Protocols + +NSSecureCoding, NSCopying, NSObjectProtocol, Sendable + +--- + +# Part 11: SHSignatureGenerator (iOS 15+) + +Converts audio into signatures. + +## From Buffers + +```swift +func append(_ buffer: AVAudioPCMBuffer, at time: AVAudioTime?) throws +func signature() -> SHSignature +``` + +## From Asset (iOS 16+) + +```swift +static func signature(from asset: AVAsset) async throws -> SHSignature +``` + +Accepts any `AVAsset` with an audio track. Multiple tracks are mixed automatically. + +--- + +# Part 12: SHCatalog (iOS 15+) + +Abstract base class for catalogs. + +## Properties + +```swift +var minimumQuerySignatureDuration: TimeInterval // Minimum query length needed +var maximumQuerySignatureDuration: TimeInterval // Maximum useful query length +``` + +--- + +# Part 13: SHCustomCatalog (iOS 15+) + +Mutable catalog for custom audio matching. + +## Adding Content + +```swift +func addReferenceSignature(_ signature: SHSignature, representing mediaItems: [SHMediaItem]) throws +``` + +## Persistence + +```swift +func write(to url: URL) throws // Save .shazamcatalog file +func add(from url: URL) throws // Load/merge from file +``` + +File extension: `.shazamcatalog` + +## Protocols + +Sendable + +--- + +# Part 14: SHLibrary (iOS 17+) + +User's synced Shazam library. Each app can only read and delete items it has added. + +## Access + +```swift +static var `default`: SHLibrary +``` + +## Methods + +```swift +func addItems(_ items: [SHMediaItem]) async throws +func removeItems(_ items: [SHMediaItem]) async throws +var items: [SHMediaItem] { get } // Observable +``` + +## Reading Current Items (Non-UI) + +```swift +let currentItems = await SHLibrary.default.items +``` + +## Observable + +Conforms to `Observable`. SwiftUI views using `SHLibrary.default.items` update automatically when items change. + +## Sync + +Items sync across devices via iCloud. Attributed to the app that added them. Visible in Shazam app and Control Center Music Recognition module. + +--- + +# Part 15: SHMediaLibrary (iOS 15+, Legacy) + +Legacy write-only access to the user's Shazam library. + +## Access + +```swift +static var `default`: SHMediaLibrary +``` + +## Methods + +```swift +func add(_ mediaItems: [SHMediaItem], completionHandler: @escaping (Error?) -> Void) +``` + +## Constraints + +- Write-only (no read, no delete) +- Only accepts items with valid Shazam catalog IDs +- End-to-end encrypted, requires two-factor authentication +- No special permission required + +--- + +# Part 16: SHError + +```swift +struct SHError: Error +``` + +## Error Codes (SHError.Code) + +### Matching Errors + +| Code | Description | +|------|-------------| +| `.matchAttemptFailed` | Match attempt failed | +| `.signatureInvalid` | Invalid signature data | + +### Catalog Errors + +| Code | Description | +|------|-------------| +| `.customCatalogInvalid` | Catalog data is corrupt or invalid | +| `.customCatalogInvalidURL` | URL for catalog is invalid | + +### Signature Errors + +| Code | Description | +|------|-------------| +| `.signatureDurationInvalid` | Signature duration too short or long | +| `.audioDiscontinuity` | Gap detected in streaming audio | + +### Media Library Errors + +| Code | Description | +|------|-------------| +| `.mediaLibrarySyncFailed` | Failed to sync with library | +| `.internalError` | Internal framework error | + +### Session Errors + +| Code | Description | +|------|-------------| +| `.invalidAudioFormat` | Audio format not supported | +| `.mediaItemFetchFailed` | Failed to fetch media item details | + +--- + +# Part 17: Shazam CLI (macOS 13+) + +Command-line tool for building custom catalogs at scale. + +## Commands + +```bash +# Create signature from media file +shazam signature --input --output + +# Create custom catalog +shazam custom-catalog create \ + --input \ + --media-items \ + --output + +# Update existing catalog +shazam custom-catalog update \ + --input \ + --media-items \ + --catalog + +# Display catalog contents +shazam custom-catalog display --catalog + +# Add/remove/export signatures and media items +shazam custom-catalog add ... +shazam custom-catalog remove ... +shazam custom-catalog export ... +``` + +Run `shazam custom-catalog create --help` for CSV header-to-property mapping. + +--- + +# Part 18: Sample Projects + +### Building a Custom Catalog and Matching Audio + +FoodMath educational app demonstrating custom catalog matching with synced UI content. Uses `SHSession` with delegate pattern. + +**Key patterns**: Custom `SHMediaItemProperty` extensions, `predictedCurrentMatchOffset` for time-sync, `SHCustomCatalog` from `.shazamsignature` files. + +### ShazamKit Dance Finder with Managed Session + +Dance discovery app using `SHManagedSession` for simplified matching. Demonstrates `SHLibrary` read/write/delete and `Observable` SwiftUI integration. + +**Key patterns**: `SHManagedSession` result/results, session state in SwiftUI, `SHLibrary.default.items` in `List`, swipe-to-delete with `removeItems`. + +--- + +## Quick Reference + +### Class Hierarchy + +``` +SHCatalog (abstract) +├── SHCustomCatalog (mutable, user-created) +└── (internal Shazam catalog) + +SHMediaItem +└── SHMatchedMediaItem (match-specific subclass) + +SHSession → delegate or AsyncSequence +SHManagedSession → AsyncSequence, Observable, handles recording +``` + +### Common Patterns + +| Task | API | +|------|-----| +| Identify song (iOS 17+) | `SHManagedSession().result()` | +| Continuous recognition | `for await result in session.results` | +| Match custom audio | `SHManagedSession(catalog: custom)` | +| Match signature file | `SHSession().match(signature)` | +| Generate from file | `SHSignatureGenerator.signature(from: asset)` | +| Generate from mic | `generator.append(buffer, at: time)` | +| Add to library | `SHLibrary.default.addItems([item])` | +| Read library | `SHLibrary.default.items` | +| Remove from library | `SHLibrary.default.removeItems([item])` | + +### File Extensions + +| Extension | Purpose | +|-----------|---------| +| `.shazamsignature` | Audio signature file | +| `.shazamcatalog` | Custom catalog file | + +--- + +## Resources + +**WWDC**: 2021-10044, 2021-10045, 2022-10028, 2023-10051 + +**Docs**: /shazamkit, /shazamkit/shmanagedsession, /shazamkit/shsession, /shazamkit/shcustomcatalog, /shazamkit/shmediaitem, /shazamkit/shlibrary + +**Skills**: shazamkit, avfoundation-ref, swift-concurrency diff --git a/.claude/skills/axiom-shazamkit-ref/agents/openai.yaml b/.claude/skills/axiom-shazamkit-ref/agents/openai.yaml new file mode 100644 index 0000000..82a61c5 --- /dev/null +++ b/.claude/skills/axiom-shazamkit-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Shazamkit Reference" + short_description: "Needing ShazamKit API details" diff --git a/.claude/skills/axiom-shazamkit/.openskills.json b/.claude/skills/axiom-shazamkit/.openskills.json new file mode 100644 index 0000000..8073904 --- /dev/null +++ b/.claude/skills/axiom-shazamkit/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-shazamkit", + "installedAt": "2026-04-12T08:06:37.786Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-shazamkit/SKILL.md b/.claude/skills/axiom-shazamkit/SKILL.md new file mode 100644 index 0000000..10016a1 --- /dev/null +++ b/.claude/skills/axiom-shazamkit/SKILL.md @@ -0,0 +1,648 @@ +--- +name: axiom-shazamkit +description: Use when implementing audio recognition, music identification, custom audio matching, second-screen sync, or working with Shazam catalog. Covers SHManagedSession (modern), SHSession (legacy), SHCustomCatalog, SHLibrary, microphone capture, signature generation, Shazam CLI. +license: MIT +--- + +# ShazamKit — Discipline + +## Core Philosophy + +> "Use SHManagedSession unless you need buffer-level control." + +**Mental model**: ShazamKit has two eras. The iOS 15-16 era required manual AVAudioEngine plumbing via `SHSession`. The iOS 17+ era introduced `SHManagedSession` which handles recording, format conversion, and matching in a few lines. Always default to the modern path unless you need custom audio sources or signature-file matching. + +## When to Use This Skill + +Use this skill when: +- Adding song identification to an app (Shazam catalog matching) +- Building second-screen experiences synced to audio/video +- Creating custom audio catalogs for proprietary content +- Matching prerecorded audio (podcasts, TV episodes, lessons) +- Managing the user's Shazam library (add, read, remove) +- Generating audio signatures from files or buffers +- Debugging recognition failures or entitlement issues + +Do NOT use this skill for: +- Sound classification (laughter, applause, speech) — use `SoundAnalysis`/`MLSoundClassifier` +- Audio playback or recording — use **avfoundation-ref** +- MusicKit integration — use MusicKit docs +- General microphone permission patterns — use **privacy-ux** + +## Related Skills + +- **shazamkit-ref** — Complete ShazamKit API reference (all classes, methods, properties) +- **avfoundation-ref** — Audio engine patterns if using SHSession with custom buffers +- **privacy-ux** — Microphone permission UX best practices +- **swift-concurrency** — Async/await patterns for managed sessions + +--- + +## API Era Decision Tree + +```dot +digraph api_era { + rankdir=TB; + "What's your minimum target?" [shape=diamond]; + "Need buffer-level control?" [shape=diamond]; + "Matching signature files?" [shape=diamond]; + + "SHManagedSession" [shape=box, label="SHManagedSession\n(iOS 17+)\nHandles recording + matching\nAsync sequences\nObservable state"]; + "SHSession + AVAudioEngine" [shape=box, label="SHSession + AVAudioEngine\n(iOS 15+)\nManual buffer plumbing\nDelegate callbacks or AsyncSequence"]; + "SHSession alone" [shape=box, label="SHSession.match(signature)\n(iOS 15+)\nFor pre-generated signatures\nNo microphone needed"]; + + "What's your minimum target?" -> "Need buffer-level control?" [label="iOS 17+"]; + "What's your minimum target?" -> "SHSession + AVAudioEngine" [label="iOS 15-16"]; + "Need buffer-level control?" -> "SHSession + AVAudioEngine" [label="yes"]; + "Need buffer-level control?" -> "Matching signature files?" [label="no"]; + "Matching signature files?" -> "SHSession alone" [label="yes"]; + "Matching signature files?" -> "SHManagedSession" [label="no"]; +} +``` + +--- + +## Use Case Decision Tree + +```dot +digraph use_case { + rankdir=TB; + "What are you building?" [shape=diamond]; + "Identify songs from Shazam catalog" [shape=box]; + "Sync content to your own audio" [shape=box]; + "Both catalog + custom" [shape=box]; + + "What are you building?" -> "Identify songs from Shazam catalog" [label="music recognition"]; + "What are you building?" -> "Sync content to your own audio" [label="second-screen\ncustom matching"]; + "What are you building?" -> "Both catalog + custom" [label="both"]; + + "Identify songs from Shazam catalog" -> "SHManagedSession()\nNo catalog argument\nRequires ShazamKit App Service" [shape=box]; + "Sync content to your own audio" -> "SHManagedSession(catalog:)\nor SHSession(catalog:)\nCustom catalog required\nNo App Service needed" [shape=box]; + "Both catalog + custom" -> "Two separate sessions\nOne per catalog type" [shape=box]; +} +``` + +### Dual-Session Pattern (Both Catalogs) + +When you need to match against both the Shazam catalog and a custom catalog simultaneously, run two `SHManagedSession` instances concurrently: + +```swift +let shazamSession = SHManagedSession() +let customSession = SHManagedSession(catalog: customCatalog) + +// Match both concurrently +async let songResult = shazamSession.result() +async let customResult = customSession.result() + +let (song, custom) = await (songResult, customResult) + +// Handle whichever matched +switch song { +case .match(let match): handleSongMatch(match) +default: break +} +switch custom { +case .match(let match): handleCustomMatch(match) +default: break +} + +// Stop both +shazamSession.cancel() +customSession.cancel() +``` + +A single session can only target one catalog. Do not try to switch catalogs on a live session — create two sessions and let them share the microphone internally. + +--- + +## Setup Checklist + +### For Shazam Catalog Matching + +1. **Enable ShazamKit App Service** in Certificates, Identifiers & Profiles: + - Identifiers → your App ID → App Services tab → check ShazamKit + - This step is NOT needed for custom catalog matching + +2. **Add microphone usage description** to Info.plist: + ```xml + NSMicrophoneUsageDescription + Identifies songs playing nearby + ``` + +3. **Import ShazamKit** and create session: + ```swift + import ShazamKit + let session = SHManagedSession() // Shazam catalog + ``` + +### For Custom Catalog Matching + +1. **Generate signatures** from your audio content (see Signature Generation below) +2. **Build a custom catalog** with metadata (see Custom Catalogs below) +3. **Bundle the `.shazamcatalog` file** in your app or download at runtime +4. No ShazamKit App Service enablement needed +5. Microphone description still required if capturing from mic + +--- + +## Modern Path: SHManagedSession (iOS 17+) + +This is the recommended approach. It handles recording, format conversion, and matching. + +### Single Match + +```swift +let session = SHManagedSession() + +let result = await session.result() + +switch result { +case .match(let match): + let item = match.mediaItems.first + print("\(item?.title ?? "") by \(item?.artist ?? "")") +case .noMatch(_): + print("No match found") +case .error(let error, _): + print("Error: \(error.localizedDescription)") +} +``` + +### Continuous Matching + +```swift +let session = SHManagedSession() + +for await result in session.results { + switch result { + case .match(let match): + handleMatch(match) + case .noMatch(_): + continue + case .error(let error, _): + print("Error: \(error.localizedDescription)") + continue + } +} +``` + +### Preparation for Faster First Match + +Call `prepare()` to preallocate resources and start prerecording before the user taps "identify." This reduces perceived latency on the first match. + +**When to use `prepare()`:** +- The UI has a visible "identify" button and the user is likely to tap it soon +- You want the first match to feel instant (e.g., music discovery apps) + +**When to skip `prepare()`:** +- One-shot recognition triggered by a user action (the latency difference is small) +- Background or automated matching where perceived speed doesn't matter + +```swift +let session = SHManagedSession() +await session.prepare() // Starts prerecording, transitions to .prerecording state + +// Later, when user taps "identify": +let result = await session.result() // Returns faster than without prepare() +``` + +Calling `prepare()` also triggers the microphone permission prompt if not already granted — useful for requesting permission at a natural moment (e.g., when the recognition screen appears) rather than on first tap. + +### Observing Session State in SwiftUI + +`SHManagedSession` conforms to `Observable` (iOS 17+), so SwiftUI views update automatically: + +```swift +struct MatchView: View { + let session: SHManagedSession + + var body: some View { + VStack { + Text(session.state == .idle ? "Hear Music?" : "Matching") + if session.state == .matching { + ProgressView() + } else { + Button("Identify Song") { + Task { await startMatching() } + } + } + } + } +} +``` + +Three states: `.idle`, `.prerecording`, `.matching`. + +### Stopping + +```swift +session.cancel() // Stops recording and cancels current match attempt +``` + +### With Custom Catalog + +```swift +let catalog = SHCustomCatalog() +try catalog.add(from: catalogURL) +let session = SHManagedSession(catalog: catalog) +``` + +--- + +## Legacy Path: SHSession + AVAudioEngine (iOS 15+) + +Use when targeting iOS 15-16, needing buffer-level control, or matching pre-generated signatures. + +### Microphone Matching with SHSession + +```swift +import ShazamKit +import AVFAudio + +class AudioMatcher: NSObject, SHSessionDelegate { + private let session = SHSession() + private let audioEngine = AVAudioEngine() + + func startMatching() throws { + session.delegate = self + + let audioFormat = AVAudioFormat( + standardFormatWithSampleRate: audioEngine.inputNode.outputFormat(forBus: 0).sampleRate, + channels: 1 + ) + + audioEngine.inputNode.installTap(onBus: 0, bufferSize: 2048, format: audioFormat) { + [weak self] buffer, audioTime in + self?.session.matchStreamingBuffer(buffer, at: audioTime) + } + + try AVAudioSession.sharedInstance().setCategory(.record) + try audioEngine.start() + } + + func stopMatching() { + audioEngine.stop() + audioEngine.inputNode.removeTap(onBus: 0) + } + + // MARK: - SHSessionDelegate + + func session(_ session: SHSession, didFind match: SHMatch) { + guard let item = match.mediaItems.first else { return } + // Handle match: item.title, item.artist, item.artworkURL + } + + func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature, error: (any Error)?) { + // Handle no match or error + } +} +``` + +### AsyncSequence Alternative (iOS 16+) + +```swift +// Instead of delegate, use async sequence: +for await case .match(let match) in session.results { + handleMatch(match) +} +``` + +### Matching a Pre-Generated Signature + +```swift +let session = SHSession() // or SHSession(catalog: customCatalog) +let signatureData = try Data(contentsOf: signatureURL) +let signature = try SHSignature(dataRepresentation: signatureData) +session.match(signature) +``` + +--- + +## Custom Catalogs + +### When to Use Custom Catalogs + +- Second-screen experiences (sync app content to video/audio playback) +- Education apps (trigger activities synced to lessons) +- Podcast companions (show notes timed to audio) +- Media apps that need to recognize their own content + +### Building a Catalog Programmatically + +```swift +let catalog = SHCustomCatalog() + +// Create signature from audio +let generator = SHSignatureGenerator() +let signature = try await generator.signature(from: avAsset) + +// Create metadata +let mediaItem = SHMediaItem(properties: [ + .title: "Episode 3: Count on Me", + .subtitle: "FoodMath Series", + .artist: "FoodMath", + .timeRanges: [14.0..<31.0, 45.0..<60.0] // Timed content +]) + +// Add to catalog +try catalog.addReferenceSignature(signature, representing: [mediaItem]) + +// Save to disk +try catalog.write(to: catalogURL) +``` + +### Building at Scale with Shazam CLI (macOS 13+) + +```bash +# Generate signature from media file +shazam signature --input video.mp4 --output video.shazamsignature + +# Create catalog from signature + CSV metadata +shazam custom-catalog create --input video.shazamsignature --media-items metadata.csv --output catalog.shazamcatalog + +# Update existing catalog with new content +shazam custom-catalog update --input newvideo.shazamsignature --media-items newmeta.csv --catalog catalog.shazamcatalog + +# Display catalog contents +shazam custom-catalog display --catalog catalog.shazamcatalog +``` + +CSV format maps headers to `SHMediaItemProperty` keys. Run `shazam custom-catalog create --help` for the full mapping. + +### Timed Media Items (iOS 16+) + +Associate metadata with specific time ranges in your audio. ShazamKit delivers match callbacks synced to time range boundaries. + +```swift +let mediaItem = SHMediaItem(properties: [ + .title: "Chapter 1: Introduction", + .timeRanges: [0.0..<30.0] // Active for first 30 seconds +]) + +// Media items with multiple time ranges (e.g., recurring chorus) +let chorusItem = SHMediaItem(properties: [ + .title: "Chorus", + .timeRanges: [60.0..<90.0, 180.0..<210.0, 300.0..<330.0] +]) +``` + +**Return rules**: +1. Media items outside their time range are NOT returned +2. Media items within range are returned, most recent first +3. Media items with no time ranges are always returned last (global metadata) +4. If all items have time ranges and none are in scope, a basic match with `predictedCurrentMatchOffset` is returned + +### Frequency Skew (Differentiating Similar Audio) + +When multiple assets share similar audio (e.g., TV episodes with same intro): + +```swift +let mediaItem = SHMediaItem(properties: [ + .title: "Episode 2", + .frequencySkewRanges: [0.03..<0.04] // 3-4% skew +]) +``` + +Keep skew under 5% — noticeable to ShazamKit but inaudible to humans. + +### Combining Catalogs + +```swift +let parentCatalog = SHCustomCatalog() +try parentCatalog.add(from: episode1URL) +try parentCatalog.add(from: episode2URL) +try parentCatalog.add(from: episode3URL) +``` + +**Best practice**: Keep catalog files focused (per track or album, not entire discography). Combine at runtime as needed. + +--- + +## Shazam Library + +### SHLibrary (iOS 17+) — Preferred + +Read, add, and remove items from the user's synced Shazam library. Your app can only read and delete items it has added. + +```swift +// Add recognized song +try await SHLibrary.default.addItems([matchedMediaItem]) + +// Read (only items your app added) +let items = SHLibrary.default.items + +// Remove +try await SHLibrary.default.removeItems([mediaItem]) +``` + +`SHLibrary` conforms to `Observable` — SwiftUI views update automatically: + +```swift +List(SHLibrary.default.items) { item in + MediaItemView(item: item) +} +``` + +### SHMediaLibrary (iOS 15+) — Legacy + +```swift +SHMediaLibrary.default.add([matchedMediaItem]) { error in + if let error { print("Error: \(error)") } +} +``` + +- Only accepts items matching songs in the Shazam catalog (must have valid Shazam ID) +- Write-only — no read or delete capability +- No special permission required, but inform users before writing +- Items synced across devices via iCloud, attributed to your app +- End-to-end encrypted, requires two-factor authentication + +--- + +## Signature Generation + +### From AVAsset (iOS 16+, Preferred) + +```swift +let asset = AVAsset(url: audioFileURL) +let generator = SHSignatureGenerator() +let signature = try await generator.signature(from: asset) +``` + +Accepts any `AVAsset` with an audio track. Multiple tracks are mixed automatically. + +### From Audio Buffers (iOS 15+) + +```swift +let generator = SHSignatureGenerator() +// In your audio engine tap: +try generator.append(buffer, at: audioTime) +// When done: +let signature = generator.signature() +``` + +Audio must be PCM format. iOS 17+ supports most PCM format settings and sample rates with automatic conversion. iOS 15-16 requires specific sample rates. + +### Saving and Loading Signatures + +```swift +// Save +let data = signature.dataRepresentation +try data.write(to: signatureFileURL) + +// Load +let data = try Data(contentsOf: signatureFileURL) +let signature = try SHSignature(dataRepresentation: data) +``` + +File extension: `.shazamsignature` + +--- + +## Common Anti-Patterns + +### NEVER create many small signatures for one piece of media + +```swift +// WRONG — splits audio into segments +for segment in audioSegments { + let sig = try await generator.signature(from: segment) + try catalog.addReferenceSignature(sig, representing: [mediaItem]) +} +``` + +```swift +// RIGHT — one signature per media asset, use timed media items +let sig = try await generator.signature(from: fullAsset) +let items = timeRanges.map { range in + SHMediaItem(properties: [.title: range.title, .timeRanges: [range.interval]]) +} +try catalog.addReferenceSignature(sig, representing: items) +``` + +One signature per asset gives better accuracy and avoids query signatures overlapping reference boundaries. + +### NEVER use SHSession for simple microphone matching on iOS 17+ + +```swift +// WRONG — 30+ lines of AVAudioEngine boilerplate +let audioEngine = AVAudioEngine() +let session = SHSession() +session.delegate = self +let format = AVAudioFormat(standardFormatWithSampleRate: audioEngine.inputNode.outputFormat(forBus: 0).sampleRate, channels: 1) +audioEngine.inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { buffer, time in + session.matchStreamingBuffer(buffer, at: time) +} +try audioEngine.start() + +// RIGHT — 3 lines with SHManagedSession +let session = SHManagedSession() +let result = await session.result() +session.cancel() +``` + +### NEVER write to library without user awareness + +```swift +// WRONG — silently adds to Shazam library +try await SHLibrary.default.addItems([item]) + +// RIGHT — let user opt in +if userWantsToSave { + try await SHLibrary.default.addItems([item]) +} +``` + +All saved songs are attributed to your app in the user's Shazam library. + +### NEVER keep the microphone recording after getting results + +```swift +// WRONG — keeps recording after match +case .match(let match): + handleMatch(match) + // session still recording... + +// RIGHT — stop immediately after getting result +case .match(let match): + session.cancel() + handleMatch(match) +``` + +### NEVER forget the ShazamKit App Service for catalog matching + +Custom catalogs don't need it, but Shazam catalog matching silently fails without it. If matching returns no results for clearly identifiable songs, check: +1. ShazamKit App Service enabled in Certificates, Identifiers & Profiles +2. App ID matches your entitlements +3. Provisioning profile regenerated after enabling + +--- + +## Pressure Scenarios + +### Scenario 1: "We already have the SHSession delegate code working" + +**Pressure**: Sunk cost — team has existing SHSession + AVAudioEngine implementation, iOS 17+ is the minimum target. + +**Why resist**: SHManagedSession eliminates 30+ lines of AVAudioEngine boilerplate, handles format conversion automatically, conforms to Observable for SwiftUI, and supports AirPod audio recognition. The delegate code is maintenance burden, not an asset. + +**Response**: "The existing code works, but SHManagedSession is a net deletion of complexity. The migration is straightforward — replace SHSession with SHManagedSession, delete the audio engine setup, delete the delegate, use async sequences instead. The result is fewer bugs and less code to maintain." + +### Scenario 2: "We'll enable the ShazamKit App Service later" + +**Pressure**: Deadline — developer skips App Service enablement during prototyping, plans to add it before release. + +**Why resist**: Shazam catalog matching silently returns no results without the App Service. The developer will spend 30+ minutes debugging "why isn't it matching?" when the fix is a 2-minute configuration step. Custom catalog matching works without it, creating false confidence that the setup is correct. + +**Response**: "Enable the App Service now. It takes 2 minutes in Certificates, Identifiers & Profiles. Skipping it means your Shazam catalog matching will silently fail with no error message, and you'll waste debugging time on a configuration issue." + +--- + +## Provisioning Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| No matches from Shazam catalog | App Service not enabled | Enable ShazamKit in App ID → App Services | +| "The operation couldn't be completed" | Missing entitlement | Regenerate provisioning profile after enabling App Service | +| Custom catalog works, Shazam catalog doesn't | App Service vs custom confusion | App Service only needed for Shazam catalog | +| Works in debug, fails in release | Profile mismatch | Ensure release profile also has ShazamKit enabled | + +--- + +## Platform Support + +| Feature | iOS | iPadOS | macOS | tvOS | watchOS | visionOS | +|---------|-----|--------|-------|------|---------|----------| +| SHSession | 15+ | 15+ | 12+ | 15+ | 8+ | 1+ | +| SHManagedSession | 17+ | 17+ | 14+ | 17+ | 10+ | 1+ | +| SHCustomCatalog | 15+ | 15+ | 12+ | 15+ | 8+ | 1+ | +| SHLibrary | 17+ | 17+ | 14+ | 17+ | 10+ | 1+ | +| SHMediaLibrary | 15+ | 15+ | 12+ | 15+ | 8+ | 1+ | +| Shazam CLI | — | — | 13+ | — | — | — | +| signatureFromAsset | 16+ | 16+ | 13+ | 16+ | 9+ | 1+ | +| Timed media items | 16+ | 16+ | 13+ | 16+ | 9+ | 1+ | +| SHManagedSession Sendable | 18+ | 18+ | 15+ | 18+ | 11+ | 2+ | + +--- + +## HIG Guidance + +Supported use cases per Apple HIG: +- Enhancing experiences with graphics that correspond to the genre of currently playing music +- Making media content accessible (closed captions or sign language synced with audio) +- Synchronizing in-app experiences with virtual content (online learning, retail) + +**Best practices** (verbatim from HIG): + +- **Stop recording as soon as possible.** "When people allow your app to record audio for recognition, they don't expect the microphone to stay on. To help preserve privacy, only record for as long as it takes to get the sample you need." +- **Let people opt in to storing recognized songs to iCloud.** "If your app can store recognized songs to iCloud, give people a way to first approve this action. Even though both the Music Recognition control and the Shazam app show your app as the source of the recognized song, people appreciate having control over which apps can store content in their library." +- **Show Apple Music attribution** when displaying matched song details (required by Apple Music Identity Guidelines) + +--- + +## Resources + +**WWDC**: 2021-10044, 2021-10045, 2022-10028, 2023-10051 + +**Docs**: /shazamkit, /shazamkit/shmanagedsession, /shazamkit/shsession, /shazamkit/shcustomcatalog + +**Skills**: shazamkit-ref, avfoundation-ref, privacy-ux, swift-concurrency diff --git a/.claude/skills/axiom-shazamkit/agents/openai.yaml b/.claude/skills/axiom-shazamkit/agents/openai.yaml new file mode 100644 index 0000000..41c11c7 --- /dev/null +++ b/.claude/skills/axiom-shazamkit/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Shazamkit" + short_description: "Implementing audio recognition, music identification, custom audio matching, second-screen sync, or working with Shaz..." diff --git a/.claude/skills/axiom-shipping/.openskills.json b/.claude/skills/axiom-shipping/.openskills.json new file mode 100644 index 0000000..2aa63d1 --- /dev/null +++ b/.claude/skills/axiom-shipping/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-shipping", + "installedAt": "2026-04-12T08:05:35.650Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-shipping/SKILL.md b/.claude/skills/axiom-shipping/SKILL.md new file mode 100644 index 0000000..5dc52f4 --- /dev/null +++ b/.claude/skills/axiom-shipping/SKILL.md @@ -0,0 +1,353 @@ +--- +name: axiom-shipping +description: Use when preparing ANY app for submission, handling App Store rejections, writing appeals, or managing App Store Connect. Covers submission checklists, rejection troubleshooting, metadata requirements, privacy manifests, age ratings, export compliance. +license: MIT +--- + +# Shipping & App Store Router + +**You MUST use this skill when preparing to submit ANY app, handling App Store rejections, or working on release workflow.** + +## When to Use + +Use this router when you encounter: +- Preparing an app for App Store submission +- App Store rejection (any guideline) +- Metadata requirements (screenshots, descriptions, keywords) +- Privacy manifest and nutrition label questions +- Age rating and content classification +- Export compliance and encryption declarations +- EU DSA trader status +- Account deletion or Sign in with Apple requirements +- Build upload and processing issues +- App Review appeals +- WWDC25 App Store Connect changes +- First-time submission workflow + +## Routing Logic + +### 1. Pre-Submission Preparation → **app-store-submission** + +**Triggers**: +- "How do I submit my app?" +- "What do I need before submitting?" +- Preparing for first submission +- Pre-flight checklist needed +- Screenshot requirements +- Metadata completeness check +- Encryption compliance questions +- Accessibility Nutrition Labels +- Privacy manifest requirements for submission + +**Why app-store-submission**: Discipline skill with 8 anti-patterns, decision trees, and pressure scenarios. Prevents the mistakes that cause 90% of rejections. + +**Invoke**: `/skill axiom-app-store-submission` + +--- + +### 2. Metadata, Guidelines, and API Reference → **app-store-ref** + +**Triggers**: +- "What fields are required in App Store Connect?" +- "What's the max length for app description?" +- Specific guideline number lookup +- Privacy manifest schema details +- Age rating tiers and questionnaire +- IAP submission metadata +- EU DSA compliance details +- Build upload methods +- WWDC25 changes to App Store Connect + +**Why app-store-ref**: 10-part reference covering every metadata field, guideline, and compliance requirement with exact specifications. + +**Invoke**: `/skill axiom-app-store-ref` + +--- + +### 3. Rejection Troubleshooting → **app-store-diag** + +**Triggers**: +- "My app was rejected" +- "Guideline 2.1 rejection" +- "Binary was rejected" +- Guideline 4.2 or 4.3 rejection (app too simple, web wrapper, spam, duplicate) +- Guideline 1.x rejection (objectionable content, UGC moderation, Kids category) +- How to respond to a rejection +- Writing an appeal +- Understanding rejection messages +- Third or repeated rejection +- Resolution Center communication + +**Why app-store-diag**: 9 diagnostic patterns mapping rejection types to root causes and fixes, including subjective rejections (4.2/4.3, 1.x). Includes appeal writing guidance and crisis scenario for repeated rejections. + +**Invoke**: `/skill axiom-app-store-diag` + +--- + +### 4. Privacy & Security Compliance → **security-privacy-scanner** (Agent) + +**Triggers**: +- "Scan my code for privacy issues before submission" +- Hardcoded API keys or secrets +- Missing privacy manifest +- Required Reason API declarations +- ATS violations + +**Why security-privacy-scanner**: Autonomous agent that scans for security vulnerabilities and privacy compliance issues that cause rejections. + +**Invoke**: Launch `security-privacy-scanner` agent or `/axiom:audit security` + +--- + +### 5. IAP Review Issues → **iap-auditor** (Agent) + +**Triggers**: +- IAP rejected or not working +- Missing transaction.finish() +- Missing restore purchases +- Subscription tracking issues + +**Why iap-auditor**: Scans IAP code for the patterns that cause StoreKit rejections. + +**Invoke**: Launch `iap-auditor` agent + +--- + +### 6. Screenshot Validation → **screenshot-validator** (Agent) + +**Triggers**: +- "Check my App Store screenshots" +- "Are my screenshots the right dimensions?" +- "Validate screenshots before submission" +- "Review my marketing screenshots" +- Screenshot content or dimension questions + +**Why screenshot-validator**: Multimodal agent that visually inspects each screenshot for placeholder text, wrong dimensions, debug artifacts, broken UI, and competitor references. Catches issues that manual review misses. + +**Invoke**: Launch `screenshot-validator` agent or `/axiom:audit screenshots` + +--- + +### 7. Programmatic ASC Access → **asc-mcp** + +**Triggers**: +- "Automate App Store Connect" +- "Submit build programmatically" +- "Manage TestFlight from Claude" +- "Respond to reviews via API" +- "Set up asc-mcp" +- "Distribute to TestFlight groups via MCP" +- "Create a new version without opening ASC" + +**Why asc-mcp**: Workflow-focused skill teaching Claude to use asc-mcp MCP tools for release pipelines, TestFlight distribution, review management, and feedback triage — all without leaving Claude Code. + +**Invoke**: `/skill axiom-asc-mcp` + +--- + +### 8. Post-Submission Monitoring → **app-store-connect-ref** + +**Triggers**: +- "How do I view crash data in App Store Connect?" +- "Where are my TestFlight crash reports?" +- "How do I read ASC metrics dashboards?" +- Post-release crash investigation +- Downloading crash logs from ASC + +**Why app-store-connect-ref**: ASC navigation for crash dashboards, TestFlight feedback, performance metrics, and data export workflows. + +**Invoke**: `/skill axiom-app-store-connect-ref` + +--- + +### 9. Distribution Signing Issues → **code-signing** / **code-signing-diag** + +**Triggers**: +- ITMS-90035 Invalid Signature on upload +- ITMS-90161 Invalid Provisioning Profile +- "No signing certificate found" when archiving +- Certificate expired before submission +- Archive succeeds but export/upload fails +- Profile doesn't match bundle ID +- Entitlement mismatch on upload + +**Why code-signing**: Distribution signing errors are the #1 cause of upload failures. Diagnosing with CLI tools takes 5 minutes. code-signing-diag has 6 decision trees mapping ITMS errors to root causes. + +**Invoke**: `/skill axiom-code-signing-diag` (troubleshooting) or `/skill axiom-code-signing` (setup) + +--- + +## Decision Tree + +```dot +digraph shipping { + "Shipping question?" [shape=diamond]; + "Rejected?" [shape=diamond]; + "Post-submission monitoring?" [shape=diamond]; + "Automate via MCP?" [shape=diamond]; + "Screenshot review?" [shape=diamond]; + "Need specific specs?" [shape=diamond]; + "IAP issue?" [shape=diamond]; + "Want code scan?" [shape=diamond]; + + "app-store-submission" [shape=box, label="app-store-submission\n(pre-flight checklist)"]; + "app-store-ref" [shape=box, label="app-store-ref\n(metadata/guideline specs)"]; + "app-store-diag" [shape=box, label="app-store-diag\n(rejection troubleshooting)"]; + "app-store-connect-ref" [shape=box, label="app-store-connect-ref\n(ASC dashboards/metrics)"]; + "security-privacy-scanner" [shape=box, label="security-privacy-scanner\n(Agent)"]; + "iap-auditor" [shape=box, label="iap-auditor\n(Agent)"]; + "screenshot-validator" [shape=box, label="screenshot-validator\n(Agent)"]; + "asc-mcp" [shape=box, label="asc-mcp\n(MCP tool workflows)"]; + "code-signing" [shape=box, label="code-signing\n(distribution signing)"]; + "Signing error?" [shape=diamond]; + + "Shipping question?" -> "Rejected?" [label="yes, about to submit or general"]; + "Rejected?" -> "app-store-diag" [label="yes, app was rejected"]; + "Rejected?" -> "Post-submission monitoring?" [label="no"]; + "Post-submission monitoring?" -> "app-store-connect-ref" [label="yes, crash data/metrics/TestFlight"]; + "Post-submission monitoring?" -> "Automate via MCP?" [label="no"]; + "Automate via MCP?" -> "asc-mcp" [label="yes, programmatic ASC access"]; + "Automate via MCP?" -> "Screenshot review?" [label="no"]; + "Screenshot review?" -> "screenshot-validator" [label="yes, validate screenshots"]; + "Screenshot review?" -> "Need specific specs?" [label="no"]; + "Need specific specs?" -> "app-store-ref" [label="yes, looking up field/guideline"]; + "Need specific specs?" -> "IAP issue?" [label="no"]; + "IAP issue?" -> "iap-auditor" [label="yes"]; + "IAP issue?" -> "Want code scan?" [label="no"]; + "Want code scan?" -> "Signing error?" [label="no"]; + "Want code scan?" -> "security-privacy-scanner" [label="yes, scan for privacy/security"]; + "Signing error?" -> "code-signing" [label="yes, ITMS/cert/profile error"]; + "Signing error?" -> "app-store-submission" [label="no, general prep"]; +} +``` + +Simplified: + +1. App was rejected? → app-store-diag +2. Post-submission crash data/metrics/TestFlight? → app-store-connect-ref +3. Automate ASC via MCP tools? → asc-mcp +4. Validate screenshots? → screenshot-validator (Agent) +5. Need specific metadata/guideline specs? → app-store-ref +6. IAP submission issue? → iap-auditor (Agent) +7. Want pre-submission code scan? → security-privacy-scanner (Agent) +8. ITMS signing/certificate/profile error on upload? → code-signing / code-signing-diag +9. General submission preparation? → app-store-submission + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "I'll just submit and see what happens" | 40% of rejections are Guideline 2.1 (completeness). app-store-submission catches them in 10 min. | +| "I've submitted apps before, I know the process" | Requirements change yearly. Privacy manifests, age rating tiers, EU DSA, Accessibility Nutrition Labels are all new since 2024. | +| "The rejection is wrong, I'll just resubmit" | Resubmitting without changes wastes 24-48 hours per cycle. app-store-diag finds the root cause. | +| "Privacy manifests are only for big apps" | Every app using Required Reason APIs needs a manifest since May 2024. Missing = automatic rejection. | +| "I'll add the metadata later" | Missing metadata blocks submission entirely. app-store-ref has the complete field list. | +| "It's just a bug fix, I don't need a full checklist" | Bug fix updates still need What's New text, correct screenshots, and valid build. app-store-submission covers it. | +| "I'll just eyeball the screenshots myself" | Human review misses dimension mismatches (even 1px off = rejection), subtle placeholder text, and debug indicators. A single missed issue costs 24-48 hours in resubmission. screenshot-validator catches it in 2 minutes. | +| "I'll just do it in the ASC web dashboard" | If asc-mcp is configured, MCP tools are faster for bulk operations — distributing builds, responding to reviews, creating versions. asc-mcp has the workflow. | +| "Upload failed with ITMS error, let me re-archive" | ITMS signing errors are configuration — wrong cert, expired profile, missing entitlement. Re-archiving with the same config produces the same result. code-signing-diag has the fix. | + +## When NOT to Use (Conflict Resolution) + +**Do NOT use axiom-shipping for these — use the correct router instead:** + +| Issue | Correct Router | Why NOT axiom-shipping | +|-------|---------------|----------------------| +| Build fails before archiving | **ios-build** | Environment/build issue, not submission | +| SwiftData migration crash | **ios-data** | Schema issue, not App Store | +| Privacy manifest coding (writing the file) | **ios-build** | security-privacy-scanner handles code scanning | +| StoreKit 2 implementation (writing IAP code) | **ios-integration** | in-app-purchases / storekit-ref covers implementation | +| Performance issues found during testing | **ios-performance** | Profiling issue, not submission | +| Accessibility implementation | **ios-accessibility** | Code-level accessibility, not App Store labels | + +**axiom-shipping is for the submission workflow**, not code implementation: +- Preparing metadata and compliance → axiom-shipping +- Writing the actual code → domain-specific router (ios-build, ios-data, etc.) +- App was rejected → axiom-shipping +- Code changes to fix rejection → domain-specific router, then back to axiom-shipping to verify + +## Example Invocations + +User: "How do I submit my app to the App Store?" +→ Invoke: `/skill axiom-app-store-submission` + +User: "My app was rejected for Guideline 2.1" +→ Invoke: `/skill axiom-app-store-diag` + +User: "What screenshots do I need?" +→ Invoke: `/skill axiom-app-store-ref` + +User: "What fields are required in App Store Connect?" +→ Invoke: `/skill axiom-app-store-ref` + +User: "How do I fill out the age rating questionnaire?" +→ Invoke: `/skill axiom-app-store-ref` + +User: "Do I need an encryption compliance declaration?" +→ Invoke: `/skill axiom-app-store-submission` + +User: "My app keeps getting rejected, what do I do?" +→ Invoke: `/skill axiom-app-store-diag` + +User: "How do I appeal an App Store rejection?" +→ Invoke: `/skill axiom-app-store-diag` + +User: "My app was rejected for Guideline 4.2 minimum functionality" +→ Invoke: `/skill axiom-app-store-diag` + +User: "Rejected for being a web wrapper / duplicate app" +→ Invoke: `/skill axiom-app-store-diag` + +User: "Rejection for user-generated content without moderation" +→ Invoke: `/skill axiom-app-store-diag` + +User: "Kids category compliance rejection" +→ Invoke: `/skill axiom-app-store-diag` + +User: "Scan my code for App Store compliance issues" +→ Invoke: `security-privacy-scanner` agent + +User: "Check my IAP implementation before submission" +→ Invoke: `iap-auditor` agent + +User: "Check my App Store screenshots in ~/Screenshots" +→ Invoke: `screenshot-validator` agent + +User: "Are my screenshots the right dimensions?" +→ Invoke: `screenshot-validator` agent + +User: "How do I find crash data in App Store Connect?" +→ Invoke: `/skill axiom-app-store-connect-ref` + +User: "Where are my TestFlight crash reports in ASC?" +→ Invoke: `/skill axiom-app-store-connect-ref` + +User: "What's new in App Store Connect for 2025?" +→ Invoke: `/skill axiom-app-store-ref` + +User: "I need to set up DSA trader status for the EU" +→ Invoke: `/skill axiom-app-store-ref` + +User: "What are Accessibility Nutrition Labels?" +→ Invoke: `/skill axiom-app-store-submission` + +User: "This is my first app submission ever" +→ Invoke: `/skill axiom-app-store-submission` + +User: "Submit this build to App Store programmatically" +→ Invoke: `/skill axiom-asc-mcp` + +User: "Set up asc-mcp for App Store Connect" +→ Invoke: `/skill axiom-asc-mcp` + +User: "Distribute build 42 to my beta testers via MCP" +→ Invoke: `/skill axiom-asc-mcp` + +User: "Respond to negative App Store reviews from Claude" +→ Invoke: `/skill axiom-asc-mcp` + +User: "ITMS-90035 Invalid Signature when uploading" +→ Invoke: `/skill axiom-code-signing-diag` + +User: "My provisioning profile expired and I can't upload" +→ Invoke: `/skill axiom-code-signing-diag` diff --git a/.claude/skills/axiom-spritekit-diag/.openskills.json b/.claude/skills/axiom-spritekit-diag/.openskills.json new file mode 100644 index 0000000..908bb40 --- /dev/null +++ b/.claude/skills/axiom-spritekit-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-spritekit-diag", + "installedAt": "2026-04-12T08:06:39.092Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-spritekit-diag/SKILL.md b/.claude/skills/axiom-spritekit-diag/SKILL.md new file mode 100644 index 0000000..c8286bc --- /dev/null +++ b/.claude/skills/axiom-spritekit-diag/SKILL.md @@ -0,0 +1,383 @@ +--- +name: axiom-spritekit-diag +description: Use when physics contacts don't fire, objects tunnel through walls, frame rate drops, touches don't register, memory spikes, coordinate confusion, or scene transition crashes +license: MIT +metadata: + version: "1.0.0" +--- + +# SpriteKit Diagnostics + +Systematic diagnosis for common SpriteKit issues with time-cost annotations. + +## When to Use This Diagnostic Skill + +Use this skill when: +- Physics contacts never fire (didBegin not called) +- Objects pass through walls (tunneling) +- Frame rate drops below 60fps +- Touches don't register on nodes +- Memory grows continuously during gameplay +- Positions and coordinates seem wrong +- App crashes during scene transitions + +## Mandatory First Step: Enable Debug Overlays + +**Time cost**: 10 seconds setup vs hours of blind debugging + +```swift +if let view = self.view as? SKView { + view.showsFPS = true + view.showsNodeCount = true + view.showsDrawCount = true + view.showsPhysics = true +} +``` + +If `showsPhysics` doesn't show expected physics body outlines, your physics bodies aren't configured correctly. **Stop and fix bodies before debugging contacts.** + +For SpriteKit architecture patterns and best practices, see `axiom-spritekit`. For API reference, see `axiom-spritekit-ref`. + +--- + +## Symptom 1: Physics Contacts Not Firing + +**Time saved**: 30-120 min → 2-5 min + +``` +didBegin(_:) never called +│ +├─ Is physicsWorld.contactDelegate set? +│ └─ NO → Set in didMove(to:): +│ physicsWorld.contactDelegate = self +│ ✓ This alone fixes ~30% of contact issues +│ +├─ Does the class conform to SKPhysicsContactDelegate? +│ └─ NO → Add conformance: +│ class GameScene: SKScene, SKPhysicsContactDelegate +│ +├─ Does body A have contactTestBitMask that includes body B's category? +│ ├─ Print: "A contact: \(bodyA.contactTestBitMask), B cat: \(bodyB.categoryBitMask)" +│ ├─ Result should be: (A.contactTestBitMask & B.categoryBitMask) != 0 +│ └─ FIX: Set contactTestBitMask to include the other body's category +│ player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy +│ +├─ Is categoryBitMask set (not default 0xFFFFFFFF)? +│ ├─ Default category means everything matches — but in unexpected ways +│ └─ FIX: Always set explicit categoryBitMask for each body type +│ +├─ Do the bodies actually overlap? (Check showsPhysics) +│ ├─ Bodies too small or offset from sprite → Fix physics body size +│ └─ Bodies never reach each other → Check collisionBitMask isn't blocking +│ +└─ Are you modifying the world inside didBegin? + ├─ Removing nodes inside didBegin can cause missed callbacks + └─ FIX: Flag nodes for removal, process in update(_:) +``` + +### Quick Diagnostic Print + +```swift +func didBegin(_ contact: SKPhysicsContact) { + print("CONTACT: \(contact.bodyA.node?.name ?? "nil") (\(contact.bodyA.categoryBitMask)) <-> \(contact.bodyB.node?.name ?? "nil") (\(contact.bodyB.categoryBitMask))") +} +``` + +If this never prints, the issue is delegate/bitmask setup. If it prints but with wrong bodies, the issue is bitmask values. + +--- + +## Symptom 2: Objects Tunneling Through Walls + +**Time saved**: 20-60 min → 5 min + +``` +Fast objects pass through thin walls +│ +├─ Is the object moving faster than wall thickness per frame? +│ ├─ At 60fps: max safe speed = wall_thickness × 60 pt/s +│ ├─ A 10pt wall is safe up to ~600 pt/s +│ └─ FIX: usesPreciseCollisionDetection = true on the fast object +│ +├─ Is usesPreciseCollisionDetection enabled? +│ ├─ Only needed on the MOVING object (not the wall) +│ └─ FIX: fastObject.physicsBody?.usesPreciseCollisionDetection = true +│ +├─ Is the wall an edge body? +│ ├─ Edge bodies have zero area — tunneling is easier +│ └─ FIX: Use volume body for walls (rectangleOf:) with isDynamic = false +│ +├─ Is the wall thick enough? +│ └─ FIX: Make walls at least 10pt thick for objects up to 600pt/s +│ +└─ Are collision bitmasks correct? + ├─ Wall's categoryBitMask must be in object's collisionBitMask + └─ FIX: Verify with print: object.collisionBitMask & wall.categoryBitMask != 0 +``` + +--- + +## Symptom 3: Poor Frame Rate + +**Time saved**: 2-4 hours → 15-30 min + +``` +FPS below 60 (or 120 on ProMotion) +│ +├─ Check showsNodeCount +│ ├─ >1000 nodes → Offscreen nodes not removed +│ │ ├─ Are you removing nodes that leave the screen? +│ │ ├─ FIX: In update(), remove nodes outside visible area +│ │ └─ FIX: Use object pooling for frequently spawned objects +│ │ +│ ├─ 200-1000 nodes → Likely manageable, check draw count +│ └─ <200 nodes → Nodes aren't the problem, check below +│ +├─ Check showsDrawCount +│ ├─ >50 draw calls → Batching problem +│ │ ├─ Using SKShapeNode for gameplay? → Replace with pre-rendered textures +│ │ ├─ Sprites from different images? → Use texture atlas +│ │ ├─ Sprites at different zPositions? → Consolidate layers +│ │ └─ ignoresSiblingOrder = false? → Set to true +│ │ +│ ├─ 10-50 draw calls → Acceptable for most games +│ └─ <10 draw calls → Drawing isn't the problem +│ +├─ Physics expensive? +│ ├─ Many texture-based physics bodies → Use circles/rectangles +│ ├─ usesPreciseCollisionDetection on too many bodies → Use only on fast objects +│ ├─ Many contact callbacks firing → Reduce contactTestBitMask scope +│ └─ Complex polygon bodies → Simplify to fewer vertices +│ +├─ Particle overload? +│ ├─ Multiple emitters active → Reduce particleBirthRate +│ ├─ High particleLifetime → Reduce (fewer active particles) +│ ├─ numParticlesToEmit = 0 (infinite) without cleanup → Add limits +│ └─ FIX: Profile with Instruments → Time Profiler +│ +├─ SKEffectNode without shouldRasterize? +│ ├─ CIFilter re-renders every frame +│ └─ FIX: effectNode.shouldRasterize = true (if content is static) +│ +└─ Complex update() logic? + ├─ O(n²) collision checking? → Use physics engine instead + ├─ String-based enumerateChildNodes every frame? → Cache references + └─ Heavy computation in update? → Spread across frames or background +``` + +### Quick Performance Audit + +```swift +#if DEBUG +private var frameCount = 0 +#endif + +override func update(_ currentTime: TimeInterval) { + #if DEBUG + frameCount += 1 + if frameCount % 60 == 0 { + print("Nodes: \(children.count)") + } + #endif +} +``` + +--- + +## Symptom 4: Touches Not Registering + +**Time saved**: 15-45 min → 2 min + +``` +touchesBegan not called on a node +│ +├─ Is isUserInteractionEnabled = true on the node? +│ ├─ SKScene: true by default +│ ├─ All other SKNode subclasses: FALSE by default +│ └─ FIX: node.isUserInteractionEnabled = true +│ +├─ Is the node hidden or alpha = 0? +│ ├─ Hidden nodes don't receive touches +│ └─ FIX: Check node.isHidden and node.alpha +│ +├─ Is another node on top intercepting touches? +│ ├─ Higher zPosition nodes with isUserInteractionEnabled get first chance +│ └─ DEBUG: Print nodes(at: touchLocation) to see what's there +│ +├─ Is the touch in the correct coordinate space? +│ ├─ Using touch.location(in: self.view)? → WRONG for SpriteKit +│ └─ FIX: Use touch.location(in: self) for scene coordinates +│ Or touch.location(in: targetNode) for node-local coordinates +│ +├─ Is the physics body blocking touch pass-through? +│ └─ Physics bodies don't affect touch handling — not the issue +│ +└─ Is the node's frame correct? + ├─ SKNode (container) has zero frame — can't be hit-tested by area + ├─ SKSpriteNode frame matches texture size × scale + └─ FIX: Use contains(point) or nodes(at:) for manual hit testing +``` + +--- + +## Symptom 5: Memory Spikes and Crashes + +**Time saved**: 1-3 hours → 15 min + +``` +Memory grows during gameplay +│ +├─ Nodes accumulating? (Check showsNodeCount over time) +│ ├─ Count increasing? → Nodes created but not removed +│ │ ├─ Missing removeFromParent() for expired objects +│ │ ├─ FIX: Add cleanup in update() or use SKAction.removeFromParent() +│ │ └─ FIX: Implement object pooling for frequently spawned items +│ │ +│ └─ Count stable? → Memory issue elsewhere +│ +├─ Infinite particle emitters? +│ ├─ numParticlesToEmit = 0 creates particles forever +│ ├─ Each emitter accumulates particles up to birthRate × lifetime +│ └─ FIX: Set finite numParticlesToEmit or manually stop and remove +│ +├─ Texture caching? +│ ├─ SKTexture(imageNamed:) caches — repeated calls don't leak +│ ├─ SKTexture(cgImage:) from camera/dynamic sources → Not cached +│ └─ FIX: Reuse texture references for dynamic textures +│ +├─ Strong reference cycles in actions? +│ ├─ SKAction.run { self.doSomething() } captures self strongly +│ ├─ In repeatForever, this prevents scene deallocation +│ └─ FIX: SKAction.run { [weak self] in self?.doSomething() } +│ +├─ Scene not deallocating? +│ ├─ Add deinit { print("Scene deallocated") } +│ ├─ If never prints → retain cycle +│ ├─ Common: strong delegate, closure capture, NotificationCenter observer +│ └─ FIX: Clean up in willMove(from:): +│ removeAllActions() +│ removeAllChildren() +│ physicsWorld.contactDelegate = nil +│ +└─ Instruments → Allocations + ├─ Filter by "SK" to see SpriteKit objects + ├─ Mark generation before/after scene transition + └─ Persistent growth = leak +``` + +--- + +## Symptom 6: Coordinate Confusion + +**Time saved**: 20-60 min → 5 min + +``` +Positions seem wrong or flipped +│ +├─ Y-axis confusion? +│ ├─ SpriteKit: origin at BOTTOM-LEFT, Y goes UP +│ ├─ UIKit: origin at TOP-LEFT, Y goes DOWN +│ └─ FIX: Use scene coordinate methods, not view coordinates +│ touch.location(in: self) ← CORRECT (scene space) +│ touch.location(in: view) ← WRONG (UIKit space, Y flipped) +│ +├─ Anchor point confusion? +│ ├─ Scene anchor (0,0) = bottom-left of view is scene origin +│ ├─ Scene anchor (0.5,0.5) = center of view is scene origin +│ ├─ Sprite anchor (0.5,0.5) = center of sprite is at position (default) +│ ├─ Sprite anchor (0,0) = bottom-left of sprite is at position +│ └─ FIX: Print anchorPoint values and draw expected position +│ +├─ Parent coordinate space? +│ ├─ node.position is relative to PARENT, not scene +│ ├─ Child at (0,0) of parent at (100,100) is at scene (100,100) +│ └─ FIX: Use convert(_:to:) and convert(_:from:) for cross-node coordinates +│ let scenePos = node.convert(localPoint, to: scene) +│ let localPos = node.convert(scenePoint, from: scene) +│ +├─ Camera offset? +│ ├─ Camera position offsets the visible area +│ ├─ HUD attached to camera stays in place +│ └─ FIX: For world coordinates, account for camera position +│ scene.convertPoint(fromView: viewPoint) +│ +└─ Scale mode cropping? + ├─ aspectFill crops edges — content at edges may be offscreen + └─ FIX: Keep important content in the "safe area" center +``` + +--- + +## Symptom 7: Scene Transition Crashes + +**Time saved**: 30-90 min → 5 min + +``` +Crash during or after scene transition +│ +├─ EXC_BAD_ACCESS after transition? +│ ├─ Old scene deallocated while something still references it +│ ├─ Common: Timer, NotificationCenter, delegate still referencing old scene +│ └─ FIX: Clean up in willMove(from:): +│ removeAllActions() +│ removeAllChildren() +│ physicsWorld.contactDelegate = nil +│ // Remove any NotificationCenter observers +│ +├─ Crash in didMove(to:) of new scene? +│ ├─ Accessing view before it's available +│ ├─ Force-unwrapping optional that's nil during init +│ └─ FIX: Use guard let view = self.view in didMove(to:) +│ +├─ Memory spike during transition? +│ ├─ Both scenes exist simultaneously during transition animation +│ ├─ For large scenes, this doubles memory usage +│ └─ FIX: Preload textures, reduce scene size, or use .fade transition +│ (fade briefly shows neither scene, reducing peak memory) +│ +├─ Nodes from old scene appearing in new scene? +│ ├─ node.move(toParent:) during transition +│ └─ FIX: Don't move nodes between scenes — recreate in new scene +│ +└─ didMove(to:) called twice? + ├─ Presenting scene multiple times (button double-tap) + └─ FIX: Disable transition trigger after first tap + guard view?.scene !== nextScene else { return } +``` + +--- + +## Common Mistakes + +These mistakes cause the majority of SpriteKit issues. Check these first before diving into symptom trees. + +1. **Leaving default bitmasks** — `collisionBitMask` defaults to `0xFFFFFFFF` (collides with everything). Always set all three masks explicitly. +2. **Forgetting `contactTestBitMask`** — Defaults to `0x00000000`. Contacts never fire without setting this. +3. **Forgetting `physicsWorld.contactDelegate = self`** — Fixes ~30% of contact issues on its own. +4. **Using SKShapeNode for gameplay** — Each instance = 1 draw call. Pre-render to texture with `view.texture(from:)`. +5. **SKAction.move on physics bodies** — Actions override physics, causing jitter and missed collisions. Use forces/impulses. +6. **Strong self in action closures** — `SKAction.run { self.foo() }` in `repeatForever` creates retain cycles. Use `[weak self]`. +7. **Not removing offscreen nodes** — Node count climbs silently, degrading performance. +8. **Missing `isUserInteractionEnabled = true`** — Default is `false` on all non-scene nodes. + +--- + +## Diagnostic Quick Reference Card + +| Symptom | First Check | Most Likely Cause | +|---------|------------|-------------------| +| Contacts don't fire | `contactDelegate` set? | Missing `contactTestBitMask` | +| Tunneling | Object speed vs wall thickness | Missing `usesPreciseCollisionDetection` | +| Low FPS | `showsDrawCount` | SKShapeNode in gameplay or missing atlas | +| Touches broken | `isUserInteractionEnabled`? | Default is `false` on non-scene nodes | +| Memory growth | `showsNodeCount` increasing? | Nodes created but never removed | +| Wrong positions | Y-axis direction | Using view coordinates instead of scene | +| Transition crash | `willMove(from:)` cleanup? | Strong references to old scene | + +## Resources + +**WWDC**: 2014-608, 2016-610, 2017-609 + +**Docs**: /spritekit/skphysicsbody, /spritekit/maximizing-node-drawing-performance + +**Skills**: axiom-spritekit, axiom-spritekit-ref diff --git a/.claude/skills/axiom-spritekit-diag/agents/openai.yaml b/.claude/skills/axiom-spritekit-diag/agents/openai.yaml new file mode 100644 index 0000000..61b9ce7 --- /dev/null +++ b/.claude/skills/axiom-spritekit-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SpriteKit Diagnostics" + short_description: "Physics contacts don't fire, objects tunnel through walls, frame rate drops, touches don't register, memory spikes, c..." diff --git a/.claude/skills/axiom-spritekit-ref/.openskills.json b/.claude/skills/axiom-spritekit-ref/.openskills.json new file mode 100644 index 0000000..ef78d8a --- /dev/null +++ b/.claude/skills/axiom-spritekit-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-spritekit-ref", + "installedAt": "2026-04-12T08:06:39.535Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-spritekit-ref/SKILL.md b/.claude/skills/axiom-spritekit-ref/SKILL.md new file mode 100644 index 0000000..349f323 --- /dev/null +++ b/.claude/skills/axiom-spritekit-ref/SKILL.md @@ -0,0 +1,711 @@ +--- +name: axiom-spritekit-ref +description: SpriteKit API reference — all node types, physics body creation, action catalog, texture atlases, constraints, scene setup, particles, SKRenderer +license: MIT +compatibility: [iOS 13+, macOS 10.15+, tvOS 13+, watchOS 6+] +metadata: + version: "1.0.0" +--- + +# SpriteKit API Reference + +Complete API reference for SpriteKit organized by category. + +## When to Use This Reference + +Use this reference when: +- Looking up specific SpriteKit API signatures or properties +- Checking which node types are available and their performance characteristics +- Finding the right physics body creation method +- Browsing the complete action catalog +- Configuring SKView, scale modes, or transitions +- Setting up particle emitter properties +- Working with SKRenderer or SKShader + +## Part 1: Node Hierarchy + +### All Node Types + +| Node | Purpose | Batches? | Performance Notes | +|------|---------|----------|-------------------| +| `SKNode` | Container, grouping | N/A | Zero rendering cost | +| `SKSpriteNode` | Textured sprites | Yes (same atlas) | Primary gameplay node | +| `SKShapeNode` | Vector paths | **No** | 1 draw call each — avoid in gameplay | +| `SKLabelNode` | Text rendering | No | 1 draw call each | +| `SKEmitterNode` | Particle systems | N/A | GPU-bound, limit birth rate | +| `SKCameraNode` | Viewport control | N/A | Attach HUD as children | +| `SKEffectNode` | Core Image filters | No | Expensive — cache with `shouldRasterize` | +| `SKCropNode` | Masking | No | Mask + content = 2+ draw calls | +| `SKTileMapNode` | Tile-based maps | Yes (same tileset) | Efficient for large maps | +| `SKVideoNode` | Video playback | No | Uses AVPlayer | +| `SK3DNode` | SceneKit content | No | Renders SceneKit scene | +| `SKReferenceNode` | Reusable .sks files | N/A | Loads archive at runtime | +| `SKLightNode` | Per-pixel lighting | N/A | Limits: 8 lights per scene | +| `SKFieldNode` | Physics fields | N/A | Gravity, electric, magnetic, etc. | +| `SKAudioNode` | Positional audio | N/A | Uses AVAudioEngine | +| `SKTransformNode` | 3D rotation wrapper | N/A | xRotation, yRotation for perspective | + +### SKSpriteNode Properties + +```swift +// Creation +SKSpriteNode(imageNamed: "player") // From asset catalog +SKSpriteNode(texture: texture) // From SKTexture +SKSpriteNode(texture: texture, size: size) // Custom size +SKSpriteNode(color: .red, size: CGSize(width: 50, height: 50)) // Solid color + +// Key properties +sprite.anchorPoint = CGPoint(x: 0.5, y: 0) // Bottom-center +sprite.colorBlendFactor = 0.5 // Tint strength (0-1) +sprite.color = .red // Tint color +sprite.normalTexture = normalMap // For lighting +sprite.lightingBitMask = 0x1 // Which lights affect this +sprite.shadowCastBitMask = 0x1 // Which lights cast shadows +sprite.shader = customShader // Per-pixel effects +``` + +### SKLabelNode Properties + +```swift +let label = SKLabelNode(text: "Score: 0") +label.fontName = "AvenirNext-Bold" +label.fontSize = 24 +label.fontColor = .white +label.horizontalAlignmentMode = .left +label.verticalAlignmentMode = .top +label.numberOfLines = 0 // Multi-line (iOS 11+) +label.preferredMaxLayoutWidth = 200 +label.lineBreakMode = .byWordWrapping +``` + +--- + +## Part 2: Physics API + +### SKPhysicsBody Creation + +```swift +// Volume bodies (have mass, respond to forces) +SKPhysicsBody(circleOfRadius: 20) // Cheapest +SKPhysicsBody(rectangleOf: CGSize(width: 40, height: 60)) +SKPhysicsBody(polygonFrom: path) // Convex only +SKPhysicsBody(texture: texture, size: size) // Pixel-perfect (expensive) +SKPhysicsBody(texture: texture, alphaThreshold: 0.5, size: size) +SKPhysicsBody(bodies: [body1, body2]) // Compound + +// Edge bodies (massless boundaries) +SKPhysicsBody(edgeLoopFrom: rect) // Rectangle boundary +SKPhysicsBody(edgeLoopFrom: path) // Path boundary +SKPhysicsBody(edgeFrom: pointA, to: pointB) // Single edge +SKPhysicsBody(edgeChainFrom: path) // Open path +``` + +### Physics Body Properties + +```swift +// Identity +body.categoryBitMask = 0x1 // What this body IS +body.collisionBitMask = 0x2 // What it bounces off +body.contactTestBitMask = 0x4 // What triggers didBegin/didEnd + +// Physical characteristics +body.mass = 1.0 // kg +body.density = 1.0 // kg/m^2 (auto-calculates mass) +body.friction = 0.2 // 0.0 (ice) to 1.0 (rubber) +body.restitution = 0.3 // 0.0 (no bounce) to 1.0 (perfect bounce) +body.linearDamping = 0.1 // Air resistance (0 = none) +body.angularDamping = 0.1 // Rotational damping + +// Behavior +body.isDynamic = true // Responds to forces +body.affectedByGravity = true // Subject to world gravity +body.allowsRotation = true // Can rotate from physics +body.pinned = false // Pinned to parent position +body.usesPreciseCollisionDetection = false // For fast objects + +// Motion (read/write) +body.velocity = CGVector(dx: 100, dy: 0) +body.angularVelocity = 0.0 + +// Force application +body.applyForce(CGVector(dx: 0, dy: 100)) // Continuous +body.applyImpulse(CGVector(dx: 0, dy: 50)) // Instant +body.applyTorque(0.5) // Continuous rotation +body.applyAngularImpulse(1.0) // Instant rotation +body.applyForce(CGVector(dx: 10, dy: 0), at: point) // Force at point +``` + +### SKPhysicsWorld + +```swift +scene.physicsWorld.gravity = CGVector(dx: 0, dy: -9.8) +scene.physicsWorld.speed = 1.0 // 0 = paused, 2 = double speed +scene.physicsWorld.contactDelegate = self + +// Ray casting +let body = scene.physicsWorld.body(at: point) +let bodyInRect = scene.physicsWorld.body(in: rect) +scene.physicsWorld.enumerateBodies(alongRayStart: start, end: end) { body, point, normal, stop in + // Process each body the ray intersects +} +``` + +### Physics Joints + +```swift +// Pin joint (pivot) +let pin = SKPhysicsJointPin.joint( + withBodyA: bodyA, bodyB: bodyB, + anchor: anchorPoint +) + +// Fixed joint (rigid connection) +let fixed = SKPhysicsJointFixed.joint( + withBodyA: bodyA, bodyB: bodyB, + anchor: anchorPoint +) + +// Spring joint +let spring = SKPhysicsJointSpring.joint( + withBodyA: bodyA, bodyB: bodyB, + anchorA: pointA, anchorB: pointB +) +spring.frequency = 1.0 // Oscillations per second +spring.damping = 0.5 // 0 = no damping + +// Sliding joint (linear constraint) +let slide = SKPhysicsJointSliding.joint( + withBodyA: bodyA, bodyB: bodyB, + anchor: point, axis: CGVector(dx: 1, dy: 0) +) + +// Limit joint (distance constraint) +let limit = SKPhysicsJointLimit.joint( + withBodyA: bodyA, bodyB: bodyB, + anchorA: pointA, anchorB: pointB +) + +// Add joint to world +scene.physicsWorld.add(joint) +// Remove: scene.physicsWorld.remove(joint) +``` + +### Physics Fields + +```swift +// Gravity (directional) +let gravity = SKFieldNode.linearGravityField(withVector: vector_float3(0, -9.8, 0)) + +// Radial gravity (toward/away from point) +let radial = SKFieldNode.radialGravityField() +radial.strength = 5.0 + +// Electric field (charge-dependent) +let electric = SKFieldNode.electricField() + +// Noise field (turbulence) +let noise = SKFieldNode.noiseField(withSmoothness: 0.5, animationSpeed: 1.0) + +// Vortex +let vortex = SKFieldNode.vortexField() + +// Drag +let drag = SKFieldNode.dragField() + +// All fields share: +field.region = SKRegion(radius: 100) // Area of effect +field.strength = 1.0 // Intensity +field.falloff = 0.0 // Distance falloff +field.minimumRadius = 10 // Inner dead zone +field.isEnabled = true +field.categoryBitMask = 0xFFFFFFFF // Which bodies affected +``` + +--- + +## Part 3: Action Catalog + +### Movement + +```swift +SKAction.move(to: point, duration: 1.0) +SKAction.move(by: CGVector(dx: 100, dy: 0), duration: 0.5) +SKAction.moveTo(x: 200, duration: 1.0) +SKAction.moveTo(y: 300, duration: 1.0) +SKAction.moveBy(x: 50, y: 0, duration: 0.5) +SKAction.follow(path, asOffset: true, orientToPath: true, duration: 2.0) +``` + +### Rotation + +```swift +SKAction.rotate(byAngle: .pi, duration: 1.0) // Relative +SKAction.rotate(toAngle: .pi / 2, duration: 0.5) // Absolute +SKAction.rotate(toAngle: angle, duration: 0.5, shortestUnitArc: true) +``` + +### Scaling + +```swift +SKAction.scale(to: 2.0, duration: 0.5) +SKAction.scale(by: 1.5, duration: 0.3) +SKAction.scaleX(to: 2.0, y: 1.0, duration: 0.5) +SKAction.resize(toWidth: 100, height: 50, duration: 0.5) +``` + +### Fading + +```swift +SKAction.fadeIn(withDuration: 0.5) +SKAction.fadeOut(withDuration: 0.5) +SKAction.fadeAlpha(to: 0.5, duration: 0.3) +SKAction.fadeAlpha(by: -0.2, duration: 0.3) +``` + +### Composition + +```swift +SKAction.sequence([action1, action2, action3]) // Sequential +SKAction.group([action1, action2]) // Parallel +SKAction.repeat(action, count: 5) // Finite repeat +SKAction.repeatForever(action) // Infinite +action.reversed() // Reverse +SKAction.wait(forDuration: 1.0) // Delay +SKAction.wait(forDuration: 1.0, withRange: 0.5) // Random delay +``` + +### Texture & Color + +```swift +SKAction.setTexture(texture) +SKAction.setTexture(texture, resize: true) +SKAction.animate(with: [tex1, tex2, tex3], timePerFrame: 0.1) +SKAction.animate(with: textures, timePerFrame: 0.1, resize: false, restore: true) +SKAction.colorize(with: .red, colorBlendFactor: 1.0, duration: 0.5) +SKAction.colorize(withColorBlendFactor: 0, duration: 0.5) +``` + +### Sound + +```swift +SKAction.playSoundFileNamed("explosion.wav", waitForCompletion: false) +``` + +### Node Tree + +```swift +SKAction.removeFromParent() +SKAction.run(block) +SKAction.run(block, queue: .main) +SKAction.customAction(withDuration: 1.0) { node, elapsed in + // Custom per-frame logic +} +``` + +### Physics + +```swift +SKAction.applyForce(CGVector(dx: 0, dy: 100), duration: 0.5) +SKAction.applyImpulse(CGVector(dx: 50, dy: 0), duration: 1.0/60.0) // ~1 frame +SKAction.applyTorque(0.5, duration: 1.0) +SKAction.changeCharge(to: 1.0, duration: 0.5) +SKAction.changeMass(to: 2.0, duration: 0.5) +``` + +### Timing Modes + +```swift +action.timingMode = .linear // Constant speed +action.timingMode = .easeIn // Slow → fast +action.timingMode = .easeOut // Fast → slow +action.timingMode = .easeInEaseOut // Slow → fast → slow + +action.speed = 2.0 // 2x speed +``` + +--- + +## Part 4: Textures and Atlases + +### SKTexture + +```swift +// From image +let tex = SKTexture(imageNamed: "player") + +// From atlas +let atlas = SKTextureAtlas(named: "Characters") +let tex = atlas.textureNamed("player_run_1") + +// Subrectangle (for manual sprite sheets) +let sub = SKTexture(rect: CGRect(x: 0, y: 0, width: 0.25, height: 0.5), in: sheetTexture) + +// From CGImage +let tex = SKTexture(cgImage: cgImage) + +// Filtering +tex.filteringMode = .nearest // Pixel art (no smoothing) +tex.filteringMode = .linear // Smooth scaling (default) + +// Preload +SKTexture.preload([tex1, tex2]) { /* Ready */ } +``` + +### SKTextureAtlas + +```swift +// Create in Xcode: Assets.xcassets → New Sprite Atlas +// Or .atlas folder in project bundle + +let atlas = SKTextureAtlas(named: "Characters") +let textureNames = atlas.textureNames // All texture names in atlas + +// Preload entire atlas +atlas.preload { /* Atlas ready */ } + +// Preload multiple atlases +SKTextureAtlas.preloadTextureAtlases([atlas1, atlas2]) { /* All ready */ } + +// Animation from atlas +let frames = (1...8).map { atlas.textureNamed("run_\($0)") } +let animate = SKAction.animate(with: frames, timePerFrame: 0.1) +``` + +--- + +## Part 5: Constraints + +```swift +// Orient toward another node +let orient = SKConstraint.orient(to: targetNode, offset: SKRange(constantValue: 0)) + +// Orient toward a point +let orient = SKConstraint.orient(to: point, offset: SKRange(constantValue: 0)) + +// Position constraint (keep X in range) +let xRange = SKConstraint.positionX(SKRange(lowerLimit: 0, upperLimit: 400)) + +// Position constraint (keep Y in range) +let yRange = SKConstraint.positionY(SKRange(lowerLimit: 50, upperLimit: 750)) + +// Distance constraint (stay within range of node) +let dist = SKConstraint.distance(SKRange(lowerLimit: 50, upperLimit: 200), to: targetNode) + +// Rotation constraint +let rot = SKConstraint.zRotation(SKRange(lowerLimit: -.pi/4, upperLimit: .pi/4)) + +// Apply constraints (processed in order) +node.constraints = [orient, xRange, yRange] + +// Toggle +node.constraints?.first?.isEnabled = false +``` + +### SKRange + +```swift +SKRange(constantValue: 100) // Exactly 100 +SKRange(lowerLimit: 50, upperLimit: 200) // 50...200 +SKRange(lowerLimit: 0) // >= 0 +SKRange(upperLimit: 500) // <= 500 +SKRange(value: 100, variance: 20) // 80...120 +``` + +--- + +## Part 6: Scene Setup + +### SKView Configuration + +```swift +let skView = SKView(frame: view.bounds) + +// Debug overlays +skView.showsFPS = true +skView.showsNodeCount = true +skView.showsDrawCount = true +skView.showsPhysics = true +skView.showsFields = true +skView.showsQuadCount = true + +// Performance +skView.ignoresSiblingOrder = true // Enables batching optimizations +skView.shouldCullNonVisibleNodes = true // Auto-hide offscreen (manual is faster) +skView.isAsynchronous = true // Default: renders asynchronously +skView.allowsTransparency = false // Opaque is faster + +// Frame rate +skView.preferredFramesPerSecond = 60 // Or 120 for ProMotion + +// Present scene +skView.presentScene(scene) +skView.presentScene(scene, transition: .fade(withDuration: 0.5)) +``` + +### Scale Mode Matrix + +| Mode | Aspect Ratio | Content | Best For | +|------|-------------|---------|----------| +| `.aspectFill` | Preserved | Fills view, crops edges | Most games | +| `.aspectFit` | Preserved | Fits in view, letterboxes | Exact layout needed | +| `.resizeFill` | Distorted | Stretches to fill | Almost never | +| `.fill` | Varies | Scene resizes to match view | Adaptive scenes | + +### SKTransition Types + +```swift +SKTransition.fade(withDuration: 0.5) +SKTransition.fade(with: .black, duration: 0.5) +SKTransition.crossFade(withDuration: 0.5) +SKTransition.flipHorizontal(withDuration: 0.5) +SKTransition.flipVertical(withDuration: 0.5) +SKTransition.reveal(with: .left, duration: 0.5) +SKTransition.moveIn(with: .right, duration: 0.5) +SKTransition.push(with: .up, duration: 0.5) +SKTransition.doorway(withDuration: 0.5) +SKTransition.doorsOpenHorizontal(withDuration: 0.5) +SKTransition.doorsOpenVertical(withDuration: 0.5) +SKTransition.doorsCloseHorizontal(withDuration: 0.5) +SKTransition.doorsCloseVertical(withDuration: 0.5) +// Custom with CIFilter: +SKTransition(ciFilter: filter, duration: 0.5) +``` + +--- + +## Part 7: Particles + +### SKEmitterNode Key Properties + +```swift +let emitter = SKEmitterNode(fileNamed: "Spark")! + +// Emission control +emitter.particleBirthRate = 100 // Particles per second +emitter.numParticlesToEmit = 0 // 0 = infinite +emitter.particleLifetime = 2.0 // Seconds +emitter.particleLifetimeRange = 0.5 // ± random + +// Position +emitter.particlePosition = .zero +emitter.particlePositionRange = CGVector(dx: 10, dy: 10) + +// Movement +emitter.emissionAngle = .pi / 2 // Direction (radians) +emitter.emissionAngleRange = .pi / 4 // Spread +emitter.particleSpeed = 100 // Points per second +emitter.particleSpeedRange = 50 // ± random +emitter.xAcceleration = 0 +emitter.yAcceleration = -100 // Gravity-like + +// Appearance +emitter.particleTexture = SKTexture(imageNamed: "spark") +emitter.particleSize = CGSize(width: 8, height: 8) +emitter.particleColor = .white +emitter.particleColorAlphaSpeed = -0.5 // Fade out +emitter.particleBlendMode = .add // Additive for fire/glow +emitter.particleAlpha = 1.0 +emitter.particleAlphaSpeed = -0.5 + +// Scale +emitter.particleScale = 1.0 +emitter.particleScaleRange = 0.5 +emitter.particleScaleSpeed = -0.3 // Shrink over time + +// Rotation +emitter.particleRotation = 0 +emitter.particleRotationSpeed = 2.0 + +// Target node (for trails) +emitter.targetNode = scene // Particles stay in world space + +// Render order +emitter.particleRenderOrder = .dontCare // .oldestFirst, .oldestLast, .dontCare + +// Physics field interaction +emitter.fieldBitMask = 0x1 +``` + +### Common Particle Presets + +| Effect | Key Settings | +|--------|-------------| +| Fire | `blendMode: .add`, fast `alphaSpeed`, orange→red color, upward speed | +| Smoke | `blendMode: .alpha`, slow speed, gray color, scale up over time | +| Sparks | `blendMode: .add`, high speed + range, short lifetime, small size | +| Rain | Downward `emissionAngle`, narrow range, long lifetime, thin texture | +| Snow | Slow downward speed, wide position range, slight x acceleration | +| Trail | Set `targetNode` to scene, narrow emission angle, medium lifetime | +| Explosion | High birth rate, short `numParticlesToEmit`, high speed range | + +--- + +## Part 8: SKRenderer and Shaders + +### SKRenderer (Metal Integration) + +```swift +import MetalKit + +let device = MTLCreateSystemDefaultDevice()! +let renderer = SKRenderer(device: device) +renderer.scene = gameScene +renderer.ignoresSiblingOrder = true + +// In Metal render loop: +func draw(in view: MTKView) { + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let rpd = view.currentRenderPassDescriptor else { return } + + renderer.update(atTime: CACurrentMediaTime()) + renderer.render( + withViewport: CGRect(origin: .zero, size: view.drawableSize), + commandBuffer: commandBuffer, + renderPassDescriptor: rpd + ) + + commandBuffer.present(view.currentDrawable!) + commandBuffer.commit() +} +``` + +### SKShader (Custom GLSL ES Effects) + +```swift +// Fragment shader for per-pixel effects +let shader = SKShader(source: """ + void main() { + vec4 color = texture2D(u_texture, v_tex_coord); + // Desaturate + float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); + gl_FragColor = vec4(vec3(gray), color.a) * v_color_mix.a; + } +""") + +sprite.shader = shader + +// With uniforms +let shader = SKShader(source: """ + void main() { + vec4 color = texture2D(u_texture, v_tex_coord); + color.rgb *= u_intensity; + gl_FragColor = color; + } +""") +shader.uniforms = [ + SKUniform(name: "u_intensity", float: 0.8) +] + +// Built-in uniforms: +// u_texture — sprite texture +// u_time — elapsed time +// u_path_length — shape node path length +// v_tex_coord — texture coordinate +// v_color_mix — color/alpha mix +// SKAttribute for per-node values +``` + +## Part 7: SwiftUI Integration + +### SpriteView + +```swift +import SpriteKit +import SwiftUI + +// Basic embedding +struct GameView: View { + var body: some View { + SpriteView(scene: makeScene()) + .ignoresSafeArea() + } + + func makeScene() -> SKScene { + let scene = GameScene(size: CGSize(width: 1024, height: 768)) + scene.scaleMode = .aspectFill + return scene + } +} + +// With options +SpriteView( + scene: scene, + transition: .fade(withDuration: 0.5), // Scene transition + isPaused: false, // Pause control + preferredFramesPerSecond: 60, // Frame rate + options: [.ignoresSiblingOrder, .shouldCullNonVisibleNodes], + debugOptions: [.showsFPS, .showsNodeCount] // Debug overlays +) +``` + +### SpriteView Options + +| Option | Purpose | +|--------|---------| +| `.ignoresSiblingOrder` | Enable draw order batching optimization | +| `.shouldCullNonVisibleNodes` | Auto-hide offscreen nodes | +| `.allowsTransparency` | Allow transparent background (slower) | + +### Debug Options + +| Option | Shows | +|--------|-------| +| `.showsFPS` | Frames per second | +| `.showsNodeCount` | Total visible nodes | +| `.showsDrawCount` | Draw calls per frame | +| `.showsPhysics` | Physics body outlines | +| `.showsFields` | Physics field regions | +| `.showsQuadCount` | Quad subdivisions | + +### Communicating Between SwiftUI and SpriteKit + +```swift +// Observable model shared between SwiftUI and scene +@Observable +class GameState { + var score = 0 + var isPaused = false + var lives = 3 +} + +// Scene reads/writes the shared model +class GameScene: SKScene { + var gameState: GameState? + + override func update(_ currentTime: TimeInterval) { + guard let state = gameState, !state.isPaused else { return } + // Game logic updates state.score, state.lives, etc. + } +} + +// SwiftUI view owns the model +struct GameContainerView: View { + @State private var gameState = GameState() + @State private var scene: GameScene = { + let s = GameScene(size: CGSize(width: 1024, height: 768)) + s.scaleMode = .aspectFill + return s + }() + + var body: some View { + VStack { + Text("Score: \(gameState.score)") + SpriteView(scene: scene, isPaused: gameState.isPaused) + .ignoresSafeArea() + } + .onAppear { scene.gameState = gameState } + } +} +``` + +**Key pattern**: Use `@Observable` model as bridge. Scene mutates it; SwiftUI observes changes. Avoid recreating scenes in view body — use `@State` to persist the scene instance. + +--- + +## Resources + +**WWDC**: 2014-608, 2016-610, 2017-609 + +**Docs**: /spritekit/skspritenode, /spritekit/skphysicsbody, /spritekit/skaction, /spritekit/skemitternode, /spritekit/skrenderer + +**Skills**: axiom-spritekit, axiom-spritekit-diag diff --git a/.claude/skills/axiom-spritekit-ref/agents/openai.yaml b/.claude/skills/axiom-spritekit-ref/agents/openai.yaml new file mode 100644 index 0000000..b556c16 --- /dev/null +++ b/.claude/skills/axiom-spritekit-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SpriteKit Reference" + short_description: "SpriteKit API reference" diff --git a/.claude/skills/axiom-spritekit/.openskills.json b/.claude/skills/axiom-spritekit/.openskills.json new file mode 100644 index 0000000..f26608d --- /dev/null +++ b/.claude/skills/axiom-spritekit/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-spritekit", + "installedAt": "2026-04-12T08:06:38.641Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-spritekit/SKILL.md b/.claude/skills/axiom-spritekit/SKILL.md new file mode 100644 index 0000000..71b6548 --- /dev/null +++ b/.claude/skills/axiom-spritekit/SKILL.md @@ -0,0 +1,962 @@ +--- +name: axiom-spritekit +description: Use when building SpriteKit games, implementing physics, actions, scene management, or debugging game performance. Covers scene graph, physics engine, actions system, game loop, rendering optimization. +license: MIT +metadata: + version: "1.0.0" +--- + +# SpriteKit Game Development Guide + +**Purpose**: Build reliable SpriteKit games by mastering the scene graph, physics engine, action system, and rendering pipeline +**iOS Version**: iOS 13+ (SwiftUI integration), iOS 11+ (SKRenderer) +**Xcode**: Xcode 15+ + +## When to Use This Skill + +Use this skill when: +- Building a new SpriteKit game or interactive simulation +- Implementing physics (collisions, contacts, forces, joints) +- Setting up game architecture (scenes, layers, cameras) +- Optimizing frame rate or reducing draw calls +- Implementing touch/input handling in a game +- Managing scene transitions and data passing +- Integrating SpriteKit with SwiftUI or Metal +- Debugging physics contacts that don't fire +- Fixing coordinate system confusion + +Do NOT use this skill for: +- SceneKit 3D rendering (`axiom-scenekit`) +- GameplayKit entity-component systems +- Metal shader programming (`axiom-metal-migration-ref`) +- General SwiftUI layout (`axiom-swiftui-layout`) + +--- + +## 1. Mental Model + +### Coordinate System + +SpriteKit uses a **bottom-left origin** with Y pointing up. This differs from UIKit (top-left, Y down). + +``` +SpriteKit: UIKit: +┌─────────┐ ┌─────────┐ +│ +Y │ │ (0,0) │ +│ ↑ │ │ ↓ │ +│ │ │ │ +Y │ +│(0,0)──→+X│ │ │ │ +└─────────┘ └─────────┘ +``` + +**Anchor Points** define which point on a sprite maps to its `position`. Default is `(0.5, 0.5)` (center). + +```swift +// Common anchor point trap: +// Anchor (0, 0) = bottom-left of sprite is at position +// Anchor (0.5, 0.5) = center of sprite is at position (DEFAULT) +// Anchor (0.5, 0) = bottom-center (useful for characters standing on ground) +sprite.anchorPoint = CGPoint(x: 0.5, y: 0) +``` + +**Scene anchor point** maps the view's frame to scene coordinates: +- `(0, 0)` — scene origin at bottom-left of view (default) +- `(0.5, 0.5)` — scene origin at center of view + +### Node Tree + +Everything in SpriteKit is an `SKNode` in a tree hierarchy. Parent transforms propagate to children. + +``` +SKScene +├── SKCameraNode (viewport control) +├── SKNode "world" (game content layer) +│ ├── SKSpriteNode "player" +│ ├── SKSpriteNode "enemy" +│ └── SKNode "platforms" +│ ├── SKSpriteNode "platform1" +│ └── SKSpriteNode "platform2" +└── SKNode "hud" (UI layer, attached to camera) + ├── SKLabelNode "score" + └── SKSpriteNode "healthBar" +``` + +### Z-Ordering + +`zPosition` controls draw order. Higher values render on top. Nodes at the same `zPosition` render in child array order (unless `ignoresSiblingOrder` is `true`). + +```swift +// Establish clear z-order layers +enum ZLayer { + static let background: CGFloat = -100 + static let platforms: CGFloat = 0 + static let items: CGFloat = 10 + static let player: CGFloat = 20 + static let effects: CGFloat = 30 + static let hud: CGFloat = 100 +} +``` + +--- + +## 2. Scene Architecture + +### Scale Mode Decision + +| Mode | Behavior | Use When | +|------|----------|----------| +| `.aspectFill` | Fills view, crops edges | Full-bleed games (most games) | +| `.aspectFit` | Fits in view, letterboxes | Puzzle games needing exact layout | +| `.resizeFill` | Stretches to fill | Almost never — distorts | +| `.fill` | Matches view size exactly | Scene adapts to any ratio | + +```swift +class GameScene: SKScene { + override func sceneDidLoad() { + scaleMode = .aspectFill + // Design for a reference size, let aspectFill crop edges + } +} +``` + +### Camera Node Pattern + +Always use `SKCameraNode` for viewport control. Attach HUD elements to the camera so they don't scroll. + +```swift +let camera = SKCameraNode() +camera.name = "mainCamera" +addChild(camera) +self.camera = camera + +// HUD follows camera automatically +let scoreLabel = SKLabelNode(text: "Score: 0") +scoreLabel.position = CGPoint(x: 0, y: size.height / 2 - 50) +camera.addChild(scoreLabel) + +// Move camera to follow player +let follow = SKConstraint.distance(SKRange(constantValue: 0), to: playerNode) +camera.constraints = [follow] +``` + +### Layer Organization + +```swift +// Create layer nodes for organization +let worldNode = SKNode() +worldNode.name = "world" +addChild(worldNode) + +let hudNode = SKNode() +hudNode.name = "hud" +camera?.addChild(hudNode) + +// All gameplay objects go in worldNode +worldNode.addChild(playerSprite) +worldNode.addChild(enemySprite) + +// All UI goes in hudNode (moves with camera) +hudNode.addChild(scoreLabel) +``` + +### Scene Transitions + +```swift +// Preload next scene for smooth transitions +guard let nextScene = LevelScene(fileNamed: "Level2") else { return } +nextScene.scaleMode = .aspectFill + +let transition = SKTransition.fade(withDuration: 0.5) +view?.presentScene(nextScene, transition: transition) +``` + +**Data passing between scenes**: Use a shared game state object, not node properties. + +```swift +class GameState { + static let shared = GameState() + var score = 0 + var currentLevel = 1 + var playerHealth = 100 +} + +// In scene transition: +let nextScene = LevelScene(size: size) +// GameState.shared is already accessible +view?.presentScene(nextScene, transition: .fade(withDuration: 0.5)) +``` + +**Note**: A singleton works for simple games. For larger projects with testing needs, consider passing a `GameState` instance through scene initializers to avoid hidden global state. + +**Cleanup in `willMove(from:)`**: + +```swift +override func willMove(from view: SKView) { + removeAllActions() + removeAllChildren() + physicsWorld.contactDelegate = nil +} +``` + +--- + +## 3. Physics Engine + +### Bitmask Discipline + +**This is the #1 source of SpriteKit bugs.** Physics bitmasks use a 32-bit system where each bit represents a category. + +```swift +struct PhysicsCategory { + static let none: UInt32 = 0 + static let player: UInt32 = 0b0001 // 1 + static let enemy: UInt32 = 0b0010 // 2 + static let ground: UInt32 = 0b0100 // 4 + static let projectile: UInt32 = 0b1000 // 8 + static let powerUp: UInt32 = 0b10000 // 16 +} +``` + +**Three bitmask properties** (all default to `0xFFFFFFFF` — everything): + +| Property | Purpose | Default | +|----------|---------|---------| +| `categoryBitMask` | What this body IS | `0xFFFFFFFF` | +| `collisionBitMask` | What it BOUNCES off | `0xFFFFFFFF` | +| `contactTestBitMask` | What TRIGGERS delegate | `0x00000000` | + +**The default `collisionBitMask` of `0xFFFFFFFF` means everything collides with everything.** This is the most common source of unexpected physics behavior. + +```swift +// CORRECT: Explicit bitmask setup +player.physicsBody?.categoryBitMask = PhysicsCategory.player +player.physicsBody?.collisionBitMask = PhysicsCategory.ground | PhysicsCategory.enemy +player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy | PhysicsCategory.powerUp + +enemy.physicsBody?.categoryBitMask = PhysicsCategory.enemy +enemy.physicsBody?.collisionBitMask = PhysicsCategory.ground | PhysicsCategory.player +enemy.physicsBody?.contactTestBitMask = PhysicsCategory.player | PhysicsCategory.projectile +``` + +### Bitmask Checklist + +For every physics body, verify: +1. `categoryBitMask` set to exactly one category +2. `collisionBitMask` set to only categories it should bounce off (NOT `0xFFFFFFFF`) +3. `contactTestBitMask` set to categories that should trigger delegate callbacks +4. Delegate is assigned: `physicsWorld.contactDelegate = self` + +### Contact Detection + +```swift +class GameScene: SKScene, SKPhysicsContactDelegate { + override func didMove(to view: SKView) { + physicsWorld.contactDelegate = self + } + + func didBegin(_ contact: SKPhysicsContact) { + // Sort bodies so bodyA has the lower category + let (first, second): (SKPhysicsBody, SKPhysicsBody) + if contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask { + (first, second) = (contact.bodyA, contact.bodyB) + } else { + (first, second) = (contact.bodyB, contact.bodyA) + } + + // Now dispatch based on categories + if first.categoryBitMask == PhysicsCategory.player && + second.categoryBitMask == PhysicsCategory.enemy { + guard let playerNode = first.node, let enemyNode = second.node else { return } + playerHitEnemy(player: playerNode, enemy: enemyNode) + } + } +} +``` + +**Modification rule**: You cannot modify the physics world inside `didBegin`/`didEnd`. Set flags and apply changes in `update(_:)`. + +```swift +var enemiesToRemove: [SKNode] = [] + +func didBegin(_ contact: SKPhysicsContact) { + // Flag for removal — don't remove here + if let enemy = contact.bodyB.node { + enemiesToRemove.append(enemy) + } +} + +override func update(_ currentTime: TimeInterval) { + for enemy in enemiesToRemove { + enemy.removeFromParent() + } + enemiesToRemove.removeAll() +} +``` + +### Body Types + +| Type | Created With | Responds to Forces | Use For | +|------|-------------|-------------------|---------| +| Dynamic volume | `init(circleOfRadius:)`, `init(rectangleOf:)`, `init(texture:size:)` | Yes | Players, enemies, projectiles | +| Static volume | Dynamic body + `isDynamic = false` | No (but collides) | Platforms, walls | +| Edge | `init(edgeLoopFrom:)`, `init(edgeFrom:to:)` | No (boundary only) | Screen boundaries, terrain | + +```swift +// Screen boundary using edge loop +physicsBody = SKPhysicsBody(edgeLoopFrom: frame) + +// Texture-based body for irregular shapes +guard let texture = enemy.texture else { return } +enemy.physicsBody = SKPhysicsBody(texture: texture, size: enemy.size) + +// Circle for performance (cheapest collision detection) +bullet.physicsBody = SKPhysicsBody(circleOfRadius: 5) +``` + +### Tunneling Prevention + +Fast-moving objects can pass through thin walls. Fix: + +```swift +// Enable precise collision detection for fast objects +bullet.physicsBody?.usesPreciseCollisionDetection = true + +// Make walls thick enough (at least as wide as fastest object moves per frame) +// At 60fps, an object at velocity 600pt/s moves 10pt/frame +``` + +### Forces vs Impulses + +```swift +// Force: continuous (applied per frame, accumulates) +body.applyForce(CGVector(dx: 0, dy: 100)) + +// Impulse: instant velocity change (one-time, like a jump) +body.applyImpulse(CGVector(dx: 0, dy: 50)) + +// Torque: continuous rotation +body.applyTorque(0.5) + +// Angular impulse: instant rotation change +body.applyAngularImpulse(1.0) +``` + +--- + +## 4. Actions System + +### Core Patterns + +```swift +// Movement +let move = SKAction.move(to: CGPoint(x: 200, y: 300), duration: 1.0) +let moveBy = SKAction.moveBy(x: 100, y: 0, duration: 0.5) + +// Rotation +let rotate = SKAction.rotate(byAngle: .pi * 2, duration: 1.0) + +// Scale +let scale = SKAction.scale(to: 2.0, duration: 0.3) + +// Fade +let fadeOut = SKAction.fadeOut(withDuration: 0.5) +let fadeIn = SKAction.fadeIn(withDuration: 0.5) +``` + +### Sequencing and Grouping + +```swift +// Sequence: one after another +let moveAndFade = SKAction.sequence([ + SKAction.move(to: target, duration: 1.0), + SKAction.fadeOut(withDuration: 0.3), + SKAction.removeFromParent() +]) + +// Group: all at once +let spinAndGrow = SKAction.group([ + SKAction.rotate(byAngle: .pi * 2, duration: 1.0), + SKAction.scale(to: 2.0, duration: 1.0) +]) + +// Repeat +let pulse = SKAction.repeatForever(SKAction.sequence([ + SKAction.scale(to: 1.2, duration: 0.3), + SKAction.scale(to: 1.0, duration: 0.3) +])) +``` + +### Named Actions (Critical for Management) + +```swift +// Use named actions so you can cancel/replace them +node.run(pulse, withKey: "pulse") + +// Later, stop the pulse: +node.removeAction(forKey: "pulse") + +// Check if running: +if node.action(forKey: "pulse") != nil { + // Still pulsing +} +``` + +### Custom Actions with Weak Self + +```swift +// WRONG: Retain cycle risk +node.run(SKAction.run { + self.score += 1 // Strong capture of self +}) + +// CORRECT: Weak capture +node.run(SKAction.run { [weak self] in + self?.score += 1 +}) + +// For repeating actions, always use weak self +let spawn = SKAction.repeatForever(SKAction.sequence([ + SKAction.run { [weak self] in self?.spawnEnemy() }, + SKAction.wait(forDuration: 2.0) +])) +scene.run(spawn, withKey: "enemySpawner") +``` + +### Timing Modes + +```swift +action.timingMode = .linear // Constant speed (default) +action.timingMode = .easeIn // Accelerate from rest +action.timingMode = .easeOut // Decelerate to rest +action.timingMode = .easeInEaseOut // Smooth start and end +``` + +### Actions vs Physics + +**Never use actions to move physics-controlled nodes.** Actions override the physics simulation, causing jittering and missed collisions. + +```swift +// WRONG: Action fights physics +playerNode.run(SKAction.moveTo(x: 200, duration: 0.5)) + +// CORRECT: Use forces/impulses for physics bodies +playerNode.physicsBody?.applyImpulse(CGVector(dx: 50, dy: 0)) + +// CORRECT: Use actions for non-physics nodes (UI, effects, decorations) +hudLabel.run(SKAction.scale(to: 1.5, duration: 0.2)) +``` + +--- + +## 5. Input Handling + +### Touch Handling + +```swift +// CRITICAL: isUserInteractionEnabled must be true on the responding node +// SKScene has it true by default; other nodes default to false + +class Player: SKSpriteNode { + init() { + super.init(texture: SKTexture(imageNamed: "player"), color: .clear, size: CGSize(width: 50, height: 50)) + isUserInteractionEnabled = true // Required! + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + // Handle touch on this specific node + } +} +``` + +### Coordinate Space Conversion + +```swift +// Touch location in SCENE coordinates (most common) +override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + let locationInScene = touch.location(in: self) + + // Touch location in a SPECIFIC NODE's coordinates + let locationInWorld = touch.location(in: worldNode) + + // Hit test: what node was touched? + let touchedNodes = nodes(at: locationInScene) +} +``` + +**Common mistake**: Using `touch.location(in: self.view)` returns UIKit coordinates (Y-flipped). Always use `touch.location(in: self)` for scene coordinates. + +### Game Controller Support + +```swift +import GameController + +func setupControllers() { + NotificationCenter.default.addObserver( + self, selector: #selector(controllerConnected), + name: .GCControllerDidConnect, object: nil + ) + + // Check already-connected controllers + for controller in GCController.controllers() { + configureController(controller) + } +} +``` + +--- + +## 6. Performance + +### Performance Priorities + +For detailed performance diagnosis, see `axiom-spritekit-diag` Symptom 3. Key priorities: + +1. **Node count** — Remove offscreen nodes, use object pooling +2. **Draw calls** — Use texture atlases, replace SKShapeNode with pre-rendered textures +3. **Physics cost** — Prefer simple body shapes, limit `usesPreciseCollisionDetection` +4. **Particles** — Limit birth rate, set finite emission counts + +### Debug Overlays (Always Enable During Development) + +```swift +if let view = self.view as? SKView { + view.showsFPS = true + view.showsNodeCount = true + view.showsDrawCount = true + view.showsPhysics = true // Shows physics body outlines + + // Performance: render order optimization + view.ignoresSiblingOrder = true +} +``` + +### Texture Atlas Batching + +Sprites using textures from the same atlas render in a single draw call. + +```swift +// Create atlas in Xcode: Assets → New Sprite Atlas +// Or use .atlas folder in project + +let atlas = SKTextureAtlas(named: "Characters") +let texture = atlas.textureNamed("player_idle") +let sprite = SKSpriteNode(texture: texture) + +// Preload atlas to avoid frame drops +SKTextureAtlas.preloadTextureAtlases([atlas]) { + // Atlas ready — present scene +} +``` + +### SKShapeNode Trap + +**SKShapeNode generates one draw call per instance.** It cannot be batched. Use it for prototyping and debug visualization only. + +```swift +// WRONG: 100 SKShapeNodes = 100 draw calls +for _ in 0..<100 { + let dot = SKShapeNode(circleOfRadius: 5) + addChild(dot) +} + +// CORRECT: Pre-render to texture, use SKSpriteNode +let shape = SKShapeNode(circleOfRadius: 5) +shape.fillColor = .red +guard let texture = view?.texture(from: shape) else { return } +for _ in 0..<100 { + let dot = SKSpriteNode(texture: texture) + addChild(dot) +} +``` + +### Object Pooling + +For frequently spawned/destroyed objects (bullets, particles, enemies): + +```swift +class BulletPool { + private var available: [SKSpriteNode] = [] + private let texture: SKTexture + + init(texture: SKTexture, initialSize: Int = 20) { + self.texture = texture + for _ in 0.. SKSpriteNode { + let bullet = SKSpriteNode(texture: texture) + bullet.physicsBody = SKPhysicsBody(circleOfRadius: 3) + bullet.physicsBody?.categoryBitMask = PhysicsCategory.projectile + bullet.physicsBody?.collisionBitMask = PhysicsCategory.none + bullet.physicsBody?.contactTestBitMask = PhysicsCategory.enemy + return bullet + } + + func spawn() -> SKSpriteNode { + if available.isEmpty { + available.append(createBullet()) + } + let bullet = available.removeLast() + bullet.isHidden = false + bullet.physicsBody?.isDynamic = true + return bullet + } + + func recycle(_ bullet: SKSpriteNode) { + bullet.removeAllActions() + bullet.removeFromParent() + bullet.physicsBody?.isDynamic = false + bullet.physicsBody?.velocity = .zero + bullet.isHidden = true + available.append(bullet) + } +} +``` + +### Offscreen Node Removal + +```swift +// Manual removal is faster than shouldCullNonVisibleNodes +override func update(_ currentTime: TimeInterval) { + enumerateChildNodes(withName: "bullet") { node, _ in + if !self.frame.intersects(node.frame) { + self.bulletPool.recycle(node as! SKSpriteNode) + } + } +} +``` + +--- + +## 7. Game Loop + +### Frame Cycle (8 Phases) + +``` +1. update(_:) ← Your game logic here +2. didEvaluateActions() ← Actions completed +3. [Physics simulation] ← SpriteKit runs physics +4. didSimulatePhysics() ← Physics done, adjust results +5. [Constraint evaluation] ← SKConstraints applied +6. didApplyConstraints() ← Constraints done +7. didFinishUpdate() ← Last chance before render +8. [Rendering] ← Frame drawn +``` + +### Delta Time + +```swift +private var lastUpdateTime: TimeInterval = 0 + +override func update(_ currentTime: TimeInterval) { + let dt: TimeInterval + if lastUpdateTime == 0 { + dt = 0 + } else { + dt = currentTime - lastUpdateTime + } + lastUpdateTime = currentTime + + // Clamp delta time to prevent spiral of death + // (when app returns from background, dt can be huge) + let clampedDt = min(dt, 1.0 / 30.0) + + updatePlayer(deltaTime: clampedDt) + updateEnemies(deltaTime: clampedDt) +} +``` + +### Pause Handling + +```swift +// Pause the scene (stops actions, physics, update loop) +scene.isPaused = true + +// Pause specific subtree only +worldNode.isPaused = true // Game paused but HUD still animates + +// Handle app backgrounding +NotificationCenter.default.addObserver( + self, selector: #selector(pauseGame), + name: UIApplication.willResignActiveNotification, object: nil +) +``` + +--- + +## 8. Particle Effects + +### Emitter Best Practices + +```swift +// Load from .sks file (designed in Xcode Particle Editor) +guard let emitter = SKEmitterNode(fileNamed: "Explosion") else { return } +emitter.position = explosionPoint +addChild(emitter) + +// CRITICAL: Auto-remove after emission completes +let duration = TimeInterval(emitter.numParticlesToEmit) / TimeInterval(emitter.particleBirthRate) + + TimeInterval(emitter.particleLifetime + emitter.particleLifetimeRange / 2) +emitter.run(SKAction.sequence([ + SKAction.wait(forDuration: duration), + SKAction.removeFromParent() +])) +``` + +### Target Node for Trails + +Without `targetNode`, particles move with the emitter. For trails (like rocket exhaust), set `targetNode` to the scene: + +```swift +let trail = SKEmitterNode(fileNamed: "RocketTrail")! +trail.targetNode = scene // Particles stay where emitted +rocketNode.addChild(trail) +``` + +### Infinite Emitter Cleanup + +```swift +// WRONG: Infinite emitter never cleaned up +let fire = SKEmitterNode(fileNamed: "Fire")! +fire.numParticlesToEmit = 0 // 0 = infinite +addChild(fire) +// Memory leak — particles accumulate forever + +// CORRECT: Set emission limit or remove when done +fire.numParticlesToEmit = 200 // Stops after 200 particles + +// Or manually stop and remove: +fire.particleBirthRate = 0 // Stop new particles +fire.run(SKAction.sequence([ + SKAction.wait(forDuration: TimeInterval(fire.particleLifetime)), + SKAction.removeFromParent() +])) +``` + +--- + +## 9. SwiftUI Integration + +### SpriteView (Recommended, iOS 14+) + +The simplest way to embed SpriteKit in SwiftUI. Use this unless you need custom SKView configuration. + +```swift +import SpriteKit +import SwiftUI + +struct GameView: View { + var body: some View { + SpriteView(scene: { + let scene = GameScene(size: CGSize(width: 390, height: 844)) + scene.scaleMode = .aspectFill + return scene + }(), debugOptions: [.showsFPS, .showsNodeCount]) + .ignoresSafeArea() + } +} +``` + +### UIViewRepresentable (Advanced) + +Use when you need full control over SKView configuration (custom frame rate, transparency, or multiple scenes). + +```swift +import SwiftUI +import SpriteKit + +struct SpriteKitView: UIViewRepresentable { + let scene: SKScene + + func makeUIView(context: Context) -> SKView { + let view = SKView() + view.showsFPS = true + view.showsNodeCount = true + view.ignoresSiblingOrder = true + return view + } + + func updateUIView(_ view: SKView, context: Context) { + if view.scene == nil { + view.presentScene(scene) + } + } +} +``` + +### SKRenderer for Metal Hybrid + +Use `SKRenderer` when SpriteKit is one layer in a Metal pipeline: + +```swift +let renderer = SKRenderer(device: metalDevice) +renderer.scene = gameScene + +// In your Metal render loop: +renderer.update(atTime: currentTime) +renderer.render( + withViewport: viewport, + commandBuffer: commandBuffer, + renderPassDescriptor: renderPassDescriptor +) +``` + +--- + +## 10. Anti-Patterns + +### Anti-Pattern 1: Default Bitmasks + +**Time cost**: 30-120 minutes debugging phantom collisions + +```swift +// WRONG: Default collisionBitMask is 0xFFFFFFFF +let body = SKPhysicsBody(circleOfRadius: 10) +node.physicsBody = body +// Collides with EVERYTHING — even things it shouldn't + +// CORRECT: Always set all three masks explicitly +body.categoryBitMask = PhysicsCategory.player +body.collisionBitMask = PhysicsCategory.ground +body.contactTestBitMask = PhysicsCategory.enemy +``` + +### Anti-Pattern 2: Missing contactTestBitMask + +**Time cost**: 30-60 minutes wondering why didBegin never fires + +```swift +// WRONG: contactTestBitMask defaults to 0 — no contacts ever fire +player.physicsBody?.categoryBitMask = PhysicsCategory.player +// Forgot contactTestBitMask! + +// CORRECT: Both bodies need compatible masks +player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy +enemy.physicsBody?.categoryBitMask = PhysicsCategory.enemy +``` + +### Anti-Pattern 3: Actions on Physics Bodies + +**Time cost**: 1-3 hours of jittering and missed collisions + +```swift +// WRONG: SKAction.move overrides physics position each frame +playerNode.run(SKAction.moveTo(x: 200, duration: 1.0)) +// Physics body position is set by action, ignoring forces/collisions + +// CORRECT: Use physics for physics-controlled nodes +playerNode.physicsBody?.applyForce(CGVector(dx: 100, dy: 0)) +``` + +### Anti-Pattern 4: SKShapeNode for Gameplay + +**Time cost**: Hours diagnosing frame drops + +Each SKShapeNode is a separate draw call that cannot be batched. 50 shape nodes = 50 draw calls. See the pre-render-to-texture pattern in Section 6 (SKShapeNode Trap) for the fix. + +### Anti-Pattern 5: Strong Self in Action Closures + +**Time cost**: Memory leaks, eventual crash + +```swift +// WRONG: Strong capture in repeating action +node.run(SKAction.repeatForever(SKAction.sequence([ + SKAction.run { self.spawnEnemy() }, + SKAction.wait(forDuration: 2.0) +]))) + +// CORRECT: Weak capture +node.run(SKAction.repeatForever(SKAction.sequence([ + SKAction.run { [weak self] in self?.spawnEnemy() }, + SKAction.wait(forDuration: 2.0) +]))) +``` + +--- + +## 11. Code Review Checklist + +### Physics +- [ ] Every physics body has explicit `categoryBitMask` (not default) +- [ ] Every physics body has explicit `collisionBitMask` (not `0xFFFFFFFF`) +- [ ] Bodies needing contact detection have `contactTestBitMask` set +- [ ] `physicsWorld.contactDelegate` is assigned +- [ ] No world modifications inside `didBegin`/`didEnd` callbacks +- [ ] Fast objects use `usesPreciseCollisionDetection` + +### Actions +- [ ] No `SKAction.move`/`rotate` on physics-controlled nodes +- [ ] Repeating actions use `withKey:` for cancellation +- [ ] `SKAction.run` closures use `[weak self]` +- [ ] One-shot emitters are removed after emission + +### Performance +- [ ] Debug overlays enabled during development +- [ ] `ignoresSiblingOrder = true` on SKView +- [ ] No SKShapeNode in gameplay sprites (use pre-rendered textures) +- [ ] Texture atlases used for related sprites +- [ ] Offscreen nodes removed manually + +### Scene Management +- [ ] `willMove(from:)` cleans up actions, children, delegates +- [ ] Scene data passed via shared state, not node properties +- [ ] Camera used for viewport control + +--- + +## 12. Pressure Scenarios + +### Scenario 1: "Physics Contacts Don't Work — Ship Tonight" + +**Pressure**: Deadline pressure to skip systematic debugging + +**Wrong approach**: Randomly changing bitmask values, adding `0xFFFFFFFF` everywhere, or disabling physics + +**Correct approach** (2-5 minutes): +1. Enable `showsPhysics` — verify bodies exist and overlap +2. Print all three bitmasks for both bodies +3. Verify `contactTestBitMask` on body A includes category of body B (or vice versa) +4. Verify `physicsWorld.contactDelegate` is set +5. Verify you're not modifying the world inside the callback + +**Push-back template**: "Let me run the 5-step bitmask checklist. It takes 2 minutes and catches 90% of contact issues. Random changes will make it worse." + +### Scenario 2: "Frame Rate Is Fine on My Device" + +**Pressure**: Authority says "it runs at 60fps for me, ship it" + +**Wrong approach**: Shipping without profiling on minimum-spec device + +**Correct approach**: +1. Enable `showsFPS`, `showsNodeCount`, `showsDrawCount` +2. Test on oldest supported device +3. If >200 nodes or >30 draw calls, investigate +4. Check for SKShapeNode in gameplay +5. Verify offscreen nodes are being removed + +**Push-back template**: "Performance varies by device. Let me check node count and draw calls — takes 30 seconds with debug overlays. If counts are low, we're safe to ship." + +### Scenario 3: "Just Use SKShapeNode, It's Faster to Code" + +**Pressure**: Sunk cost — already built with SKShapeNode, don't want to redo + +**Wrong approach**: Shipping with 100+ SKShapeNodes causing frame drops + +**Correct approach**: +1. Check `showsDrawCount` — each SKShapeNode adds a draw call +2. If >20 shape nodes in gameplay, pre-render to textures +3. Use `view.texture(from:)` to convert once, reuse as SKSpriteNode +4. Keep SKShapeNode only for debug visualization + +**Push-back template**: "Each SKShapeNode is a separate draw call. Converting to pre-rendered textures is a 15-minute refactor that can double frame rate. SKSpriteNode from atlas = 1 draw call for all of them." + +## Resources + +**WWDC**: 2014-608, 2016-610, 2017-609, 2013-502 + +**Docs**: /spritekit, /spritekit/skscene, /spritekit/skphysicsbody, /spritekit/maximizing-node-drawing-performance + +**Skills**: axiom-spritekit-ref, axiom-spritekit-diag diff --git a/.claude/skills/axiom-spritekit/agents/openai.yaml b/.claude/skills/axiom-spritekit/agents/openai.yaml new file mode 100644 index 0000000..a646c85 --- /dev/null +++ b/.claude/skills/axiom-spritekit/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SpriteKit" + short_description: "Building SpriteKit games, implementing physics, actions, scene management, or debugging game performance" diff --git a/.claude/skills/axiom-sqlitedata-migration/.openskills.json b/.claude/skills/axiom-sqlitedata-migration/.openskills.json new file mode 100644 index 0000000..456b2b9 --- /dev/null +++ b/.claude/skills/axiom-sqlitedata-migration/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-sqlitedata-migration", + "installedAt": "2026-04-12T08:06:40.364Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-sqlitedata-migration/SKILL.md b/.claude/skills/axiom-sqlitedata-migration/SKILL.md new file mode 100644 index 0000000..b8c1c92 --- /dev/null +++ b/.claude/skills/axiom-sqlitedata-migration/SKILL.md @@ -0,0 +1,292 @@ +--- +name: axiom-sqlitedata-migration +description: Use when migrating from SwiftData to SQLiteData — decision guide, pattern equivalents, code examples, CloudKit sharing (SwiftData can't), performance benchmarks, gradual migration strategy +license: MIT +metadata: + version: "1.0.0" +--- + +# Migrating from SwiftData to SQLiteData + +## When to Switch + +``` +┌─────────────────────────────────────────────────────────┐ +│ Should I switch from SwiftData to SQLiteData? │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ Performance problems with 10k+ records? │ +│ YES → SQLiteData (10-50x faster for large datasets) │ +│ │ +│ Need CloudKit record SHARING (not just sync)? │ +│ YES → SQLiteData (SwiftData cannot share records) │ +│ │ +│ Complex queries across multiple tables? │ +│ YES → SQLiteData + raw GRDB when needed │ +│ │ +│ Need Sendable models for Swift 6 concurrency? │ +│ YES → SQLiteData (value types, not classes) │ +│ │ +│ Testing @Model classes is painful? │ +│ YES → SQLiteData (pure structs, easy to mock) │ +│ │ +│ Happy with SwiftData for simple CRUD? │ +│ YES → Stay with SwiftData (simpler for basic apps) │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Pattern Equivalents + +| SwiftData | SQLiteData | +|-----------|------------| +| `@Model class Item` | `@Table nonisolated struct Item` | +| `@Attribute(.unique)` | `@Column(primaryKey: true)` or SQL UNIQUE | +| `@Relationship var tags: [Tag]` | `var tagIDs: [Tag.ID]` + join query | +| `@Query var items: [Item]` | `@FetchAll var items: [Item]` | +| `@Query(sort: \.title)` | `@FetchAll(Item.order(by: \.title))` | +| `@Query(filter: #Predicate { $0.isActive })` | `@FetchAll(Item.where(\.isActive))` | +| `@Environment(\.modelContext)` | `@Dependency(\.defaultDatabase)` | +| `context.insert(item)` | `Item.insert { Item.Draft(...) }.execute(db)` | +| `context.delete(item)` | `Item.find(id).delete().execute(db)` | +| `try context.save()` | Automatic in `database.write { }` block | +| `ModelContainer(for:)` | `prepareDependencies { $0.defaultDatabase = }` | + +--- + +## Code Example + +**SwiftData (Before)** + +```swift +import SwiftData + +@Model +class Task { + var id: UUID + var title: String + var isCompleted: Bool + var project: Project? + + init(title: String) { + self.id = UUID() + self.title = title + self.isCompleted = false + } +} + +struct TaskListView: View { + @Environment(\.modelContext) private var context + @Query(sort: \.title) private var tasks: [Task] + + var body: some View { + List(tasks) { task in + Text(task.title) + } + } + + func addTask(_ title: String) { + let task = Task(title: title) + context.insert(task) + } + + func deleteTask(_ task: Task) { + context.delete(task) + } +} +``` + +**SQLiteData (After)** + +```swift +import SQLiteData + +@Table +nonisolated struct Task: Identifiable { + let id: UUID + var title = "" + var isCompleted = false + var projectID: Project.ID? +} + +struct TaskListView: View { + @Dependency(\.defaultDatabase) var database + @FetchAll(Task.order(by: \.title)) var tasks + + var body: some View { + List(tasks) { task in + Text(task.title) + } + } + + func addTask(_ title: String) { + try database.write { db in + try Task.insert { + Task.Draft(title: title) + } + .execute(db) + } + } + + func deleteTask(_ task: Task) { + try database.write { db in + try Task.find(task.id).delete().execute(db) + } + } +} +``` + +**Key differences:** +- `class` → `struct` with `nonisolated` +- `@Model` → `@Table` +- `@Query` → `@FetchAll` +- `@Environment(\.modelContext)` → `@Dependency(\.defaultDatabase)` +- Implicit save → Explicit `database.write { }` block +- Direct init → `.Draft` type for inserts +- `@Relationship` → Explicit foreign key + join + +--- + +## CloudKit Sharing (SwiftData Can't Do This) + +SwiftData supports CloudKit **sync** but NOT **sharing**. SQLiteData is the only Apple-native option for record sharing. + +```swift +// 1. Setup SyncEngine with sharing +prepareDependencies { + $0.defaultDatabase = try! appDatabase() + $0.defaultSyncEngine = try SyncEngine( + for: $0.defaultDatabase, + tables: Task.self, Project.self + ) +} + +// 2. Share a record +@Dependency(\.defaultSyncEngine) var syncEngine +@State var sharedRecord: SharedRecord? + +func shareProject(_ project: Project) async throws { + sharedRecord = try await syncEngine.share(record: project) { share in + share[CKShare.SystemFieldKey.title] = "Join my project!" + } +} + +// 3. Present native sharing UI +.sheet(item: $sharedRecord) { record in + CloudSharingView(sharedRecord: record) +} +``` + +**Sharing enables:** Collaborative lists, shared workspaces, family sharing, team features. + +--- + +## Performance Comparison + +| Operation | SwiftData | SQLiteData | Improvement | +|-----------|-----------|------------|-------------| +| Insert 50k records | ~4 minutes | ~45 seconds | **5x** | +| Query 10k with predicate | ~2 seconds | ~50ms | **40x** | +| Memory (10k objects) | ~80MB | ~20MB | **4x smaller** | +| Cold launch (large DB) | ~3 seconds | ~200ms | **15x** | + +*Benchmarks approximate, vary by device and data shape.* + +--- + +## Migrating Existing User Data + +**Critical**: Schema migration alone loses all user data. You must export from SwiftData and import into SQLiteData. + +```swift +// 1. Read all records from SwiftData's backing store +func migrateExistingData(from modelContext: ModelContext, to database: any DatabaseWriter) throws { + // Fetch all SwiftData records + let descriptor = FetchDescriptor() + let existingTasks = try modelContext.fetch(descriptor) + + // 2. Bulk insert into SQLiteData + try database.write { db in + for task in existingTasks { + try SQLiteTask.insert { + SQLiteTask.Draft( + id: task.id, + title: task.title, + isCompleted: task.isCompleted, + projectID: task.project?.id + ) + } + .execute(db) + } + } + + // 3. Verify migration + let count = try database.read { db in + try SQLiteTask.fetchCount(db) + } + assert(count == existingTasks.count, "Migration count mismatch!") +} +``` + +**Migration checklist:** +- [ ] Export all models before deleting SwiftData container +- [ ] Migrate relationships (fetch parent IDs for foreign keys) +- [ ] Verify record counts match after migration +- [ ] Keep SwiftData container as backup until confirmed working +- [ ] Run migration on first launch with a version flag in UserDefaults + +## Gradual Migration Strategy + +You don't have to migrate everything at once: + +1. **Add SQLiteData for new features** — Keep SwiftData for existing simple CRUD +2. **Migrate one model at a time** — Start with the performance bottleneck +3. **Use separate databases initially** — SQLiteData for heavy data/sharing, SwiftData for preferences +4. **Consolidate if needed** — Or keep hybrid if it works + +--- + +## Common Gotchas + +### Relationships → Foreign Keys + +```swift +// SwiftData: implicit relationship +@Relationship var tasks: [Task] + +// SQLiteData: explicit column + query +// In child: var projectID: Project.ID +// To fetch: Task.where { $0.projectID.eq(#bind(project.id)) } +``` + +### Cascade Deletes + +```swift +// SwiftData: @Relationship(deleteRule: .cascade) + +// SQLiteData: Define in SQL schema +// "REFERENCES parent(id) ON DELETE CASCADE" +``` + +### No Automatic Inverse + +```swift +// SwiftData: @Relationship(inverse: \Task.project) + +// SQLiteData: Query both directions manually +let tasks = Task.where { $0.projectID.eq(#bind(project.id)) } +let project = Project.find(task.projectID) +``` + +--- + +**Related Skills:** +- `axiom-sqlitedata` — Full SQLiteData API reference +- `axiom-swiftdata` — SwiftData patterns if staying with Apple's framework +- `axiom-grdb` — Raw GRDB for complex queries + +--- + +**History:** See git log for changes diff --git a/.claude/skills/axiom-sqlitedata-migration/agents/openai.yaml b/.claude/skills/axiom-sqlitedata-migration/agents/openai.yaml new file mode 100644 index 0000000..8c77782 --- /dev/null +++ b/.claude/skills/axiom-sqlitedata-migration/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SQLiteData Migration" + short_description: "Migrating from SwiftData to SQLiteData" diff --git a/.claude/skills/axiom-sqlitedata-ref/.openskills.json b/.claude/skills/axiom-sqlitedata-ref/.openskills.json new file mode 100644 index 0000000..56ab775 --- /dev/null +++ b/.claude/skills/axiom-sqlitedata-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-sqlitedata-ref", + "installedAt": "2026-04-12T08:06:40.787Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-sqlitedata-ref/SKILL.md b/.claude/skills/axiom-sqlitedata-ref/SKILL.md new file mode 100644 index 0000000..bcd4c29 --- /dev/null +++ b/.claude/skills/axiom-sqlitedata-ref/SKILL.md @@ -0,0 +1,896 @@ +--- +name: axiom-sqlitedata-ref +description: SQLiteData advanced patterns, @Selection column groups, single-table inheritance, recursive CTEs, database views, custom aggregates, TableAlias self-joins, JSON/string aggregation +license: MIT +metadata: + version: "1.0.0" + last-updated: "2025-12-19 — Split from sqlitedata discipline skill" +--- + +# SQLiteData Advanced Reference + +## Overview + +Advanced query patterns and schema composition techniques for [SQLiteData](https://github.com/pointfreeco/sqlite-data) by Point-Free. Built on [GRDB](https://github.com/groue/GRDB.swift) and [StructuredQueries](https://github.com/pointfreeco/swift-structured-queries). + +**For core patterns** (CRUD, CloudKit setup, @Table basics), see the `axiom-sqlitedata` discipline skill. + +**This reference covers** advanced querying, schema composition, views, and custom aggregates. + +**Requires** iOS 17+, Swift 6 strict concurrency +**Framework** SQLiteData 1.4+ + +--- + +## Column Groups and Schema Composition + +SQLiteData provides powerful tools for composing schema types, enabling reuse, better organization, and single-table inheritance patterns. + +### Column Groups + +Group related columns into reusable types with `@Selection`: + +```swift +// Define a reusable column group +@Selection +struct Timestamps { + let createdAt: Date + let updatedAt: Date? +} + +// Use in multiple tables +@Table +nonisolated struct RemindersList: Identifiable { + let id: UUID + var title = "" + let timestamps: Timestamps // Embedded column group +} + +@Table +nonisolated struct Reminder: Identifiable { + let id: UUID + var title = "" + var isCompleted = false + let timestamps: Timestamps // Same group, reused +} +``` + +**Important:** SQLite has no concept of grouped columns. Flatten all groupings in your CREATE TABLE: + +```sql +CREATE TABLE "remindersLists" ( + "id" TEXT PRIMARY KEY NOT NULL DEFAULT (uuid()), + "title" TEXT NOT NULL DEFAULT '', + "createdAt" TEXT NOT NULL, + "updatedAt" TEXT +) STRICT +``` + +#### Querying Column Groups + +Access fields inside groups with dot syntax: + +```swift +// Query a field inside the group +RemindersList + .where { $0.timestamps.createdAt <= cutoffDate } + .fetchAll(db) + +// Compare entire group (flattens to tuple in SQL) +RemindersList + .where { + $0.timestamps <= Timestamps(createdAt: date1, updatedAt: date2) + } +``` + +#### Nesting Groups in @Selection + +Use column groups in custom query results: + +```swift +@Selection +struct Row { + let reminderTitle: String + let listTitle: String + let timestamps: Timestamps // Nested group +} + +let results = try Reminder + .join(RemindersList.all) { $0.remindersListID.eq($1.id) } + .select { + Row.Columns( + reminderTitle: $0.title, + listTitle: $1.title, + timestamps: $0.timestamps // Pass entire group + ) + } + .fetchAll(db) +``` + +### Single-Table Inheritance with Enums + +Model polymorphic data using `@CasePathable @Selection` enums — a value-type alternative to class inheritance: + +```swift +import CasePaths + +@Table +nonisolated struct Attachment: Identifiable { + let id: UUID + let kind: Kind + + @CasePathable @Selection + enum Kind { + case link(URL) + case note(String) + case image(URL) + } +} +``` + +**Note:** `@CasePathable` is required and comes from Point-Free's [CasePaths](https://github.com/pointfreeco/swift-case-paths) library. + +#### SQL Schema for Enum Tables + +Flatten all cases into nullable columns: + +```sql +CREATE TABLE "attachments" ( + "id" TEXT PRIMARY KEY NOT NULL DEFAULT (uuid()), + "link" TEXT, + "note" TEXT, + "image" TEXT +) STRICT +``` + +#### Querying Enum Tables + +```swift +// Fetch all — decoding determines which case +let attachments = try Attachment.all.fetchAll(db) + +// Filter by case +let images = try Attachment + .where { $0.kind.image.isNot(nil) } + .fetchAll(db) +``` + +#### Inserting Enum Values + +```swift +try Attachment.insert { + Attachment.Draft(kind: .note("Hello world!")) +} +.execute(db) +// Inserts: (id, NULL, 'Hello world!', NULL) +``` + +#### Updating Enum Values + +```swift +try Attachment.find(id).update { + $0.kind = #bind(.link(URL(string: "https://example.com")!)) +} +.execute(db) +// Sets link column, NULLs note and image columns +``` + +### Complex Enum Cases with Grouped Columns + +Enum cases can hold structured data using nested `@Selection` types: + +```swift +@Table +nonisolated struct Attachment: Identifiable { + let id: UUID + let kind: Kind + + @CasePathable @Selection + enum Kind { + case link(URL) + case note(String) + case image(Attachment.Image) // Fully qualify nested types + } + + @Selection + struct Image { + var caption = "" + var url: URL + } +} +``` + +SQL schema flattens all nested fields: + +```sql +CREATE TABLE "attachments" ( + "id" TEXT PRIMARY KEY NOT NULL DEFAULT (uuid()), + "link" TEXT, + "note" TEXT, + "caption" TEXT, + "url" TEXT +) STRICT +``` + +### Passing Rows to Database Functions + +With column groups, `@DatabaseFunction` can accept entire table rows: + +```swift +@DatabaseFunction +func isPastDue(reminder: Reminder) -> Bool { + !reminder.isCompleted && reminder.dueDate < Date() +} + +// Use in queries — columns are flattened/reconstituted automatically +let pastDue = try Reminder + .where { $isPastDue(reminder: $0) } + .fetchAll(db) +``` + +### Column Groups vs SwiftData Inheritance + +| Approach | SQLiteData | SwiftData | +|----------|-----------|-----------| +| Type | Value types (enums/structs) | Reference types (classes) | +| Exhaustivity | Compiler-enforced switch | Runtime type checking | +| Verbosity | Concise enum cases | Verbose class hierarchy | +| Inheritance | Single-table via enum | @Model class inheritance | +| Reusable columns | `@Selection` groups | Manual repetition | + +**SwiftData equivalent (more verbose):** +```swift +@Model class Attachment { var isActive: Bool } +@Model class Link: Attachment { var url: URL } +@Model class Note: Attachment { var note: String } +@Model class Image: Attachment { var url: URL } +// Each needs explicit init calling super.init +``` + +--- + +## Query Composition + +Build reusable scopes as static properties/methods: + +```swift +extension Item { + static let active = Item.where { !$0.isArchived && !$0.isDeleted } + static let inStock = Item.where(\.isInStock) + + static func createdAfter(_ date: Date) -> Where { + Item.where { $0.createdAt > date } + } +} + +// Chain scopes +let results = try Item.active.inStock.order(by: \.title).fetchAll(db) + +// Use as base for @FetchAll +@FetchAll(Item.active) var items +``` + +Extend `Where` to add composable filters: + +```swift +extension Where { + func matching(_ search: String) -> Where { + self.where { $0.title.contains(search) || $0.notes.contains(search) } + } +} +let results = try Item.inStock.matching(searchText).fetchAll(db) +``` + +--- + +## Custom Fetch Requests with @Fetch + +Use `@Fetch` when you need multiple pieces of data in a single read transaction (use `@FetchAll`/`@FetchOne` for single-table queries): + +```swift +struct DashboardRequest: FetchKeyRequest { + struct Value: Sendable { + let totalItems: Int + let activeItems: [Item] + let categories: [Category] + } + + func fetch(_ db: Database) throws -> Value { + try Value( + totalItems: Item.count().fetchOne(db) ?? 0, + activeItems: Item.where { !$0.isArchived }.order(by: \.updatedAt.desc()).limit(10).fetchAll(db), + categories: Category.order(by: \.name).fetchAll(db) + ) + } +} + +@Fetch(DashboardRequest()) var dashboard +``` + +Dynamic loading with `.load()`: + +```swift +@Fetch var results = SearchRequest.Value() + +.task(id: query) { + try? await $results.load(SearchRequest(query: query), animation: .default) +} +``` + +Key benefits: atomic reads, automatic observation, type-safe results. + +--- + +## Advanced Query Patterns + +### String Functions + +| Function | Usage | SQL | +|----------|-------|-----| +| `upper()` / `lower()` | `$0.title.upper()` | UPPER/LOWER | +| `trim()` / `ltrim()` / `rtrim()` | `$0.title.trim()` | TRIM | +| `substr(start, len)` | `$0.title.substr(0, 3)` | SUBSTR | +| `replace(old, new)` | `$0.title.replace("old", "new")` | REPLACE | +| `length()` | `$0.title.length()` | LENGTH | +| `instr(search)` | `$0.title.instr("search") > 0` | INSTR | +| `like(pattern)` | `$0.title.like("%phone%")` | LIKE | +| `hasPrefix` / `hasSuffix` / `contains` | `$0.title.contains("Max")` | Swift-style | +| `collate(.nocase)` | `$0.title.collate(.nocase).eq(#bind("X"))` | COLLATE | + +### Null Handling + +```swift +// Coalesce — first non-null value +let name = try User.select { $0.nickname ?? $0.firstName ?? "Anonymous" }.fetchAll(db) + +// Null checks +let withDue = try Reminder.where { $0.dueDate.isNot(nil) }.fetchAll(db) +let noDue = try Reminder.where { $0.dueDate.is(nil) }.fetchAll(db) + +// Null-safe ordering +let sorted = try Item.order { $0.priority.desc(nulls: .last) }.fetchAll(db) +``` + +### Range and Set Membership + +```swift +// IN (set or subquery) +let selected = try Item.where { $0.id.in(selectedIds) }.fetchAll(db) +let inActive = try Item.where { $0.categoryID.in( + Category.where(\.isActive).select(\.id) +)}.fetchAll(db) + +// NOT IN +let excluded = try Item.where { !$0.id.in(excludedIds) }.fetchAll(db) + +// BETWEEN (or Swift range syntax) +let midRange = try Item.where { $0.price.between(10, and: 100) }.fetchAll(db) +``` + +### Pagination + +```swift +// Offset-based +let items = try Item.order(by: \.createdAt).limit(20, offset: page * 20).fetchAll(db) + +// Cursor-based (more efficient for deep pages) +let items = try Item.where { $0.id > lastSeenId }.order(by: \.id).limit(20).fetchAll(db) +``` + +### Distinct Results + +```swift +let categories = try Item.select(\.category).distinct().fetchAll(db) +``` + +--- + +## RETURNING Clause + +Fetch generated values from INSERT, UPDATE, or DELETE operations: + +```swift +// Insert and get auto-generated ID +let newId = try Item.insert { Item.Draft(title: "New Item") } + .returning(\.id).fetchOne(db) + +// Update and return new values +let updates = try Item.find(id).update { $0.count += 1 } + .returning { ($0.id, $0.count) }.fetchOne(db) + +// Capture deleted records before removal +let deleted = try Item.where { $0.isArchived }.delete() + .returning(Item.self).fetchAll(db) +``` + +Use RETURNING to avoid a second query for auto-generated IDs, audit deletions, or verify updates. + +--- + +## Joins + +### Join Types + +```swift +// INNER JOIN — only matching rows +let items = try Item.join(Category.all) { $0.categoryID.eq($1.id) }.fetchAll(db) + +// LEFT JOIN — all from left, matching from right (nullable) +let items = try Item.leftJoin(Category.all) { $0.categoryID.eq($1.id) } + .select { ($0, $1) } // (Item, Category?) + .fetchAll(db) +``` + +Also available: `.rightJoin()` (all from right) and `.fullJoin()` (all from both). + +Multi-table joins chain naturally: + +```swift +extension Reminder { + static let withTags = group(by: \.id) + .leftJoin(ReminderTag.all) { $0.id.eq($1.reminderID) } + .leftJoin(Tag.all) { $1.tagID.eq($2.primaryKey) } +} +``` + +### Self-Joins with TableAlias + +```swift +struct ManagerAlias: TableAlias { typealias Table = Employee } + +let employeesWithManagers = try Employee + .leftJoin(Employee.all.as(ManagerAlias.self)) { $0.managerID.eq($1.id) } + .select { (employeeName: $0.name, managerName: $1.name) } + .fetchAll(db) +``` + +--- + +## Case Expressions + +```swift +// Simple case — map values +let labels = try Item.select { + Case($0.priority).when(1, then: "Low").when(2, then: "Medium") + .when(3, then: "High").else("Unknown") +}.fetchAll(db) + +// Searched case — boolean conditions +let status = try Order.select { + Case().when($0.shippedAt.isNot(nil), then: "Shipped") + .when($0.paidAt.isNot(nil), then: "Paid").else("Unknown") +}.fetchAll(db) + +// Case in updates (toggle pattern) +try Reminder.find(id).update { + $0.status = Case($0.status) + .when(#bind(.incomplete), then: #bind(.completing)) + .when(#bind(.completing), then: #bind(.completed)) + .else(#bind(.incomplete)) +}.execute(db) +``` + +--- + +## Common Table Expressions (CTEs) + +### Non-Recursive CTEs + +```swift +// Single CTE +let expensiveItems = try With { + Item.where { $0.price > 1000 } +} query: { expensive in + expensive.order(by: \.price).limit(10) +}.fetchAll(db) + +// Multiple CTEs +let report = try With { + Customer.where { $0.totalSpent > 10000 } +} with: { + Order.where { $0.createdAt > lastMonth } +} query: { highValue, recentOrders in + highValue.join(recentOrders) { $0.id.eq($1.customerID) } + .select { ($0.name, $1.total) } +}.fetchAll(db) +``` + +Use CTEs to break complex queries into readable parts, reuse subqueries, or improve query plans. + +### Recursive CTEs + +Query hierarchical data (trees, org charts, threaded comments): + +```swift +@Table +nonisolated struct Category: Identifiable { + let id: UUID + var name = "" + var parentID: UUID? // Self-referential +} + +// Get all descendants of a root category +let allDescendants = try With { + Category.where { $0.id.eq(#bind(rootCategoryId)) } // Base case +} recursiveUnion: { cte in + Category.all.join(cte) { $0.parentID.eq($1.id) }.select { $0 } // Recursive case +} query: { cte in + cte.order(by: \.name) +}.fetchAll(db) +``` + +Reverse the join condition (`$0.id.eq($1.parentID)`) to walk up the tree instead of down. + +--- + +## Full-Text Search (FTS5) + +### Basic FTS5 + +```swift +@Table +struct ReminderText: FTS5 { + let rowid: Int + let title: String + let notes: String + let tags: String +} + +// Create FTS table in migration +try #sql( + """ + CREATE VIRTUAL TABLE "reminderTexts" USING fts5( + "title", "notes", "tags", + tokenize = 'trigram' + ) + """ +) +.execute(db) +``` + +### Advanced FTS5 Features + +```swift +// Highlight search terms +let results = try ItemText.where { $0.match(query) } + .select { ($0.rowid, $0.title.highlight("", "")) }.fetchAll(db) + +// Snippets with context +let snippets = try ItemText.where { $0.match(query) } + .select { $0.description.snippet("", "", "...", 64) }.fetchAll(db) + +// BM25 relevance ranking +let ranked = try ItemText.where { $0.match(query) } + .order { $0.bm25().desc() }.fetchAll(db) +``` + +--- + +## Aggregation + +### String and JSON Aggregation + +```swift +// groupConcat — comma-separated tags per item +let itemsWithTags = try Item.group(by: \.id) + .leftJoin(ItemTag.all) { $0.id.eq($1.itemID) } + .leftJoin(Tag.all) { $1.tagID.eq($2.id) } + .select { ($0.title, $2.name.groupConcat(separator: ", ")) } + .fetchAll(db) +// ("iPhone", "electronics, mobile, apple") + +// jsonGroupArray — aggregate into JSON array +let itemsJson = try Store.group(by: \.id) + .leftJoin(Item.all) { $0.id.eq($1.storeID) } + .select { ($0.name, $1.title.jsonGroupArray()) } + .fetchAll(db) +``` + +Options: `.groupConcat(distinct: true)`, `.groupConcat(order: { $0.asc() })`, `.jsonGroupArray(filter: $1.isActive)`, `jsonObject("key", $0.value)`. + +### Conditional Aggregation + +All aggregate functions accept a `filter:` parameter: + +```swift +let stats = try Item.select { + Stats.Columns( + total: $0.count(), + activeCount: $0.count(filter: $0.isActive), + avgActivePrice: $0.price.avg(filter: $0.isActive), + totalRevenue: $0.revenue.sum(filter: $0.status.eq(#bind(.completed))) + ) +}.fetchOne(db) +``` + +### HAVING Clause + +`.where()` filters rows before grouping; `.having()` filters groups after aggregation: + +```swift +let frequentCustomers = try Customer.group(by: \.id) + .leftJoin(Order.all) { $0.id.eq($1.customerID) } + .having { $1.count() > 5 } + .select { ($0.name, $1.count()) } + .fetchAll(db) +``` + +--- + +## Schema Creation with #sql Macro + +The `#sql` macro enables type-safe raw SQL for schema creation and migrations. + +### CREATE TABLE + +```swift +migrator.registerMigration("Create initial tables") { db in + try #sql(""" + CREATE TABLE "items" ( + "id" TEXT PRIMARY KEY NOT NULL DEFAULT (uuid()), + "title" TEXT NOT NULL DEFAULT '', + "isInStock" INTEGER NOT NULL DEFAULT 1, + "price" REAL NOT NULL DEFAULT 0.0, + "createdAt" TEXT NOT NULL DEFAULT (datetime('now')) + ) STRICT + """).execute(db) +} +``` + +### Parameter Interpolation + +- `\(value)` → Automatically escaped (safe for user input) +- `\(raw: value)` → Inserted literally (only for identifiers you control) +- **Never** use `\(raw: userInput)` — SQL injection vulnerability + +### Other DDL + +```swift +// CREATE INDEX (with optional WHERE for partial indexes) +try #sql("""CREATE INDEX "idx_items_search" ON "items" ("title") WHERE "isArchived" = 0""").execute(db) + +// CREATE TRIGGER +try #sql(""" + CREATE TRIGGER "update_timestamp" AFTER UPDATE ON "items" + BEGIN UPDATE "items" SET "updatedAt" = datetime('now') WHERE "id" = NEW."id"; END + """).execute(db) + +// ALTER TABLE +try #sql("""ALTER TABLE "items" ADD COLUMN "notes" TEXT NOT NULL DEFAULT ''""").execute(db) +``` + +Use `#sql` for DDL (CREATE, ALTER, indexes, triggers). Use the query builder for regular CRUD. + +### Foreign Key Relationships + +```swift +migrator.registerMigration("Create tables with foreign keys") { db in + try #sql(""" + CREATE TABLE "itemCategories" ( + "itemID" TEXT NOT NULL REFERENCES "items"("id") ON DELETE CASCADE, + "categoryID" TEXT NOT NULL REFERENCES "categories"("id") ON DELETE CASCADE, + PRIMARY KEY ("itemID", "categoryID") + ) STRICT + """).execute(db) +} +``` + +**Critical**: Enable foreign key enforcement — SQLite disables it by default: + +```swift +var configuration = Configuration() +configuration.prepareDatabase { db in + try db.execute(sql: "PRAGMA foreign_keys = ON") +} +``` + +Without `PRAGMA foreign_keys = ON`, `REFERENCES` and `ON DELETE CASCADE` are silently ignored. + +### Transaction Context for Batch Operations + +Wrap batch operations in explicit transactions for atomicity and performance: + +```swift +try database.write { db in + // All operations share one transaction + for item in items { + try Item.insert { Item.Draft(title: item.title) }.execute(db) + } +} +// Commits once on success, rolls back entirely on failure +``` + +The `database.write { }` block is already a transaction. For read-heavy batch analysis, use `database.read { }` which provides a consistent snapshot. + +--- + +## Database Views + +### @Selection for Custom Query Results + +`@Selection` generates a `.Columns` type for compile-time verified query results: + +```swift +@Selection +struct ReminderWithList: Identifiable { + var id: Reminder.ID { reminder.id } + let reminder: Reminder + let remindersList: RemindersList +} + +@FetchAll( + Reminder.join(RemindersList.all) { $0.remindersListID.eq($1.id) } + .select { ReminderWithList.Columns(reminder: $0, remindersList: $1) } +) +var reminders: [ReminderWithList] +``` + +Also works for aggregate queries — see the Conditional Aggregation section above. + +### Temporary Views + +For reusable complex queries, combine `@Table @Selection` and `createTemporaryView`: + +```swift +@Table @Selection +private struct ReminderWithList { + let reminderTitle: String + let remindersListTitle: String +} + +try database.write { db in + try ReminderWithList.createTemporaryView( + as: Reminder.join(RemindersList.all) { $0.remindersListID.eq($1.id) } + .select { ReminderWithList.Columns(reminderTitle: $0.title, remindersListTitle: $1.title) } + ).execute(db) +} + +// Query like a table — join complexity hidden +let results = try ReminderWithList.order { ($0.remindersListTitle, $0.reminderTitle) }.fetchAll(db) +``` + +Temporary views exist for the connection lifetime. For persistent views, use `#sql("CREATE VIEW ...")` in migrations. + +To make views writable, add `createTemporaryTrigger(insteadOf: .insert { ... })` to reroute operations to underlying tables. + +--- + +## Custom Aggregate Functions + +Write complex aggregation in Swift with `@DatabaseFunction`, avoiding contorted SQL subqueries: + +```swift +// 1. Define — takes Sequence, returns aggregate result +@DatabaseFunction +func mode(priority priorities: some Sequence) -> Reminder.Priority? { + var occurrences: [Reminder.Priority: Int] = [:] + for priority in priorities { + guard let priority else { continue } + occurrences[priority, default: 0] += 1 + } + return occurrences.max { $0.value < $1.value }?.key +} + +// 2. Register +configuration.prepareDatabase { db in db.add(function: $mode) } + +// 3. Use in queries +let results = try RemindersList.group(by: \.id) + .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) } + .select { ($0.title, $mode(priority: $1.priority)) } + .fetchAll(db) +``` + +Common uses: mode, median, weighted average, custom filtering. Functions run in Swift (not SQLite's C engine), so use built-in aggregates (`count`, `sum`, `avg`, `min`, `max`) when possible. + +--- + +## Batch Upsert Performance + +For high-volume sync (50K+ records), use cached statements instead of the type-safe API: + +```swift +func batchUpsert(_ items: [Item], in db: Database) throws { + let statement = try db.cachedStatement(sql: """ + INSERT INTO items (id, name, libraryID, remoteID, updatedAt) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(libraryID, remoteID) DO UPDATE SET + name = excluded.name, updatedAt = excluded.updatedAt + WHERE excluded.updatedAt >= items.updatedAt + """) + for item in items { + try statement.execute(arguments: [item.id, item.name, item.libraryID, item.remoteID, item.updatedAt]) + } +} +``` + +For even higher throughput, build multi-row VALUES clauses. Query the variable limit at runtime: `sqlite3_limit(db.sqliteConnection, SQLITE_LIMIT_VARIABLE_NUMBER, -1)` (32,766 on iOS 14+, 999 on iOS 13). + +| Pattern | Throughput | Trade-off | +|---------|------------|-----------| +| Type-safe upsert | ~1K rows/sec | Best DX, compile-time checks | +| Cached statement | ~10K rows/sec | Good balance | +| Multi-row VALUES | ~50K rows/sec | Most complex | + +--- + +## Miscellaneous Advanced Patterns + +### Database Triggers + +```swift +try database.write { db in + try Reminder.createTemporaryTrigger( + after: .insert { new in + Reminder + .find(new.id) + .update { + $0.position = Reminder.select { ($0.position.max() ?? -1) + 1 } + } + } + ) + .execute(db) +} +``` + +### Custom Update Logic + +```swift +extension Updates { + mutating func toggleStatus() { + self.status = Case(self.status) + .when(#bind(.incomplete), then: #bind(.completing)) + .else(#bind(.incomplete)) + } +} + +// Usage +try Reminder.find(reminder.id).update { $0.toggleStatus() }.execute(db) +``` + +### Enum Support + +```swift +enum Priority: Int, QueryBindable { + case low = 1 + case medium = 2 + case high = 3 +} + +enum Status: Int, QueryBindable { + case incomplete = 0 + case completing = 1 + case completed = 2 +} + +@Table +nonisolated struct Reminder: Identifiable { + let id: UUID + var priority: Priority? + var status: Status = .incomplete +} +``` + +### Compound Selects + +```swift +// UNION (deduplicated), UNION ALL (keep duplicates) +let all = try Customer.select(\.email).union(Supplier.select(\.email)).fetchAll(db) + +// INTERSECT (in both), EXCEPT (in first but not second) +let shared = try Customer.select(\.email).intersect(Supplier.select(\.email)).fetchAll(db) +``` + +--- + +## Resources + +**GitHub**: pointfreeco/sqlite-data, pointfreeco/swift-structured-queries, groue/GRDB.swift + +**Skills**: axiom-sqlitedata, axiom-sqlitedata-migration, axiom-database-migration, axiom-grdb + +--- + +**Targets:** iOS 17+, Swift 6 +**Framework:** SQLiteData 1.4+ +**History:** See git log for changes diff --git a/.claude/skills/axiom-sqlitedata-ref/agents/openai.yaml b/.claude/skills/axiom-sqlitedata-ref/agents/openai.yaml new file mode 100644 index 0000000..1961cfa --- /dev/null +++ b/.claude/skills/axiom-sqlitedata-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SQLiteData Reference" + short_description: "SQLiteData advanced patterns, @Selection column groups, single-table inheritance, recursive CTEs, database views, cus..." diff --git a/.claude/skills/axiom-sqlitedata/.openskills.json b/.claude/skills/axiom-sqlitedata/.openskills.json new file mode 100644 index 0000000..42e1da8 --- /dev/null +++ b/.claude/skills/axiom-sqlitedata/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-sqlitedata", + "installedAt": "2026-04-12T08:06:39.965Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-sqlitedata/SKILL.md b/.claude/skills/axiom-sqlitedata/SKILL.md new file mode 100644 index 0000000..0b7ac37 --- /dev/null +++ b/.claude/skills/axiom-sqlitedata/SKILL.md @@ -0,0 +1,924 @@ +--- +name: axiom-sqlitedata +description: Use when working with SQLiteData @Table models, CRUD operations, query patterns, CloudKit SyncEngine setup, or batch imports. Covers model definitions, @FetchAll/@FetchOne, upsert patterns, database setup with Dependencies. +license: MIT +metadata: + version: "3.0.0" + last-updated: "2025-12-19" +--- + +# SQLiteData + +## Overview + +Type-safe SQLite persistence using SQLiteData (pointfreeco/sqlite-data) by Point-Free. A fast, lightweight replacement for SwiftData with CloudKit synchronization support, built on GRDB (groue/GRDB.swift) and StructuredQueries (pointfreeco/swift-structured-queries). + +**Core principle:** Value types (`struct`) + `@Table` macro + `database.write { }` blocks for all mutations. + +**For advanced patterns** (CTEs, views, custom aggregates, schema composition), see the `axiom-sqlitedata-ref` reference skill. + +**Requires:** iOS 17+, Swift 6 strict concurrency +**License:** MIT + +## When to Use SQLiteData + +**Choose SQLiteData when you need:** +- Type-safe SQLite with compiler-checked queries +- CloudKit sync with record sharing +- Large datasets (50k+ records) with near-raw-SQLite performance +- Value types (structs) instead of classes +- Swift 6 strict concurrency support + +**Use SwiftData instead when:** +- Simple CRUD with native Apple integration +- Prefer `@Model` classes over structs +- Don't need CloudKit record sharing + +**Use raw GRDB when:** +- Complex SQL joins across 4+ tables +- Custom migration logic beyond schema changes +- Performance-critical operations needing manual SQL + +--- + +## Quick Reference + +```swift +// MODEL +@Table nonisolated struct Item: Identifiable { + let id: UUID // First let = auto primary key + var title = "" // Default = non-nullable + var notes: String? // Optional = nullable + @Column(as: Color.Hex.self) + var color: Color = .blue // Custom representation + @Ephemeral var isSelected = false // Not persisted +} + +// SETUP +prepareDependencies { $0.defaultDatabase = try! appDatabase() } +@Dependency(\.defaultDatabase) var database + +// FETCH +@FetchAll var items: [Item] +@FetchAll(Item.order(by: \.title).where(\.isInStock)) var items +@FetchOne(Item.count()) var count = 0 + +// FETCH (static helpers - v1.4.0+) +try Item.fetchAll(db) // vs Item.all.fetchAll(db) +try Item.find(db, key: id) // returns non-optional Item + +// INSERT +try database.write { db in + try Item.insert { Item.Draft(title: "New") }.execute(db) +} + +// UPDATE (single) +try database.write { db in + try Item.find(id).update { $0.title = #bind("Updated") }.execute(db) +} + +// UPDATE (bulk) +try database.write { db in + try Item.where(\.isInStock).update { $0.notes = #bind("") }.execute(db) +} + +// DELETE +try database.write { db in + try Item.find(id).delete().execute(db) + try Item.where { $0.id.in(ids) }.delete().execute(db) // bulk +} + +// QUERY +Item.where(\.isActive) // Keypath (simple) +Item.where { $0.title.contains("phone") } // Closure (complex) +Item.where { $0.status.eq(#bind(.done)) } // Enum comparison +Item.order(by: \.title) // Sort +Item.order { $0.createdAt.desc() } // Sort descending +Item.limit(10).offset(20) // Pagination + +// RAW SQL (#sql macro) +#sql("SELECT * FROM items WHERE price > 100") // Type-safe raw SQL +#sql("coalesce(date(\(dueDate)) = date(\(now)), 0)") // Custom expressions + +// CLOUDKIT (v1.2-1.4+) +prepareDependencies { + $0.defaultSyncEngine = try SyncEngine( + for: $0.defaultDatabase, + tables: Item.self + ) +} +@Dependency(\.defaultSyncEngine) var syncEngine + +// Manual sync control (v1.3.0+) +try await syncEngine.fetchChanges() // Pull from CloudKit +try await syncEngine.sendChanges() // Push to CloudKit +try await syncEngine.syncChanges() // Bidirectional + +// Sync state observation (v1.2.0+) +syncEngine.isSendingChanges // true during upload +syncEngine.isFetchingChanges // true during download +syncEngine.isSynchronizing // either sending or fetching +``` + +--- + +## Anti-Patterns (Common Mistakes) + +### ❌ Using `==` in predicates +```swift +// WRONG — removed in StructuredQueries 0.31+ (compiler error) +.where { $0.status == .completed } + +// CORRECT — use comparison methods +.where { $0.status.eq(#bind(.completed)) } +``` + +### ❌ Missing `#bind` in update assignments (StructuredQueries 0.31+) +```swift +// WRONG — compiler error in StructuredQueries 0.31+ +Item.find(id).update { $0.title = "New" }.execute(db) + +// CORRECT — wrap literal values with #bind +Item.find(id).update { $0.title = #bind("New") }.execute(db) + +// NOTE: Compound operators (+=, -=) don't need #bind — they auto-bind +Item.find(id).update { $0.title += "!" }.execute(db) // OK +``` + +### ❌ Wrong update order +```swift +// WRONG — .update before .where +Item.update { $0.title = #bind("X") }.where { $0.id.eq(#bind(id)) } + +// CORRECT — .find() for single, .where() before .update() for bulk +Item.find(id).update { $0.title = #bind("X") }.execute(db) +Item.where(\.isOld).update { $0.archived = #bind(true) }.execute(db) +``` + +### ❌ Instance methods for insert +```swift +// WRONG — no instance insert method +let item = Item(id: UUID(), title: "Test") +try item.insert(db) + +// CORRECT — static insert with .Draft +try Item.insert { Item.Draft(title: "Test") }.execute(db) +``` + +### ❌ Missing `nonisolated` +```swift +// WRONG — Swift 6 concurrency warning +@Table struct Item { ... } + +// CORRECT +@Table nonisolated struct Item { ... } +``` + +### ❌ Awaiting inside write block +```swift +// WRONG — write block is synchronous +try await database.write { db in ... } + +// CORRECT — no await inside the block +try database.write { db in + try Item.insert { ... }.execute(db) +} +``` + +### ❌ Forgetting `.execute(db)` +```swift +// WRONG — builds query but doesn't run it +try database.write { db in + Item.insert { Item.Draft(title: "X") } // Does nothing! +} + +// CORRECT +try database.write { db in + try Item.insert { Item.Draft(title: "X") }.execute(db) +} +``` + +--- + +## @Table Model Definitions + +### Basic Table + +```swift +import SQLiteData + +@Table +nonisolated struct Item: Identifiable { + let id: UUID // First `let` = auto primary key + var title = "" + var isInStock = true + var notes = "" +} +``` + +**Key patterns:** +- Use `struct`, not `class` (value types) +- Add `nonisolated` for Swift 6 concurrency +- First `let` property is automatically the primary key +- Use defaults (`= ""`, `= true`) for non-nullable columns +- Optional properties (`String?`) map to nullable SQL columns + +### Custom Primary Key + +```swift +@Table +nonisolated struct Tag: Hashable, Identifiable { + @Column(primaryKey: true) + var title: String // Custom primary key + var id: String { title } +} +``` + +### Column Customization + +```swift +@Table +nonisolated struct RemindersList: Hashable, Identifiable { + let id: UUID + + @Column(as: Color.HexRepresentation.self) // Custom type representation + var color: Color = .blue + + var position = 0 + var title = "" +} +``` + +### Foreign Keys + +```swift +@Table +nonisolated struct Reminder: Hashable, Identifiable { + let id: UUID + var title = "" + var remindersListID: RemindersList.ID // Foreign key (explicit column) +} + +@Table +nonisolated struct Attendee: Hashable, Identifiable { + let id: UUID + var name = "" + var syncUpID: SyncUp.ID // References parent +} +``` + +**Note:** SQLiteData uses explicit foreign key columns. Relationships are expressed through joins, not `@Relationship` macros. + +### Querying Related Tables (Joins) + +**Don't fetch all records and filter in Swift** — push filtering to the database: + +```swift +// ❌ Anti-pattern: Fetch all, filter in Swift +let allReminders = try database.read { try Reminder.all.fetch($0) } +let filtered = allReminders.filter { $0.remindersListID == listID } + +// ✅ Filter at database level +let filtered = try database.read { + try Reminder.all + .filter { $0.remindersListID.eq(#bind(listID)) } + .fetch($0) +} + +// ✅ Join across tables with filtering +let remindersWithList = try database.read { + try Reminder.all + .join(RemindersList.all) { $0.remindersListID.eq($1.id) } + .filter { $1.name.eq(#bind("Shopping")) } + .fetch($0) +} + +// ✅ Left join (include reminders even if no list) +let allWithOptionalList = try database.read { + try Reminder.all + .leftJoin(RemindersList.all) { $0.remindersListID.eq($1.id) } + .fetch($0) +} +``` + +For complex joins across 4+ tables, drop down to raw GRDB (see `axiom-grdb`). + +### @Ephemeral — Non-Persisted Properties + +Mark properties that exist in Swift but not in the database: + +```swift +@Table +nonisolated struct Item: Identifiable { + let id: UUID + var title = "" + var price: Decimal = 0 + + @Ephemeral + var isSelected = false // Not stored in database + + @Ephemeral + var formattedPrice: String { // Computed, not stored + "$\(price)" + } +} +``` + +**Use cases:** +- UI state (selection, expansion, hover) +- Computed properties derived from stored columns +- Transient flags for business logic +- Default values for properties not yet in schema + +**Important:** `@Ephemeral` properties must have default values since they won't be populated from the database. + +--- + +## Database Setup + +### Create Database + +```swift +import Dependencies +import SQLiteData +import GRDB + +func appDatabase() throws -> any DatabaseWriter { + var configuration = Configuration() + configuration.prepareDatabase { db in + // Configure database behavior + db.trace { print("SQL: \($0)") } // Optional SQL logging + } + + let database = try DatabaseQueue(configuration: configuration) + + var migrator = DatabaseMigrator() + + // Register migrations + migrator.registerMigration("v1") { db in + try #sql( + """ + CREATE TABLE "items" ( + "id" TEXT PRIMARY KEY NOT NULL DEFAULT (uuid()), + "title" TEXT NOT NULL DEFAULT '', + "isInStock" INTEGER NOT NULL DEFAULT 1, + "notes" TEXT NOT NULL DEFAULT '' + ) STRICT + """ + ) + .execute(db) + } + + try migrator.migrate(database) + return database +} +``` + +### Register in Dependencies + +```swift +extension DependencyValues { + var defaultDatabase: any DatabaseWriter { + get { self[DefaultDatabaseKey.self] } + set { self[DefaultDatabaseKey.self] = newValue } + } +} + +private enum DefaultDatabaseKey: DependencyKey { + static let liveValue: any DatabaseWriter = { + try! appDatabase() + }() +} + +// In app init or @main +prepareDependencies { + $0.defaultDatabase = try! appDatabase() +} +``` + +--- + +## Query Patterns + +### Property Wrappers (@FetchAll, @FetchOne) + +The primary way to observe database changes in SwiftUI: + +```swift +struct ItemsList: View { + @FetchAll(Item.order(by: \.title)) var items + + var body: some View { + List(items) { item in + Text(item.title) + } + } +} +``` + +**Key behaviors:** +- Automatically subscribes to database changes +- Updates when any `Item` changes +- Runs on the main thread +- Cancels observation when view disappears (iOS 17+) + +### @FetchOne for Aggregates + +```swift +struct StatsView: View { + @FetchOne(Item.count()) var totalCount = 0 + @FetchOne(Item.where(\.isInStock).count()) var inStockCount = 0 + + var body: some View { + Text("Total: \(totalCount), In Stock: \(inStockCount)") + } +} +``` + +### Lifecycle-Aware Fetching (v1.4.0+) + +Use `.task` to automatically cancel observation when view disappears: + +```swift +struct ItemsList: View { + @Fetch(Item.all, animation: .default) + private var items = [Item]() + + @State var searchQuery = "" + + var body: some View { + List(items) { item in + Text(item.title) + } + .searchable(text: $searchQuery) + .task(id: searchQuery) { + // Automatically cancels when view disappears or searchQuery changes + try? await $items.load( + Item.where { $0.title.contains(searchQuery) } + .order(by: \.title) + ).task // ← .task for auto-cancellation + } + } +} +``` + +**Before v1.4.0** (manual cleanup): +```swift +.task { + try? await $items.load(query) +} +.onDisappear { + Task { try await $items.load(Item.none) } +} +``` + +**With v1.4.0** (automatic): +```swift +.task { + try? await $items.load(query).task // Auto-cancels +} +``` + +### Filtering + +```swift +// Simple keypath filter +let active = Item.where(\.isActive) + +// Complex closure filter +let recent = Item.where { $0.createdAt > lastWeek && !$0.isArchived } + +// Contains/prefix/suffix +let matches = Item.where { $0.title.contains("phone") } +let starts = Item.where { $0.title.hasPrefix("iPhone") } +``` + +### Sorting + +```swift +// Single column +let sorted = Item.order(by: \.title) + +// Descending +let descending = Item.order { $0.createdAt.desc() } + +// Multiple columns +let multiSort = Item.order { ($0.priority, $0.createdAt.desc()) } +``` + +### Static Fetch Helpers (v1.4.0+) + +Cleaner syntax for fetching: + +```swift +// OLD (verbose) +let items = try Item.all.fetchAll(db) +let item = try Item.find(id).fetchOne(db) // returns Optional + +// NEW (concise) +let items = try Item.fetchAll(db) +let item = try Item.find(db, key: id) // returns Item (non-optional) + +// Works with where clauses too +let active = try Item.where(\.isActive).find(db, key: id) +``` + +**Key improvement:** `.find(db, key:)` returns non-optional, throwing an error if not found. + +--- + +## Insert / Update / Delete + +### Insert + +```swift +try database.write { db in + try Item.insert { + Item.Draft(title: "New Item", isInStock: true) + } + .execute(db) +} +``` + +### Insert with RETURNING (get generated ID) + +```swift +let newId = try database.write { db in + try Item.insert { + Item.Draft(title: "New Item") + } + .returning(\.id) + .fetchOne(db) +} +``` + +### Update Single Record + +```swift +try database.write { db in + try Item.find(itemId) + .update { $0.title = #bind("Updated Title") } + .execute(db) +} +``` + +### Update Multiple Records + +```swift +try database.write { db in + try Item.where(\.isArchived) + .update { $0.isDeleted = #bind(true) } + .execute(db) +} +``` + +### Delete + +```swift +// Delete single +try database.write { db in + try Item.find(id).delete().execute(db) +} + +// Delete multiple +try database.write { db in + try Item.where { $0.createdAt < cutoffDate } + .delete() + .execute(db) +} +``` + +### Upsert (Insert or Update) + +SQLite's UPSERT (`INSERT ... ON CONFLICT ... DO UPDATE`) expresses "insert if missing, otherwise update" in one statement. + +```swift +try database.write { db in + try Item.insert { + item + } onConflict: { cols in + (cols.libraryID, cols.remoteID) // Conflict target columns + } doUpdate: { row, excluded in + row.name = excluded.name // Merge semantics + row.notes = excluded.notes + } + .execute(db) +} +``` + +#### Parameters + +- `onConflict:` — Columns defining "same row" (must match UNIQUE constraint/index) +- `doUpdate:` — What to update on conflict + - `row` = existing database row + - `excluded` = proposed insert values (SQLite's `excluded` table) + +#### With Partial Unique Index + +When your UNIQUE index has a `WHERE` clause, add a conflict filter: + +```swift +try Item.insert { + item +} onConflict: { cols in + (cols.libraryID, cols.remoteID) +} where: { cols in + cols.remoteID.isNot(nil) // Match partial index condition +} doUpdate: { row, excluded in + row.name = excluded.name +} +.execute(db) +``` + +#### Schema Requirement + +```sql +CREATE UNIQUE INDEX idx_items_sync_identity +ON items (libraryID, remoteID) +WHERE remoteID IS NOT NULL +``` + +#### Merge Strategies + +##### Replace All Mutable Fields (Sync Mirror) + +```swift +doUpdate: { row, excluded in + row.name = excluded.name + row.notes = excluded.notes + row.updatedAt = excluded.updatedAt +} +``` + +##### Merge Without Clobbering + +```swift +doUpdate: { row, excluded in + row.name = excluded.name.ifnull(row.name) + row.notes = excluded.notes.ifnull(row.notes) +} +``` + +##### Last-Write-Wins (Raw SQL) + +```swift +try db.execute(sql: """ + INSERT INTO items (id, name, updatedAt) VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + updatedAt = excluded.updatedAt + WHERE excluded.updatedAt >= items.updatedAt + """, arguments: [item.id, item.name, item.updatedAt]) +// Use >= to handle timestamp ties (last arrival wins) +``` + +#### ❌ Common Upsert Mistakes + +##### Missing UNIQUE Constraint + +```swift +// WRONG — no index to conflict against +onConflict: { ($0.libraryID, $0.remoteID) } +// but table has no UNIQUE(libraryID, remoteID) +``` + +##### Using INSERT OR REPLACE + +```swift +// WRONG — REPLACE deletes then inserts, breaking FK relationships +try db.execute(sql: "INSERT OR REPLACE INTO items ...") + +// CORRECT — use ON CONFLICT for true upsert +try Item.insert { ... } onConflict: { ... } doUpdate: { ... } +``` + +--- + +## Batch Operations + +### Batch Insert + +```swift +try database.write { db in + try Item.insert { + ($0.title, $0.isInStock) + } values: { + items.map { ($0.title, $0.isInStock) } + } + .execute(db) +} +``` + +### Transaction Safety + +All mutations inside `database.write { }` are wrapped in a transaction: + +```swift +try database.write { db in + // These all succeed or all fail together + try Item.insert { ... }.execute(db) + try Item.find(id).update { ... }.execute(db) + try OtherTable.find(otherId).delete().execute(db) +} +``` + +If any operation throws, the entire transaction rolls back. + +--- + +## Raw SQL with #sql Macro + +When you need custom SQL expressions beyond the type-safe query builder, use the `#sql` macro from StructuredQueries: + +### Custom Query Expressions + +```swift +nonisolated extension Item.TableColumns { + var isPastDue: some QueryExpression { + @Dependency(\.date.now) var now + return !isCompleted && #sql("coalesce(date(\(dueDate)) < date(\(now)), 0)") + } +} + +// Use in queries +let overdue = try Item.where { $0.isPastDue }.fetchAll(db) +``` + +### Raw SQL Queries + +```swift +// Direct SQL with parameter interpolation +try #sql("SELECT * FROM items WHERE price > \(minPrice)").execute(db) + +// Using \(raw:) for literal values +let tableName = "items" +try #sql("SELECT * FROM \(raw: tableName)").execute(db) +``` + +#### Why #sql + +- Type-safe parameter binding (prevents SQL injection) +- Compile-time syntax checking +- Seamless integration with query builder +- Parameter interpolation automatically escapes values + +For schema creation (CREATE TABLE, migrations), see the `axiom-sqlitedata-ref` reference skill for complete examples. + +--- + +## CloudKit Sync + +### Basic Setup + +```swift +import CloudKit + +extension DependencyValues { + var defaultSyncEngine: SyncEngine { + get { self[DefaultSyncEngineKey.self] } + set { self[DefaultSyncEngineKey.self] = newValue } + } +} + +private enum DefaultSyncEngineKey: DependencyKey { + static let liveValue = { + @Dependency(\.defaultDatabase) var database + return try! SyncEngine( + for: database, + tables: Item.self, + privateTables: SensitiveItem.self, // Private database + startImmediately: true + ) + }() +} + +// In app init +prepareDependencies { + $0.defaultDatabase = try! appDatabase() + $0.defaultSyncEngine = try! SyncEngine( + for: $0.defaultDatabase, + tables: Item.self + ) +} +``` + +### Manual Sync Control (v1.3.0+) + +Control when sync happens instead of automatic background sync: + +```swift +@Dependency(\.defaultSyncEngine) var syncEngine + +// Pull changes from CloudKit +try await syncEngine.fetchChanges() + +// Push local changes to CloudKit +try await syncEngine.sendChanges() + +// Bidirectional sync +try await syncEngine.syncChanges() +``` + +**Use cases:** +- User-triggered "Refresh" button +- Sync after critical operations +- Custom sync scheduling +- Testing sync behavior + +### Sync State Observation (v1.2.0+) + +Show UI feedback during sync: + +```swift +struct SyncStatusView: View { + @Dependency(\.defaultSyncEngine) var syncEngine + + var body: some View { + HStack { + if syncEngine.isSynchronizing { + ProgressView() + if syncEngine.isSendingChanges { + Text("Uploading...") + } else if syncEngine.isFetchingChanges { + Text("Downloading...") + } + } else { + Image(systemName: "checkmark.circle") + Text("Synced") + } + } + } +} +``` + +**Observable properties:** +- `isSendingChanges: Bool` — True during CloudKit upload +- `isFetchingChanges: Bool` — True during CloudKit download +- `isSynchronizing: Bool` — True if either sending or fetching +- `isRunning: Bool` — True if sync engine is active + +### Query Sync Metadata (v1.3.0+) + +Access CloudKit sync information for records: + +```swift +import CloudKit + +// Get sync metadata for a record +let metadata = try SyncMetadata.find(item.syncMetadataID).fetchOne(db) + +// Join items with their sync metadata +let itemsWithSync = try Item.all + .leftJoin(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { (item: $0, metadata: $1) } + .fetchAll(db) + +// Check if record is shared +let sharedItems = try Item.all + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .where { $1.isShared } + .fetchAll(db) +``` + +### Migration Helpers + +Migrate primary keys when switching sync strategies: + +```swift +try await syncEngine.migratePrimaryKeys( + from: OldItem.self, + to: NewItem.self +) +``` + +--- + +## When to Drop to GRDB + +SQLiteData is built on GRDB. Use raw GRDB when you need: + +- Complex joins across 4+ tables +- Window functions (ROW_NUMBER, RANK, etc.) +- Performance-critical paths where you've profiled and confirmed the query builder is the bottleneck + +See `axiom-grdb` for raw SQL patterns, ValueObservation, and DatabaseMigrator usage. + +--- + +## tvOS + +SQLiteData with CloudKit SyncEngine is the **recommended tvOS data solution**. tvOS has no persistent local storage — the system deletes Caches (including Application Support) under storage pressure. With SyncEngine, iCloud is your persistent store and the local database is just a cache that rebuilds automatically after deletion. See `axiom-tvos` for full tvOS storage constraints. + +--- + +## Resources + +**GitHub**: pointfreeco/sqlite-data, pointfreeco/swift-structured-queries, groue/GRDB.swift + +**Skills**: axiom-sqlitedata-ref, axiom-sqlitedata-migration, axiom-database-migration, axiom-grdb + +--- + +**Targets:** iOS 17+, Swift 6 +**Framework:** SQLiteData 1.4+ +**History:** See git log for changes diff --git a/.claude/skills/axiom-sqlitedata/agents/openai.yaml b/.claude/skills/axiom-sqlitedata/agents/openai.yaml new file mode 100644 index 0000000..9305ad3 --- /dev/null +++ b/.claude/skills/axiom-sqlitedata/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SQLiteData" + short_description: "Working with SQLiteData @Table models, CRUD operations, query patterns, CloudKit SyncEngine setup, or batch imports" diff --git a/.claude/skills/axiom-storage-diag/.openskills.json b/.claude/skills/axiom-storage-diag/.openskills.json new file mode 100644 index 0000000..dbf0776 --- /dev/null +++ b/.claude/skills/axiom-storage-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-storage-diag", + "installedAt": "2026-04-12T08:06:42.072Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-storage-diag/SKILL.md b/.claude/skills/axiom-storage-diag/SKILL.md new file mode 100644 index 0000000..140c254 --- /dev/null +++ b/.claude/skills/axiom-storage-diag/SKILL.md @@ -0,0 +1,350 @@ +--- +name: axiom-storage-diag +description: Use when debugging 'files disappeared', 'data missing after restart', 'backup too large', 'can't save file', 'file not found', 'storage full error', 'file inaccessible when locked' - systematic local file storage diagnostics +license: MIT +metadata: + version: "1.0.0" + last-updated: "2025-12-12" +--- + +# Local File Storage Diagnostics + +## Overview + +**Core principle** 90% of file storage problems stem from choosing the wrong storage location, misunderstanding file protection levels, or missing backup exclusions—not iOS file system bugs. + +The iOS file system is battle-tested across millions of apps and devices. If your files are disappearing, becoming inaccessible, or causing backup issues, the problem is almost always in storage location choice or protection configuration. + +## Red Flags — Suspect File Storage Issue + +If you see ANY of these: +- Files mysteriously disappear after device restart +- Files disappear randomly (weeks after creation) +- App backup size unexpectedly large (>500 MB) +- "File not found" after app background/foreground cycle +- Files inaccessible when device is locked +- Users report lost data after iOS update +- Background tasks can't access files + +❌ **FORBIDDEN** "iOS deleted my files, the file system is broken" +- iOS file system handles billions of files daily across all apps +- System behavior is documented and predictable +- 99% of issues are location/protection mismatches + +## Mandatory First Steps + +**ALWAYS check these FIRST** (before changing code): + +```swift +// 1. Check WHERE file is stored +func diagnoseFileLocation(_ url: URL) { + let path = url.path + if path.contains("/tmp/") { + print("⚠️ File in tmp/ - system purges aggressively") + } else if path.contains("/Caches/") { + print("⚠️ File in Caches/ - purged under storage pressure") + } else if path.contains("/Documents/") { + print("✅ File in Documents/ - never purged, backed up") + } else if path.contains("/Library/Application Support/") { + print("✅ File in Application Support/ - never purged, backed up") + } +} + +// 2. Check file protection level +func diagnoseFileProtection(_ url: URL) throws { + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + if let protection = attrs[.protectionKey] as? FileProtectionType { + print("Protection: \(protection)") + if protection == .complete { + print("⚠️ File inaccessible when device locked") + } + } +} + +// 3. Check backup status +func diagnoseBackupStatus(_ url: URL) throws { + let values = try url.resourceValues(forKeys: [.isExcludedFromBackupKey]) + if let excluded = values.isExcludedFromBackup { + print("Excluded from backup: \(excluded)") + } +} + +// 4. Check file existence and size +func diagnoseFileState(_ url: URL) { + if FileManager.default.fileExists(atPath: url.path) { + if let size = try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64 { + print("File exists, size: \(size) bytes") + } + } else { + print("❌ File does not exist") + } +} +``` + +--- + +## Decision Tree + +### Files Disappeared + +``` +Files missing? → Check where stored + +├─ Disappeared after device restart +│ ├─ Was in tmp/? → EXPECTED (tmp/ purged on reboot) +│ │ → FIX: Move to Caches/ or Application Support/ +│ │ +│ ├─ Was in Caches/? → System purged (storage pressure) +│ │ → FIX: Move to Application Support/ if can't be regenerated +│ │ +│ └─ Protection level .complete? → Inaccessible until unlock +│ → FIX: Wait for unlock or use .completeUntilFirstUserAuthentication +│ +├─ Disappeared randomly (weeks later) +│ ├─ In Caches/? → System purged under storage pressure +│ │ → EXPECTED if re-downloadable +│ │ → FIX: Re-download when needed, or move to Application Support/ +│ │ +│ └─ In Documents or Application Support/? +│ → Check if user deleted app (purges all data) +│ → Check iOS update (rare, but check migration path) +│ +└─ Only some files missing + → Check isExcludedFromBackup + iCloud sync + → Check if file names have special characters + → Check file permissions +``` + +### Files Inaccessible + +``` +Can't access file? + +├─ Error: "No permission" or NSFileReadNoPermissionError +│ ├─ Device locked? → Check file protection +│ │ └─ .complete protection? → Wait for unlock +│ │ → FIX: Use .completeUntilFirstUserAuthentication +│ │ +│ └─ Background task accessing? → .complete blocks background +│ → FIX: Change to .completeUntilFirstUserAuthentication +│ +├─ File exists but read returns empty/nil +│ └─ Check actual file size on disk +│ → May be zero-byte file from failed write +│ +└─ File exists in debugger but not at runtime + → Check if using wrong directory (Documents vs Caches) + → Check URL construction +``` + +### Backup Too Large + +``` +App backup > 500 MB? + +├─ Check Documents directory size +│ └─ Large files (>10 MB each)? +│ ├─ Can they be re-downloaded? → Move to Caches + isExcludedFromBackup +│ └─ User-created? → Keep in Documents (warn user if >1 GB) +│ +├─ Check Application Support size +│ └─ Downloaded media/podcasts? +│ → Mark isExcludedFromBackup = true +│ +└─ Audit backup with code: + ```swift + func auditBackupSize() { + let docsURL = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + )[0] + let size = getDirectorySize(url: docsURL) + print("Documents (backed up): \(size / 1_000_000) MB") + } + ``` +``` + +--- + +## Common Patterns by Symptom + +### Pattern 1: Files in tmp/ Disappear + +**Symptom**: Temp files missing after restart or even during app lifecycle + +**Cause**: tmp/ is purged aggressively by system + +**Fix**: +```swift +// ❌ WRONG: Using tmp/ for anything that should persist +let tmpURL = FileManager.default.temporaryDirectory +let fileURL = tmpURL.appendingPathComponent("data.json") +try data.write(to: fileURL) // WILL BE DELETED + +// ✅ CORRECT: Use Caches/ for re-generable data +let cacheURL = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask +)[0] +let fileURL = cacheURL.appendingPathComponent("data.json") +try data.write(to: fileURL) +``` + +### Pattern 2: Caches Purged, Data Lost + +**Symptom**: Downloaded content disappears weeks later + +**Cause**: Caches/ is purged under storage pressure (expected behavior) + +**Fix**: Either re-download on demand OR move to Application Support if can't be regenerated +```swift +// ✅ CORRECT: Handle missing cache gracefully +func loadCachedImage(url: URL) async throws -> UIImage { + let cacheURL = getCacheURL(for: url) + + // Try cache first + if FileManager.default.fileExists(atPath: cacheURL.path), + let data = try? Data(contentsOf: cacheURL), + let image = UIImage(data: data) { + return image + } + + // Cache miss - re-download + let (data, _) = try await URLSession.shared.data(from: url) + try data.write(to: cacheURL) + return UIImage(data: data)! +} +``` + +### Pattern 3: .complete Protection Blocks Background + +**Symptom**: Background tasks fail with "permission denied" + +**Cause**: Files with .complete protection inaccessible when locked + +**Fix**: +```swift +// ❌ WRONG: .complete protection for background-accessed files +try data.write(to: url, options: .completeFileProtection) +// Background task fails when device locked + +// ✅ CORRECT: Use .completeUntilFirstUserAuthentication +try data.write( + to: url, + options: .completeFileProtectionUntilFirstUserAuthentication +) +// Accessible in background after first unlock +``` + +### Pattern 4: Backup Bloat from Downloaded Content + +**Symptom**: App backup >1 GB, app rejected or users complain + +**Cause**: Downloaded content in Documents/ or not marked excluded + +**Fix**: +```swift +// ✅ CORRECT: Exclude re-downloadable content +func downloadPodcast(url: URL) async throws { + let appSupportURL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0] + + let podcastURL = appSupportURL + .appendingPathComponent("Podcasts") + .appendingPathComponent(url.lastPathComponent) + + // Download + let (data, _) = try await URLSession.shared.data(from: url) + try data.write(to: podcastURL) + + // Mark excluded from backup + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try podcastURL.setResourceValues(resourceValues) +} +``` + +--- + +## Production Crisis Scenario + +**SYMPTOM**: Users report lost photos after iOS update + +**DIAGNOSIS STEPS**: + +1. **Check storage location** (5 min): + ```swift + // Were photos in Caches/? + let photosInCaches = path.contains("/Caches/") + // If yes → system purged them (expected) + ``` + +2. **Check if backed up** (5 min): + ```swift + // Check if excluded from backup + let excluded = try? url.resourceValues( + forKeys: [.isExcludedFromBackupKey] + ).isExcludedFromBackup + // If excluded=true AND not synced → lost + ``` + +3. **Check migration path** (10 min): + - Did app container path change? + - Did we migrate data from old location? + +**ROOT CAUSES** (90% of cases): +- Photos in Caches/ (purged under storage pressure) +- Photos excluded from backup + no cloud sync +- Migration code missing after major iOS update + +**FIX**: +- User photos MUST be in Documents/ +- Never exclude user-created content from backup +- Always have cloud sync OR backup for user content + +--- + +## Quick Diagnostic Checklist + +Run this on any storage problem: + +```swift +func diagnoseStorageIssue(fileURL: URL) { + print("=== Storage Diagnosis ===") + + // 1. Location + diagnoseFileLocation(fileURL) + + // 2. Protection + try? diagnoseFileProtection(fileURL) + + // 3. Backup status + try? diagnoseBackupStatus(fileURL) + + // 4. File state + diagnoseFileState(fileURL) + + // 5. Directory size + if let parentURL = fileURL.deletingLastPathComponent() as URL? { + let size = getDirectorySize(url: parentURL) + print("Parent directory size: \(size / 1_000_000) MB") + } + + print("=== End Diagnosis ===") +} +``` + +--- + +## Related Skills + +- `axiom-storage` — Correct storage location decisions +- `axiom-file-protection-ref` — Understanding protection levels +- `axiom-storage-management-ref` — Purge behavior and capacity APIs + +--- + +**Last Updated**: 2025-12-12 +**Skill Type**: Diagnostic diff --git a/.claude/skills/axiom-storage-diag/agents/openai.yaml b/.claude/skills/axiom-storage-diag/agents/openai.yaml new file mode 100644 index 0000000..b9e5db5 --- /dev/null +++ b/.claude/skills/axiom-storage-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Storage Diagnostics" + short_description: "Debugging 'files disappeared', 'data missing after restart', 'backup too large', 'can't save file', 'file not found',..." diff --git a/.claude/skills/axiom-storage-management-ref/.openskills.json b/.claude/skills/axiom-storage-management-ref/.openskills.json new file mode 100644 index 0000000..a1aecf0 --- /dev/null +++ b/.claude/skills/axiom-storage-management-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-storage-management-ref", + "installedAt": "2026-04-12T08:06:42.492Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-storage-management-ref/SKILL.md b/.claude/skills/axiom-storage-management-ref/SKILL.md new file mode 100644 index 0000000..66c5a1c --- /dev/null +++ b/.claude/skills/axiom-storage-management-ref/SKILL.md @@ -0,0 +1,613 @@ +--- +name: axiom-storage-management-ref +description: Use when asking about 'purge files', 'storage pressure', 'disk space iOS', 'isExcludedFromBackup', 'URL resource values', 'volumeAvailableCapacity', 'low storage', 'file purging priority', 'cache management' - comprehensive reference for iOS storage management and URL resource value APIs +license: MIT +compatibility: iOS 5.0+, iPadOS 5.0+, macOS 10.7+ +metadata: + version: "1.0.0" + last-updated: "2025-12-12" +--- + +# iOS Storage Management Reference + +**Purpose**: Comprehensive reference for storage pressure, purging policies, disk space, and URL resource values +**Availability**: iOS 5.0+ (basic), iOS 11.0+ (modern capacity APIs) +**Context**: Answer to "Does iOS provide any way to mark files as 'purge as last resort'?" + +## When to Use This Skill + +Use this skill when you need to: +- Understand iOS file purging behavior +- Check available disk space correctly +- Set purge priorities for cached files +- Exclude files from backup +- Monitor storage pressure +- Mark files as purgeable +- Understand volume capacity APIs +- Handle "low storage" scenarios + +## The Core Question + +> **"Does iOS provide any way to mark files as 'purge as last resort'?"** + +**Answer**: Not directly, but iOS provides two approaches: + +1. **Location-based purging** (implicit priority): + - `tmp/` → Purged aggressively (anytime) + - `Library/Caches/` → Purged under storage pressure + - `Documents/`, `Application Support/` → Never purged + +2. **Capacity checking** (explicit strategy): + - `volumeAvailableCapacityForImportantUsage` — For must-save data + - `volumeAvailableCapacityForOpportunisticUsage` — For nice-to-have data + - Check before saving, choose location based on available space + +--- + +## URL Resource Values for Storage + +### Complete Reference Table + +| Resource Key | Type | Purpose | Availability | +|--------------|------|---------|--------------| +| `volumeAvailableCapacityKey` | Int64 | Total available space | iOS 5.0+ | +| `volumeAvailableCapacityForImportantUsageKey` | Int64 | Space for essential files | iOS 11.0+ | +| `volumeAvailableCapacityForOpportunisticUsageKey` | Int64 | Space for optional files | iOS 11.0+ | +| `volumeTotalCapacityKey` | Int64 | Total volume capacity | iOS 5.0+ | +| `isExcludedFromBackupKey` | Bool | Exclude from iCloud/iTunes backup | iOS 5.1+ | +| `isPurgeableKey` | Bool | System can delete under pressure | iOS 9.0+ | +| `fileAllocatedSizeKey` | Int64 | Actual disk space used | iOS 5.0+ | +| `totalFileAllocatedSizeKey` | Int64 | Total allocated (including metadata) | iOS 5.0+ | + +### Checking Available Space (Modern Approach) + +```swift +// ✅ CORRECT: Check appropriate capacity before saving +func checkSpaceBeforeSaving(fileSize: Int64, isEssential: Bool) -> Bool { + let homeURL = FileManager.default.homeDirectoryForCurrentUser + + do { + let values = try homeURL.resourceValues(forKeys: [ + .volumeAvailableCapacityForImportantUsageKey, + .volumeAvailableCapacityForOpportunisticUsageKey + ]) + + if isEssential { + // For must-save data (user-created content, critical app data) + let importantCapacity = values.volumeAvailableCapacityForImportantUsage ?? 0 + return fileSize < importantCapacity + } else { + // For nice-to-have data (caches, thumbnails) + let opportunisticCapacity = values.volumeAvailableCapacityForOpportunisticUsage ?? 0 + return fileSize < opportunisticCapacity + } + } catch { + print("Error checking capacity: \(error)") + return false + } +} + +// Usage +if checkSpaceBeforeSaving(fileSize: imageData.count, isEssential: true) { + try imageData.write(to: documentsURL.appendingPathComponent("photo.jpg")) +} else { + showLowStorageAlert() +} +``` + +### Important vs Opportunistic Capacity + +**volumeAvailableCapacityForImportantUsage**: +- Space reserved for **essential** operations +- Use for: User-created content, must-save data +- System reserves this space more aggressively +- Higher threshold + +**volumeAvailableCapacityForOpportunisticUsage**: +- Space available for **optional** operations +- Use for: Caches, thumbnails, pre-fetching +- Lower threshold (system may already be under pressure) +- Indicates "go ahead if you want, but system is getting full" + +```swift +// ✅ CORRECT: Different thresholds for different data types +func shouldDownloadThumbnail(size: Int64) -> Bool { + let capacity = try? FileManager.default.homeDirectoryForCurrentUser + .resourceValues(forKeys: [.volumeAvailableCapacityForOpportunisticUsageKey]) + .volumeAvailableCapacityForOpportunisticUsage ?? 0 + + // Only download optional content if there's plenty of space + return size < capacity +} + +func canSaveUserDocument(size: Int64) -> Bool { + let capacity = try? FileManager.default.homeDirectoryForCurrentUser + .resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + .volumeAvailableCapacityForImportantUsage ?? 0 + + // User documents are essential + return size < capacity +} +``` + +--- + +## Backup Exclusion + +### isExcludedFromBackup + +Files in `Caches/` are automatically excluded from backup, but you should **explicitly mark** re-downloadable files in other directories. + +```swift +// ✅ CORRECT: Exclude large re-downloadable files from backup +func markExcludedFromBackup(url: URL) throws { + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try url.setResourceValues(resourceValues) +} + +// Example: Downloaded podcast episodes +func downloadPodcast(url: URL) throws { + let appSupportURL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0] + + let podcastURL = appSupportURL + .appendingPathComponent("Podcasts") + .appendingPathComponent(url.lastPathComponent) + + // Download file + let data = try Data(contentsOf: url) + try data.write(to: podcastURL) + + // Mark as excluded from backup (can re-download) + try markExcludedFromBackup(url: podcastURL) +} +``` + +**When to exclude from backup**: +- ✅ Downloaded content that can be re-fetched +- ✅ Generated thumbnails +- ✅ Cached API responses +- ✅ Large media files from server +- ❌ User-created content (always back up) +- ❌ App data that can't be recreated + +### Checking Backup Status + +```swift +// ✅ Check if file is excluded from backup +func isExcludedFromBackup(url: URL) -> Bool { + let values = try? url.resourceValues(forKeys: [.isExcludedFromBackupKey]) + return values?.isExcludedFromBackup ?? false +} +``` + +--- + +## Purgeable Files + +### isPurgeable + +Mark files as candidates for automatic purging by the system. + +```swift +// ✅ CORRECT: Mark cache files as purgeable +func markAsPurgeable(url: URL) throws { + var resourceValues = URLResourceValues() + resourceValues.isPurgeable = true + try url.setResourceValues(resourceValues) +} + +// Example: Thumbnail cache +func cacheThumbnail(image: UIImage, for url: URL) throws { + let cacheURL = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + )[0] + + let thumbnailURL = cacheURL.appendingPathComponent(url.lastPathComponent) + + // Save thumbnail + try image.pngData()?.write(to: thumbnailURL) + + // Mark as purgeable + try markAsPurgeable(url: thumbnailURL) + + // Also exclude from backup + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try thumbnailURL.setResourceValues(resourceValues) +} +``` + +**Note**: Files in `Caches/` are already purgeable by location. Setting `isPurgeable` is advisory for files in other locations. + +--- + +## Implicit Purge Priority (Location-Based) + +iOS purges files based on **location**, not explicit priority flags. + +### Purge Priority Hierarchy + +``` +PURGED FIRST (Aggressive): +└── tmp/ + - Purged: Anytime (even while app running) + - Lifetime: Hours to days + - Use for: Truly temporary intermediates + +PURGED SECOND (Storage Pressure): +└── Library/Caches/ + - Purged: When system needs space + - Lifetime: Weeks to months (if space available) + - Use for: Re-downloadable, regenerable content + +NEVER PURGED (Permanent): +├── Documents/ +│ - Backed up: ✅ Yes +│ - Purged: ❌ Never (unless app deleted) +│ - Use for: User-created content +│ +└── Library/Application Support/ + - Backed up: ✅ Yes + - Purged: ❌ Never (unless app deleted) + - Use for: Essential app data +``` + +### Implementation Strategy + +```swift +// ✅ CORRECT: Choose location based on purge priority needs +func saveFile(data: Data, priority: FilePriority) throws { + let url: URL + + switch priority { + case .essential: + // Never purged - for user-created or critical app data + url = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + )[0].appendingPathComponent("important.dat") + + case .cacheable: + // Purged under storage pressure - for re-downloadable content + url = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + )[0].appendingPathComponent("cache.dat") + + case .temporary: + // Purged aggressively - for temp files + url = FileManager.default.temporaryDirectory + .appendingPathComponent("temp.dat") + } + + try data.write(to: url) + + // For cacheable files, mark excluded from backup + if priority == .cacheable { + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try url.setResourceValues(resourceValues) + } +} + +enum FilePriority { + case essential // Never purge + case cacheable // Purge under pressure + case temporary // Purge aggressively +} +``` + +--- + +## Storage Pressure Detection + +### Responding to Low Storage + +```swift +// ✅ CORRECT: Monitor for low storage and clean up proactively +class StorageMonitor { + func checkStorageAndCleanup() { + let homeURL = FileManager.default.homeDirectoryForCurrentUser + + guard let values = try? homeURL.resourceValues(forKeys: [ + .volumeAvailableCapacityForOpportunisticUsageKey, + .volumeTotalCapacityKey + ]) else { return } + + let availableSpace = values.volumeAvailableCapacityForOpportunisticUsage ?? 0 + let totalSpace = values.volumeTotalCapacity ?? 1 + + // Calculate percentage + let percentAvailable = Double(availableSpace) / Double(totalSpace) + + if percentAvailable < 0.10 { // Less than 10% free + print("⚠️ Low storage detected, cleaning up...") + cleanupCaches() + } + } + + func cleanupCaches() { + let cacheURL = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + )[0] + + // Delete old cache files + let fileManager = FileManager.default + guard let files = try? fileManager.contentsOfDirectory( + at: cacheURL, + includingPropertiesForKeys: [.contentModificationDateKey] + ) else { return } + + // Sort by modification date + let sortedFiles = files.sorted { url1, url2 in + let date1 = (try? url1.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate + let date2 = (try? url2.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate + return (date1 ?? .distantPast) < (date2 ?? .distantPast) + } + + // Delete oldest files first + for fileURL in sortedFiles.prefix(100) { + try? fileManager.removeItem(at: fileURL) + } + } +} +``` + +### Background Cleanup Task + +```swift +// ✅ CORRECT: Register background task to clean up storage +import BackgroundTasks + +func registerBackgroundCleanup() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.example.app.cleanup", + using: nil + ) { task in + self.handleStorageCleanup(task: task as! BGProcessingTask) + } +} + +func handleStorageCleanup(task: BGProcessingTask) { + task.expirationHandler = { + task.setTaskCompleted(success: false) + } + + // Clean up old caches + cleanupOldFiles() + + task.setTaskCompleted(success: true) +} +``` + +--- + +## File Size Calculation + +### Getting Accurate File Sizes + +```swift +// ✅ CORRECT: Get actual disk usage (includes filesystem overhead) +func getFileSize(url: URL) -> Int64? { + let values = try? url.resourceValues(forKeys: [ + .fileAllocatedSizeKey, + .totalFileAllocatedSizeKey + ]) + + // Use totalFileAllocatedSize for accurate disk usage + return values?.totalFileAllocatedSize.map { Int64($0) } +} + +// ✅ Calculate directory size +func getDirectorySize(url: URL) -> Int64 { + guard let enumerator = FileManager.default.enumerator( + at: url, + includingPropertiesForKeys: [.totalFileAllocatedSizeKey] + ) else { return 0 } + + var totalSize: Int64 = 0 + + for case let fileURL as URL in enumerator { + if let size = getFileSize(url: fileURL) { + totalSize += size + } + } + + return totalSize +} + +// Usage +let cacheSize = getDirectorySize(url: cachesDirectory) +print("Cache using \(cacheSize / 1_000_000) MB") +``` + +--- + +## Common Patterns + +### Pattern 1: Smart Download Based on Available Space + +```swift +// ✅ CORRECT: Only download optional content if space available +func downloadOptionalContent(url: URL, size: Int64) async throws { + // Check opportunistic capacity + let homeURL = FileManager.default.homeDirectoryForCurrentUser + let values = try homeURL.resourceValues(forKeys: [ + .volumeAvailableCapacityForOpportunisticUsageKey + ]) + + guard let available = values.volumeAvailableCapacityForOpportunisticUsage, + size < available else { + print("Skipping download - low storage") + return + } + + // Proceed with download + let data = try await URLSession.shared.data(from: url).0 + try data.write(to: cachesDirectory.appendingPathComponent(url.lastPathComponent)) +} +``` + +### Pattern 2: Progressive Cache Cleanup + +```swift +// ✅ CORRECT: Clean up caches when approaching storage limits +class CacheManager { + func addToCache(data: Data, key: String) throws { + let cacheURL = getCacheURL(for: key) + + // Check if we should clean up first + if shouldCleanupCache(addingSize: Int64(data.count)) { + cleanupOldestFiles(targetSize: 100 * 1_000_000) // 100 MB + } + + try data.write(to: cacheURL) + } + + func shouldCleanupCache(addingSize: Int64) -> Bool { + let homeURL = FileManager.default.homeDirectoryForCurrentUser + guard let values = try? homeURL.resourceValues(forKeys: [ + .volumeAvailableCapacityForOpportunisticUsageKey + ]) else { return false } + + let available = values.volumeAvailableCapacityForOpportunisticUsage ?? 0 + + // Clean up if less than 200 MB free + return available < 200 * 1_000_000 + } + + func cleanupOldestFiles(targetSize: Int64) { + // Delete oldest cache files until under target + // (implementation similar to earlier example) + } +} +``` + +### Pattern 3: Exclude Downloaded Media from Backup + +```swift +// ✅ CORRECT: Downloaded podcast/video management +class MediaDownloader { + func downloadMedia(url: URL) async throws { + let data = try await URLSession.shared.data(from: url).0 + + // Store in Application Support (not Caches, so it persists) + let mediaURL = applicationSupportDirectory + .appendingPathComponent("Downloads") + .appendingPathComponent(url.lastPathComponent) + + try data.write(to: mediaURL) + + // But exclude from backup (can re-download) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try mediaURL.setResourceValues(resourceValues) + } +} +``` + +--- + +## Debugging Storage Issues + +### Audit Backup Size + +```swift +// ✅ Check what's being backed up +func auditBackupSize() { + let documentsURL = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + )[0] + + let size = getDirectorySize(url: documentsURL) + print("Documents (backed up): \(size / 1_000_000) MB") + + // Check for large files that should be excluded + if size > 100 * 1_000_000 { // > 100 MB + print("⚠️ Large backup size - check for re-downloadable files") + findLargeFiles(in: documentsURL) + } +} + +func findLargeFiles(in directory: URL) { + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.totalFileAllocatedSizeKey] + ) else { return } + + for case let fileURL as URL in enumerator { + if let size = getFileSize(url: fileURL), + size > 10 * 1_000_000 { // > 10 MB + print("Large file: \(fileURL.lastPathComponent) (\(size / 1_000_000) MB)") + + // Check if excluded from backup + if !isExcludedFromBackup(url: fileURL) { + print("⚠️ Should this be excluded from backup?") + } + } + } +} +``` + +--- + +## Quick Reference + +| Task | API | Code | +|------|-----|------| +| Check space for essential file | `volumeAvailableCapacityForImportantUsageKey` | `values.volumeAvailableCapacityForImportantUsage` | +| Check space for cache | `volumeAvailableCapacityForOpportunisticUsageKey` | `values.volumeAvailableCapacityForOpportunisticUsage` | +| Exclude from backup | `isExcludedFromBackupKey` | `resourceValues.isExcludedFromBackup = true` | +| Mark purgeable | `isPurgeableKey` | `resourceValues.isPurgeable = true` | +| Get file size | `totalFileAllocatedSizeKey` | `values.totalFileAllocatedSize` | +| Purge priority | Location-based | Use `tmp/` or `Caches/` directory | + +--- + +## File Protection Quick Reference + +Set encryption level per file. See `axiom-file-protection-ref` for full guide. + +| Level | When Accessible | Use For | +|-------|----------------|---------| +| `.complete` | Only while unlocked | Passwords, tokens, health data | +| `.completeUnlessOpen` | After first unlock if already open | Active downloads, media recording | +| `.completeUntilFirstUserAuthentication` | After first unlock (default) | Most app data | +| `.none` | Always, even before unlock | Background fetch data, push payloads | + +```swift +// Set protection on file +try data.write(to: url, options: .completeFileProtection) + +// Set protection on directory +try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true, + attributes: [.protectionKey: FileProtectionType.complete] +) + +// Check current protection +let values = try url.resourceValues(forKeys: [.fileProtectionKey]) +print("Protection: \(values.fileProtection ?? .none)") +``` + +--- + +## Related Skills + +- `axiom-storage` — Decide where to store files +- `axiom-file-protection-ref` — File encryption and security +- `axiom-storage-diag` — Debug storage-related issues + +--- + +**Last Updated**: 2025-12-12 +**Skill Type**: Reference +**Minimum iOS**: 5.0 (basic), 11.0 (modern capacity APIs) diff --git a/.claude/skills/axiom-storage-management-ref/agents/openai.yaml b/.claude/skills/axiom-storage-management-ref/agents/openai.yaml new file mode 100644 index 0000000..b75f061 --- /dev/null +++ b/.claude/skills/axiom-storage-management-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Storage Management Reference" + short_description: "Asking about 'purge files', 'storage pressure', 'disk space iOS', 'isExcludedFromBackup', 'URL resource values', 'vol..." diff --git a/.claude/skills/axiom-storage/.openskills.json b/.claude/skills/axiom-storage/.openskills.json new file mode 100644 index 0000000..2f80665 --- /dev/null +++ b/.claude/skills/axiom-storage/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-storage", + "installedAt": "2026-04-12T08:06:41.167Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-storage/SKILL.md b/.claude/skills/axiom-storage/SKILL.md new file mode 100644 index 0000000..8e648b3 --- /dev/null +++ b/.claude/skills/axiom-storage/SKILL.md @@ -0,0 +1,563 @@ +--- +name: axiom-storage +description: Use when asking 'where should I store this data', 'should I use SwiftData or files', 'CloudKit vs iCloud Drive', 'Documents vs Caches', 'local or cloud storage', 'how do I sync data', 'where do app files go' - comprehensive decision framework for all iOS storage options +license: MIT +compatibility: iOS 17+, iPadOS 17+, macOS Sonoma+ +metadata: + version: "1.0.0" + last-updated: "2025-12-12" +--- + +# iOS Storage Guide + +**Purpose**: Navigation hub for ALL storage decisions — database vs files, local vs cloud, specific locations +**iOS Version**: iOS 17+ (iOS 26+ for latest features) +**Context**: Complete storage decision framework integrating SwiftData (WWDC 2023), CKSyncEngine (WWDC 2023), and file management best practices + +## When to Use This Skill + +✅ **Use this skill when**: +- Starting a new project and choosing storage approach +- Asking "where should I store this data?" +- Deciding between SwiftData, Core Data, SQLite, or files +- Choosing between CloudKit and iCloud Drive for sync +- Determining Documents vs Caches vs Application Support +- Planning data architecture for offline/online scenarios +- Migrating from one storage solution to another +- Debugging "files disappeared" or "data not syncing" + +❌ **Do NOT use this skill for**: +- SwiftData implementation details (use `axiom-swiftdata` skill) +- SQLite/GRDB specifics (use `axiom-sqlitedata` or `axiom-grdb` skills) +- CloudKit sync implementation (use `axiom-cloudkit-ref` skill) +- File protection APIs (use `axiom-file-protection-ref` skill) + +**Related Skills**: +- Existing database skills: `axiom-swiftdata`, `axiom-sqlitedata`, `axiom-grdb` +- New file skills: `axiom-file-protection-ref`, `axiom-storage-management-ref`, `axiom-storage-diag` +- New cloud skills: `axiom-cloudkit-ref`, `axiom-icloud-drive-ref`, `axiom-cloud-sync-diag` + +## Core Philosophy + +> **"Choose the right tool for your data shape. Then choose the right location."** + +Storage decisions have two dimensions: +1. **Format**: How is data structured? (Queryable records vs files) +2. **Location**: Where is it stored? (Local vs cloud, which directory) + +Getting the format wrong forces workarounds. Getting the location wrong causes data loss or backup bloat. + +--- + +## The Complete Decision Tree + +### Level 1: Format — What Are You Storing? + +``` +What is the shape of your data? + +├─ STRUCTURED DATA (queryable records, relationships, search) +│ Examples: User profiles, task lists, notes, contacts, transactions +│ → Continue to "Structured Data Path" below +│ +└─ FILES (documents, images, videos, downloads, caches) + Examples: Photos, PDFs, downloaded content, thumbnails, temp files + → Continue to "File Storage Path" below +``` + +--- + +## Structured Data Path + +### Modern Apps (iOS 17+) + +```swift +// ✅ CORRECT: SwiftData for modern structured persistence +import SwiftData + +@Model +class Task { + var title: String + var isCompleted: Bool + var dueDate: Date + + init(title: String, isCompleted: Bool = false, dueDate: Date) { + self.title = title + self.isCompleted = isCompleted + self.dueDate = dueDate + } +} + +// Query with type safety +@Query(sort: \Task.dueDate) var tasks: [Task] +``` + +**Why SwiftData**: +- Modern Swift-native API (no Objective-C) +- Type-safe queries +- Built-in CloudKit sync support +- Observable models integrate with SwiftUI +- **Use skill**: `axiom-swiftdata` for implementation details + +**When NOT to use SwiftData**: +- Need advanced SQLite features (FTS5, complex joins) +- Existing Core Data app (migration overhead) +- Ultra-performance-critical (direct SQLite is faster) + +### Advanced Control Needed + +```swift +// ✅ CORRECT: SQLiteData or GRDB for advanced features +import SQLiteData + +// Full-text search, custom indices, raw SQL when needed +let results = try db.prepare("SELECT * FROM users WHERE name MATCH ?", "John") +``` + +**Use SQLiteData when**: +- Need full-text search (FTS5) +- Custom SQL queries and indices +- Maximum performance (direct SQLite) +- Migration from existing SQLite database +- **Use skill**: `axiom-sqlitedata` for modern SQLite patterns + +**Use GRDB when**: +- Need reactive queries (ValueObservation) +- Complex database operations +- Type-safe query builders +- **Use skill**: `axiom-grdb` for advanced patterns + +### Legacy Apps (iOS 16 and earlier) + +```swift +// ❌ LEGACY: Core Data (avoid for new projects) +import CoreData + +// NSManagedObject, NSFetchRequest, NSPredicate... +``` + +**Only use Core Data if**: +- Maintaining existing Core Data app +- Can't upgrade to iOS 17 minimum deployment + +--- + +## File Storage Path + +### Decision Tree for Files + +``` +What kind of file is it? + +├─ USER-CREATED CONTENT (documents, photos created by user) +│ Where: Documents/ directory +│ Backed up: ✅ Yes (iCloud/iTunes) +│ Purged: ❌ Never +│ Visible in Files app: ✅ Yes +│ Example: User's edited photos, documents, exported data +│ → See "Documents Directory" section below +│ +├─ APP-GENERATED DATA (not user-visible, must persist) +│ Where: Library/Application Support/ +│ Backed up: ✅ Yes +│ Purged: ❌ Never +│ Visible in Files app: ❌ No +│ Example: Database files, user settings, downloaded assets +│ → See "Application Support Directory" section below +│ +├─ RE-DOWNLOADABLE / REGENERABLE CONTENT +│ Where: Library/Caches/ +│ Backed up: ❌ No (set isExcludedFromBackup) +│ Purged: ✅ Yes (under storage pressure) +│ Example: Thumbnails, API responses, downloaded images +│ → See "Caches Directory" section below +│ +└─ TEMPORARY FILES (can be deleted anytime) + Where: tmp/ + Backed up: ❌ No + Purged: ✅ Yes (aggressive, even while app running) + Example: Image processing intermediates, export staging + → See "Temporary Directory" section below +``` + +### Documents Directory + +```swift +// ✅ CORRECT: User-created content in Documents +func saveUserDocument(_ data: Data, filename: String) throws { + let documentsURL = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + )[0] + + let fileURL = documentsURL.appendingPathComponent(filename) + + // Enable file protection + try data.write(to: fileURL, options: .completeFileProtection) +} +``` + +**Key rules**: +- ✅ DO store: User-created documents, exported files, user-visible content +- ❌ DON'T store: Downloaded data that can be re-fetched, caches, temp files +- ⚠️ WARNING: Everything here is backed up to iCloud. Large re-downloadable files will bloat backups and may get your app rejected. + +**Use skill**: `axiom-file-protection-ref` for encryption options + +### Application Support Directory + +```swift +// ✅ CORRECT: App data in Application Support +func getAppDataURL() -> URL { + let appSupportURL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0] + + // Create app-specific subdirectory + let appDataURL = appSupportURL.appendingPathComponent( + Bundle.main.bundleIdentifier ?? "AppData" + ) + + try? FileManager.default.createDirectory( + at: appDataURL, + withIntermediateDirectories: true + ) + + return appDataURL +} +``` + +**Use for**: +- SwiftData/SQLite database files +- User preferences +- Downloaded assets that must persist +- Configuration files + +### Caches Directory + +```swift +// ✅ CORRECT: Re-downloadable content in Caches +func cacheDownloadedImage(data: Data, for url: URL) throws { + let cacheURL = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + )[0] + + let filename = url.lastPathComponent + let fileURL = cacheURL.appendingPathComponent(filename) + + try data.write(to: fileURL) + + // Mark as excluded from backup (explicit, though Caches is auto-excluded) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try fileURL.setResourceValues(resourceValues) +} +``` + +**Key rules**: +- ✅ The system CAN and WILL delete files here under storage pressure +- ✅ Always have a way to re-download or regenerate +- ❌ Don't store anything that can't be recreated + +**Use skill**: `axiom-storage-management-ref` for purge policies and disk space management + +### Temporary Directory + +```swift +// ✅ CORRECT: Truly temporary files in tmp +func processImageWithTempFile(image: UIImage) throws { + let tmpURL = FileManager.default.temporaryDirectory + let tempFileURL = tmpURL.appendingPathComponent(UUID().uuidString + ".jpg") + + // Write temp file + try image.jpegData(compressionQuality: 0.8)?.write(to: tempFileURL) + + // Process... + processImage(at: tempFileURL) + + // Clean up (though system will auto-clean eventually) + try? FileManager.default.removeItem(at: tempFileURL) +} +``` + +**Key rules**: +- System can delete files here AT ANY TIME (even while app is running) +- Always clean up after yourself +- Don't rely on files persisting between app launches + +--- + +## Cloud Storage Decisions + +### Should Data Sync to Cloud? + +``` +Does this data need to sync across user's devices? + +├─ NO → Use local storage (paths above) +│ +└─ YES → What kind of data? + │ + ├─ STRUCTURED DATA (queryable, relationships) + │ → Use CloudKit + │ → See "CloudKit Path" below + │ + ├─ FILES (documents, images) + │ → Use iCloud Drive (ubiquitous containers) + │ → See "iCloud Drive Path" below + │ + └─ SMALL PREFERENCES (<1 MB, key-value pairs) + → Use NSUbiquitousKeyValueStore + → See "Key-Value Store" below +``` + +### CloudKit Path (Structured Data Sync) + +```swift +// ✅ CORRECT: SwiftData with CloudKit sync (iOS 17+) +import SwiftData + +let container = try ModelContainer( + for: Task.self, + configurations: ModelConfiguration( + cloudKitDatabase: .private("iCloud.com.example.app") + ) +) +``` + +**Three approaches to CloudKit**: + +1. **SwiftData + CloudKit** (Recommended, iOS 17+): + - Automatic sync for SwiftData models + - Private database only + - Easiest approach + - **Use skill**: `axiom-swiftdata` for details + +2. **CKSyncEngine** (Custom persistence, iOS 17+): + - For SQLite, GRDB, or custom stores + - Manages sync automatically + - Modern replacement for manual CloudKit + - **Use skill**: `axiom-cloudkit-ref` for CKSyncEngine patterns + +3. **Raw CloudKit APIs** (Legacy): + - CKContainer, CKDatabase, CKRecord + - Manual sync management + - Only if CKSyncEngine doesn't fit + - **Use skill**: `axiom-cloudkit-ref` for raw API reference + +### iCloud Drive Path (File Sync) + +```swift +// ✅ CORRECT: iCloud Drive for file-based sync +func saveToICloud(_ data: Data, filename: String) throws { + // Get ubiquitous container + guard let iCloudURL = FileManager.default.url( + forUbiquityContainerIdentifier: nil + ) else { + throw StorageError.iCloudUnavailable + } + + let documentsURL = iCloudURL.appendingPathComponent("Documents") + try FileManager.default.createDirectory( + at: documentsURL, + withIntermediateDirectories: true + ) + + let fileURL = documentsURL.appendingPathComponent(filename) + try data.write(to: fileURL) +} +``` + +**When to use iCloud Drive**: +- User-created documents that sync +- File-based collaboration +- Simple file sync (like Dropbox) + +**Use skill**: `axiom-icloud-drive-ref` for implementation details + +### Key-Value Store (Small Preferences) + +```swift +// ✅ CORRECT: Small synced preferences +let store = NSUbiquitousKeyValueStore.default + +store.set(true, forKey: "darkModeEnabled") +store.set(2.0, forKey: "textSize") +store.synchronize() +``` + +**Limitations**: +- Max 1 MB total storage +- Max 1024 keys +- Max 1 MB per value +- For preferences ONLY, not data storage + +--- + +## Common Patterns and Anti-Patterns + +### ✅ DO: Choose Based on Data Shape + +```swift +// ✅ CORRECT: Structured data → SwiftData +@Model +class Note { + var title: String + var content: String + var tags: [Tag] // Relationships +} + +// ✅ CORRECT: Files → FileManager + proper directory +let imageData = capturedPhoto.jpegData(compressionQuality: 0.9) +try imageData?.write(to: documentsURL.appendingPathComponent("photo.jpg")) +``` + +### ❌ DON'T: Use Files for Structured Data + +```swift +// ❌ WRONG: Storing queryable data as JSON files +let tasks = [Task(...), Task(...), Task(...)] +let jsonData = try JSONEncoder().encode(tasks) +try jsonData.write(to: appSupportURL.appendingPathComponent("tasks.json")) + +// Why it's wrong: +// - Can't query individual tasks +// - Can't filter or sort efficiently +// - No relationships +// - Entire file loaded into memory +// - Concurrent access issues + +// ✅ CORRECT: Use SwiftData instead +@Model class Task { ... } +``` + +### ❌ DON'T: Store Re-downloadable Content in Documents + +```swift +// ❌ WRONG: Downloaded images in Documents (bloats backup!) +func downloadProfileImage(url: URL) throws { + let data = try Data(contentsOf: url) + let documentsURL = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + )[0] + try data.write(to: documentsURL.appendingPathComponent("profile.jpg")) +} + +// ✅ CORRECT: Use Caches instead +func downloadProfileImage(url: URL) throws { + let data = try Data(contentsOf: url) + let cacheURL = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + )[0] + let fileURL = cacheURL.appendingPathComponent("profile.jpg") + try data.write(to: fileURL) + + // Mark excluded from backup + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try fileURL.setResourceValues(resourceValues) +} +``` + +### ❌ DON'T: Use CloudKit for Simple File Sync + +```swift +// ❌ WRONG: Storing files as CKAssets with manual sync +let asset = CKAsset(fileURL: documentURL) +let record = CKRecord(recordType: "Document") +record["file"] = asset +// ... manual upload, conflict handling, etc. + +// ✅ CORRECT: Use iCloud Drive for files +// Files automatically sync via ubiquitous container +try data.write(to: iCloudDocumentsURL.appendingPathComponent("doc.pdf")) +``` + +--- + +## Quick Reference Table + +| Data Type | Format | Local Location | Cloud Sync | Use Skill | +|-----------|--------|----------------|------------|-----------| +| User tasks, notes | Structured | Application Support | SwiftData + CloudKit | `axiom-swiftdata` → `axiom-cloudkit-ref` | +| User photos (created) | File | Documents | iCloud Drive | `axiom-file-protection-ref` → `axiom-icloud-drive-ref` | +| Downloaded images | File | Caches | None (re-download) | `axiom-storage-management-ref` | +| Thumbnails | File | Caches | None (regenerate) | `axiom-storage-management-ref` | +| Database file | File | Application Support | CKSyncEngine (if custom) | `axiom-sqlitedata` → `axiom-cloudkit-ref` | +| Temp processing | File | tmp | None | N/A | +| User settings | Key-Value | UserDefaults | NSUbiquitousKeyValueStore | N/A | + +--- + +## tvOS Storage + +**tvOS has no persistent local storage.** This catches every iOS developer. + +| Directory | tvOS Behavior | +|-----------|--------------| +| Documents | Does not exist | +| Application Support | System can delete when app is not running | +| Caches | System deletes at any time | +| tmp | System deletes at any time | +| UserDefaults | 500 KB limit (vs ~4 MB on iOS) | + +**Every local file can vanish between app launches.** Your tvOS app must survive starting from zero. + +**Recommended**: Use iCloud (CloudKit, NSUbiquitousKeyValueStore, or iCloud Drive) as primary storage. Treat local files as cache only. See `axiom-tvos` for full tvOS storage patterns. + +--- + +## Debugging: Data Missing or Not Syncing? + +**Files disappeared**: +- Check if stored in Caches or tmp (system purged them) +- Check file protection level (may be inaccessible when locked) +- **Use skill**: `axiom-storage-diag` + +**Backup too large**: +- Check if re-downloadable content is in Documents (should be in Caches) +- Check if `isExcludedFromBackup` is set on large files +- **Use skill**: `axiom-storage-management-ref` + +**Data not syncing**: +- CloudKit: Check CKSyncEngine status, account availability + - **Use skill**: `axiom-cloud-sync-diag` +- iCloud Drive: Check ubiquitous container entitlements, file coordinator + - **Use skill**: `axiom-icloud-drive-ref`, `axiom-cloud-sync-diag` + +--- + +## Migration Checklist + +When changing storage approach: + +**Database to Database** (e.g., Core Data → SwiftData): +- [ ] Create SwiftData models matching Core Data entities +- [ ] Write migration code to copy data +- [ ] Test with production-size datasets +- [ ] Keep old database for rollback + +**Files to Database**: +- [ ] Identify all JSON/plist files storing structured data +- [ ] Create SwiftData models +- [ ] Write one-time migration on first launch +- [ ] Verify all data migrated, then delete old files + +**Local to Cloud**: +- [ ] Ensure proper entitlements (CloudKit/iCloud) +- [ ] Handle initial upload carefully (bandwidth) +- [ ] Test conflict resolution +- [ ] Provide user control (opt-in) + +--- + +**Last Updated**: 2025-12-12 +**Skill Type**: Discipline +**Related WWDC Sessions**: +- WWDC 2023-10187: Meet SwiftData +- WWDC 2023-10188: Sync to iCloud with CKSyncEngine +- WWDC 2024-10137: What's new in SwiftData diff --git a/.claude/skills/axiom-storage/agents/openai.yaml b/.claude/skills/axiom-storage/agents/openai.yaml new file mode 100644 index 0000000..b6bebcf --- /dev/null +++ b/.claude/skills/axiom-storage/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Storage" + short_description: "Asking 'where should I store this data', 'should I use SwiftData or files', 'CloudKit vs iCloud Drive', 'Documents vs..." diff --git a/.claude/skills/axiom-storekit-ref/.openskills.json b/.claude/skills/axiom-storekit-ref/.openskills.json new file mode 100644 index 0000000..706d3af --- /dev/null +++ b/.claude/skills/axiom-storekit-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-storekit-ref", + "installedAt": "2026-04-12T08:06:42.926Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-storekit-ref/SKILL.md b/.claude/skills/axiom-storekit-ref/SKILL.md new file mode 100644 index 0000000..09bc33b --- /dev/null +++ b/.claude/skills/axiom-storekit-ref/SKILL.md @@ -0,0 +1,1560 @@ +--- +name: axiom-storekit-ref +description: Reference — Complete StoreKit 2 API guide covering Product, Transaction, AppTransaction, RenewalInfo, SubscriptionStatus, StoreKit Views, purchase options, server APIs, and all iOS 18.4 enhancements with WWDC 2025 code examples +license: MIT +compatibility: iOS 15+ (iOS 18.4+ for latest features) +metadata: + version: "1.0.0" + last-updated: "2025-12-08" +--- + +# StoreKit 2 — Complete API Reference + +## Overview + +StoreKit 2 is Apple's modern in-app purchase framework with async/await APIs, automatic receipt validation, and SwiftUI integration. This reference covers every API, iOS 18.4 enhancements, and comprehensive WWDC 2025 code examples. + +### Product Types Supported + +**Consumable**: +- Products that can be purchased multiple times +- Examples: coins, hints, temporary boosts +- Do NOT restore on new devices + +**Non-Consumable**: +- Products purchased once, owned forever +- Examples: premium features, level packs, remove ads +- MUST restore on new devices + +**Auto-Renewable Subscription**: +- Subscriptions that renew automatically +- Organized into subscription groups +- MUST restore on new devices +- Support: free trials, intro offers, promotional offers, win-back offers + +**Non-Renewing Subscription**: +- Fixed duration subscriptions (no auto-renewal) +- Examples: seasonal passes +- MUST restore on new devices + +### Key Improvements Over StoreKit 1 + +- **Async/Await**: Modern concurrency instead of delegates/closures +- **Automatic Verification**: JSON Web Signature (JWS) verification built-in +- **Transaction Types**: Strong Swift types instead of SKPaymentTransaction +- **Testing**: StoreKit configuration files for local testing +- **SwiftUI Views**: Pre-built purchase UIs (ProductView, SubscriptionStoreView) +- **Server APIs**: App Store Server API and Server Notifications + +--- + +## When to Use This Reference + +Use this reference when: +- Implementing in-app purchases with StoreKit 2 +- Understanding new iOS 18.4 fields (appTransactionID, offerPeriod, etc.) +- Looking up specific API signatures and parameters +- Planning subscription architecture +- Debugging transaction issues +- Implementing StoreKit Views +- Integrating with App Store Server APIs + +**Related Skills**: +- `axiom-in-app-purchases` — Discipline skill with testing-first workflow, architecture patterns +- (Future: `iap-auditor` agent for auditing existing IAP code) +- (Future: `iap-implementation` agent for implementing IAP from scratch) + +--- + +## Product + +### Overview + +`Product` represents an in-app purchase item configured in App Store Connect or StoreKit configuration file. + +### Loading Products + +**Basic Loading**: +```swift +import StoreKit + +let productIDs = [ + "com.app.coins_100", + "com.app.premium", + "com.app.pro_monthly" +] + +let products = try await Product.products(for: productIDs) +``` + +#### From WWDC 2021-10114 + +**Handling Missing Products**: +```swift +let products = try await Product.products(for: productIDs) + +// Check what loaded +let loadedIDs = Set(products.map { $0.id }) +let missingIDs = Set(productIDs).subtracting(loadedIDs) + +if !missingIDs.isEmpty { + print("Missing products: \(missingIDs)") + // Products not configured in App Store Connect or .storekit file +} +``` + +### Product Properties + +**Basic Properties**: +```swift +let product: Product + +product.id // "com.app.premium" +product.displayName // "Premium Upgrade" +product.description // "Unlock all features" +product.displayPrice // "$4.99" +product.price // Decimal(4.99) +product.type // .nonConsumable +``` + +**Product Type Enum**: +```swift +switch product.type { +case .consumable: + // Coins, hints, boosts +case .nonConsumable: + // Premium features, level packs +case .autoRenewable: + // Monthly/annual subscriptions +case .nonRenewing: + // Seasonal passes +@unknown default: + break +} +``` + +### Subscription-Specific Properties + +**Check if Product is Subscription**: +```swift +if let subscriptionInfo = product.subscription { + // Product is auto-renewable subscription + let groupID = subscriptionInfo.subscriptionGroupID + let period = subscriptionInfo.subscriptionPeriod +} +``` + +**Subscription Period**: +```swift +let period = product.subscription?.subscriptionPeriod + +switch period?.unit { +case .day: + print("\(period?.value ?? 0) days") +case .week: + print("\(period?.value ?? 0) weeks") +case .month: + print("\(period?.value ?? 0) months") +case .year: + print("\(period?.value ?? 0) years") +default: + break +} +``` + +**Introductory Offer**: +```swift +if let introOffer = product.subscription?.introductoryOffer { + print("Free trial: \(introOffer.period.value) \(introOffer.period.unit)") + print("Price: \(introOffer.displayPrice)") + + switch introOffer.paymentMode { + case .freeTrial: + print("Free trial - no charge") + case .payAsYouGo: + print("Discounted price per period") + case .payUpFront: + print("One-time discounted price") + @unknown default: + break + } +} +``` + +**Promotional Offers**: +```swift +let offers = product.subscription?.promotionalOffers ?? [] + +for offer in offers { + print("Offer ID: \(offer.id)") + print("Price: \(offer.displayPrice)") + print("Period: \(offer.period.value) \(offer.period.unit)") +} +``` + +### Purchase Methods + +**Purchase with UI Context (iOS 18.2+)**: +```swift +let product: Product +let scene: UIWindowScene + +let result = try await product.purchase(confirmIn: scene) +``` + +#### From WWDC 2025-241:9:32 + +**Purchase with Options**: +```swift +let accountToken = UUID() + +let result = try await product.purchase( + confirmIn: scene, + options: [ + .appAccountToken(accountToken) + ] +) +``` + +#### From WWDC 2025-241:11:01 + +**Purchase with Promotional Offer (JWS Format)**: +```swift +let jwsSignature: String // From your server + +let result = try await product.purchase( + confirmIn: scene, + options: [ + .promotionalOffer(offerID: "promo_winback", signature: jwsSignature) + ] +) +``` + +#### From WWDC 2025-241:10:55 + +**Purchase with Custom Intro Eligibility**: +```swift +let jwsSignature: String // From your server + +let result = try await product.purchase( + confirmIn: scene, + options: [ + .introductoryOfferEligibility(signature: jwsSignature) + ] +) +``` + +#### From WWDC 2025-241:10:42 + +**SwiftUI Purchase (Using Environment)**: +```swift +struct ProductView: View { + let product: Product + @Environment(\.purchase) private var purchase + + var body: some View { + Button("Buy \(product.displayPrice)") { + Task { + do { + let result = try await purchase(product) + // Handle result + } catch { + print("Purchase failed: \(error)") + } + } + } + } +} +``` + +#### From WWDC 2025-241:9:50 + +### PurchaseResult + +**Handling Purchase Results**: +```swift +let result = try await product.purchase(confirmIn: scene) + +switch result { +case .success(let verificationResult): + // Purchase succeeded - verify transaction + guard let transaction = try? verificationResult.payloadValue else { + print("Transaction verification failed") + return + } + + // Grant entitlement + await grantEntitlement(for: transaction) + await transaction.finish() + +case .userCancelled: + // User tapped "Cancel" in payment sheet + print("User cancelled purchase") + +case .pending: + // Purchase requires action (Ask to Buy, payment issue) + // Transaction will arrive via Transaction.updates when approved + print("Purchase pending approval") + +@unknown default: + break +} +``` + +#### From WWDC 2025-241 + +--- + +## Transaction + +### Overview + +`Transaction` represents a successful in-app purchase. Contains purchase metadata, product ID, purchase date, and for subscriptions, expiration date. + +### New Fields (iOS 18.4) + +**appTransactionID**: +```swift +let transaction: Transaction +let appTransactionID = transaction.appTransactionID +// Unique ID for app download (same across all purchases by same Apple Account) +``` + +#### From WWDC 2025-241:4:13 + +**offerPeriod**: +```swift +if let offerPeriod = transaction.offer?.period { + print("Offer duration: \(offerPeriod)") + // ISO 8601 duration format (e.g., "P1M" for 1 month) +} +``` + +#### From WWDC 2025-249:3:11 + +**advancedCommerceInfo**: +```swift +if let advancedInfo = transaction.advancedCommerceInfo { + // Only present for Advanced Commerce API purchases + // nil for standard IAP +} +``` + +#### From WWDC 2025-241:4:42 + +### Essential Properties + +**Basic Fields**: +```swift +let transaction: Transaction + +transaction.id // Unique transaction ID +transaction.originalID // Original transaction ID (consistent across renewals) +transaction.productID // "com.app.pro_monthly" +transaction.productType // .autoRenewable +transaction.purchaseDate // Date of purchase +transaction.appAccountToken // UUID set at purchase time (if provided) +``` + +**Subscription Fields**: +```swift +transaction.expirationDate // When subscription expires +transaction.isUpgraded // true if user upgraded to higher tier +transaction.revocationDate // Date of refund (nil if not refunded) +transaction.revocationReason // .developerIssue or .other +``` + +**Offer Fields**: +```swift +if let offer = transaction.offer { + offer.type // .introductory or .promotional or .code + offer.id // Offer identifier from App Store Connect + offer.paymentMode // .freeTrial, .payAsYouGo, .payUpFront, .oneTime +} +``` + +#### From WWDC 2025-241:8:00 + +### Current Entitlements + +**Get All Current Entitlements**: +```swift +var purchasedProductIDs: Set = [] + +for await result in Transaction.currentEntitlements { + guard let transaction = try? result.payloadValue else { + continue + } + + // Only include non-refunded transactions + if transaction.revocationDate == nil { + purchasedProductIDs.insert(transaction.productID) + } +} +``` + +#### From WWDC 2025-241 + +**Get Entitlements for Specific Product (iOS 18.4+)**: +```swift +let productID = "com.app.premium" + +for await result in Transaction.currentEntitlements(for: productID) { + if let transaction = try? result.payloadValue, + transaction.revocationDate == nil { + // User owns this product + return true + } +} +``` + +#### From WWDC 2025-241:3:31 + +**Deprecated API (iOS 18.4)**: +```swift +// ❌ Deprecated in iOS 18.4 +let entitlement = await Transaction.currentEntitlement(for: productID) + +// ✅ Use this instead (returns sequence, handles Family Sharing) +for await result in Transaction.currentEntitlements(for: productID) { + // ... +} +``` + +#### From WWDC 2025-241:3:31 + +### Transaction History + +**Get All Transactions**: +```swift +for await result in Transaction.all { + guard let transaction = try? result.payloadValue else { + continue + } + + print("Transaction: \(transaction.productID) on \(transaction.purchaseDate)") +} +``` + +**Get Transactions for Product**: +```swift +for await result in Transaction.all(matching: productID) { + guard let transaction = try? result.payloadValue else { + continue + } + + // All transactions for this product +} +``` + +### Transaction Listener + +**Listen for Real-Time Updates (REQUIRED)**: +```swift +func listenForTransactions() -> Task { + Task.detached { + for await verificationResult in Transaction.updates { + await handleTransaction(verificationResult) + } + } +} + +func handleTransaction(_ result: VerificationResult) async { + guard let transaction = try? result.payloadValue else { + return + } + + // Grant or revoke entitlement + if transaction.revocationDate != nil { + await revokeEntitlement(for: transaction.productID) + } else { + await grantEntitlement(for: transaction) + } + + // CRITICAL: Always finish transaction + await transaction.finish() +} +``` + +#### From WWDC 2021-10114 + +**Transaction Sources**: +- In-app purchases +- Purchases from App Store (promoted IAP) +- Offer code redemptions +- Subscription renewals +- Family Sharing transactions +- Pending purchases (Ask to Buy) that complete +- Refund notifications + +### Verification + +**VerificationResult**: +```swift +let result: VerificationResult + +switch result { +case .verified(let transaction): + // ✅ Transaction signed by App Store + await grantEntitlement(for: transaction) + await transaction.finish() + +case .unverified(let transaction, let error): + // ❌ Transaction signature invalid + print("Unverified: \(error)") + // DO NOT grant entitlement + await transaction.finish() // Still finish to clear queue +} +``` + +**What Verification Checks**: +- Transaction signed by App Store (not fraudulent) +- Transaction belongs to this app (bundle ID match) +- Transaction belongs to this device + +### Finishing Transactions + +**Always Call finish()**: +```swift +await transaction.finish() +``` + +**When to finish**: +- ✅ After granting entitlement to user +- ✅ After storing transaction receipt/ID +- ✅ Even for unverified transactions (to clear queue) +- ✅ Even for refunded transactions + +**What happens if you don't finish**: +- Transaction redelivered on next app launch +- `Transaction.updates` re-emits transaction +- Queue builds up over time + +--- + +## AppTransaction + +### Overview + +`AppTransaction` represents the original app download. Available via `AppTransaction.shared`. + +### New Fields (iOS 18.4) + +**appTransactionID**: +```swift +let appTransaction = try await AppTransaction.shared + +switch appTransaction { +case .verified(let transaction): + let appTransactionID = transaction.appTransactionID + // Globally unique ID for this Apple Account + app + // Same value appears in Transaction and RenewalInfo + +case .unverified(_, let error): + print("AppTransaction verification failed: \(error)") +} +``` + +#### From WWDC 2025-241:1:42 + +**originalPlatform**: +```swift +if let appTransaction = try? await AppTransaction.shared.payloadValue { + let platform = appTransaction.originalPlatform + + switch platform { + case .iOS: + print("Originally downloaded on iPhone/iPad") + case .macOS: + print("Originally downloaded on Mac") + case .tvOS: + print("Originally downloaded on Apple TV") + case .visionOS: + print("Originally downloaded on Vision Pro") + @unknown default: + break + } +} +``` + +#### From WWDC 2025-241:2:11 + +**Note**: Apps downloaded on watchOS show `originalPlatform = .iOS` + +### Essential Properties + +```swift +let appTransaction: AppTransaction + +appTransaction.appVersion // "1.2.3" +appTransaction.originalAppVersion // "1.0.0" +appTransaction.originalPurchaseDate // First download date +appTransaction.bundleID // "com.company.app" +appTransaction.deviceVerification // UUID for device +appTransaction.deviceVerificationNonce // Nonce for verification +``` + +### Use Cases + +**Check App Version**: +```swift +if let appTransaction = try? await AppTransaction.shared.payloadValue { + if appTransaction.appVersion != currentVersion { + // Prompt user to update + } +} +``` + +#### From WWDC 2025-241:0:51 + +**Business Model Migration**: +```swift +// Moving from paid app to free app with IAP +if appTransaction.originalPlatform == .iOS, + appTransaction.originalPurchaseDate < migrationDate { + // User paid for app before migration - grant premium + await grantPremiumAccess() +} +``` + +#### From WWDC 2025-241:2:32 + +--- + +## Product.SubscriptionInfo.RenewalInfo + +### Overview + +`RenewalInfo` provides information about auto-renewable subscription renewal state, including whether it will renew, expiration reason, and upcoming offers. + +### New Fields (iOS 18.4) + +**appTransactionID**: +```swift +let renewalInfo: RenewalInfo +let appTransactionID = renewalInfo.appTransactionID +``` + +#### From WWDC 2025-241:6:40 + +**offerPeriod**: +```swift +if let offerPeriod = renewalInfo.offerPeriod { + print("Next renewal offer period: \(offerPeriod)") + // ISO 8601 duration (applies at next renewal) +} +``` + +#### From WWDC 2025-249:3:11 + +**appAccountToken**: +```swift +if let token = renewalInfo.appAccountToken { + // UUID associating subscription with your server account +} +``` + +#### From WWDC 2025-241:6:56 + +**advancedCommerceInfo**: +```swift +if let advancedInfo = renewalInfo.advancedCommerceInfo { + // Only for Advanced Commerce API subscriptions +} +``` + +#### From WWDC 2025-241:6:50 + +### Essential Properties + +**Renewal State**: +```swift +let renewalInfo: RenewalInfo + +renewalInfo.willAutoRenew // true if subscription will renew +renewalInfo.autoRenewPreference // Product ID customer will renew to +renewalInfo.expirationReason // Why subscription expired (if expired) +``` + +**Expiration Reasons**: +```swift +switch renewalInfo.expirationReason { +case .autoRenewDisabled: + // User turned off auto-renewal +case .billingError: + // Payment method issue +case .didNotConsentToPriceIncrease: + // User didn't accept price increase - show win-back offer! +case .productUnavailable: + // Product no longer available +case .unknown: + // Unknown reason +@unknown default: + break +} +``` + +#### From WWDC 2025-241:5:38 + +**Grace Period**: +```swift +if let gracePeriodExpiration = renewalInfo.gracePeriodExpirationDate { + // Subscription in grace period - billing issue + // Show update payment method UI +} +``` + +**Price Increase Consent**: +```swift +if let consentStatus = renewalInfo.priceIncreaseStatus { + switch consentStatus { + case .agreed: + // User accepted price increase + case .notYetResponded: + // User hasn't responded - show consent UI + @unknown default: + break + } +} +``` + +### Accessing RenewalInfo + +**From SubscriptionStatus**: +```swift +let statuses = try await Product.SubscriptionInfo.status(for: groupID) + +for status in statuses { + switch status.renewalInfo { + case .verified(let renewalInfo): + print("Will renew: \(renewalInfo.willAutoRenew)") + case .unverified(_, let error): + print("Renewal info verification failed: \(error)") + } +} +``` + +--- + +## Product.SubscriptionInfo.Status + +### Overview + +`SubscriptionStatus` represents the current state of an auto-renewable subscription, including whether it's active, expired, in grace period, or in billing retry. + +### Subscription States + +**State Enum**: +```swift +let status: Product.SubscriptionInfo.Status + +switch status.state { +case .subscribed: + // User has active subscription - full access + +case .expired: + // Subscription expired - show resubscribe/win-back offer + +case .inGracePeriod: + // Billing issue but access maintained - show update payment UI + +case .inBillingRetryPeriod: + // Apple retrying payment - maintain access + +case .revoked: + // Family Sharing access removed - revoke access + +@unknown default: + break +} +``` + +#### From WWDC 2025-241 + +### Getting Subscription Status + +**For Subscription Group**: +```swift +let groupID = "pro_tier" + +let statuses = try await Product.SubscriptionInfo.status(for: groupID) + +// Find highest service level +let activeStatus = statuses + .filter { $0.state == .subscribed } + .max { $0.transaction.productID < $1.transaction.productID } +``` + +#### From WWDC 2025-241:6:22 + +**For Specific Transaction (iOS 18.4+)**: +```swift +let transactionID = transaction.id + +let status = try await Product.SubscriptionInfo.status(for: transactionID) +``` + +#### From WWDC 2025-241:6:40 + +**Listen for Status Updates**: +```swift +for await statuses in Product.SubscriptionInfo.Status.updates(for: groupID) { + // Process updated statuses + for status in statuses { + print("Status: \(status.state)") + } +} +``` + +### Status Properties + +```swift +let status: Product.SubscriptionInfo.Status + +status.state // .subscribed, .expired, etc. +status.transaction // VerificationResult +status.renewalInfo // VerificationResult +``` + +--- + +## StoreKit Views + +### ProductView (iOS 17+) + +**Basic Usage**: +```swift +import StoreKit + +struct ContentView: View { + let productID = "com.app.premium" + + var body: some View { + ProductView(id: productID) + } +} +``` + +#### From WWDC 2023-10013 + +**With Loaded Product**: +```swift +struct ContentView: View { + let product: Product + + var body: some View { + ProductView(for: product) + } +} +``` + +**Custom Icon**: +```swift +ProductView(id: productID) { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) +} +``` + +**Control Styles**: +```swift +ProductView(id: productID) + .productViewStyle(.regular) // Default + +ProductView(id: productID) + .productViewStyle(.compact) // Smaller + +ProductView(id: productID) + .productViewStyle(.large) // Prominent +``` + +### StoreView (iOS 17+) + +**Basic Store**: +```swift +struct ContentView: View { + let productIDs = [ + "com.app.coins_100", + "com.app.coins_500", + "com.app.coins_1000" + ] + + var body: some View { + StoreView(ids: productIDs) + } +} +``` + +#### From WWDC 2023-10013 + +**With Loaded Products**: +```swift +struct ContentView: View { + let products: [Product] + + var body: some View { + StoreView(products: products) + } +} +``` + +### SubscriptionStoreView (iOS 17+) + +**Basic Subscription Store**: +```swift +struct SubscriptionView: View { + let groupID = "pro_tier" + + var body: some View { + SubscriptionStoreView(groupID: groupID) { + // Marketing content above subscription options + VStack { + Image("app-icon") + Text("Go Pro") + .font(.largeTitle.bold()) + Text("Unlock all features") + } + } + } +} +``` + +#### From WWDC 2023-10013 + +**Control Style**: +```swift +SubscriptionStoreView(groupID: groupID) { + // Marketing content +} +.subscriptionStoreControlStyle(.automatic) // Default +.subscriptionStoreControlStyle(.picker) // Horizontal picker +.subscriptionStoreControlStyle(.buttons) // Stacked buttons +.subscriptionStoreControlStyle(.prominentPicker) // Large picker (iOS 18.4+) +``` + +#### From WWDC 2025-241 + +### SubscriptionOfferView (iOS 18.4+) + +**Basic Offer View**: +```swift +struct ContentView: View { + let productID = "com.app.pro_monthly" + + var body: some View { + SubscriptionOfferView(id: productID) + } +} +``` + +#### From WWDC 2025-241:14:27 + +**With Loaded Product**: +```swift +let product: Product // Already loaded via Product.products(for:) + +SubscriptionOfferView(product: product) +``` + +**With Promotional Icon**: +```swift +SubscriptionOfferView( + id: productID, + prefersPromotionalIcon: true +) + +// Also available as modifier +SubscriptionOfferView(id: productID) + .prefersPromotionalIcon(true) +``` + +**With Custom Icon**: +```swift +SubscriptionOfferView(id: productID) { + Image("custom-icon") + .resizable() + .frame(width: 60, height: 60) +} placeholder: { + Image(systemName: "photo") + .foregroundStyle(.gray) +} +``` + +#### From WWDC 2025-241:15:14 + +**With Detail Action**: +```swift +@State private var showStore = false + +var body: some View { + SubscriptionOfferView(id: productID) + .subscriptionOfferViewDetailAction { + showStore = true + } + .sheet(isPresented: $showStore) { + SubscriptionStoreView(groupID: "pro_tier") + } +} +``` + +#### From WWDC 2025-241:15:38 + +**Visible Relationship**: +```swift +// Only show if customer can upgrade +SubscriptionOfferView( + groupID: "pro_tier", + visibleRelationship: .upgrade +) + +// Only show if customer can downgrade +SubscriptionOfferView( + groupID: "pro_tier", + visibleRelationship: .downgrade +) + +// Show crossgrade options (same tier, different billing period) +SubscriptionOfferView( + groupID: "pro_tier", + visibleRelationship: .crossgrade +) + +// Show current subscription (only if offer available) +SubscriptionOfferView( + groupID: "pro_tier", + visibleRelationship: .current +) + +// Show any plan in group +SubscriptionOfferView( + groupID: "pro_tier", + visibleRelationship: .all +) +``` + +#### From WWDC 2025-241:17:44 + +**With App Icon**: +```swift +SubscriptionOfferView( + groupID: groupID, + visibleRelationship: .all, + useAppIcon: true +) +``` + +#### From WWDC 2025-241:19:06 + +### Offer Modifiers + +**Promotional Offer (JWS)**: +```swift +SubscriptionStoreView(groupID: groupID) + .subscriptionPromotionalOffer( + for: { subscription in + // Return offer for this subscription + return subscription.promotionalOffers.first + }, + signature: { subscription, offer in + // Get JWS signature from server + let signature = try await server.signOffer( + productID: subscription.id, + offerID: offer.id + ) + return signature + } + ) +``` + +#### From WWDC 2025-241:12:17 + +### subscriptionStatusTask Modifier (iOS 18.4+) + +Track subscription status at the app level with a SwiftUI modifier. Eliminates manual polling by reacting to status changes automatically. + +**Basic Usage**: +```swift +@main +struct MyApp: App { + @State private var customerStatus: CustomerStatus = .unknown + + var body: some Scene { + WindowGroup { + ContentView() + .environment(\.customerSubscriptionStatus, customerStatus) + .subscriptionStatusTask(for: "your.group.id") { statuses in + if statuses.contains(where: { $0.state == .subscribed }) { + customerStatus = .subscribed + } else if statuses.contains(where: { $0.state == .expired }) { + customerStatus = .expired + } else { + customerStatus = .notSubscribed + } + } + } + } +} +``` + +**Key behavior**: +- Fires on app launch with current statuses +- Fires again when subscription status changes (renewal, expiration, upgrade) +- Translate StoreKit statuses to your app's model — keep your domain model simple +- Attach at the top of your view hierarchy (App or root WindowGroup) + +--- + +## Offer Codes (iOS 18.2+) + +### Overview + +Offer codes now support all product types (previously subscription-only): +- Consumables +- Non-consumables +- Non-renewing subscriptions +- Auto-renewable subscriptions + +### Redeem in App + +**UIKit**: +```swift +func showOfferCodeSheet() { + guard let scene = view.window?.windowScene else { return } + + StoreKit.AppStore.presentOfferCodeRedeemSheet(in: scene) +} +``` + +#### From WWDC 2025-241:7:38 + +**SwiftUI**: +```swift +.offerCodeRedemption(isPresented: $showRedeemSheet) +``` + +### Payment Mode + +**New: .oneTime**: +```swift +let transaction: Transaction + +if let offer = transaction.offer { + switch offer.paymentMode { + case .freeTrial: + // No charge during offer period + case .payAsYouGo: + // Discounted price per billing period + case .payUpFront: + // One-time discounted price for entire duration + case .oneTime: + // ✨ New: One-time offer code redemption (iOS 17.2+) + @unknown default: + break + } +} +``` + +#### From WWDC 2025-241:8:17 + +**Legacy Access (iOS 15-17.1)**: +```swift +if let offerMode = transaction.offerPaymentModeStringRepresentation { + // String representation for older OS versions + print(offerMode) // "oneTime" +} +``` + +#### From WWDC 2025-241:8:49 + +--- + +## App Store Server Library + +### Overview + +Open-source library for signing IAP requests and decoding server API responses. Available in Swift, Java, Python, Node.js. + +### Create Promotional Offer Signature + +**Swift Example**: +```swift +import AppStoreServerLibrary + +// Configure signing +let signingKey = "YOUR_PRIVATE_KEY" +let keyID = "YOUR_KEY_ID" +let issuerID = "YOUR_ISSUER_ID" +let bundleID = "com.app.bundle" + +let creator = PromotionalOfferV2SignatureCreator( + privateKey: signingKey, + keyID: keyID, + issuerID: issuerID, + bundleID: bundleID +) + +// Create signature +let productID = "com.app.pro_monthly" +let offerID = "promo_winback" +let transactionID = transaction.id // Optional but recommended + +let signature = try creator.createSignature( + productIdentifier: productID, + subscriptionOfferIdentifier: offerID, + applicationUsername: nil, + nonce: UUID(), + timestamp: Date().timeIntervalSince1970, + transactionIdentifier: transactionID +) + +// Send signature to app +return signature // Compact JWS string +``` + +#### From WWDC 2025-241:12:44, 2025-249 + +**Server Endpoint Example**: +```swift +app.get("promo-offer") { req async throws -> String in + let productID = try req.query.get(String.self, at: "productID") + let offerID = try req.query.get(String.self, at: "offerID") + + let signature = try creator.createSignature( + productIdentifier: productID, + subscriptionOfferIdentifier: offerID, + transactionIdentifier: nil + ) + + return signature +} +``` + +#### From WWDC 2025-241:12:52 + +--- + +## App Store Server API + +### Set App Account Token + +**Endpoint**: +``` +PATCH /inApps/v1/transactions/{originalTransactionId} +``` + +**Request Body**: +```json +{ + "appAccountToken": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +**Usage**: +- Set appAccountToken for purchases made outside your app (offer codes, App Store) +- Update appAccountToken when account ownership changes +- Associates transaction with customer account on your server + +#### From WWDC 2025-249:5:19 + +### Get App Transaction Info + +**Endpoint**: +``` +GET /inApps/v2/appTransaction/{transactionId} +``` + +**Response**: +```json +{ + "signedAppTransactionInfo": "eyJhbGc..." +} +``` + +**Usage**: +- Get app download information on server +- Check app version, platform, environment +- Available later in 2025 + +#### From WWDC 2025-249:10:48 + +### Send Consumption Information V2 + +**Endpoint**: +``` +PUT /inApps/v2/transactions/consumption/{transactionId} +``` + +**Request Body**: +```json +{ + "customerConsented": true, + "sampleContentProvided": false, + "deliveryStatus": "DELIVERED", + "refundPreference": "GRANT_PRORATED", + "consumptionPercentage": 25000 +} +``` + +**Fields**: +- `customerConsented` (required): User consented to send consumption data +- `sampleContentProvided` (optional): Sample provided before purchase +- `deliveryStatus` (required): "DELIVERED" or various UNDELIVERED statuses +- `refundPreference` (optional): "NO_REFUND", "GRANT_REFUND", "GRANT_PRORATED" +- `consumptionPercentage` (optional): 0-100000 (millipercent, e.g., 25000 = 25%) + +**Prorated Refund**: +- New in 2025 +- Supports partial consumption (consumables, non-consumables, non-renewing) +- For auto-renewable subscriptions, App Store calculates based on time remaining + +#### From WWDC 2025-249:16:09 + +### Refund Notifications + +**REFUND Notification**: +```json +{ + "notificationType": "REFUND", + "data": { + "signedTransactionInfo": "...", + "refundPercentage": 75, + "revocationType": "REFUND_PRORATED" + } +} +``` + +**revocationType Values**: +- `REFUND_FULL`: 100% refund - revoke all access +- `REFUND_PRORATED`: Partial refund - revoke proportional access +- `FAMILY_REVOKE`: Family Sharing removed - revoke access + +#### From WWDC 2025-249:20:17 + +--- + +## Edge Cases + +### Family Sharing + +**Detect Family Shared Transactions**: +```swift +// appAccountToken is NOT available for family shared transactions +let transaction: Transaction + +if transaction.appAccountToken == nil { + // Might be family shared (or appAccountToken not set) + // Check ownershipType (if available) +} +``` + +**Subscription Status for Family Sharing**: +```swift +// Each family member has unique appTransactionID +// Use appTransactionID to identify individual family members +``` + +#### From WWDC 2025-241:1:54 + +### Refunds + +**Handle Refund**: +```swift +func handleTransaction(_ transaction: Transaction) async { + if let revocationDate = transaction.revocationDate { + // Transaction was refunded + print("Refunded on \(revocationDate)") + + switch transaction.revocationReason { + case .developerIssue: + // Refund due to app issue + case .other: + // Other refund reason + @unknown default: + break + } + + // Revoke entitlement + await revokeEntitlement(for: transaction.productID) + } +} +``` + +### Advanced Commerce API + +The Advanced Commerce API enables support for: +- In-app purchases for large content catalogs +- Creator experiences (tipping, patronage) +- Subscriptions with optional add-ons + +**Check if Transaction Uses Advanced Commerce**: +```swift +if transaction.advancedCommerceInfo != nil { + // Transaction from Advanced Commerce API + // Large catalogs, creator experiences, subscriptions with add-ons +} +``` + +Accessible through the `advancedCommerceInfo` field on both `Transaction` and `RenewalInfo`. Returns `nil` for standard IAP transactions. + +#### From WWDC 2025-241:4:51 + +### Win-Back Offers + +**Show Win-Back for Expired Subscription**: +```swift +let renewalInfo: RenewalInfo + +if renewalInfo.expirationReason == .didNotConsentToPriceIncrease { + // Perfect time for win-back offer! + SubscriptionOfferView( + groupID: groupID, + visibleRelationship: .current + ) + .preferredSubscriptionOffer(offer: winBackOffer) +} +``` + +#### From WWDC 2025-241:5:38 + +--- + +## Testing + +### StoreKit Configuration File + +**Create**: +1. Xcode → File → New → StoreKit Configuration File +2. Add products (consumables, non-consumables, subscriptions) +3. Configure prices, images, descriptions + +**Enable in Scheme**: +1. Scheme → Edit Scheme → Run → Options +2. StoreKit Configuration: Select .storekit file + +**Test Scenarios**: +- Successful purchases +- Cancelled purchases +- Subscription renewals (accelerated time) +- Subscription expirations +- Upgrades/downgrades +- Offer code redemptions +- Family Sharing (enable in config file) + +### Transaction Manager + +Use the Transaction Manager window in Xcode to inspect and manipulate transactions during testing: + +- Create transactions manually (test specific purchase flows) +- Modify transaction properties (expiration, renewal state) +- Test subscription offer scenarios +- Inspect transaction details and verification status + +**Open**: Debug → StoreKit → Manage Transactions (while running with StoreKit configuration) + +### Sandbox Testing + +**Create Sandbox Account**: +1. App Store Connect → Users and Access → Sandbox Testers +2. Create test Apple ID +3. Sign in on device Settings → App Store → Sandbox Account + +**Clear Purchase History**: +- Settings → App Store → Sandbox Account → Clear Purchase History + +--- + +## Migration from StoreKit 1 + +### Key Changes + +**Delegates → Async/Await**: +```swift +// StoreKit 1 +class StoreObserver: NSObject, SKPaymentTransactionObserver { + func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + // Handle transactions + } +} + +// StoreKit 2 +for await result in Transaction.updates { + // Handle transactions +} +``` + +**Receipt → Transaction**: +```swift +// StoreKit 1 +let receiptURL = Bundle.main.appStoreReceiptURL +let receipt = try Data(contentsOf: receiptURL!) + +// StoreKit 2 +let transaction: Transaction // Automatically verified! +``` + +**Products → Product.products(for:)**: +```swift +// StoreKit 1 +let request = SKProductsRequest(productIdentifiers: Set(productIDs)) +request.delegate = self +request.start() + +// StoreKit 2 +let products = try await Product.products(for: productIDs) +``` + +--- + +## Resources + +**WWDC**: 2025-241, 2025-249, 2024-10061, 2024-10062, 2024-10110, 2023-10013, 2023-10140, 2022-10007, 2022-110404, 2021-10114 + +**Docs**: /storekit + +**Skills**: axiom-in-app-purchases + +--- + +## Quick Reference + +### Product Types +- `.consumable` - Can purchase multiple times (coins, boosts) +- `.nonConsumable` - Purchase once, own forever (premium, level packs) +- `.autoRenewable` - Auto-renewing subscriptions +- `.nonRenewing` - Fixed duration subscriptions + +### Transaction States +- `success` - Purchase completed +- `userCancelled` - User tapped cancel +- `pending` - Requires action (Ask to Buy) + +### Subscription States +- `.subscribed` - Active subscription +- `.expired` - Subscription ended +- `.inGracePeriod` - Billing issue, access maintained +- `.inBillingRetryPeriod` - Apple retrying payment +- `.revoked` - Family Sharing removed + +### Essential Calls +```swift +// Load products +try await Product.products(for: productIDs) + +// Purchase +try await product.purchase(confirmIn: scene) + +// Current entitlements +Transaction.currentEntitlements(for: productID) + +// Transaction listener +Transaction.updates + +// Subscription status +Product.SubscriptionInfo.status(for: groupID) + +// Restore purchases +try await AppStore.sync() + +// Finish transaction (REQUIRED) +await transaction.finish() +``` diff --git a/.claude/skills/axiom-storekit-ref/agents/openai.yaml b/.claude/skills/axiom-storekit-ref/agents/openai.yaml new file mode 100644 index 0000000..7f42e9c --- /dev/null +++ b/.claude/skills/axiom-storekit-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "StoreKit Reference" + short_description: "Reference — Complete StoreKit 2 API guide covering Product, Transaction, AppTransaction, RenewalInfo, SubscriptionSta..." diff --git a/.claude/skills/axiom-swift-concurrency-ref/.openskills.json b/.claude/skills/axiom-swift-concurrency-ref/.openskills.json new file mode 100644 index 0000000..cbad22b --- /dev/null +++ b/.claude/skills/axiom-swift-concurrency-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swift-concurrency-ref", + "installedAt": "2026-04-12T08:06:43.712Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swift-concurrency-ref/SKILL.md b/.claude/skills/axiom-swift-concurrency-ref/SKILL.md new file mode 100644 index 0000000..219e979 --- /dev/null +++ b/.claude/skills/axiom-swift-concurrency-ref/SKILL.md @@ -0,0 +1,1398 @@ +--- +name: axiom-swift-concurrency-ref +description: Swift concurrency API reference — actors, Sendable, Task/TaskGroup, AsyncStream, continuations, isolation patterns, DispatchQueue-to-actor migration with gotcha tables +license: MIT +metadata: + version: "1.0.0" + last-updated: "2026-02-26" +--- + +# Swift Concurrency API Reference + +Complete Swift concurrency API reference for copy-paste patterns and syntax lookup. + +Complements `axiom-swift-concurrency` (which covers *when* and *why* to use concurrency — progressive journey, decision trees, @concurrent, isolated conformances). + +**Related skills**: `axiom-swift-concurrency` (progressive journey, decision trees), `axiom-synchronization` (Mutex, locks), `axiom-assume-isolated` (assumeIsolated patterns) + +## Part 1: Actor Patterns + +### Actor Definition + +```swift +actor ImageCache { + private var cache: [URL: UIImage] = [:] + + func image(for url: URL) -> UIImage? { + cache[url] + } + + func store(_ image: UIImage, for url: URL) { + cache[url] = image + } +} + +// Usage — must await across isolation boundary +let cache = ImageCache() +let image = await cache.image(for: url) +``` + +All properties and methods on an actor are isolated by default. Callers outside the actor's isolation domain must use `await` to access them. + +### Actor Isolation Rules + +Every actor's stored properties and methods are isolated to that actor. Access from outside the isolation boundary requires `await`, which suspends the caller until the actor can process the request. + +```swift +actor Counter { + var count = 0 // Isolated — external access requires await + let name: String // let constants are implicitly nonisolated + + func increment() { // Isolated — await required from outside + count += 1 + } + + nonisolated func identity() -> String { + name // OK: accessing nonisolated let + } +} + +let counter = Counter(name: "main") +await counter.increment() // Must await across isolation boundary +let id = counter.identity() // No await needed — nonisolated +``` + +### nonisolated Keyword + +Opt out of isolation for synchronous access to non-mutable state. + +```swift +actor MyActor { + let id: UUID // let constants are implicitly nonisolated + + nonisolated var description: String { + "Actor \(id)" // Can only access nonisolated state + } + + nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(id) // Only nonisolated properties + } +} +``` + +`nonisolated` methods cannot access any isolated stored properties. Use this for protocol conformances (like `Hashable`, `CustomStringConvertible`) that require synchronous access. + +### Actor Reentrancy + +Suspension points (`await`) inside an actor allow other callers to interleave. State may change between any two `await` expressions. + +```swift +actor BankAccount { + var balance: Double = 0 + + func transfer(amount: Double, to other: BankAccount) async { + guard balance >= amount else { return } + balance -= amount + // REENTRANCY HAZARD: another caller could modify balance here + // while we await the deposit on the other actor + await other.deposit(amount) + } + + func deposit(_ amount: Double) { + balance += amount + } +} +``` + +**Pattern**: Re-check state after every `await` inside an actor: + +```swift +actor BankAccount { + var balance: Double = 0 + + func transfer(amount: Double, to other: BankAccount) async -> Bool { + guard balance >= amount else { return false } + balance -= amount + + await other.deposit(amount) + + // Re-check invariants after await if needed + return true + } +} +``` + +### Global Actors + +A global actor provides a single shared isolation domain accessible from anywhere. + +```swift +@globalActor +actor MyGlobalActor { + static let shared = MyGlobalActor() +} + +@MyGlobalActor +func doWork() { /* isolated to MyGlobalActor */ } + +@MyGlobalActor +class MyService { + var state: Int = 0 // Isolated to MyGlobalActor +} +``` + +### @MainActor + +The built-in global actor for UI work. All UI updates must happen on `@MainActor`. + +```swift +@MainActor +class ViewModel: ObservableObject { + @Published var items: [Item] = [] + + func loadItems() async { + let data = await fetchFromNetwork() + items = data // Safe: already on MainActor + } +} + +// Annotate individual members +class MixedService { + @MainActor var uiState: String = "" + + @MainActor + func updateUI() { + uiState = "Done" + } + + func backgroundWork() async -> String { + await heavyComputation() + } +} +``` + +**Subclass inheritance**: If a class is `@MainActor`, all subclasses inherit that isolation. + +### Actor Init + +Actor initializers are NOT isolated to the actor. You cannot call isolated methods from init. + +```swift +actor DataManager { + var data: [String] = [] + + init() { + // Cannot call isolated methods here + // self.loadDefaults() // ERROR: actor-isolated method in non-isolated init + } + + // Use a factory method instead + static func create() async -> DataManager { + let manager = DataManager() + await manager.loadDefaults() + return manager + } + + func loadDefaults() { + data = ["default"] + } +} +``` + +### Actor Gotcha Table + +| Gotcha | Symptom | Fix | +|---|---|---| +| Actor reentrancy | State changes between awaits | Re-check state after each await | +| nonisolated accessing isolated state | Compiler error | Remove nonisolated or make property nonisolated | +| Calling actor method from sync context | "Expression is 'async'" | Wrap in Task {} or make caller async | +| Global actor inheritance | Subclass inherits @MainActor | Be intentional about which methods need isolation | +| Actor init not isolated | Can't call isolated methods in init | Use factory method or populate after init | +| Actor protocol conformance | "Non-isolated" conformance error | Use nonisolated for protocol methods, or isolated conformance (Swift 6.2+) | +| Using actor for ViewModel | @Published won't work, UI updates require await | Use @MainActor class for UI-facing code, actor only for non-UI shared state | +| GCD queue-hopping inside actor | Breaks isolation guarantees, risks thread explosion | Remove GCD — actor isolation already serializes access | + +--- + +## Part 2: Sendable Patterns + +### Automatic Sendable Conformance + +Value types are Sendable when all stored properties are Sendable. + +```swift +// Structs: Sendable when all stored properties are Sendable +struct UserProfile: Sendable { + let name: String + let age: Int +} + +// Enums: Sendable when all associated values are Sendable +enum LoadState: Sendable { + case idle + case loading + case loaded(String) // String is Sendable + case failed(Error) // ERROR: Error is not Sendable +} + +// Fix: use a Sendable error type +enum LoadState: Sendable { + case idle + case loading + case loaded(String) + case failed(any Error & Sendable) +} +``` + +### @Sendable Closures + +Closures passed across isolation boundaries must be `@Sendable`. A `@Sendable` closure cannot capture mutable local state. + +```swift +func runInBackground(_ work: @Sendable () -> Void) { + Task.detached { work() } +} + +// All captured values must be Sendable +var count = 0 +runInBackground { + // ERROR: capture of mutable local variable + // count += 1 +} + +let snapshot = count +runInBackground { + print(snapshot) // OK: let binding of Sendable type +} +``` + +### @unchecked Sendable + +Manual guarantee of thread safety. Use only when you provide synchronization yourself. + +```swift +final class ThreadSafeCache: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String: Any] = [:] + + func get(_ key: String) -> Any? { + lock.lock() + defer { lock.unlock() } + return storage[key] + } + + func set(_ key: String, value: Any) { + lock.lock() + defer { lock.unlock() } + storage[key] = value + } +} +``` + +#### Requirements for @unchecked Sendable +- Class must be `final` +- All mutable state must be protected by a synchronization primitive (lock, queue, Mutex) +- You are responsible for correctness — the compiler will not check + +### Conditional Conformance + +```swift +struct Box { + let value: T +} + +// Box is Sendable only when T is Sendable +extension Box: Sendable where T: Sendable {} + +// Standard library uses this extensively: +// Array: Sendable where Element: Sendable +// Dictionary: Sendable where Key: Sendable, Value: Sendable +// Optional: Sendable where Wrapped: Sendable +``` + +### sending Parameter Modifier (SE-0430) + +Transfer ownership of a value across isolation boundaries. The caller gives up access. + +```swift +func process(_ value: sending String) async { + // Caller can no longer access value after this call + await store(value) +} + +// Useful for transferring non-Sendable types when caller won't use them again +func handOff(_ connection: sending NetworkConnection) async { + await manager.accept(connection) +} +``` + +### Build Settings + +Control the strictness of Sendable checking in Xcode: + +| Setting | Value | Behavior | +|---|---|---| +| `SWIFT_STRICT_CONCURRENCY` | `minimal` | Only explicit Sendable annotations checked | +| `SWIFT_STRICT_CONCURRENCY` | `targeted` | Inferred Sendable + closure checking | +| `SWIFT_STRICT_CONCURRENCY` | `complete` | Full strict concurrency (Swift 6 default) | + +### Sendable Gotcha Table + +| Gotcha | Symptom | Fix | +|---|---|---| +| Class can't be Sendable | "Class cannot conform to Sendable" | Make final + immutable, or @unchecked Sendable with locks | +| Closure captures non-Sendable | "Capture of non-Sendable type" | Copy value before capture, or make type Sendable | +| Protocol can't require Sendable | Generic constraints complex | Use `where T: Sendable` | +| @unchecked Sendable hides bugs | Data races at runtime | Only use when lock/queue guarantees safety | +| Array/Dictionary conditional | Collection is Sendable only if Element is | Ensure element types are Sendable | +| Error not Sendable | "Type does not conform to Sendable" | Use `any Error & Sendable` or typed errors | + +--- + +## Part 3: Task Management + +### Task { } + +Creates an unstructured task that inherits the current actor context and priority. + +```swift +// Inherits actor context — if called from @MainActor, runs on MainActor +let task = Task { + try await fetchData() +} + +// Get the result +let result = try await task.value + +// Get Result +let outcome = await task.result +``` + +### Task.detached { } + +Creates a task with no inherited context. Does not inherit the actor or priority. + +```swift +Task.detached(priority: .background) { + // NOT on MainActor even if created from MainActor + await processLargeFile() +} +``` + +**When to use**: Background work that must NOT run on the calling actor. Prefer `Task {}` in most cases — `Task.detached` is rarely needed. + +### Task Cancellation + +Cancellation is cooperative. Setting cancellation is a request; the task must check and respond. + +```swift +let task = Task { + for item in largeCollection { + // Option 1: Check boolean + if Task.isCancelled { break } + + // Option 2: Throw CancellationError + try Task.checkCancellation() + + await process(item) + } +} + +// Request cancellation +task.cancel() +``` + +### Task.sleep + +Suspends the current task for a duration. Supports cancellation — throws `CancellationError` if cancelled during sleep. + +```swift +// Duration-based (preferred) +try await Task.sleep(for: .seconds(2)) +try await Task.sleep(for: .milliseconds(500)) + +// Nanoseconds (older API) +try await Task.sleep(nanoseconds: 2_000_000_000) +``` + +### Task.yield + +Voluntarily yields execution to allow other tasks to run. Use in long-running synchronous loops. + +```swift +for i in 0..<1_000_000 { + if i.isMultiple(of: 1000) { + await Task.yield() + } + process(i) +} +``` + +### Task Priority + +| Priority | Use Case | +|---|---| +| `.userInitiated` | Direct user action, visible result | +| `.high` | Same as .userInitiated | +| `.medium` | Default when not specified | +| `.low` | Prefetching, non-urgent work | +| `.utility` | Long computation, progress shown | +| `.background` | Maintenance, cleanup, not time-sensitive | + +```swift +Task(priority: .userInitiated) { + await loadVisibleContent() +} + +Task(priority: .background) { + await cleanupTempFiles() +} +``` + +### @TaskLocal + +Task-scoped values that propagate to child tasks automatically. + +```swift +enum RequestContext { + @TaskLocal static var requestID: String? + @TaskLocal static var userID: String? +} + +// Set values for a scope +RequestContext.$requestID.withValue("req-123") { + RequestContext.$userID.withValue("user-456") { + // Both values available here and in child tasks + Task { + print(RequestContext.requestID) // "req-123" + print(RequestContext.userID) // "user-456" + } + } +} + +// Outside scope — values are nil +print(RequestContext.requestID) // nil +``` + +**Propagation rules**: `@TaskLocal` values propagate to child tasks created with `Task {}`. They do NOT propagate to `Task.detached {}`. + +### Task Timeout Pattern + +Enforce a deadline on any async operation using a task group race: + +```swift +func withTimeout( + _ duration: Duration, + operation: @Sendable @escaping () async throws -> T +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(for: duration) + throw TimeoutError() + } + guard let result = try await group.next() else { + throw TimeoutError() + } + group.cancelAll() // Cancel the loser — without this it keeps running + return result + } +} +``` + +`group.cancelAll()` is critical. Without it, the losing task (either the timeout or the operation) continues running until the group scope exits. + +### Task Retain Cycles + +Tasks capture variables like closures. Stored tasks that reference `self` create retain cycles. + +```swift +// ❌ Retain cycle: self → task → self +task = Task { + while true { await self.poll() } +} + +// ✅ Weak capture breaks the cycle +task = Task { [weak self] in + while let self, !Task.isCancelled { + await self.poll() + } +} +``` + +**Rule**: Use `[weak self]` when the Task is stored as a property or iterates an infinite async sequence. Short-lived Tasks that complete quickly can use strong captures. + +### Thread.current in Swift 6 + +`Thread.current` is unavailable from async contexts in Swift 6 language mode: + +```swift +// ❌ Compiler error in Swift 6 mode +func check() async { print(Thread.current) } + +// ✅ Workaround for debugging only +extension Thread { + static var currentThread: Thread { Thread.current } +} +``` + +Don't rely on thread identity for correctness — tasks move between threads at suspension points. Reason about isolation domains instead. + +### Task Gotcha Table + +| Gotcha | Symptom | Fix | +|---|---|---| +| Task never cancelled | Resource leak, work continues after view disappears | Store task, cancel in deinit/onDisappear | +| Ignoring cancellation | Task runs to completion even when cancelled | Check Task.isCancelled in loops, use checkCancellation() | +| Task.detached loses actor context | "Not isolated to MainActor" | Use Task {} when you need actor isolation | +| Capturing self in stored Task | Retain cycle, deinit never called | Use [weak self] for long-lived or stored tasks | +| Assuming async = background | Code stays on calling actor | Use @concurrent to force background execution | +| TaskLocal not propagated | Value is nil in detached task | TaskLocal only propagates to child tasks, not detached | +| Task priority inversion | Low-priority task blocks high-priority | System handles most cases; avoid awaiting low-priority from high | +| Thread.current in async context | Compiler error in Swift 6 mode | Don't rely on thread identity — use isolation domains | + +--- + +## Part 4: Structured Concurrency + +### async let + +Run a fixed number of operations in parallel. All `async let` bindings are implicitly awaited when the scope exits. + +```swift +async let images = fetchImages() +async let metadata = fetchMetadata() +async let config = loadConfig() + +// All three run concurrently, await together +let (imgs, meta, cfg) = try await (images, metadata, config) +``` + +**Semantics**: If one `async let` throws, the others are cancelled. All must complete (or be cancelled) before the enclosing scope exits. + +### TaskGroup — Non-Throwing + +Dynamic number of parallel tasks where none throw. + +```swift +let results = await withTaskGroup(of: String.self) { group in + for name in names { + group.addTask { + await fetchGreeting(for: name) + } + } + + var greetings: [String] = [] + for await greeting in group { + greetings.append(greeting) + } + return greetings +} +``` + +### TaskGroup — Throwing + +Dynamic number of parallel tasks that can throw. + +```swift +let images = try await withThrowingTaskGroup(of: (URL, UIImage).self) { group in + for url in urls { + group.addTask { + let image = try await downloadImage(url) + return (url, image) + } + } + + var results: [URL: UIImage] = [:] + for try await (url, image) in group { + results[url] = image + } + return results +} +``` + +### withDiscardingTaskGroup (iOS 17+) + +For when you need concurrency but don't need to collect results. More memory-efficient than regular TaskGroup — no result storage. + +```swift +try await withThrowingDiscardingTaskGroup { group in + for connection in connections { + group.addTask { + try await connection.monitor() + // Results are discarded — useful for long-running services + } + } + // Group stays alive until all tasks complete or one throws +} +``` + +#### Real-world pattern — merge multiple notification streams + +```swift +extension NotificationCenter { + func notifications(named names: [Notification.Name]) -> AsyncStream { + AsyncStream { continuation in + let task = Task { + await withDiscardingTaskGroup { group in + for name in names { + group.addTask { + for await _ in self.notifications(named: name) { + continuation.yield() + } + } + } + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } +} +``` + +### TaskGroup Control + +```swift +await withTaskGroup(of: Data.self) { group in + // Add tasks conditionally + group.addTaskUnlessCancelled { + await fetchData() + } + + // Cancel remaining tasks + group.cancelAll() + + // Wait without collecting + await group.waitForAll() + + // Iterate one at a time + while let result = await group.next() { + process(result) + } +} +``` + +### Task Tree Semantics + +Structured concurrency forms a tree: +- **Parent cancellation cancels all children** — cancelling a task cancels all `async let` and TaskGroup children +- **Child error propagates to parent** — in throwing groups, a child error cancels siblings and propagates up +- **All children must complete before parent returns** — the scope awaits all children, even cancelled ones + +```swift +// If fetchImages() throws, fetchMetadata() is automatically cancelled +async let images = fetchImages() +async let metadata = fetchMetadata() +let result = try await (images, metadata) +``` + +### Structured Concurrency Gotcha Table + +| Gotcha | Symptom | Fix | +|---|---|---| +| async let unused | Work still executes but result is discarded silently | Assign all async let results or use withDiscardingTaskGroup | +| TaskGroup accumulating memory | Memory grows with 10K+ tasks | Process results as they arrive, don't collect all | +| Capturing mutable state in addTask | "Mutation of captured var" | Use let binding or actor | +| Not handling partial failure | Some tasks succeed, some fail | Use group.next() and handle errors individually | +| async let in loop | Compiler error — async let must be in fixed positions | Use TaskGroup instead | +| Returning from group early | Remaining tasks still run | Call group.cancelAll() before returning | + +--- + +## Part 5: Async Sequences + +### AsyncStream + +Non-throwing stream for producing values over time. + +```swift +let stream = AsyncStream { continuation in + for i in 0..<10 { + continuation.yield(i) + } + continuation.finish() +} + +for await value in stream { + print(value) +} +``` + +### AsyncThrowingStream + +Stream that can fail with an error. + +```swift +let stream = AsyncThrowingStream { continuation in + let monitor = NetworkMonitor() + monitor.onData = { data in + continuation.yield(data) + } + monitor.onError = { error in + continuation.finish(throwing: error) + } + monitor.onComplete = { + continuation.finish() + } + + continuation.onTermination = { @Sendable _ in + monitor.stop() + } + + monitor.start() +} + +do { + for try await data in stream { + process(data) + } +} catch { + handleStreamError(error) +} +``` + +### Continuation API + +```swift +let stream = AsyncStream { continuation in + // Emit a value + continuation.yield(value) + + // End the stream normally + continuation.finish() + + // Cleanup when consumer cancels or stream ends + continuation.onTermination = { @Sendable termination in + switch termination { + case .cancelled: + cleanup() + case .finished: + finalCleanup() + @unknown default: + break + } + } +} + +// For throwing streams +let stream = AsyncThrowingStream { continuation in + continuation.yield(value) + continuation.finish() // Normal end + continuation.finish(throwing: error) // End with error +} +``` + +### Buffering Policies + +Control what happens when values are produced faster than consumed. + +```swift +// Keep all values (default) — memory can grow unbounded +let stream = AsyncStream(bufferingPolicy: .unbounded) { continuation in + // ... +} + +// Keep oldest N values, drop new ones when buffer is full +let stream = AsyncStream(bufferingPolicy: .bufferingOldest(100)) { continuation in + // ... +} + +// Keep newest N values, drop old ones when buffer is full +let stream = AsyncStream(bufferingPolicy: .bufferingNewest(100)) { continuation in + // ... +} +``` + +| Policy | Behavior | Use When | +|---|---|---| +| `.unbounded` | Keeps all values | Consumer keeps up, or bounded producer | +| `.bufferingOldest(N)` | Drops new values when full | Order matters, older values have priority | +| `.bufferingNewest(N)` | Drops old values when full | Latest state matters (UI updates, sensor data) | + +### Custom AsyncSequence + +```swift +struct Counter: AsyncSequence { + typealias Element = Int + let limit: Int + + struct AsyncIterator: AsyncIteratorProtocol { + var current = 0 + let limit: Int + + mutating func next() async -> Int? { + guard current < limit else { return nil } + defer { current += 1 } + return current + } + } + + func makeAsyncIterator() -> AsyncIterator { + AsyncIterator(limit: limit) + } +} + +// Usage +for await number in Counter(limit: 5) { + print(number) // 0, 1, 2, 3, 4 +} +``` + +### AsyncSequence Operators + +Standard operators work on any `AsyncSequence`: + +```swift +// Map +for await name in users.map(\.name) { } + +// Filter +for await adult in users.filter({ $0.age >= 18 }) { } + +// CompactMap +for await image in urls.compactMap({ await tryLoadImage($0) }) { } + +// Prefix +for await first5 in stream.prefix(5) { } + +// first(where:) +let match = await stream.first(where: { $0 > threshold }) + +// Contains +let hasMatch = await stream.contains(where: { $0 > threshold }) + +// Reduce +let sum = await numbers.reduce(0, +) +``` + +### Built-in Async Sequences + +```swift +// NotificationCenter +for await notification in NotificationCenter.default.notifications(named: .didUpdate) { + handleUpdate(notification) +} + +// URLSession bytes +let (bytes, response) = try await URLSession.shared.bytes(from: url) +for try await byte in bytes { + process(byte) +} + +// FileHandle bytes +for try await line in FileHandle.standardInput.bytes.lines { + process(line) +} +``` + +### Async Sequence Gotcha Table + +| Gotcha | Symptom | Fix | +|---|---|---| +| Continuation yielded after finish | Runtime warning, value lost | Track finished state, guard before yield | +| Stream never finishing | for-await loop hangs forever | Always call continuation.finish() in all code paths | +| No onTermination handler | Resource leak when consumer cancels | Set continuation.onTermination for cleanup | +| Unbounded buffer | Memory growth under load | Use .bufferingNewest(N) or .bufferingOldest(N) | +| Multiple consumers | Only first consumer gets values | AsyncStream is single-consumer; create separate streams per consumer | +| for-await on MainActor | UI freezes waiting for values | Use Task {} to consume off the main path | + +--- + +## Part 6: Isolation Patterns + +### @MainActor on Functions + +```swift +@MainActor +func updateUI() { + label.text = "Done" +} + +// Call from async context +func doWork() async { + let result = await computeResult() + await updateUI() // Hops to MainActor +} +``` + +### MainActor.run + +Explicitly execute a closure on the main actor from any context. + +```swift +func processData() async { + let result = await heavyComputation() + + await MainActor.run { + self.label.text = result + self.progressView.isHidden = true + } +} +``` + +### MainActor.assumeIsolated (iOS 17+) + +Assert that code is already running on the main actor. Crashes at runtime if the assertion is false. + +```swift +func legacyCallback() { + // We KNOW this is called on main thread (UIKit guarantee) + MainActor.assumeIsolated { + self.viewModel.update() // Access @MainActor state + } +} +``` + +See `axiom-assume-isolated` for comprehensive patterns. + +### nonisolated + +Opt out of the enclosing actor's isolation. + +```swift +@MainActor +class ViewModel { + let id: UUID // Implicitly nonisolated (let) + + nonisolated var analyticsID: String { // Explicitly nonisolated + id.uuidString + } + + var items: [Item] = [] // Isolated to MainActor +} +``` + +### nonisolated(unsafe) + +Compiler escape hatch. Tells the compiler to treat a property as if it's not isolated, without any safety guarantees. + +```swift +// Use only when you have external guarantees of thread safety +nonisolated(unsafe) var legacyState: Int = 0 + +// Common for global constants that the compiler can't verify +nonisolated(unsafe) let formatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + return f +}() +``` + +**Warning**: `nonisolated(unsafe)` provides zero runtime protection. Data races will not be caught. Use only as a last resort for bridging legacy code. + +### @preconcurrency + +Suppress concurrency warnings for pre-concurrency APIs during migration. + +```swift +// Suppress warnings for entire module +@preconcurrency import MyLegacyFramework + +// Suppress for specific protocol conformance +class MyDelegate: @preconcurrency SomeLegacyDelegate { + func delegateCallback() { + // No Sendable warnings for this conformance + } +} +``` + +### #isolation (Swift 6.0+) + +Capture the caller's isolation context so a function runs on whatever actor the caller is on. + +```swift +func doWork(isolation: isolated (any Actor)? = #isolation) async { + // Runs on caller's actor — no hop if caller is already isolated + performWork() +} + +// Called from @MainActor — runs on MainActor +@MainActor +func setup() async { + await doWork() // doWork runs on MainActor +} + +// Called from custom actor — runs on that actor +actor MyActor { + func run() async { + await doWork() // doWork runs on MyActor + } +} +``` + +### #isolation Capture in Task Closures (SE-0420) + +When spawning `Task` closures that need to work with non-Sendable types, capture the isolation parameter to inherit the caller's context. + +```swift +func process( + delegate: NonSendableDelegate, + isolation: isolated (any Actor)? = #isolation +) { + Task { + _ = isolation // Forces capture — Task inherits caller's isolation + delegate.doWork() // ✅ Safe: running on caller's actor + } +} +``` + +**Why `_ = isolation` is required**: Per SE-0420, `Task` closures only inherit isolation when a non-optional binding of an isolated parameter is captured by the closure. The `_ = isolation` statement forces this capture. Without it, the Task runs on the default executor and the non-Sendable capture is a compiler error. + +**When to use**: Spawning Tasks that work with non-Sendable delegate objects, fire-and-forget async work that needs access to caller's state, or bridging callback-based APIs while keeping delegates alive. + +### Isolation Gotcha Table + +| Gotcha | Symptom | Fix | +|---|---|---| +| MainActor.run from MainActor | Unnecessary hop, potential deadlock risk | Check context or use assumeIsolated | +| nonisolated(unsafe) data race | Crash at runtime, corrupted state | Use proper isolation or Mutex | +| @preconcurrency hiding real issues | Runtime crashes in production | Migrate to proper concurrency before shipping | +| #isolation not available pre-5.9 | Compiler error | Use traditional @MainActor annotation | +| #isolation not captured in Task | Non-Sendable capture error | Add `_ = isolation` inside Task closure (SE-0420) | +| nonisolated on actor method | Can't access any isolated state | Only use for computed properties from non-isolated state | +| Thread.current in async context | Compiler error in Swift 6 mode | Don't rely on thread identity — reason about isolation domains | + +--- + +## Part 7: Continuations + +Bridge callback-based APIs to async/await. + +### withCheckedContinuation + +Non-throwing bridge. + +```swift +func currentLocation() async -> CLLocation { + await withCheckedContinuation { continuation in + locationManager.requestLocation { location in + continuation.resume(returning: location) + } + } +} +``` + +### withCheckedThrowingContinuation + +Throwing bridge. + +```swift +func fetchUser(id: String) async throws -> User { + try await withCheckedThrowingContinuation { continuation in + api.fetchUser(id: id) { result in + switch result { + case .success(let user): + continuation.resume(returning: user) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } +} +``` + +### Continuation Resume Methods + +```swift +// Return a value +continuation.resume(returning: value) + +// Throw an error +continuation.resume(throwing: error) + +// From a Result type +continuation.resume(with: result) // Result +``` + +### Resume-Exactly-Once Rule + +A continuation MUST be resumed exactly once: +- **Resuming twice** crashes with `"Continuation already resumed"` (checked) or undefined behavior (unsafe) +- **Never resuming** causes the awaiting task to hang forever — a silent leak + +```swift +// DANGEROUS: callback might not be called +func riskyBridge() async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + api.fetch { data, error in + if let error { + continuation.resume(throwing: error) + return + } + if let data { + continuation.resume(returning: data) + return + } + // BUG: if both are nil, continuation is never resumed + // Fix: add a fallback + continuation.resume(throwing: BridgeError.noResponse) + } + } +} +``` + +### Bridging Delegates + +```swift +class LocationBridge: NSObject, CLLocationManagerDelegate { + private var continuation: CheckedContinuation? + private let manager = CLLocationManager() + + func requestLocation() async throws -> CLLocation { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + manager.delegate = self + manager.requestLocation() + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + continuation?.resume(returning: locations[0]) + continuation = nil // Prevent double resume + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + continuation?.resume(throwing: error) + continuation = nil + } +} +``` + +### Unsafe Continuations + +Skip runtime checks for performance. Same API as checked, but misuse causes undefined behavior instead of a diagnostic crash. + +```swift +func fastBridge() async -> Data { + await withUnsafeContinuation { continuation in + // No runtime check for double-resume or missing resume + fastCallback { data in + continuation.resume(returning: data) + } + } +} +``` + +**Use checked continuations during development, switch to unsafe only after thorough testing and when profiling shows the check is a bottleneck.** + +### Continuation Gotcha Table + +| Gotcha | Symptom | Fix | +|---|---|---| +| Resume called twice | "Continuation already resumed" crash | Set continuation to nil after resume | +| Resume never called | Task hangs indefinitely | Ensure all code paths resume — including error/nil cases | +| Capturing continuation | Continuation escapes scope | Store in property, ensure single resume | +| Unsafe continuation in debug | No diagnostics for misuse | Use withCheckedContinuation during development | +| Delegate called multiple times | Crash on second resume | Use AsyncStream instead of continuation for repeated callbacks | +| Callback on wrong thread | Doesn't matter for continuation | Continuations can be resumed from any thread | + +--- + +## Part 8: Migration Patterns + +Common migrations from GCD and completion handlers to Swift concurrency. + +### DispatchQueue to Actor + +```swift +// BEFORE: DispatchQueue for thread safety +class ImageCache { + private let queue = DispatchQueue(label: "cache", attributes: .concurrent) + private var cache: [URL: UIImage] = [:] + + func get(_ url: URL, completion: @escaping (UIImage?) -> Void) { + queue.async { completion(self.cache[url]) } + } + + func set(_ url: URL, image: UIImage) { + queue.async(flags: .barrier) { self.cache[url] = image } + } +} + +// AFTER: Actor +actor ImageCache { + private var cache: [URL: UIImage] = [:] + + func get(_ url: URL) -> UIImage? { + cache[url] + } + + func set(_ url: URL, image: UIImage) { + cache[url] = image + } +} +``` + +### DispatchGroup to TaskGroup + +```swift +// BEFORE: DispatchGroup +let group = DispatchGroup() +var results: [Data] = [] +for url in urls { + group.enter() + fetch(url) { data in + results.append(data) + group.leave() + } +} +group.notify(queue: .main) { use(results) } + +// AFTER: TaskGroup +let results = await withTaskGroup(of: Data.self) { group in + for url in urls { + group.addTask { await fetch(url) } + } + var collected: [Data] = [] + for await data in group { + collected.append(data) + } + return collected +} +use(results) +``` + +### Completion Handler to async + +```swift +// BEFORE +func fetchData(completion: @escaping (Result) -> Void) { + URLSession.shared.dataTask(with: url) { data, _, error in + if let error { completion(.failure(error)); return } + guard let data else { completion(.failure(FetchError.noData)); return } + completion(.success(data)) + }.resume() +} + +// AFTER +func fetchData() async throws -> Data { + let (data, _) = try await URLSession.shared.data(from: url) + return data +} +``` + +### @objc Delegates with @MainActor + +```swift +@MainActor +class ViewController: UIViewController, UITableViewDelegate { + // @objc delegate methods inherit @MainActor isolation from the class + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + // Already on MainActor — safe to update UI + updateSelection(indexPath) + } +} +``` + +### NotificationCenter to AsyncSequence + +```swift +// BEFORE +let observer = NotificationCenter.default.addObserver( + forName: .didUpdate, object: nil, queue: .main +) { notification in + handleUpdate(notification) +} +// Must remove observer in deinit + +// AFTER +let task = Task { + for await notification in NotificationCenter.default.notifications(named: .didUpdate) { + await handleUpdate(notification) + } +} +// Cancel task in deinit — no manual observer removal needed +``` + +### Timer to AsyncSequence + +```swift +// BEFORE +let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + updateUI() +} +// Must invalidate in deinit + +// AFTER +let task = Task { + while !Task.isCancelled { + await updateUI() + try? await Task.sleep(for: .seconds(1)) + } +} +// Cancel task in deinit +``` + +### DispatchSemaphore to Actor + +```swift +// BEFORE: Semaphore to limit concurrent operations +let semaphore = DispatchSemaphore(value: 3) +for url in urls { + DispatchQueue.global().async { + semaphore.wait() + defer { semaphore.signal() } + download(url) + } +} + +// AFTER: TaskGroup with limited concurrency +await withTaskGroup(of: Void.self) { group in + var inFlight = 0 + for url in urls { + if inFlight >= 3 { + await group.next() // Wait for one to finish + inFlight -= 1 + } + group.addTask { await download(url) } + inFlight += 1 + } + await group.waitForAll() +} +``` + +### Migration Gotcha Table + +| Gotcha | Symptom | Fix | +|---|---|---| +| DispatchQueue.sync to actor | Deadlock potential | Remove .sync, use await | +| Global dispatch to actor contention | Slowdown from serialization | Profile with Concurrency Instruments | +| Legacy delegate + Sendable | "Cannot conform to Sendable" | Use @preconcurrency import or @MainActor isolation | +| Callback called multiple times | Continuation crash | Use AsyncStream instead of continuation | +| Semaphore.wait in async context | Thread starvation, potential deadlock | Use TaskGroup with manual concurrency limiting | +| DispatchQueue.main.async to MainActor | Subtle timing differences | MainActor.run is the equivalent — test edge cases | +| Replacing structured tasks with top-level Tasks | Losing cancellation propagation and error handling | Use async let or TaskGroup for related parallel work | +| Batch @unchecked Sendable to fix warnings | Hiding real data races throughout codebase | Fix one type at a time with proper Sendable, actor, or sending | + +--- + +## API Quick Reference + +| Task | API | Swift Version | +|---|---|---| +| Define isolated type | `actor MyActor { }` | 5.5+ | +| Run on main thread | `@MainActor` | 5.5+ | +| Mark as safe to share | `: Sendable` | 5.5+ | +| Mark closure safe to share | `@Sendable` | 5.5+ | +| Parallel tasks (fixed) | `async let` | 5.5+ | +| Parallel tasks (dynamic) | `withTaskGroup` | 5.5+ | +| Stream values | `AsyncStream` | 5.5+ | +| Bridge callback | `withCheckedContinuation` | 5.5+ | +| Check cancellation | `Task.checkCancellation()` | 5.5+ | +| Task-scoped values | `@TaskLocal` | 5.5+ | +| Assert isolation | `MainActor.assumeIsolated` | 5.9+ (iOS 17+) | +| Capture caller isolation | `#isolation` | 6.0+ | +| Lock-based sync | `Mutex` | 6.0+ (iOS 18+) | +| Discard results | `withDiscardingTaskGroup` | 5.9+ (iOS 17+) | +| Transfer ownership | `sending` parameter | 6.0+ | +| Force background | `@concurrent` | 6.2+ | +| Isolated conformance | `extension: @MainActor Proto` | 6.2+ | + +## Resources + +**WWDC**: 2021-10132, 2021-10134, 2022-110350, 2025-268 + +**Docs**: /swift/concurrency, /swift/actor, /swift/sendable, /swift/taskgroup + +**Skills**: swift-concurrency, assume-isolated, synchronization, concurrency-profiling diff --git a/.claude/skills/axiom-swift-concurrency-ref/agents/openai.yaml b/.claude/skills/axiom-swift-concurrency-ref/agents/openai.yaml new file mode 100644 index 0000000..585adb8 --- /dev/null +++ b/.claude/skills/axiom-swift-concurrency-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Swift Concurrency Reference" + short_description: "Swift concurrency API reference" diff --git a/.claude/skills/axiom-swift-concurrency/.openskills.json b/.claude/skills/axiom-swift-concurrency/.openskills.json new file mode 100644 index 0000000..639fc15 --- /dev/null +++ b/.claude/skills/axiom-swift-concurrency/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swift-concurrency", + "installedAt": "2026-04-12T08:06:43.327Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swift-concurrency/SKILL.md b/.claude/skills/axiom-swift-concurrency/SKILL.md new file mode 100644 index 0000000..f075771 --- /dev/null +++ b/.claude/skills/axiom-swift-concurrency/SKILL.md @@ -0,0 +1,1097 @@ +--- +name: axiom-swift-concurrency +description: Use when you see 'actor-isolated', 'Sendable', 'data race', '@MainActor' errors, or asking 'how do I use async/await', 'my app crashes with concurrency errors', 'how do I fix data races'. Covers Swift 6 concurrency, @concurrent, actors. +license: MIT +metadata: + version: "1.0.0" + last-updated: "2026-04-05" +--- + +# Swift 6 Concurrency Guide + +**Purpose**: Progressive journey from single-threaded to concurrent Swift code +**Swift Version**: Swift 6.3 (strict concurrency by default). `@concurrent` requires Swift 6.2+. +**iOS Version**: iOS 17+ (iOS 26+ for `@concurrent`) +**Xcode**: Xcode 16+ (Xcode 26+ for `@concurrent`) + +## When to Use This Skill + +✅ **Use this skill when**: +- Starting a new project and deciding concurrency strategy +- Debugging Swift 6 concurrency errors (actor isolation, data races, Sendable warnings) +- Deciding when to introduce async/await vs concurrency +- Implementing `@MainActor` classes or async functions +- Converting delegate callbacks to async-safe patterns +- Deciding between `@MainActor`, `nonisolated`, `@concurrent`, or actor isolation +- Resolving "Sending 'self' risks causing data races" errors +- Making types conform to `Sendable` +- Offloading CPU-intensive work to background threads +- UI feels unresponsive and profiling shows main thread bottleneck + +❌ **Do NOT use this skill for**: +- General Swift syntax (use Swift documentation) +- SwiftUI-specific patterns (use `axiom-swiftui-debugging` or `axiom-swiftui-performance`) +- API-specific patterns (use API documentation) + +## Core Philosophy: Think in Isolation Domains, Not Threads + +> "Your apps should start by running all of their code on the main thread, and you can get really far with single-threaded code." — Apple + +**Stop asking**: "What thread should this run on?" +**Start asking**: "What isolation domain should own this work?" + +- `@MainActor` → UI state ownership +- Custom `actor` → shared mutable state ownership +- `nonisolated` → no ownership, caller decides +- `@concurrent` → force background execution + +**Async does not mean background.** An `async` function suspends without blocking, but resumes on the *same actor* it was called from. A `@MainActor` async function runs entirely on the main actor — `await` just yields control, it does not switch threads. Use `@concurrent` (Swift 6.2+) when you need to force work off the calling actor. + +**Prefer structured concurrency.** Use `async let` and `TaskGroup` for parallel work — they propagate cancellation and errors automatically through the task tree. Unstructured `Task {}` is for bridging sync→async boundaries (event handlers, SwiftUI `.task`). `Task.detached` is a last resort. + +**GCD is a bridge pattern, not a default.** In new code, do not use `DispatchQueue`, `DispatchGroup`, `DispatchSemaphore`, or completion handlers as primary architecture. Use them only to bridge legacy APIs that don't have async alternatives yet. Isolate bridge code and keep the rest of the codebase idiomatic Swift 6. + +### The Progressive Journey + +``` +Single-Threaded → Asynchronous → Concurrent → Actors + ↓ ↓ ↓ ↓ + Start here Hide latency Background Move data + (network) CPU work off main +``` + +**When to advance**: +1. **Stay single-threaded** if UI is responsive and operations are fast +2. **Add async/await** when high-latency operations (network, file I/O) block UI +3. **Add concurrency** when CPU-intensive work (image processing, parsing) freezes UI +4. **Add actors** when too much main actor code causes contention + +**Key insight**: Concurrent code is more complex. Only introduce concurrency when profiling shows it's needed. + +--- + +## Step 1: Single-Threaded Code (Start Here) + +With Main Actor Mode enabled (the default for new projects in Xcode 26+), all code runs on the **main thread** unless explicitly marked otherwise. + +```swift +// ✅ Simple, single-threaded +class ImageModel { + var imageCache: [URL: Image] = [:] + + func fetchAndDisplayImage(url: URL) throws { + let data = try Data(contentsOf: url) // Reads local file + let image = decodeImage(data) + view.displayImage(image) + } + + func decodeImage(_ data: Data) -> Image { + // Decode image data + return Image() + } +} +``` + +#### Main Actor Mode (Xcode 26+) + +- Enabled by default for new projects +- All code protected by `@MainActor` unless explicitly marked otherwise +- Access shared state safely without worrying about concurrent access + +#### Build Setting (Xcode 26+) + +``` +Build Settings → Swift Compiler — Language +→ "Default Actor Isolation" = Main Actor + +Build Settings → Swift Compiler — Upcoming Features +→ "Approachable Concurrency" = Yes +``` + +**When this is enough**: If all operations are fast (<16ms for 60fps), stay single-threaded! + +--- + +## Step 2: Asynchronous Tasks (Hide Latency) + +Add async/await when **waiting on data** (network, file I/O) would freeze UI. + +### Problem: Network Access Blocks UI + +```swift +// ❌ Blocks main thread until network completes +func fetchAndDisplayImage(url: URL) throws { + let data = try Data(contentsOf: url) // ❌ Synchronous network fetch, freezes UI! + let image = decodeImage(data) + view.displayImage(image) +} +``` + +### Solution: Async/Await + +```swift +// ✅ Suspends without blocking main thread +func fetchAndDisplayImage(url: URL) async throws { + let (data, _) = try await URLSession.shared.data(from: url) // ✅ Suspends here + let image = decodeImage(data) // ✅ Resumes here when data arrives + view.displayImage(image) +} +``` + +**What happens**: +1. Function starts on main thread +2. `await` suspends function without blocking main thread +3. URLSession fetches data on background thread (library handles this) +4. Function resumes on main thread when data arrives +5. UI stays responsive the entire time + +### Task Creation + +Create tasks in response to user events: + +```swift +class ImageModel { + var url: URL = URL(string: "https://swift.org")! + + func onTapEvent() { + Task { // ✅ Create task for user action + do { + try await fetchAndDisplayImage(url: url) + } catch { + displayError(error) + } + } + } +} +``` + +### Task Interleaving (Important Concept) + +Multiple async tasks can run on the **same thread** by taking turns: + +``` +Task 1: [Fetch Image] → (suspend) → [Decode] → [Display] +Task 2: [Fetch News] → (suspend) → [Display News] + +Main Thread Timeline: +[Fetch Image] → [Fetch News] → [Decode Image] → [Display Image] → [Display News] +``` + +**Benefits**: +- Main thread never sits idle +- Tasks make progress as soon as possible +- No concurrency yet—still single-threaded! + +**Critical**: Both tasks above run on the main actor. The `await` keyword suspends the task and frees the thread, but when the task resumes, it returns to the *same isolation domain* (main actor). No background thread is involved unless the awaited API (like URLSession) handles that internally. + +**When to use tasks**: +- High-latency operations (network, file I/O) +- Library APIs handle background work for you (URLSession, FileManager) +- Your own code stays on main thread + +--- + +## Step 3: Concurrent Code (Background Threads) + +Add concurrency when **CPU-intensive work** blocks UI. + +### Problem: Decoding Blocks UI + +Profiling shows `decodeImage()` takes 200ms, causing UI glitches: + +```swift +func fetchAndDisplayImage(url: URL) async throws { + let (data, _) = try await URLSession.shared.data(from: url) + let image = decodeImage(data) // ❌ 200ms on main thread! + view.displayImage(image) +} +``` + +### Solution 1: `@concurrent` Attribute (Swift 6.2+) + +Forces function to **always run on background thread**: + +```swift +func fetchAndDisplayImage(url: URL) async throws { + let (data, _) = try await URLSession.shared.data(from: url) + let image = await decodeImage(data) // ✅ Runs on background thread + view.displayImage(image) +} + +@concurrent +func decodeImage(_ data: Data) async -> Image { + // ✅ Always runs on background thread pool + // Good for: image processing, file I/O, parsing + return Image() +} +``` + +**What `@concurrent` does**: +- Function always switches to background thread pool +- Compiler highlights main actor data access (shows what you need to fix) +- Cannot access `@MainActor` properties without `await` + +**Requirements**: Swift 6.2+, Xcode 26+, iOS 26+ + +### Solution 2: `nonisolated` (Library APIs) + +If providing a general-purpose API, use `nonisolated` instead: + +```swift +// ✅ Stays on caller's actor +nonisolated +func decodeImage(_ data: Data) -> Image { + // Runs on whatever actor called it + // Main actor → stays on main actor + // Background → stays on background + return Image() +} +``` + +**When to use `nonisolated`**: +- Library APIs where **caller decides** where work happens +- Small operations that might be OK on main thread +- General-purpose code used in many contexts + +**When to use `@concurrent`**: +- Operations that **should always** run on background (image processing, parsing) +- Performance-critical work that shouldn't block UI + +### Breaking Ties to Main Actor + +When you mark a function `@concurrent`, compiler shows main actor access: + +```swift +@MainActor +class ImageModel { + var cachedImage: [URL: Image] = [:] // Main actor data + + @concurrent + func decodeImage(_ data: Data, at url: URL) async -> Image { + if let image = cachedImage[url] { // ❌ Error: main actor access! + return image + } + // decode... + } +} +``` + +#### Strategy 1: Move to caller + +```swift +func fetchAndDisplayImage(url: URL) async throws { + // ✅ Check cache on main actor BEFORE async work + if let image = cachedImage[url] { + view.displayImage(image) + return + } + + let (data, _) = try await URLSession.shared.data(from: url) + let image = await decodeImage(data) // No URL needed now + view.displayImage(image) +} + +@concurrent +func decodeImage(_ data: Data) async -> Image { + // ✅ No main actor access needed + return Image() +} +``` + +#### Strategy 2: Access via @MainActor helper + +```swift +@MainActor +func getCachedImage(for url: URL) -> Image? { + cachedImage[url] +} + +@concurrent +func decodeImage(_ data: Data, at url: URL) async -> Image { + // ✅ Call @MainActor function to access isolated state + if let image = await getCachedImage(for: url) { + return image + } + // decode... +} +``` + +#### Strategy 3: Make nonisolated + +```swift +nonisolated +func decodeImage(_ data: Data) -> Image { + // ✅ No actor isolation, can call from anywhere + return Image() +} +``` + +### Concurrent Thread Pool + +When work runs on background: + +``` +Main Thread: [UI] → (suspend) → [UI Update] + ↓ +Background Pool: [Task A] → [Task B] → [Task A resumes] + Thread 1 Thread 2 Thread 3 +``` + +**Key points**: +- System manages thread pool size (1-2 threads on Watch, many on Mac) +- Task can resume on different thread than it started +- You never specify which thread—system optimizes automatically + +--- + +## Step 4: Actors (Move Data Off Main Thread) + +Add actors when **too much code runs on main actor** causing contention. + +### Problem: Main Actor Contention + +```swift +@MainActor +class ImageModel { + var cachedImage: [URL: Image] = [:] + let networkManager: NetworkManager = NetworkManager() // ❌ Also @MainActor + + func fetchAndDisplayImage(url: URL) async throws { + // ✅ Background work... + let connection = await networkManager.openConnection(for: url) // ❌ Hops to main! + let data = try await connection.data(from: url) + await networkManager.closeConnection(connection, for: url) // ❌ Hops to main! + + let image = await decodeImage(data) + view.displayImage(image) + } +} +``` + +**Issue**: Background task keeps hopping to main actor for network manager access. + +### Solution: Network Manager Actor + +```swift +// ✅ Move network state off main actor +actor NetworkManager { + var openConnections: [URL: Connection] = [:] + + func openConnection(for url: URL) -> Connection { + if let connection = openConnections[url] { + return connection + } + let connection = Connection() + openConnections[url] = connection + return connection + } + + func closeConnection(_ connection: Connection, for url: URL) { + openConnections.removeValue(forKey: url) + } +} + +@MainActor +class ImageModel { + let networkManager: NetworkManager = NetworkManager() + + func fetchAndDisplayImage(url: URL) async throws { + // ✅ Now runs mostly on background + let connection = await networkManager.openConnection(for: url) + let data = try await connection.data(from: url) + await networkManager.closeConnection(connection, for: url) + + let image = await decodeImage(data) + view.displayImage(image) + } +} +``` + +**What changed**: +- `NetworkManager` is now an `actor` instead of `@MainActor class` +- Network state isolated in its own actor +- Background code can access network manager without hopping to main actor +- Main thread freed up for UI work + +### When to Use Actors + +✅ **Use actors for**: +- Non-UI subsystems with independent state (network manager, cache, database) +- Data that's causing main actor contention +- Separating concerns from UI code + +❌ **Do NOT use actors for**: +- UI-facing classes (ViewModels, View Controllers) → Use `@MainActor` +- Model classes used by UI → Keep `@MainActor` or non-Sendable +- Every class in your app (actors add complexity) + +**Guideline**: Profile first. If main actor has too much state causing bottlenecks, extract one subsystem at a time into actors. + +--- + +## Sendable Types (Data Crossing Actor Boundaries) + +When data passes between actors or tasks, Swift checks it's **Sendable** (safe to share). + +### Value Types Are Sendable + +```swift +// ✅ Value types copy when passed +let url = URL(string: "https://swift.org")! + +Task { + // ✅ This is a COPY of url, not the original + // URLSession.shared.data runs on background automatically + let data = try await URLSession.shared.data(from: url) +} + +// ✅ Original url unchanged by background task +``` + +**Why safe**: Each actor gets its own independent copy. Changes don't affect other copies. + +### What's Sendable? + +```swift +// ✅ Basic types +extension URL: Sendable {} +extension String: Sendable {} +extension Int: Sendable {} +extension Date: Sendable {} + +// ✅ Collections of Sendable elements +extension Array: Sendable where Element: Sendable {} +extension Dictionary: Sendable where Key: Sendable, Value: Sendable {} + +// ✅ Structs/enums with Sendable storage +struct Track: Sendable { + let id: String + let title: String + let duration: TimeInterval +} + +enum PlaybackState: Sendable { + case stopped + case playing + case paused +} + +// ✅ Main actor types +@MainActor class ImageModel {} // Implicitly Sendable (actor protects state) + +// ✅ Actor types +actor NetworkManager {} // Implicitly Sendable (actor protects state) +``` + +### Reference Types (Classes) and Sendable + +```swift +// ❌ Classes are NOT Sendable by default +class MyImage { + var width: Int + var height: Int + var pixels: [Color] + + func scale(by factor: Double) { + // Mutates shared state + } +} + +let image = MyImage() +let otherImage = image // ✅ Both reference SAME object + +image.scale(by: 0.5) // ✅ Changes visible through otherImage! +``` + +**Problem with concurrency**: + +```swift +func scaleAndDisplay(imageName: String) { + let image = loadImage(imageName) + + Task { + image.scale(by: 0.5) // Background task modifying + } + + view.displayImage(image) // Main thread reading + // ❌ DATA RACE! Both threads could touch same object! +} +``` + +#### Solution 1: Finish modifications before sending + +```swift +@concurrent +func scaleAndDisplay(imageName: String) async { + let image = loadImage(imageName) + image.scale(by: 0.5) // ✅ All modifications on background + image.applyAnotherEffect() // ✅ Still on background + + await view.displayImage(image) // ✅ Send to main actor AFTER modifications done + // ✅ Main actor now owns image exclusively +} +``` + +#### Solution 2: Don't share classes concurrently + +Keep model classes `@MainActor` or non-Sendable to prevent concurrent access. + +### Sendable Checking + +Happens automatically when: +- Passing data into/out of actors +- Passing data into/out of tasks +- Crossing actor boundaries with `await` + +```swift +func fetchAndDisplayImage(url: URL) async throws { + let (data, _) = try await URLSession.shared.data(from: url) + // ↑ Sendable ↑ Sendable (crosses to background) + + let image = await decodeImage(data) + // ↑ data crosses to background (must be Sendable) + // ↑ image returns to main (must be Sendable) +} +``` + +--- + +## Common Patterns (Copy-Paste Templates) + +### Pattern 1: Sendable Enum/Struct + +**When**: Type crosses actor boundaries + +```swift +// ✅ Enum (no associated values) +private enum PlaybackState: Sendable { + case stopped + case playing + case paused +} + +// ✅ Struct (all properties Sendable) +struct Track: Sendable { + let id: String + let title: String + let artist: String? +} + +// ✅ Enum with Sendable associated values +enum FetchResult: Sendable { + case success(data: Data) + case failure(error: Error) // Error is Sendable +} +``` + +--- + +### Pattern 2: Delegate Value Capture (CRITICAL) + +**When**: `nonisolated` delegate method needs to update `@MainActor` state + +```swift +nonisolated func delegate(_ param: SomeType) { + // ✅ Step 1: Capture delegate parameter values BEFORE Task + let value = param.value + let status = param.status + + // ✅ Step 2: Task hop to MainActor + Task { @MainActor in + // ✅ Step 3: Safe to access self (we're on MainActor) + self.property = value + print("Status: \(status)") + } +} +``` + +**Why**: Delegate methods are `nonisolated` (called from library's threads). Capture parameters before Task. Accessing `self` inside `Task { @MainActor in }` is safe. + +--- + +### Pattern 3: Weak Self in Tasks + +**When**: Task is stored as property OR runs for long time + +```swift +class MusicPlayer { + private var progressTask: Task? + + func startMonitoring() { + progressTask = Task { [weak self] in // ✅ Weak capture + guard let self = self else { return } + + while !Task.isCancelled { + await self.updateProgress() + } + } + } + + deinit { + progressTask?.cancel() + } +} +``` + +**Note**: Short-lived Tasks (not stored) can use strong captures. + +--- + +### Pattern 4: Background Work with @concurrent + +**When**: CPU-intensive work should always run on background (Swift 6.2+) + +```swift +@concurrent +func decodeImage(_ data: Data) async -> Image { + // ✅ Always runs on background thread pool + // Good for: image processing, file I/O, JSON parsing + return Image() +} + +// Usage +let image = await decodeImage(data) // Automatically offloads +``` + +**Requirements**: Swift 6.2+, Xcode 26+, iOS 26+ + +--- + +### Pattern 5: Isolated Protocol Conformances (Swift 6.2+) + +**When**: Type needs to conform to protocol with specific actor isolation + +```swift +protocol Exportable { + func export() +} + +class PhotoProcessor { + @MainActor + func exportAsPNG() { + // Export logic requiring UI access + } +} + +// ✅ Conform with explicit isolation +extension StickerModel: @MainActor Exportable { + func export() { + photoProcessor.exportAsPNG() // ✅ Safe: both on MainActor + } +} +``` + +**When to use**: Protocol methods need specific actor context (main actor for UI, background for processing) + +--- + +### Pattern 6: #isolation Capture for Non-Sendable Types (SE-0420) + +**When**: Task closure needs to work with non-Sendable delegates or objects + +```swift +func process( + delegate: NonSendableDelegate, + isolation: isolated (any Actor)? = #isolation +) { + Task { + _ = isolation // Forces capture — Task inherits caller's isolation + delegate.doWork() // ✅ Safe: running on caller's actor + } +} +``` + +**Why `_ = isolation` is required**: Per SE-0420, Task closures only inherit isolation when a non-optional binding of an isolated parameter is captured by the closure. Without this line, the Task runs on the default executor and the non-Sendable capture is an error. + +**When to use**: Spawning Tasks that work with non-Sendable delegate objects, fire-and-forget async work that needs access to caller's state, or bridging callback-based APIs to async streams. + +--- + +### Pattern 7: Task Timeout + +**When**: Async operation must complete within a deadline + +```swift +func withTimeout( + _ duration: Duration, + operation: @Sendable @escaping () async throws -> T +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(for: duration) + throw TimeoutError() + } + guard let result = try await group.next() else { + throw TimeoutError() + } + group.cancelAll() // Cancel the loser (critical — without this it keeps running) + return result + } +} + +// Usage +let data = try await withTimeout(.seconds(5)) { + try await slowNetworkRequest() +} +``` + +--- + +### Pattern 8: MainActor for UI Code + +**When**: Code touches UI + +```swift +@MainActor +class PlayerViewModel: ObservableObject { + @Published var currentTrack: Track? + @Published var isPlaying: Bool = false + + func play(_ track: Track) async { + // Already on MainActor + self.currentTrack = track + self.isPlaying = true + } +} +``` + +--- + +## Data Persistence Concurrency Patterns + +### Pattern 9: Background SwiftData Access + +```swift +actor DataFetcher { + let modelContainer: ModelContainer + + func fetchAllTracks() async throws -> [Track] { + let context = ModelContext(modelContainer) + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.title)] + ) + return try context.fetch(descriptor) + } +} + +@MainActor +class TrackViewModel: ObservableObject { + @Published var tracks: [Track] = [] + + func loadTracks() async { + let fetchedTracks = try await fetcher.fetchAllTracks() + self.tracks = fetchedTracks // Back on MainActor + } +} +``` + +### Pattern 10: Core Data Thread-Safe Fetch + +```swift +actor CoreDataFetcher { + func fetchTracksID(genre: String) async throws -> [String] { + let context = persistentContainer.newBackgroundContext() + var trackIDs: [String] = [] + + try await context.perform { + let request = NSFetchRequest(entityName: "Track") + request.predicate = NSPredicate(format: "genre = %@", genre) + let results = try context.fetch(request) + trackIDs = results.map { $0.id } // Extract IDs before leaving context + } + + return trackIDs // Lightweight, Sendable + } +} +``` + +### Pattern 11: Batch Import with Progress + +```swift +actor DataImporter { + func importRecords(_ records: [RawRecord], onProgress: @Sendable @MainActor (Int, Int) -> Void) async throws { + let chunkSize = 1000 + let context = ModelContext(modelContainer) + + for (index, chunk) in records.chunked(into: chunkSize).enumerated() { + for record in chunk { + context.insert(Track(from: record)) + } + try context.save() + + let processed = (index + 1) * chunkSize + await onProgress(min(processed, records.count), records.count) + + if Task.isCancelled { throw CancellationError() } + } + } +} +``` + +### Pattern 12: GRDB Background Execution + +```swift +actor DatabaseQueryExecutor { + let dbQueue: DatabaseQueue + + func fetchUserWithPosts(userId: String) async throws -> (user: User, posts: [Post]) { + return try await dbQueue.read { db in + let user = try User.filter(Column("id") == userId).fetchOne(db)! + let posts = try Post + .filter(Column("userId") == userId) + .order(Column("createdAt").desc) + .limit(100) + .fetchAll(db) + return (user, posts) + } + } +} +``` + +--- + +## Quick Decision Tree + +``` +Starting new feature? +└─ Is UI responsive with all operations on main thread? + ├─ YES → Stay single-threaded (Step 1) + └─ NO → Continue... + └─ Do you have high-latency operations? (network, file I/O) + ├─ YES → Add async/await (Step 2) + └─ NO → Continue... + └─ Do you have CPU-intensive work? (Instruments shows main thread busy) + ├─ YES → Add @concurrent or nonisolated (Step 3) + └─ NO → Continue... + └─ Is main actor contention causing slowdowns? + └─ YES → Extract subsystem to actor (Step 4) + +Error: "Main actor-isolated property accessed from nonisolated context" +├─ In delegate method? +│ └─ Pattern 2: Value Capture Before Task +├─ In async function? +│ └─ Add @MainActor or call from Task { @MainActor in } +└─ In @concurrent function? + └─ Move access to caller, use await, or make nonisolated + +Error: "Main actor-isolated property can not be referenced from a Sendable closure" +├─ Read-only access? +│ └─ Capture the value: { [count] in print(count) } +├─ Need mutation after background work? +│ └─ Extract background work to @concurrent func, call from @MainActor context: +│ func upload(_ data: Data) { Task { await doUpload(data); self.count += 1 } } +│ @concurrent func doUpload(_ data: Data) async { /* heavy work */ } +├─ Need mutation only? +│ └─ Task { @MainActor in self.count += 1 } +└─ Own the API? + └─ Make closure parameter @MainActor: (_ closure: @Sendable @MainActor () -> Void) + +Error: "Capture of non-sendable type in @Sendable closure" +├─ Type is a struct? +│ └─ Add `: Sendable` (all stored properties must be Sendable) +├─ Type is a class you own? +│ └─ Convert to actor, or make final + immutable + Sendable +└─ Migration staging? + └─ @unchecked Sendable temporarily, track removal ticket + +Error: "Value of non-Sendable type accessed after being transferred" +├─ Can avoid concurrent access? +│ └─ Capture as let: Task { [myArray] in ... } +├─ Need shared mutable access? +│ └─ Wrap in actor +└─ Redesign possible? + └─ Rework to avoid sharing mutable state across tasks + +Error: "Reference to captured var in concurrently-executing code" +├─ Variable doesn't need to be mutable? +│ └─ Change var to let +├─ Needs to stay var? +│ └─ Capture in closure: { [task] in ... } or shadow: let t = task +└─ In a loop? + └─ Create let binding inside loop body before Task + +Error: "Type does not conform to Sendable" +├─ Enum/struct with Sendable properties? +│ └─ Add `: Sendable` +└─ Class? + └─ Make @MainActor or keep non-Sendable (don't share concurrently) + +Want to offload work to background? +├─ Always background (image processing)? +│ └─ Use @concurrent (Swift 6.2+) +├─ Caller decides? +│ └─ Use nonisolated +└─ Too much main actor state? + └─ Extract to actor +``` + +--- + +## Build Settings (Xcode 16+) + +``` +Build Settings → Swift Compiler — Language +→ "Default Actor Isolation" = Main Actor +→ "Approachable Concurrency" = Yes + +Build Settings → Swift Compiler — Concurrency +→ "Strict Concurrency Checking" = Complete +``` + +**Swift Package Manager equivalent**: + +```swift +.target( + name: "MyTarget", + swiftSettings: [ + .swiftLanguageMode(.v6), + .defaultIsolation(MainActor.self) + ] +) +``` + +**What this enables**: +- Main actor mode (all code @MainActor by default) +- Compile-time data race prevention +- Progressive concurrency adoption + +--- + +## Anti-Patterns and Anti-Rationalizations + +| Rationalization | Why it's wrong | Do this instead | +|---|---|---| +| "I'll use `Task.detached` to make it background" | `Task.detached` loses actor context, priority, and task-local values. It's rarely what you want. | Use `@concurrent` (Swift 6.2+) for forced background. Use `Task {}` when you need actor inheritance. | +| "I'll add `@unchecked Sendable` to silence this" | You're hiding a data race from the compiler. It will crash in production. | Make the type genuinely Sendable (struct/enum), use an actor, or use `sending` parameter. | +| "I'll use `nonisolated(unsafe)` to fix this" | Zero runtime protection. The compiler stops checking — data races go undetected. | Use proper isolation (`@MainActor`, actor, Mutex). Reserve for global constants only. | +| "This async function runs on a background thread" | `async` suspends without blocking but resumes on the **same actor**. A `@MainActor` async function runs on the main thread. | Use `@concurrent` to force background. Don't assume async = background. | +| "I'll wrap this in `DispatchQueue.global().async`" | GCD queue-hopping inside structured concurrency breaks isolation guarantees and risks thread explosion. | Use `@concurrent` or extract to an actor. Keep GCD in bridge layers only. | +| "Every class needs to be an actor" | Actors add serialization overhead. UI code on a custom actor can't update views. | Use `@MainActor` for UI/ViewModel code. Actors are for non-UI shared mutable state only. | +| "I'll use `@preconcurrency` to ship faster" | You're assuming thread-safety the compiler can't verify. Crashes appear in production. | Migrate to proper concurrency. Use `@preconcurrency` only as a temporary bridge with a removal ticket. | +| "I need concurrency for this feature" | Concurrency adds complexity. Most app code runs fine single-threaded on MainActor. | Profile first. Only escalate when Instruments shows a bottleneck. | +| "Each Task maps to a thread" | Tasks share a cooperative thread pool. A task can resume on any thread after suspension. | Think in isolation domains, not threads. | +| "I'll spawn a Task for each piece of work" | Unstructured Tasks lose cancellation propagation and error handling. They're harder to reason about. | Use `async let` (fixed count) or `TaskGroup` (dynamic count) for related parallel work. Reserve `Task {}` for sync→async bridges. | + +### ❌ Using Concurrency When Not Needed + +```swift +// ❌ Premature optimization +@concurrent +func addNumbers(_ a: Int, _ b: Int) async -> Int { + return a + b // ❌ Trivial work, concurrency adds overhead +} + +// ✅ Keep simple +func addNumbers(_ a: Int, _ b: Int) -> Int { + return a + b +} +``` + +### ❌ Strong Self in Stored Tasks + +```swift +// ❌ Memory leak — task retains self, self retains task +progressTask = Task { + while true { + await self.update() // ❌ Strong capture in infinite loop + } +} + +// ✅ Weak capture breaks the cycle +progressTask = Task { [weak self] in + while let self, !Task.isCancelled { + await self.updateProgress() + } +} +``` + +**Rule of thumb**: Strong self is fine in short-lived Tasks that complete quickly. Use `[weak self]` when the Task is stored as a property or runs indefinitely (loops, async sequences). + +### ❌ Making Every Class an Actor + +```swift +// ❌ Don't do this +actor MyViewModel: ObservableObject { // ❌ UI code should be @MainActor! + @Published var state: State // ❌ Won't work correctly +} + +// ✅ Do this +@MainActor +class MyViewModel: ObservableObject { + @Published var state: State +} +``` + +### ❌ Thread.current in Async Contexts + +```swift +// ❌ Swift 6 language mode: compiler error +func checkThread() async { + print(Thread.current) // ERROR: unavailable from asynchronous contexts +} + +// ✅ If you must inspect threads (debugging only) +extension Thread { + static var currentThread: Thread { Thread.current } +} +print(Thread.currentThread) // Works, but don't rely on thread identity +``` + +**Why**: Tasks move between threads at suspension points. Thread identity is meaningless in Swift Concurrency — reason about isolation domains instead. + +--- + +## Code Review Checklist + +### Before Adding Concurrency +- [ ] Profiled and confirmed UI unresponsiveness +- [ ] Identified specific slow operations (network, CPU, contention) +- [ ] Started with simplest solution (async → concurrent → actors) + +### Async/Await +- [ ] Used for high-latency operations only +- [ ] Task creation in response to events +- [ ] Error handling with do-catch + +### Background Work +- [ ] `@concurrent` for always-background work (Swift 6.2+) +- [ ] `nonisolated` for library APIs +- [ ] No blocking operations on main actor + +### Sendable +- [ ] Value types for data crossing actors +- [ ] Classes stay @MainActor or non-Sendable +- [ ] No concurrent modification of shared classes + +### Actors +- [ ] Only for non-UI subsystems +- [ ] UI code stays @MainActor +- [ ] Model classes stay @MainActor or non-Sendable + +--- + +## Migration Habits + +When migrating an existing codebase to strict concurrency: + +1. **Iterate in small batches** — Fix one module or one diagnostic category at a time. Rebuild, test, commit. Don't batch. +2. **Design new types as Sendable from the start** — Retrofitting Sendable onto existing types cascades through the codebase. +3. **Set default isolation early** — For app modules, set `@MainActor` as the default isolation. This eliminates most false warnings. +4. **Avoid scope creep** — Concurrency migration PRs should contain only concurrency changes, not architecture refactors. +5. **Don't suppress warnings to ship faster** — `@unchecked Sendable`, `nonisolated(unsafe)`, and `@preconcurrency` are migration tools, not permanent solutions. Track each one with a removal ticket. + +--- + +## Resources + +**WWDC**: 2025-268, 2025-245, 2022-110351, 2021-10133 + +**Docs**: /swift/adoptingswift6, /swift/sendable + +**Skills**: axiom-lldb (debug actor/task state in the debugger) + +--- + +**Last Updated**: 2025-12-01 +**Status**: Enhanced with WWDC 2025-268 progressive journey, @concurrent attribute, isolated conformances, and approachable concurrency patterns diff --git a/.claude/skills/axiom-swift-concurrency/agents/openai.yaml b/.claude/skills/axiom-swift-concurrency/agents/openai.yaml new file mode 100644 index 0000000..c1ff49f --- /dev/null +++ b/.claude/skills/axiom-swift-concurrency/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Swift Concurrency" + short_description: "You see 'actor-isolated', 'Sendable', 'data race', '@MainActor' errors, or asking 'how do I use async/await', 'my app..." diff --git a/.claude/skills/axiom-swift-modern/.openskills.json b/.claude/skills/axiom-swift-modern/.openskills.json new file mode 100644 index 0000000..23d94ae --- /dev/null +++ b/.claude/skills/axiom-swift-modern/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swift-modern", + "installedAt": "2026-04-12T08:06:44.096Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swift-modern/SKILL.md b/.claude/skills/axiom-swift-modern/SKILL.md new file mode 100644 index 0000000..0f64e66 --- /dev/null +++ b/.claude/skills/axiom-swift-modern/SKILL.md @@ -0,0 +1,89 @@ +--- +name: axiom-swift-modern +description: Use when reviewing or generating Swift code for modern idiom correctness — catches outdated APIs, pre-Swift 5.5 patterns, and Foundation legacy usage that Claude defaults to +license: MIT +--- + +# Modern Swift Idioms + +## Purpose + +Claude frequently generates outdated Swift patterns from its training data. This skill corrects the most common ones — patterns that compile fine but use legacy APIs when modern equivalents are clearer, more efficient, or more correct. + +**Philosophy**: "Don't repeat what LLMs already know — focus on edge cases, surprises, soft deprecations." (Paul Hudson) + +## Modern API Replacements + +| Old Pattern | Modern Swift | Since | Why | +|-------------|-------------|-------|-----| +| `Date()` | `Date.now` | 5.6 | Clearer intent | +| `filter { }.count` | `count(where:)` | 5.0 | Single pass, no intermediate allocation | +| `replacingOccurrences(of:with:)` | `replacing(_:with:)` | 5.7 | Swift native, no Foundation bridge | +| `CGFloat` | `Double` | 5.5 | Implicit bridging; exceptions: optionals, inout, ObjC-bridged APIs | +| `Task.sleep(nanoseconds:)` | `Task.sleep(for: .seconds(1))` | 5.7 | Type-safe Duration API | +| `DateFormatter()` | `.formatted()` / `FormatStyle` | 5.5 | No instance management, localizable by default | +| `String(format: "%.2f", val)` | `val.formatted(.number.precision(.fractionLength(2)))` | 5.5 | Type-safe, localized | +| `localizedCaseInsensitiveContains()` | `localizedStandardContains()` | 5.0 | Handles diacritics, ligatures, width variants | +| `"\(firstName) \(lastName)"` | `PersonNameComponents` with `.formatted()` | 5.5 | Respects locale name ordering | +| `"yyyy-MM-dd"` with DateFormatter | `try Date(string, strategy: .iso8601)` | 5.6 | Modern parsing (throws); use "y" not "yyyy" for display | +| `contains()` on user input | `localizedStandardContains()` | 5.0 | Required for correct text search/filtering | + +## Modern Syntax + +| Old Pattern | Modern Swift | Since | +|-------------|-------------|-------| +| `if let value = value {` | `if let value {` | 5.7 | +| Explicit `return` in single-expression | Omit `return`; `if`/`switch` are expressions | 5.9 | +| `Circle()` in modifiers | `.circle` (static member lookup) | 5.5 | +| `import UIKit` alongside `import SwiftUI` | Often not needed — SwiftUI re-exports most UIKit/AppKit types. Retain for UIKit-only APIs (`UIApplication`, etc.) | 5.5 | + +## Foundation Modernization + +| Old Pattern | Modern Foundation | Since | +|-------------|------------------|-------| +| `FileManager.default.urls(for: .documentDirectory, ...)` | `URL.documentsDirectory` | 5.7 | +| `url.appendingPathComponent("file")` | `url.appending(path: "file")` | 5.7 | +| `books.sorted { $0.author < $1.author }` (repeated) | Conform to `Comparable`, call `.sorted()` | — | +| `"yyyy"` in date format for display | `"y"` — correct in all calendar systems | — | + +## SwiftUI Convenience APIs Claude Misses + +- **`ContentUnavailableView.search(text: searchText)`** (iOS 17+) automatically includes the search term — no need to compose a custom string +- **`LabeledContent` in Forms** (iOS 16+) provides consistent label alignment without manual HStack layout +- **`confirmationDialog()` must attach to triggering UI** — Liquid Glass morphing animations depend on the source element + +## Swift 6.3 Concurrency Posture + +Write Swift 6.3-first code, not Swift 5-era code. These defaults apply to ALL new Swift code, not just when concurrency errors appear. + +| Default | Rationale | +|---------|-----------| +| Assume strict concurrency and MainActor default isolation for app/UI modules | Swift 6.3 language mode; Xcode 26+ default for new projects | +| Prefer async/await over GCD, DispatchGroup, and callback pyramids | GCD is a bridge pattern for legacy APIs, not default architecture | +| Async does not mean background — use `@concurrent` (Swift 6.2+) to force off-main | Async functions resume on the same actor they were called from | +| Prefer structured concurrency (`async let`, `TaskGroup`) over unstructured `Task {}` | Structured tasks propagate cancellation and errors automatically | +| Do not use `Task.detached` unless there is a specific, stated reason | Loses actor context, priority, and task-local values | +| Prefer Sendable structs/enums for data that crosses actor boundaries | Value types are inherently safe to share | +| Use actors only for truly shared mutable state across concurrency domains | Don't make every class an actor — UI code stays @MainActor | +| Treat `@unchecked Sendable`, `@preconcurrency`, `nonisolated(unsafe)` as temporary bridge tools | Each should have a removal ticket, not be permanent | +| Do not add escape hatches just to silence compiler errors | They hide data races that crash in production | + +For detailed patterns, decision trees, and error-specific guidance, see `axiom-swift-concurrency`. + +## Common Claude Hallucinations + +These patterns appear frequently in Claude-generated code: + +1. **Creates `DateFormatter` instances inline** — Use `.formatted()` or `FormatStyle` instead. If a formatter must exist, make it `static let`. +2. **Uses `DispatchQueue.main.async`** — Use `@MainActor` or `MainActor.run`. GCD is a bridge pattern, not a default. +3. **Uses `DispatchQueue.global().async` for background work** — Use `@concurrent` (Swift 6.2+) or extract to an actor. +4. **Uses `Task.detached` to "make it background"** — Use `@concurrent`. `Task.detached` loses actor context. +5. **Uses `CGFloat` for SwiftUI parameters** — `Double` works everywhere since Swift 5.5 implicit bridging. +6. **Generates `guard let x = x else`** — Use `guard let x else` shorthand. +7. **Returns explicitly in single-expression computed properties** — Omit `return`. +8. **Spawns unstructured `Task {}` in loops** — Use `TaskGroup` for dynamic parallel work. +9. **Adds `@unchecked Sendable` to silence warnings** — Convert to actor or proper Sendable type. + +## Resources + +**Skills**: axiom-swift-performance, axiom-swift-concurrency, axiom-swiftui-architecture diff --git a/.claude/skills/axiom-swift-modern/agents/openai.yaml b/.claude/skills/axiom-swift-modern/agents/openai.yaml new file mode 100644 index 0000000..5b43492 --- /dev/null +++ b/.claude/skills/axiom-swift-modern/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Swift Modern" + short_description: "Reviewing or generating Swift code for modern idiom correctness" diff --git a/.claude/skills/axiom-swift-performance/.openskills.json b/.claude/skills/axiom-swift-performance/.openskills.json new file mode 100644 index 0000000..781c641 --- /dev/null +++ b/.claude/skills/axiom-swift-performance/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swift-performance", + "installedAt": "2026-04-12T08:06:44.489Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swift-performance/SKILL.md b/.claude/skills/axiom-swift-performance/SKILL.md new file mode 100644 index 0000000..4335c17 --- /dev/null +++ b/.claude/skills/axiom-swift-performance/SKILL.md @@ -0,0 +1,1236 @@ +--- +name: axiom-swift-performance +description: Use when optimizing Swift code performance, reducing memory usage, improving runtime efficiency, dealing with COW, ARC overhead, generics specialization, or collection optimization +license: MIT +metadata: + version: "1.2.0" +--- + +# Swift Performance Optimization + +## Purpose + +**Core Principle**: Optimize Swift code by understanding language-level performance characteristics—value semantics, ARC behavior, generic specialization, and memory layout—to write fast, efficient code without premature micro-optimization. + +**Swift Version**: Swift 6.2+ (for InlineArray, Span, `@concurrent`) +**Xcode**: 16+ +**Platforms**: iOS 18+, macOS 15+ + +**Related Skills**: +- `axiom-performance-profiling` — Use Instruments to measure (do this first!) +- `axiom-swiftui-performance` — SwiftUI-specific optimizations +- `axiom-build-performance` — Compilation speed +- `axiom-swift-concurrency` — Correctness-focused concurrency patterns + +## When to Use This Skill + +### ✅ Use this skill when + +- App profiling shows Swift code as the bottleneck (Time Profiler hotspots) +- Excessive memory allocations or retain/release traffic +- Implementing performance-critical algorithms or data structures +- Writing framework or library code with performance requirements +- Optimizing tight loops or frequently called methods +- Dealing with large data structures or collections +- Code review identifying performance anti-patterns + +## Quick Decision Tree + +``` +Performance issue identified? +│ +├─ Profiler shows excessive copying? +│ └─ → Part 1: Noncopyable Types +│ └─ → Part 2: Copy-on-Write +│ +├─ Retain/release overhead in Time Profiler? +│ └─ → Part 4: ARC Optimization +│ +├─ Generic code in hot path? +│ └─ → Part 5: Generics & Specialization +│ +├─ Collection operations slow? +│ └─ → Part 7: Collection Performance +│ +├─ Async/await overhead visible? +│ └─ → Part 8: Concurrency Performance +│ +├─ Struct vs class decision? +│ └─ → Part 3: Value vs Reference +│ +└─ Memory layout concerns? + └─ → Part 9: Memory Layout +``` + +--- + +## The Four Principles of Swift Performance + +From WWDC 2024-10217: Swift's low-level performance characteristics come down to four areas. Each maps to a Part in this skill. + +| Principle | What It Costs | Skill Coverage | +|-----------|--------------|----------------| +| **Function Calls** | Dispatch overhead, optimization barriers | Part 5 (Generics), Part 6 (Inlining) | +| **Memory Allocation** | Stack vs heap, allocation frequency | Part 3 (Value vs Reference), Part 7 (Collections) | +| **Memory Layout** | Cache locality, padding, contiguity | Part 9 (Memory Layout), Part 11 (Span) | +| **Value Copying** | COW triggers, defensive copies, ARC traffic | Part 1 (Noncopyable), Part 2 (COW), Part 4 (ARC) | + +Understanding which principle is causing your bottleneck determines which Part to use. + +--- + +## Part 1: Noncopyable Types (~Copyable) + +**Swift 6.0+** introduces noncopyable types for performance-critical scenarios where you want to avoid implicit copies. + +### When to Use + +- Large types that should never be copied (file handles, GPU buffers) +- Types with ownership semantics (must be explicitly consumed) +- Performance-critical code where copies are expensive + +### Basic Pattern + +```swift +// Noncopyable type +struct FileHandle: ~Copyable { + private let fd: Int32 + + init(path: String) throws { + self.fd = open(path, O_RDONLY) + guard fd != -1 else { throw FileError.openFailed } + } + + deinit { + close(fd) + } + + // Must explicitly consume + consuming func close() { + _ = consume self + } +} + +// Usage +func processFile() throws { + let handle = try FileHandle(path: "/data.txt") + // handle is automatically consumed at end of scope + // Cannot accidentally copy handle +} +``` + +### Ownership Annotations + +```swift +// consuming - takes ownership, caller cannot use after +func process(consuming data: [UInt8]) { + // data is consumed +} + +// borrowing - temporary access without ownership +func validate(borrowing data: [UInt8]) -> Bool { + // data can still be used by caller + return data.count > 0 +} + +// inout - mutable access +func modify(inout data: [UInt8]) { + data.append(0) +} +``` + +### Performance Impact + +- **Eliminates implicit copies**: Compiler error instead of runtime copy +- **Zero-cost abstraction**: Same performance as manual memory management +- **Use when**: Type is expensive to copy (>64 bytes) and copies are rare + +--- + +## Part 2: Copy-on-Write (COW) + +Swift collections use COW for efficient memory sharing. Understanding when copies happen is critical for performance. + +### How COW Works + +```swift +var array1 = [1, 2, 3] // Single allocation +var array2 = array1 // Share storage (no copy) +array2.append(4) // Now copies (array1 modified array2) +``` + +For custom COW implementation, see Copy-Paste Pattern 1 (COW Wrapper) below. + +### Performance Tips + +```swift +// ❌ Accidental copy in loop +for i in 0.. 64 bytes or contains large data | +| **Identity** | No identity needed | Needs identity (===) | +| **Inheritance** | Not needed | Inheritance required | +| **Mutation** | Infrequent | Frequent in-place updates | +| **Sharing** | No sharing needed | Must be shared across scope | + +### Small Structs (Fast) + +```swift +// ✅ Fast - fits in registers, no heap allocation +struct Point { + var x: Double // 8 bytes + var y: Double // 8 bytes +} // Total: 16 bytes - excellent for struct + +struct Color { + var r, g, b, a: UInt8 // 4 bytes total - perfect for struct +} +``` + +### Large Structs (Slow) + +```swift +// ❌ Slow - excessive copying +struct HugeData { + var buffer: [UInt8] // 1MB + var metadata: String +} + +func process(_ data: HugeData) { // Copies 1MB! + // ... +} + +// ✅ Use reference semantics for large data +final class HugeData { + var buffer: [UInt8] + var metadata: String +} + +func process(_ data: HugeData) { // Only copies pointer (8 bytes) + // ... +} +``` + +### Indirect Storage for Flexibility + +For large data that needs value semantics externally with reference storage internally, use the COW Wrapper pattern — see Copy-Paste Pattern 1 below. + +--- + +## Part 4: ARC Optimization + +Automatic Reference Counting adds overhead. Minimize it where possible. + +### Weak vs Unowned Performance + +```swift +class Parent { + var child: Child? +} + +class Child { + // ❌ Weak adds overhead (optional, thread-safe zeroing) + weak var parent: Parent? +} + +// ✅ Unowned when you know lifetime guarantees +class Child { + unowned let parent: Parent // No overhead, crashes if parent deallocated +} +``` + +**Performance**: `unowned` is ~2x faster than `weak` (no atomic operations). + +**Use when**: Child lifetime < Parent lifetime (guaranteed). + +### Closure Capture Optimization + +```swift +class DataProcessor { + var data: [Int] + + // ❌ Captures self strongly, then uses weak - unnecessary weak overhead + func process(completion: @escaping () -> Void) { + DispatchQueue.global().async { [weak self] in + guard let self else { return } + self.data.forEach { print($0) } + completion() + } + } + + // ✅ Capture only what you need + func process(completion: @escaping () -> Void) { + let data = self.data // Copy value type + DispatchQueue.global().async { + data.forEach { print($0) } // No self captured + completion() + } + } +} +``` + +### Closure Capture Costs + +From WWDC 2024-10217: Closures have different performance profiles depending on whether they escape. + +```swift +// Non-escaping closure — stack-allocated context, zero ARC overhead +func processItems(_ items: [Item], using transform: (Item) -> Result) -> [Result] { + items.map(transform) // Closure context lives on stack +} + +// Escaping closure — heap-allocated context, ARC on every captured reference +func processItemsLater(_ items: [Item], transform: @escaping (Item) -> Result) { + // Closure context heap-allocated as anonymous class instance + // Each captured reference gets retain/release + self.pending = { items.map(transform) } +} +``` + +**Why this matters**: `@Sendable` closures are always escaping, meaning every Task closure heap-allocates its capture context. + +**In hot paths**: Prefer non-escaping closures. If you see `swift_allocObject` in Time Profiler for closure contexts, look for escaping closures that could be non-escaping. + +### Observable Object Lifetimes + +**From WWDC 2021-10216**: Object lifetimes end at **last use**, not at closing brace. + +```swift +// ❌ Relying on observed lifetime is fragile +class Traveler { + weak var account: Account? + + deinit { + print("Deinitialized") // May run BEFORE expected with ARC optimizations! + } +} + +func test() { + let traveler = Traveler() + let account = Account(traveler: traveler) + // traveler's last use is above - may deallocate here! + account.printSummary() // weak reference may be nil! +} + +// ✅ Explicitly extend lifetime when needed +func test() { + let traveler = Traveler() + let account = Account(traveler: traveler) + + withExtendedLifetime(traveler) { + account.printSummary() // traveler guaranteed to live + } +} +``` + +Object lifetimes can change between Xcode versions, Debug vs Release, and unrelated code changes. Enable "Optimize Object Lifetimes" (Xcode 13+) during development to expose hidden lifetime bugs early. + +--- + +## Part 5: Generics & Specialization + +Generic code can be fast or slow depending on specialization. + +### Specialization Basics + +```swift +// Generic function +func process(_ value: T) { + print(value) +} + +// Calling with concrete type +process(42) // Compiler specializes: process_Int(42) +process("hello") // Compiler specializes: process_String("hello") +``` + +### Existential Overhead + +```swift +protocol Drawable { + func draw() +} + +// ❌ Existential container - expensive (heap allocation, indirection) +func drawAll(shapes: [any Drawable]) { + for shape in shapes { + shape.draw() // Dynamic dispatch through witness table + } +} + +// ✅ Generic with constraint - can specialize +func drawAll(shapes: [T]) { + for shape in shapes { + shape.draw() // Static dispatch after specialization + } +} +``` + +**Performance**: Generic version ~10x faster (eliminates witness table overhead). + +### Existential Container Overhead + +**From WWDC 2016-416**: `any Protocol` uses a 40-byte existential container (5 words on 64-bit). The container stores type metadata + protocol witness table (16 bytes) plus a 24-byte inline value buffer. Types ≤24 bytes are stored directly in the buffer (fast, ~5ns access); larger types require a heap allocation with pointer indirection (slower, ~15ns). `some Protocol` eliminates all container overhead (~2ns). + +**When `some` isn't available** (heterogeneous collections require `any`): +- **Reduce type sizes to ≤24 bytes** — keep protocol-conforming types small enough for inline storage (3 words: e.g., `Point { x, y, z: Double }` fits exactly) +- **Use enum dispatch instead** — eliminates containers entirely, trades open extensibility for performance: + +```swift +// ❌ Existential: 40 bytes/element, witness table dispatch +let shapes: [any Drawable] = [circle, rect] + +// ✅ Enum: value-sized, static dispatch via switch +enum Shape { case circle(Circle), rect(Rect) } +func draw(_ shape: Shape) { + switch shape { + case .circle(let c): c.draw() + case .rect(let r): r.draw() + } +} +``` + +- **Batch operations** — amortize per-element existential overhead by processing in chunks rather than one-at-a-time +- **Measure first** — existential overhead (~10ns/access) only matters in tight loops; for UI-level code it's negligible + +### `@_specialize` Attribute + +Force specialization for common types when the compiler doesn't do it automatically: + +```swift +@_specialize(where T == Int) +@_specialize(where T == String) +func process(_ value: T) -> T { value } +// Generates specialized versions + generic fallback +``` + +--- + +## Part 6: Inlining + +Inlining eliminates function call overhead but increases code size. + +### When to Inline + +```swift +// ✅ Small, frequently called functions +@inlinable +public func fastAdd(_ a: Int, _ b: Int) -> Int { + return a + b +} + +// ❌ Large functions - code bloat +@inlinable // Don't do this! +public func complexAlgorithm() { + // 100 lines of code... +} +``` + +### Cross-Module Optimization + +```swift +// Framework code +public struct Point { + public var x: Double + public var y: Double + + // ✅ Inlinable for cross-module optimization + @inlinable + public func distance(to other: Point) -> Double { + let dx = x - other.x + let dy = y - other.y + return sqrt(dx*dx + dy*dy) + } +} + +// Client code +let p1 = Point(x: 0, y: 0) +let p2 = Point(x: 3, y: 4) +let d = p1.distance(to: p2) // Inlined across module boundary +``` + +### `@usableFromInline` + +```swift +// Internal helper that can be inlined +@usableFromInline +internal func helperFunction() { } + +// Public API that uses it +@inlinable +public func publicAPI() { + helperFunction() // Can inline internal function +} +``` + +**Trade-off**: `@inlinable` exposes implementation, prevents future optimization. + +--- + +## Part 7: Collection Performance + +Choosing the right collection and using it correctly matters. + +### Array vs ContiguousArray + +```swift +// ❌ Array - may use NSArray bridging (Swift/ObjC interop) +let array: Array = [1, 2, 3] + +// ✅ ContiguousArray - guaranteed contiguous memory (no bridging) +let array: ContiguousArray = [1, 2, 3] +``` + +**Use `ContiguousArray` when**: No ObjC bridging needed (pure Swift), ~15% faster. + +### Reserve Capacity + +```swift +// ❌ Multiple reallocations +var array: [Int] = [] +for i in 0..<10000 { + array.append(i) // Reallocates ~14 times +} + +// ✅ Single allocation +var array: [Int] = [] +array.reserveCapacity(10000) +for i in 0..<10000 { + array.append(i) // No reallocations +} +``` + +### Dictionary Hashing + +```swift +struct BadKey: Hashable { + var data: [Int] + + // ❌ Expensive hash (iterates entire array) + func hash(into hasher: inout Hasher) { + for element in data { + hasher.combine(element) + } + } +} + +struct GoodKey: Hashable { + var id: UUID // Fast hash + var data: [Int] // Not hashed + + // ✅ Hash only the unique identifier + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} +``` + +### InlineArray (Swift 6.2) + +Fixed-size arrays stored directly on the stack—no heap allocation, no COW overhead. Uses value generics to encode size in the type. + +```swift +// Traditional Array - heap allocated, COW overhead +var sprites: [Sprite] = Array(repeating: .default, count: 40) + +// InlineArray - stack allocated, no COW (value generic syntax) +var sprites = InlineArray<40, Sprite>(repeating: .default) +``` + +**Conformances**: `RandomAccessCollection`, `MutableCollection`, `BitwiseCopyable`, `Sendable`. Supports `~Copyable` element types. + +**When to Use InlineArray**: +- Fixed size known at compile time +- Performance-critical paths (tight loops, hot paths) +- Want to avoid heap allocation entirely +- Small to medium sizes (practical limit ~1KB stack usage) + +InlineArray is stack-allocated (no heap), eagerly copied (not COW), and provides `.span`/`.mutableSpan` for zero-copy access. Measure your own benchmarks for allocation/copy/mutation trade-offs vs Array. + +**Copy Semantics Warning**: +```swift +// ❌ Unexpected: InlineArray copies eagerly +func processLarge(_ data: InlineArray<1000, UInt8>) { + // Copies all 1000 bytes on call! +} + +// ✅ Use Span to avoid copy +func processLarge(_ data: Span) { + // Zero-copy view, no matter the size +} + +// Best practice: Store InlineArray, pass Span +struct Buffer { + var storage = InlineArray<1000, UInt8>(repeating: 0) + + func process() { + helper(storage.span) // Pass view, not copy + } +} +``` + +**When NOT to Use InlineArray**: +- Dynamic sizes (use Array) +- Large data (>1KB stack usage risky) +- Frequently passed by value (use Span instead) +- Need COW semantics (use Array) + +### Lazy Sequences + +```swift +// ❌ Eager evaluation - processes entire array +let result = array + .map { expensive($0) } + .filter { $0 > 0 } + .first // Only need first element! + +// ✅ Lazy evaluation - stops at first match +let result = array + .lazy + .map { expensive($0) } + .filter { $0 > 0 } + .first // Only evaluates until first match +``` + +--- + +## Part 8: Concurrency Performance + +Async/await and actors add overhead. Use appropriately. + +### Actor Isolation Overhead + +```swift +actor Counter { + private var value = 0 + + // ❌ Actor call overhead for simple operation + func increment() { + value += 1 + } +} + +// Calling from different isolation domain +for _ in 0..<10000 { + await counter.increment() // 10,000 actor hops! +} + +// ✅ Batch operations to reduce actor overhead +actor Counter { + private var value = 0 + + func incrementBatch(_ count: Int) { + value += count + } +} + +await counter.incrementBatch(10000) // Single actor hop +``` + +### Async Overhead + +Each async suspension costs ~20-30μs. Keep synchronous operations synchronous—don't mark a function `async` if it doesn't need to await. + +### Task Creation Cost + +```swift +// ❌ Creating task per item (~100μs overhead each) +for item in items { + Task { + await process(item) + } +} + +// ✅ Single task for batch +Task { + for item in items { + await process(item) + } +} + +// ✅ Or use TaskGroup for parallelism +await withTaskGroup(of: Void.self) { group in + for item in items { + group.addTask { + await process(item) + } + } +} +``` + +### `@concurrent` Attribute (Swift 6.2) + +```swift +// Force background execution +@concurrent +func expensiveComputation() -> Int { + // Always runs on background thread, even if called from MainActor + return complexCalculation() +} + +// Safe to call from main actor without blocking +@MainActor +func updateUI() async { + let result = await expensiveComputation() // Guaranteed off main thread + label.text = "\(result)" +} +``` + +For `nonisolated` performance patterns and detailed actor isolation guidance, see `axiom-swift-concurrency`. + +--- + +## Part 9: Memory Layout + +Understanding memory layout helps optimize cache performance and reduce allocations. + +### Struct Padding + +```swift +// ❌ Poor layout (24 bytes due to padding) +struct BadLayout { + var a: Bool // 1 byte + 7 padding + var b: Int64 // 8 bytes + var c: Bool // 1 byte + 7 padding +} +print(MemoryLayout.size) // 24 bytes + +// ✅ Optimized layout (16 bytes) +struct GoodLayout { + var b: Int64 // 8 bytes + var a: Bool // 1 byte + var c: Bool // 1 byte + 6 padding +} +print(MemoryLayout.size) // 16 bytes +``` + +### Alignment + +```swift +// Query alignment +print(MemoryLayout.alignment) // 8 +print(MemoryLayout.alignment) // 4 + +// Structs align to largest member +struct Mixed { + var int32: Int32 // 4 bytes, 4-byte aligned + var double: Double // 8 bytes, 8-byte aligned +} +print(MemoryLayout.alignment) // 8 (largest member) +``` + +### Cache-Friendly Data Structures + +```swift +// ❌ Poor cache locality +struct PointerBased { + var next: UnsafeMutablePointer? // Pointer chasing +} + +// ✅ Array-based for cache locality +struct ArrayBased { + var data: ContiguousArray // Contiguous memory +} + +// Array iteration ~10x faster due to cache prefetching +``` + +### Exclusivity Checks + +From WWDC 2025-312: Runtime exclusivity enforcement (`swift_beginAccess`/`swift_endAccess`) appears in Time Profiler when the compiler cannot prove memory safety statically. + +**What they are**: Swift enforces that no two accesses to the same variable overlap if one is a write. For struct properties, this is checked at compile time. For class stored properties, runtime checks are inserted. + +**How to identify**: Look for `swift_beginAccess` and `swift_endAccess` in Time Profiler or Processor Trace flame graphs. + +```swift +// ❌ Class properties require runtime exclusivity checks +class Parser { + var state: ParserState + var cache: [Int: Pixel] + + func parse() { + state.advance() // swift_beginAccess / swift_endAccess + cache[key] = pixel // swift_beginAccess / swift_endAccess + } +} + +// ✅ Struct properties checked at compile time — zero runtime cost +struct Parser { + var state: ParserState + var cache: InlineArray<64, Pixel> + + mutating func parse() { + state.advance() // No runtime check + cache[key] = pixel // No runtime check + } +} +``` + +**Real-world impact**: In WWDC 2025-312's QOI image parser, moving properties from a class to a struct eliminated all runtime exclusivity checks, contributing to a measurable speedup as part of a >700x total improvement. + +--- + +## Part 10: Typed Throws (Swift 6) + +Typed throws can be faster than untyped by avoiding existential overhead. + +### Untyped vs Typed + +```swift +// Untyped - existential container for error +func fetchData() throws -> Data { + // Can throw any Error + throw NetworkError.timeout +} + +// Typed - concrete error type +func fetchData() throws(NetworkError) -> Data { + // Can only throw NetworkError + throw NetworkError.timeout +} +``` + +### Performance Impact + +```swift +// Measure with tight loop +func untypedThrows() throws -> Int { + throw GenericError.failed +} + +func typedThrows() throws(GenericError) -> Int { + throw GenericError.failed +} + +// Benchmark: typed ~5-10% faster (no existential overhead) +``` + +### When to Use + +- **Typed**: Library code with well-defined error types, hot paths +- **Untyped**: Application code, error types unknown at compile time + +--- + +## Part 11: Span Types + +**Swift 6.2+** introduces Span—a non-escapable, non-owning view into memory that provides safe, efficient access to contiguous data. + +### What is Span? + +Span is a modern replacement for `UnsafeBufferPointer` that provides: +- **Spatial safety**: Bounds-checked operations prevent out-of-bounds access +- **Temporal safety**: Lifetime inherited from source, preventing use-after-free +- **Zero overhead**: No heap allocation, no reference counting +- **Non-escapable**: Cannot outlive the data it references + +```swift +// Traditional unsafe approach +func processUnsafe(_ data: UnsafeMutableBufferPointer) { + data[100] = 0 // Crashes if out of bounds! +} + +// Safe Span approach +func processSafe(_ data: MutableSpan) { + data[100] = 0 // Traps with clear error if out of bounds +} +``` + +### When to Use Span vs Array vs UnsafeBufferPointer + +| Use Case | Recommendation | +|----------|---------------| +| **Own the data** | Array (full ownership, COW) | +| **Temporary view for reading** | Span (safe, fast) | +| **Temporary view for writing** | MutableSpan (safe, fast) | +| **C interop, performance-critical** | RawSpan (untyped bytes) | +| **Unsafe performance** | UnsafeBufferPointer (legacy, avoid) | + +### Basic Span Usage + +```swift +let array = [1, 2, 3, 4, 5] +let span = array.span // Read-only view +print(span[0]) // Subscript access +for element in span { } // Safe iteration +let slice = span[1..<3] // Span slice, no copy +``` + +### MutableSpan for Modifications + +```swift +var array = [10, 20, 30, 40, 50] +let mutableSpan = array.mutableSpan +mutableSpan[0] = 100 // Modifies array in-place, bounds-checked +``` + +### RawSpan for Untyped Bytes + +```swift +func parsePacket(_ data: RawSpan) -> PacketHeader? { + guard data.count >= MemoryLayout.size else { return nil } + // Safe byte-level access via subscript + return PacketHeader(version: data[0], flags: data[1], + length: UInt16(data[3]) << 8 | UInt16(data[2])) +} + +let header = parsePacket(bytes.rawSpan) // .rawSpan on any [UInt8] +``` + +All Swift 6.2 collections provide `.span` and `.mutableSpan` properties, including `Array`, `ContiguousArray`, and `UnsafeBufferPointer` (migration path). Span access speed matches `UnsafeBufferPointer` (~2ns) with bounds checking. + +### Non-Escapable Lifetime Safety + +Span's lifetime is bound to its source. The compiler prevents returning a Span from a function where the source would be deallocated — unlike `UnsafeBufferPointer`, which allows this bug silently. + +```swift +func dangerousSpan() -> Span { + let array = [1, 2, 3] + return array.span // ❌ Error: Cannot return non-escapable value +} +``` + +InlineArray also provides `.span`/`.mutableSpan` — see Part 7 for InlineArray usage and copy-avoidance via Span. + +### Migration from UnsafeBufferPointer + +```swift +// ❌ Old: unsafe, no bounds checking +func parseLegacy(_ buffer: UnsafeBufferPointer) -> Header { + Header(magic: buffer[0], version: buffer[1]) // Silent OOB crash +} + +// ✅ New: safe, bounds-checked, same performance +func parseModern(_ span: Span) -> Header { + Header(magic: span[0], version: span[1]) // Traps on OOB +} + +// Bridge: existing UnsafeBufferPointer → Span +let span = buffer.span // Wrap unsafe in safe span +parseModern(span) +``` + +### OutputSpan — Safe Initialization + +OutputSpan/OutputRawSpan replace `UnsafeMutableBufferPointer` for initializing new collections without intermediate allocations. + +```swift +// Binary serialization: write header bytes safely +@lifetime(&output) +func writeHeader(to output: inout OutputRawSpan) { + output.append(0x01) // version + output.append(0x00) // flags + output.append(UInt16(42)) // length (type-safe) +} +``` + +Use for building byte arrays, binary serialization, image pixel data. Apple's open-source [Swift Binary Parsing](https://github.com/apple/swift-binary-parsing) library is built entirely on Span types. + +### When NOT to Use Span + +- **Ownership**: Span can't be stored in structs/classes — use Array for owned data, provide `.span` access via computed property +- **Return values**: Span is non-escapable — process in scope, return owned data +- **Long-lived references**: Span lifetime is bound to source — use Array if data must outlive the current scope + +--- + +## Copy-Paste Patterns + +### Pattern 1: COW Wrapper + +```swift +final class Storage { + var value: T + init(_ value: T) { self.value = value } +} + +struct COWWrapper { + private var storage: Storage + + init(_ value: T) { + storage = Storage(value) + } + + var value: T { + get { storage.value } + set { + if !isKnownUniquelyReferenced(&storage) { + storage = Storage(newValue) + } else { + storage.value = newValue + } + } + } +} +``` + +### Pattern 2: Performance-Critical Loop + +```swift +func processLargeArray(_ input: [Int]) -> [Int] { + var result = ContiguousArray() + result.reserveCapacity(input.count) + + for element in input { + result.append(transform(element)) + } + + return Array(result) +} +``` + +### Pattern 3: Inline Cache Lookup + +```swift +private var cache: [Key: Value] = [:] + +@inlinable +func getCached(_ key: Key) -> Value? { + return cache[key] // Inlined across modules +} +``` + +--- + +## Anti-Patterns + +| Anti-Pattern | Problem | Fix | +|---|---|---| +| **Premature optimization** | Complex COW/ContiguousArray with no profiling data | Start simple, profile, optimize what matters | +| **Weak everywhere** | `weak` on every delegate (atomic overhead) | Use `unowned` when lifetime is guaranteed (see Part 4) | +| **Actor for everything** | Actor isolation on simple counters (~100μs/call) | Use lock-free atomics (`ManagedAtomic`) for simple sync data | + +--- + +## Code Review Checklist + +### Memory Management +- [ ] Large structs (>64 bytes) use indirect storage or are classes +- [ ] COW types use `isKnownUniquelyReferenced` before mutation +- [ ] Collections use `reserveCapacity` when size is known +- [ ] Weak references only where needed (prefer unowned when safe) + +### Generics +- [ ] Protocol types use `some` instead of `any` where possible +- [ ] Hot paths use concrete types or `@_specialize` +- [ ] Generic constraints are as specific as possible + +### Collections +- [ ] Pure Swift code uses `ContiguousArray` over `Array` +- [ ] Dictionary keys have efficient `hash(into:)` implementations +- [ ] Lazy evaluation used for short-circuit operations + +### Concurrency +- [ ] Synchronous operations don't use `async` +- [ ] Actor calls are batched when possible +- [ ] Task creation is minimized (use TaskGroup) +- [ ] CPU-intensive work uses `@concurrent` (Swift 6.2) + +### Optimization +- [ ] Profiling data exists before optimization +- [ ] Inlining only for small, frequently called functions +- [ ] Memory layout optimized for cache locality (large structs) + +--- + +## Pressure Scenarios + +### Scenario 1: "Just make it faster, we ship tomorrow" + +**The Pressure**: Manager sees "slow" in profiler, demands immediate action. + +**Red Flags**: +- No baseline measurements +- No Time Profiler data showing hotspots +- "Make everything faster" without targets + +**Time Cost Comparison**: +- Premature optimization: 2 days of work, no measurable improvement +- Profile-guided optimization: 2 hours profiling + 4 hours fixing actual bottleneck = 40% faster + +**How to Push Back Professionally**: +``` +"I want to optimize effectively. Let me spend 30 minutes with Instruments +to find the actual bottleneck. This prevents wasting time on code that's +not the problem. I've seen this save days of work." +``` + +### Scenario 2: "Use actors everywhere for thread safety" + +**The Pressure**: Team adopts Swift 6, decides "everything should be an actor." + +**Red Flags**: +- Actor for simple value types +- Actor for synchronous-only operations +- Async overhead in tight loops + +**Time Cost Comparison**: +- Actor everywhere: 100μs overhead per operation, janky UI +- Appropriate isolation: 10μs overhead, smooth 60fps + +**How to Push Back Professionally**: +``` +"Actors are great for isolation, but they add overhead. For this simple +counter, lock-free atomics are 10x faster. Let's use actors where we need +them—shared mutable state—and avoid them for pure value types." +``` + +### Scenario 3: "Inline everything for speed" + +**The Pressure**: Someone reads that inlining is faster, marks everything `@inlinable`. + +**Red Flags**: +- Large functions marked `@inlinable` +- Internal implementation details exposed +- Binary size increases 50% + +**Time Cost Comparison**: +- Inline everything: Code bloat, slower app launch (3s → 5s) +- Selective inlining: Fast launch, actual hotspots optimized + +**How to Push Back Professionally**: +``` +"Inlining trades code size for speed. The compiler already inlines when +beneficial. Manual @inlinable should be for small, frequently called +functions. Let's profile and inline the 3 actual hotspots, not everything." +``` + +--- + +## Real-World Examples + +### Example 1: Image Processing Pipeline + +**Problem**: Processing 1000 images takes 30 seconds. + +**Investigation**: +```swift +// Original code +func processImages(_ images: [UIImage]) -> [ProcessedImage] { + var results: [ProcessedImage] = [] + for image in images { + results.append(expensiveProcess(image)) // Reallocations! + } + return results +} +``` + +**Solution**: +```swift +func processImages(_ images: [UIImage]) -> [ProcessedImage] { + var results = ContiguousArray() + results.reserveCapacity(images.count) // Single allocation + + for image in images { + results.append(expensiveProcess(image)) + } + + return Array(results) +} +``` + +**Result**: 30s → 8s (73% faster) by eliminating reallocations. + +### Example 2: Generic Specialization + +**Problem**: Protocol-based rendering is slow. + +**Investigation**: +```swift +// Original - existential overhead +func render(shapes: [any Shape]) { + for shape in shapes { + shape.draw() // Dynamic dispatch + } +} +``` + +**Solution**: +```swift +// Specialized generic +func render(shapes: [S]) { + for shape in shapes { + shape.draw() // Static dispatch after specialization + } +} + +// Or use @_specialize +@_specialize(where S == Circle) +@_specialize(where S == Rectangle) +func render(shapes: [S]) { } +``` + +**Result**: 100ms → 10ms (10x faster) by eliminating witness table overhead. + +--- + +## Resources + +**WWDC**: 2025-312, 2024-10217, 2024-10170, 2021-10216, 2016-416 + +**Docs**: /swift/inlinearray, /swift/span, /swift/outputspan + +**Skills**: axiom-performance-profiling, axiom-swift-concurrency, axiom-swiftui-performance + +--- diff --git a/.claude/skills/axiom-swift-performance/agents/openai.yaml b/.claude/skills/axiom-swift-performance/agents/openai.yaml new file mode 100644 index 0000000..f4d7214 --- /dev/null +++ b/.claude/skills/axiom-swift-performance/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Swift Performance" + short_description: "Optimizing Swift code performance, reducing memory usage, improving runtime efficiency, dealing with COW, ARC overhea..." diff --git a/.claude/skills/axiom-swift-testing/.openskills.json b/.claude/skills/axiom-swift-testing/.openskills.json new file mode 100644 index 0000000..364385c --- /dev/null +++ b/.claude/skills/axiom-swift-testing/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swift-testing", + "installedAt": "2026-04-12T08:06:44.876Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swift-testing/SKILL.md b/.claude/skills/axiom-swift-testing/SKILL.md new file mode 100644 index 0000000..15618bb --- /dev/null +++ b/.claude/skills/axiom-swift-testing/SKILL.md @@ -0,0 +1,830 @@ +--- +name: axiom-swift-testing +description: Use when writing unit tests, adopting Swift Testing framework, making tests run faster without simulator, architecting code for testability, testing async code reliably, or migrating from XCTest - covers @Test/@Suite macros, #expect/#require, parameterized tests, traits, tags, parallel execution, host-less testing +license: MIT +metadata: + version: "1.0.0" + last-updated: "WWDC 2024 (Swift Testing framework)" +--- + +# Swift Testing + +## Overview + +Swift Testing is Apple's modern testing framework introduced at WWDC 2024. It uses Swift macros (`@Test`, `#expect`) instead of naming conventions, runs tests in parallel by default, and integrates seamlessly with Swift concurrency. + +**Core principle**: Tests should be fast, reliable, and expressive. The fastest tests run without launching your app or simulator. + +## The Speed Hierarchy + +Tests run at dramatically different speeds depending on how they're configured: + +| Configuration | Typical Time | Use Case | +|---------------|--------------|----------| +| `swift test` (Package) | ~0.1s | Pure logic, models, algorithms | +| Host Application: None | ~3s | Framework code, no UI dependencies | +| Bypass app launch | ~6s | App target but skip initialization | +| Full app launch | 20-60s | UI tests, integration tests | + +**Key insight**: Move testable logic into Swift Packages or frameworks, then test with `swift test` or "None" host application. + +--- + +## Building Blocks + +### @Test Functions + +```swift +import Testing + +@Test func videoHasCorrectMetadata() { + let video = Video(named: "example.mp4") + #expect(video.duration == 120) +} +``` + +**Key differences from XCTest**: +- No `test` prefix required — `@Test` attribute is explicit +- Can be global functions, not just methods in a class +- Supports `async`, `throws`, and actor isolation +- Each test runs on a fresh instance of its containing suite + +### #expect and #require + +```swift +// Basic expectation — test continues on failure +#expect(result == expected) +#expect(array.isEmpty) +#expect(numbers.contains(42)) + +// Required expectation — test stops on failure +let user = try #require(await fetchUser(id: 123)) +#expect(user.name == "Alice") + +// Unwrap optionals safely +let first = try #require(items.first) +#expect(first.isValid) +``` + +**Why #expect is better than XCTAssert**: +- Captures source code and sub-values automatically +- Single macro handles all operators (==, >, contains, etc.) +- No need for specialized assertions (XCTAssertEqual, XCTAssertNil, etc.) + +### Error Testing + +```swift +// Expect any error +#expect(throws: (any Error).self) { + try dangerousOperation() +} + +// Expect specific error type +#expect(throws: NetworkError.self) { + try fetchData() +} + +// Expect specific error value +#expect(throws: ValidationError.invalidEmail) { + try validate(email: "not-an-email") +} + +// Custom validation +#expect { + try process(data) +} throws: { error in + guard let networkError = error as? NetworkError else { return false } + return networkError.statusCode == 404 +} +``` + +### @Suite Types + +```swift +@Suite("Video Processing Tests") +struct VideoTests { + let video = Video(named: "sample.mp4") // Fresh instance per test + + @Test func hasCorrectDuration() { + #expect(video.duration == 120) + } + + @Test func hasCorrectResolution() { + #expect(video.resolution == CGSize(width: 1920, height: 1080)) + } +} +``` + +**Key behaviors**: +- Structs preferred (value semantics, no accidental state sharing) +- Each `@Test` gets its own suite instance +- Use `init` for setup, `deinit` for teardown (actors/classes only) +- Nested suites supported for organization + +--- + +## Traits + +Traits customize test behavior: + +```swift +// Display name +@Test("User can log in with valid credentials") +func loginWithValidCredentials() { } + +// Disable with reason +@Test(.disabled("Waiting for backend fix")) +func brokenFeature() { } + +// Conditional execution +@Test(.enabled(if: FeatureFlags.newUIEnabled)) +func newUITest() { } + +// Time limit +@Test(.timeLimit(.minutes(1))) +func longRunningTest() async { } + +// Bug reference +@Test(.bug("https://github.com/org/repo/issues/123", "Flaky on CI")) +func sometimesFailingTest() { } + +// OS version requirement +@available(iOS 18, *) +@Test func iOS18OnlyFeature() { } +``` + +### Tags for Organization + +```swift +// Define tags +extension Tag { + @Tag static var networking: Self + @Tag static var performance: Self + @Tag static var slow: Self +} + +// Apply to tests +@Test(.tags(.networking, .slow)) +func networkIntegrationTest() async { } + +// Apply to entire suite +@Suite(.tags(.performance)) +struct PerformanceTests { + @Test func benchmarkSort() { } // Inherits .performance tag +} +``` + +**Use tags to**: +- Run subsets of tests (filter by tag in Test Navigator) +- Exclude slow tests from quick feedback loops +- Group related tests across different files/suites + +--- + +## Parameterized Testing + +Transform repetitive tests into a single parameterized test: + +```swift +// ❌ Before: Repetitive +@Test func vanillaHasNoNuts() { + #expect(!IceCream.vanilla.containsNuts) +} +@Test func chocolateHasNoNuts() { + #expect(!IceCream.chocolate.containsNuts) +} +@Test func almondHasNuts() { + #expect(IceCream.almond.containsNuts) +} + +// ✅ After: Parameterized +@Test(arguments: [IceCream.vanilla, .chocolate, .strawberry]) +func flavorWithoutNuts(_ flavor: IceCream) { + #expect(!flavor.containsNuts) +} + +@Test(arguments: [IceCream.almond, .pistachio]) +func flavorWithNuts(_ flavor: IceCream) { + #expect(flavor.containsNuts) +} +``` + +### Two-Collection Parameterization + +```swift +// Test all combinations (4 × 3 = 12 test cases) +@Test(arguments: [1, 2, 3, 4], ["a", "b", "c"]) +func allCombinations(number: Int, letter: String) { + // Tests: (1,"a"), (1,"b"), (1,"c"), (2,"a"), ... +} + +// Test paired values only (3 test cases) +@Test(arguments: zip([1, 2, 3], ["one", "two", "three"])) +func pairedValues(number: Int, name: String) { + // Tests: (1,"one"), (2,"two"), (3,"three") +} +``` + +### Benefits Over For-Loops + +| For-Loop | Parameterized | +|----------|---------------| +| Stops on first failure | All arguments run | +| Unclear which value failed | Each argument shown separately | +| Sequential execution | Parallel execution | +| Can't re-run single case | Re-run individual arguments | + +--- + +## Fast Tests: Architecture for Testability + +### Strategy 1: Swift Package for Logic (Fastest) + +Extract app logic into a Swift Package. Tests run with `swift test` (~0.4s) instead of `xcodebuild test` (~25s) — no simulator, no app launch. This is the key enabler for TDD in Claude Code hooks. + +#### Step 1: Create Package.swift + +Create the package directory alongside your `.xcodeproj`: + +```swift +// MyAppCore/Package.swift +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "MyAppCore", + platforms: [.iOS(.v18), .macOS(.v15)], + products: [ + .library(name: "MyAppCore", targets: ["MyAppCore"]), + ], + targets: [ + .target(name: "MyAppCore"), + .testTarget(name: "MyAppCoreTests", dependencies: ["MyAppCore"]), + ] +) +``` + +#### Step 2: Link Package to App + +Create an `.xcworkspace` containing both the app project and the package: +1. File → New → Workspace +2. Drag your `.xcodeproj` into the workspace +3. File → Add Package Dependencies → Add Local → select `MyAppCore/` +4. Add `MyAppCore` framework to your app target's "Frameworks, Libraries, and Embedded Content" + +#### Step 3: Move Logic, Expose Root View + +Move models, services, and view models into `MyAppCore/Sources/MyAppCore/`. Types used by the app must be `public`. Create a public root view that accepts dependencies via injection: + +```swift +// In MyAppCore +public struct MyAppRootView: View { + @State private var appState: AppStateController + + public init(modelContainer: ModelContainer) { + _appState = State(initialValue: AppStateController(container: modelContainer)) + } + + public var body: some View { /* ... */ } +} +``` + +#### Step 4: Thin-Shell App.swift + +The app target becomes a thin shell that imports the package and delegates (see `axiom-app-composition` for the full thin-shell principle): + +```swift +import SwiftUI +import MyAppCore + +@main +struct MyApp: App { + let container = try! ModelContainer(for: /* schemas */) + + var body: some Scene { + WindowGroup { + MyAppRootView(modelContainer: container) + } + } +} +``` + +#### What Stays vs What Moves + +| Stays in App Target | Moves to Package | +|---------------------|------------------| +| `@main` App.swift (thin shell) | Models, view models, services | +| Asset catalogs, resources | Business logic, algorithms | +| Info.plist, entitlements | Navigation, state management | +| Launch screen | Utilities, extensions | + +Tests use `@testable import MyAppCore` for internal access. + +#### Running Tests + +```bash +cd MyAppCore +swift test # All tests (~0.4s) +swift test --filter MyAppCoreTests.UserTests # Single suite +``` + +For project-level scripts separating unit from UI tests: + +```bash +# script/test +#!/bin/bash +case "${1:-unit}" in + unit) cd MyAppCore && swift test ;; + ui) xcodebuild test -workspace MyApp.xcworkspace \ + -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16' ;; +esac +``` + +#### Progressive Extraction for Existing Projects + +For apps that can't extract everything at once, move modules incrementally: + +#### Phase 1: Leaf Modules First +Start with code that has no dependencies on the app target: +- Data models and DTOs +- Networking layer (API clients, request builders) +- Business logic and validation rules +- Utility extensions + +#### Phase 2: Break Circular Dependencies +If package code needs to call back into app-owned types: +1. Define a protocol in the package (the package owns the abstraction) +2. Inject a conforming implementation from the app target at startup +3. Move the implementation into the package once all its dependencies are in the package + +#### Phase 3: Maintain Both Test Targets +During transition, keep two test targets: +- `MyAppCoreTests` — runs with `swift test` (extracted logic) +- `MyAppTests` — runs with `xcodebuild test` (remaining app-level tests) + +Gradually migrate tests from `MyAppTests` to `MyAppCoreTests` as you extract their source files. + +**Goal**: Each extraction should leave the app building and all tests passing. Never extract more than one module boundary at a time. + +### Strategy 2: Framework with No Host Application + +For code that must stay in the app project: + +1. **Create a framework target** (File → New → Target → Framework) +2. **Move model code** into the framework +3. **Make types public** that need external access +4. **Add imports** in files using the framework +5. **Set Host Application to "None"** in test target settings + +``` +Project Settings → Test Target → Testing + Host Application: None ← Key setting + ☐ Allow testing Host Application APIs +``` + +Build+test time: ~3 seconds vs 20-60 seconds with app launch. + +### Strategy 3: Bypass SwiftUI App Launch + +If you can't use a framework, bypass the app launch: + +```swift +// Simple solution (no custom startup code) +@main +struct ProductionApp: App { + var body: some Scene { + WindowGroup { + if !isRunningTests { + ContentView() + } + } + } + + private var isRunningTests: Bool { + NSClassFromString("XCTestCase") != nil + } +} +``` + +```swift +// Thorough solution (custom startup code) +@main +struct MainEntryPoint { + static func main() { + if NSClassFromString("XCTestCase") != nil { + TestApp.main() // Empty app for tests + } else { + ProductionApp.main() + } + } +} + +struct TestApp: App { + var body: some Scene { + WindowGroup { } // Empty + } +} +``` + +--- + +## Async Testing + +### Basic Async Tests + +```swift +@Test func fetchUserReturnsData() async throws { + let user = try await userService.fetch(id: 123) + #expect(user.name == "Alice") +} +``` + +### Testing Callbacks with Continuations + +```swift +// Convert completion handler to async +@Test func legacyAPIWorks() async throws { + let result = try await withCheckedThrowingContinuation { continuation in + legacyService.fetchData { result in + continuation.resume(with: result) + } + } + #expect(result.count > 0) +} +``` + +### Confirmations for Multiple Events + +```swift +@Test func cookiesAreEaten() async { + await confirmation("cookie eaten", expectedCount: 10) { confirm in + let jar = CookieJar(count: 10) + jar.onCookieEaten = { confirm() } + await jar.eatAll() + } +} + +// Confirm something never happens +await confirmation(expectedCount: 0) { confirm in + let cache = Cache() + cache.onEviction = { confirm() } + cache.store("small-item") // Should not trigger eviction +} +``` + +### Reliable Async Testing with Concurrency Extras + +**Problem**: Async tests can be flaky due to scheduling unpredictability. + +```swift +// ❌ Flaky: Task scheduling is unpredictable +@Test func loadingStateChanges() async { + let model = ViewModel() + let task = Task { await model.loadData() } + #expect(model.isLoading == true) // Often fails! + await task.value +} +``` + +**Solution**: Use Point-Free's `swift-concurrency-extras`: + +```swift +import ConcurrencyExtras + +@Test func loadingStateChanges() async { + await withMainSerialExecutor { + let model = ViewModel() + let task = Task { await model.loadData() } + await Task.yield() + #expect(model.isLoading == true) // Deterministic! + await task.value + #expect(model.isLoading == false) + } +} +``` + +**Why it works**: Serializes async work to main thread, making suspension points deterministic. + +### Deterministic Time with TestClock + +Use Point-Free's `swift-clocks` to control time in tests: + +```swift +import Clocks + +@MainActor +class FeatureModel: ObservableObject { + @Published var count = 0 + let clock: any Clock + var timerTask: Task? + + init(clock: any Clock) { + self.clock = clock + } + + func startTimer() { + timerTask = Task { + while true { + try await clock.sleep(for: .seconds(1)) + count += 1 + } + } + } +} + +// Test with controlled time +@Test func timerIncrements() async { + let clock = TestClock() + let model = FeatureModel(clock: clock) + + model.startTimer() + + await clock.advance(by: .seconds(1)) + #expect(model.count == 1) + + await clock.advance(by: .seconds(4)) + #expect(model.count == 5) + + model.timerTask?.cancel() +} +``` + +**Clock types**: +- `TestClock` — Advance time manually, deterministic +- `ImmediateClock` — All sleeps return instantly (great for previews) +- `UnimplementedClock` — Fails if used (catch unexpected time dependencies) + +--- + +## Parallel Testing + +Swift Testing runs tests in parallel by default. + +### When to Serialize + +```swift +// Serialize tests in a suite that share external state +@Suite(.serialized) +struct DatabaseTests { + @Test func createUser() { } + @Test func deleteUser() { } // Runs after createUser +} + +// Serialize parameterized test cases +@Test(.serialized, arguments: [1, 2, 3]) +func sequentialProcessing(value: Int) { } +``` + +### Hidden Dependencies + +```swift +// ❌ Bug: Tests depend on execution order +@Suite struct CookieTests { + static var cookie: Cookie? + + @Test func bakeCookie() { + Self.cookie = Cookie() // Sets shared state + } + + @Test func eatCookie() { + #expect(Self.cookie != nil) // Fails if runs first! + } +} + +// ✅ Fixed: Each test is independent +@Suite struct CookieTests { + @Test func bakeCookie() { + let cookie = Cookie() + #expect(cookie.isBaked) + } + + @Test func eatCookie() { + let cookie = Cookie() + cookie.eat() + #expect(cookie.isEaten) + } +} +``` + +**Random order** helps expose these bugs — fix them rather than serialize. + +--- + +## Known Issues + +Handle expected failures without noise: + +```swift +@Test func featureUnderDevelopment() { + withKnownIssue("Backend not ready yet") { + try callUnfinishedAPI() + } +} + +// Conditional known issue +@Test func platformSpecificBug() { + withKnownIssue("Fails on iOS 17.0") { + try reproduceEdgeCaseBug() + } when: { + ProcessInfo().operatingSystemVersion.majorVersion == 17 + } +} +``` + +**Better than .disabled because**: +- Test still compiles (catches syntax errors) +- You're notified when the issue is fixed +- Results show "expected failure" not "skipped" + +--- + +## Migration from XCTest + +### Comparison Table + +| XCTest | Swift Testing | +|--------|---------------| +| `func testFoo()` | `@Test func foo()` | +| `XCTAssertEqual(a, b)` | `#expect(a == b)` | +| `XCTAssertNil(x)` | `#expect(x == nil)` | +| `XCTAssertThrowsError` | `#expect(throws:)` | +| `XCTUnwrap(x)` | `try #require(x)` | +| `class FooTests: XCTestCase` | `@Suite struct FooTests` | +| `setUp()` / `tearDown()` | `init` / `deinit` | +| `continueAfterFailure = false` | `#require` (per-expectation) | +| `addTeardownBlock` | `deinit` or defer | + +### Keep Using XCTest For + +- **UI tests** (XCUIApplication) +- **Performance tests** (XCTMetric) +- **Objective-C tests** + +### Migration Tips + +1. Both frameworks can coexist in the same target +2. Migrate incrementally, one test file at a time +3. Consolidate similar XCTests into parameterized Swift tests +4. Single-test XCTestCase → global `@Test` function + +--- + +## Common Mistakes + +### ❌ Mixing Assertions + +```swift +// Don't mix XCTest and Swift Testing +@Test func badExample() { + XCTAssertEqual(1, 1) // ❌ Wrong framework + #expect(1 == 1) // ✅ Use this +} +``` + +### ❌ Using Classes for Suites + +```swift +// ❌ Avoid: Reference semantics can cause shared state bugs +@Suite class VideoTests { } + +// ✅ Prefer: Value semantics isolate each test +@Suite struct VideoTests { } +``` + +### ❌ Forgetting @MainActor + +```swift +// ❌ May fail with Swift 6 strict concurrency +@Test func updateUI() async { + viewModel.updateTitle("New") // Data race warning +} + +// ✅ Isolate to main actor +@Test @MainActor func updateUI() async { + viewModel.updateTitle("New") +} +``` + +### ❌ Over-Serializing + +```swift +// ❌ Don't serialize just because tests use async +@Suite(.serialized) struct APITests { } // Defeats parallelism + +// ✅ Only serialize when tests truly share mutable state +``` + +### ❌ XCTestCase with Swift 6.2 MainActor Default + +Swift 6.2's `default-actor-isolation = MainActor` breaks XCTestCase: + +```swift +// ❌ Error: Main actor-isolated initializer 'init()' has different +// actor isolation from nonisolated overridden declaration +final class PlaygroundTests: XCTestCase { + override func setUp() async throws { + try await super.setUp() + } +} +``` + +**Solution**: Mark XCTestCase subclass as `nonisolated`: + +```swift +// ✅ Works with MainActor default isolation +nonisolated final class PlaygroundTests: XCTestCase { + @MainActor + override func setUp() async throws { + try await super.setUp() + } + + @Test @MainActor + func testSomething() async { + // Individual tests can be @MainActor + } +} +``` + +**Why**: XCTestCase is Objective-C, not annotated for Swift concurrency. Its initializers are `nonisolated`, causing conflicts with MainActor-isolated subclasses. + +**Better solution**: Migrate to Swift Testing (`@Suite struct`) which handles isolation properly. + +--- + +## Xcode Optimization for Fast Feedback + +### Turn Off Parallel XCTest Execution + +Swift Testing runs in parallel by default; XCTest parallelization adds overhead: + +``` +Test Plan → Options → Parallelization → "Swift Testing Only" +``` + +### Turn Off Test Debugger + +Attaching the debugger costs ~1 second per run: + +``` +Scheme → Edit Scheme → Test → Info → ☐ Debugger +``` + +### Delete UI Test Templates + +Xcode's default UI tests slow everything down. Remove them: +1. Delete UI test target (Project Settings → select target → -) +2. Delete UI test source folder + +### Disable dSYM for Debug Builds + +``` +Build Settings → Debug Information Format + Debug: DWARF + Release: DWARF with dSYM File +``` + +### Check Build Scripts + +Run Script phases without defined inputs/outputs cause full rebuilds. Always specify: +- Input Files / Input File Lists +- Output Files / Output File Lists + +--- + +## Checklist + +### Before Writing Tests +- [ ] Identify what can move to a Swift Package (pure logic) +- [ ] Set up framework target if package isn't viable +- [ ] Configure Host Application: None for unit tests + +### Writing Tests +- [ ] Use `@Test` with clear display names +- [ ] Use `#expect` for all assertions +- [ ] Use `#require` to fail fast on preconditions +- [ ] Use parameterization for similar test cases +- [ ] Add `.tags()` for organization + +### Async Tests +- [ ] Mark test functions `async` and use `await` +- [ ] Use `confirmation()` for callback-based code +- [ ] Consider `withMainSerialExecutor` for flaky tests + +### Parallel Safety +- [ ] Avoid shared mutable state between tests +- [ ] Use fresh instances in each test +- [ ] Only use `.serialized` when absolutely necessary + +--- + +## Resources + +**WWDC**: 2024-10179, 2024-10195 + +**Docs**: /testing, /testing/migratingfromxctest, /testing/testing-asynchronous-code, /testing/parallelization + +**GitHub**: pointfreeco/swift-concurrency-extras, pointfreeco/swift-clocks + +--- + +**History:** See git log for changes diff --git a/.claude/skills/axiom-swift-testing/agents/openai.yaml b/.claude/skills/axiom-swift-testing/agents/openai.yaml new file mode 100644 index 0000000..84c2c7a --- /dev/null +++ b/.claude/skills/axiom-swift-testing/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Swift Testing" + short_description: "Writing unit tests, adopting Swift Testing framework, making tests run faster without simulator, architecting code fo..." diff --git a/.claude/skills/axiom-swiftdata-migration-diag/.openskills.json b/.claude/skills/axiom-swiftdata-migration-diag/.openskills.json new file mode 100644 index 0000000..fb422c4 --- /dev/null +++ b/.claude/skills/axiom-swiftdata-migration-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftdata-migration-diag", + "installedAt": "2026-04-12T08:06:46.029Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftdata-migration-diag/SKILL.md b/.claude/skills/axiom-swiftdata-migration-diag/SKILL.md new file mode 100644 index 0000000..cb6bc7b --- /dev/null +++ b/.claude/skills/axiom-swiftdata-migration-diag/SKILL.md @@ -0,0 +1,591 @@ +--- +name: axiom-swiftdata-migration-diag +description: Use when SwiftData migrations crash, fail to preserve relationships, lose data, or work in simulator but fail on device - systematic diagnostics for schema version mismatches, relationship errors, and migration testing gaps +license: MIT +metadata: + version: "1.0.0" +--- + +# SwiftData Migration Diagnostics + +## Overview + +SwiftData migration failures manifest as production crashes, data loss, corrupted relationships, or simulator-only success. **Core principle** 90% of migration failures stem from missing models in VersionedSchema, relationship inverse issues, or untested migration paths—not SwiftData bugs. + +## Red Flags — Suspect SwiftData Migration Issue + +If you see ANY of these, suspect a migration configuration problem: + +- App crashes on launch after schema change +- "Expected only Arrays for Relationships" error +- "The model used to open the store is incompatible with the one used to create the store" +- "Failed to fulfill faulting for [relationship]" +- Migration works in simulator but crashes on real device +- Data exists before migration, gone after +- Relationships broken after migration (nil where they shouldn't be) +- ❌ **FORBIDDEN** "SwiftData migrations are broken, we should use Core Data" + - SwiftData handles millions of migrations in production apps + - Schema mismatches and relationship errors are always configuration, not framework + - Do not rationalize away the issue—diagnose it + +**Critical distinction** Simulator deletes the database on each rebuild, hiding schema mismatch issues. Real devices keep persistent databases and crash immediately on schema mismatch. **MANDATORY: Test migrations on real device with real data before shipping.** + +## Mandatory First Steps + +**ALWAYS run these FIRST** (before changing code): + +```swift +// 1. Identify the crash/issue type +// Screenshot the crash message and note: +// - "Expected only Arrays" = relationship inverse missing +// - "incompatible model" = schema version mismatch +// - "Failed to fulfill faulting" = relationship integrity broken +// - Simulator works, device crashes = untested migration path +// Record: "Error type: [exact message]" + +// 2. Check schema version configuration +// In your migration plan: +enum MigrationPlan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { + // ✅ VERIFY: All versions in order? + // ✅ VERIFY: Latest version matches container? + [SchemaV1.self, SchemaV2.self, SchemaV3.self] + } + + static var stages: [MigrationStage] { + // ✅ VERIFY: Migration stages match schema transitions? + [migrateV1toV2, migrateV2toV3] + } +} + +// In your app: +let schema = Schema(versionedSchema: SchemaV3.self) // ✅ VERIFY: Matches latest in plan? +let container = try ModelContainer( + for: schema, + migrationPlan: MigrationPlan.self // ✅ VERIFY: Plan is registered? +) +// Record: "Schema version: latest is [version]" + +// 3. Check all models included in VersionedSchema +enum SchemaV2: VersionedSchema { + static var models: [any PersistentModel.Type] { + // ✅ VERIFY: Are ALL models listed? (even unchanged ones) + [Note.self, Folder.self, Tag.self] + } +} +// Record: "Missing models? Yes/no" + +// 4. Check relationship inverse declarations +@Model +final class Note { + @Relationship(deleteRule: .nullify, inverse: \Folder.notes) // ✅ VERIFY: inverse specified? + var folder: Folder? + + @Relationship(deleteRule: .nullify, inverse: \Tag.notes) // ✅ VERIFY: inverse specified? + var tags: [Tag] = [] +} +// Record: "Relationship inverses: all specified? Yes/no" + +// 5. Enable SwiftData debug logging +// In Xcode scheme, add argument: +// -com.apple.coredata.swiftdata.debug 1 +// Run and check Console for SQL queries +// Record: "Debug log shows: [what you see]" +``` + +#### What this tells you + +- **"Expected only Arrays for Relationships"** → Proceed to Pattern 1 (relationship inverse fix) +- **"incompatible model"** → Proceed to Pattern 2 (schema version mismatch) +- **Missing models in VersionedSchema** → Proceed to Pattern 3 (complete schema snapshot) +- **Simulator works, device crashes** → Proceed to Pattern 4 (migration testing) +- **Data lost after migration** → Proceed to Pattern 5 (willMigrate/didMigrate misuse) + +#### MANDATORY INTERPRETATION + +Before changing ANY code, identify ONE of these: + +1. If error is "Expected only Arrays" AND relationship inverse missing → Relationship configuration issue +2. If error mentions "incompatible" AND schema versions don't match → Version mismatch +3. If models are missing from VersionedSchema → Incomplete schema snapshot +4. If simulator succeeds but device fails → Untested migration path +5. If data exists before but not after → willMigrate/didMigrate limitation violated + +#### If diagnostics are contradictory or unclear + +- STOP. Do NOT proceed to patterns yet +- Add `-com.apple.coredata.swiftdata.debug 1` and examine SQL output +- Check file system: does .sqlite file exist? What size? +- Establish baseline: what's actually happening vs. what you assumed + +--- + +## Verifying Migration Completed Successfully + +**Use this section when migration appears to complete without errors, but you want to verify data integrity.** + +### Quick Verification Checklist + +After migration runs without crashing: + +```swift +// 1. Verify record count matches pre-migration +let context = container.mainContext +let postMigrationCount = try context.fetch(FetchDescriptor()).count +print("Post-migration count: \(postMigrationCount)") +// Compare to pre-migration count + +// 2. Spot-check specific records +let sampleNote = try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == "known-test-id" }) +).first +print("Sample note title: \(sampleNote?.title ?? "MISSING")") + +// 3. Verify relationships intact +if let note = sampleNote { + print("Folder relationship: \(note.folder != nil ? "✓" : "✗")") + print("Tags count: \(note.tags.count)") + + // Verify inverse relationships + if let folder = note.folder { + let folderHasNote = folder.notes.contains { $0.id == note.id } + print("Inverse relationship: \(folderHasNote ? "✓" : "✗")") + } +} + +// 4. Check for orphaned data +let orphanedNotes = try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.folder == nil }) +) +print("Orphaned notes (should be 0 if cascade delete worked): \(orphanedNotes.count)") +``` + +### What Successful Migration Looks Like + +**Console Output:** +``` +Post-migration count: 1523 // Matches pre-migration +Sample note title: Test Note // Not "MISSING" +Folder relationship: ✓ +Tags count: 3 +Inverse relationship: ✓ +Orphaned notes: 0 +``` + +**If you see:** +- Record count differs → Data loss (check willMigrate logic) +- "MISSING" records → Schema mismatch or fetch error +- Relationships nil → Inverse configuration or prefetching issue +- Orphaned records >0 → Cascade delete rule not working + +See patterns below for specific fixes. + +--- + +## Decision Tree + +``` +SwiftData migration problem suspected? +├─ Error: "Expected only Arrays for Relationships"? +│ └─ YES → Relationship inverse missing +│ ├─ Many-to-many relationship? → Pattern 1a (explicit inverse) +│ ├─ One-to-many relationship? → Pattern 1b (verify both sides) +│ └─ iOS 17.0 alphabetical bug? → Pattern 1c (default value workaround) +│ +├─ Error: "incompatible model" or crash on launch? +│ └─ YES → Schema version mismatch +│ ├─ Latest schema not in plan? → Pattern 2a (add to schemas array) +│ ├─ Migration stage missing? → Pattern 2b (add stage) +│ └─ Container using wrong schema? → Pattern 2c (verify version) +│ +├─ Migration runs but data missing? +│ └─ YES → Data loss during migration +│ ├─ Used didMigrate to access old models? → Pattern 3a (use willMigrate) +│ ├─ Forgot to save in willMigrate? → Pattern 3b (add context.save()) +│ └─ Custom migration logic wrong? → Pattern 3c (debug transformation) +│ +├─ Works in simulator but crashes on device? +│ └─ YES → Untested migration path +│ ├─ Never tested on real device? → Pattern 4a (real device testing) +│ ├─ Never tested upgrade path? → Pattern 4b (test v1 → v2 upgrade) +│ └─ Production data differs from test? → Pattern 4c (test with prod data) +│ +└─ Relationships nil after migration? + └─ YES → Relationship integrity broken + ├─ Forgot to prefetch relationships? → Pattern 5a (add prefetching) + ├─ Inverse relationship wrong? → Pattern 5b (fix inverse) + └─ Delete rule caused cascade? → Pattern 5c (check delete rules) +``` + +--- + +## Common Patterns + +### Pattern 1a: Fix "Expected only Arrays for Relationships" + +**PRINCIPLE** Many-to-many relationships require explicit inverse declarations. + +#### ❌ WRONG (Causes "Expected only Arrays" error) +```swift +@Model +final class Note { + var tags: [Tag] = [] // ❌ Missing inverse +} + +@Model +final class Tag { + var notes: [Note] = [] // ❌ Missing inverse +} +``` + +#### ✅ CORRECT (Explicit inverse) +```swift +@Model +final class Note { + @Relationship(deleteRule: .nullify, inverse: \Tag.notes) + var tags: [Tag] = [] // ✅ Inverse specified +} + +@Model +final class Tag { + @Relationship(deleteRule: .nullify, inverse: \Note.tags) + var notes: [Note] = [] // ✅ Inverse specified +} +``` + +**Why this works** SwiftData requires explicit inverse for many-to-many to create junction table correctly. + +**Time cost** 2 minutes to add inverse declarations + +--- + +### Pattern 1b: iOS 17.0 Alphabetical Bug Workaround + +**PRINCIPLE** In iOS 17.0, many-to-many relationships could fail if model names were in alphabetical order. + +#### ❌ WRONG (Crashes in iOS 17.0) +```swift +@Model +final class Actor { + @Relationship(deleteRule: .nullify, inverse: \Movie.actors) + var movies: [Movie] // ❌ No default value +} + +@Model +final class Movie { + @Relationship(deleteRule: .nullify, inverse: \Actor.movies) + var actors: [Actor] // ❌ No default value +} +// Crashes if "Actor" < "Movie" alphabetically +``` + +#### ✅ CORRECT (Works in iOS 17.0+) +```swift +@Model +final class Actor { + @Relationship(deleteRule: .nullify, inverse: \Movie.actors) + var movies: [Movie] = [] // ✅ Default value +} + +@Model +final class Movie { + @Relationship(deleteRule: .nullify, inverse: \Actor.movies) + var actors: [Actor] = [] // ✅ Default value +} +``` + +**Fixed in** iOS 17.1+ + +**Time cost** 1 minute to add default values + +--- + +### Pattern 2a: Schema Version Mismatch + +**PRINCIPLE** Migration plan's schemas array must include ALL versions in order. + +#### ❌ WRONG (Missing version causes crash) +```swift +enum MigrationPlan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { + [SchemaV1.self, SchemaV3.self] // ❌ Missing V2! + } + + static var stages: [MigrationStage] { + [migrateV1toV2, migrateV2toV3] // References V2 but not in schemas + } +} +``` + +#### ✅ CORRECT (All versions in order) +```swift +enum MigrationPlan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { + [SchemaV1.self, SchemaV2.self, SchemaV3.self] // ✅ All versions + } + + static var stages: [MigrationStage] { + [migrateV1toV2, migrateV2toV3] + } +} +``` + +**Time cost** 2 minutes to add missing version + +--- + +### Pattern 3a: Data Loss from willMigrate/didMigrate Misuse + +**PRINCIPLE** Old models only accessible in willMigrate, new models only in didMigrate. + +#### ❌ WRONG (Tries to access old models in didMigrate) +```swift +static let migrate = MigrationStage.custom( + fromVersion: SchemaV1.self, + toVersion: SchemaV2.self, + willMigrate: nil, + didMigrate: { context in + // ❌ CRASH: SchemaV1.Note doesn't exist here + let oldNotes = try context.fetch(FetchDescriptor()) + + // Data lost because transformation never ran + } +) +``` + +#### ✅ CORRECT (Transform in willMigrate) +```swift +static let migrate = MigrationStage.custom( + fromVersion: SchemaV1.self, + toVersion: SchemaV2.self, + willMigrate: { context in + // ✅ SchemaV1.Note exists here + let oldNotes = try context.fetch(FetchDescriptor()) + + // Transform data while old models still accessible + for note in oldNotes { + note.transformed = transformLogic(note.oldValue) + } + + try context.save() // ✅ Save before migration completes + }, + didMigrate: nil +) +``` + +**Time cost** 5 minutes to move logic to correct closure + +--- + +### Pattern 4a: Real Device Testing + +**PRINCIPLE** Simulator deletes database on rebuild. Real devices keep persistent databases. + +#### Testing Workflow + +```bash +# 1. Install v1 on real device +# Build with SchemaV1 as current version +# Run app, create sample data (100+ records) + +# 2. Verify data exists +# Check app: should see 100+ records + +# 3. Install v2 with migration +# Build with SchemaV2 as current version + migration plan +# Install over existing app (don't delete) + +# 4. Verify migration succeeded +# App launches without crash +# Data still exists (100+ records) +# Relationships intact +``` + +#### Migration Test Code + +```swift +import Testing +import SwiftData + +@Test func testMigrationOnRealDevice() throws { + // This test MUST run on real device, not simulator + #if targetEnvironment(simulator) + throw XCTSkip("Migration test requires real device") + #endif + + let container = try ModelContainer( + for: Schema(versionedSchema: SchemaV2.self), + migrationPlan: MigrationPlan.self + ) + + let context = container.mainContext + let notes = try context.fetch(FetchDescriptor()) + + // Verify data preserved + #expect(notes.count > 0) + + // Verify relationships + for note in notes { + if note.folder != nil { + #expect(note.folder?.notes.contains { $0.id == note.id } == true) + } + } +} +``` + +**Time cost** 15 minutes to test on real device + +--- + +### Pattern 5a: Relationship Prefetching to Preserve Integrity + +**PRINCIPLE** Fetch relationships eagerly during migration to avoid faulting errors. + +#### ❌ WRONG (Relationships may fault and break) +```swift +static let migrate = MigrationStage.custom( + fromVersion: SchemaV1.self, + toVersion: SchemaV2.self, + willMigrate: { context in + let notes = try context.fetch(FetchDescriptor()) + + for note in notes { + // ❌ May trigger fault, relationship not loaded + let folderName = note.folder?.name + } + }, + didMigrate: nil +) +``` + +#### ✅ CORRECT (Prefetch relationships) +```swift +static let migrate = MigrationStage.custom( + fromVersion: SchemaV1.self, + toVersion: SchemaV2.self, + willMigrate: { context in + var fetchDesc = FetchDescriptor() + + // ✅ Prefetch relationships + fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags] + + let notes = try context.fetch(fetchDesc) + + for note in notes { + // ✅ Relationships already loaded + let folderName = note.folder?.name + let tagCount = note.tags.count + } + + try context.save() + }, + didMigrate: nil +) +``` + +**Time cost** 3 minutes to add prefetching + +--- + +## Quick Reference: Error → Fix Mapping + +| Error Message | Root Cause | Fix | Time | +|--------------|------------|-----|------| +| "Expected only Arrays for Relationships" | Many-to-many inverse missing | Add `@Relationship(inverse:)` to both sides | 2 min | +| "The model used to open the store is incompatible" | Schema version mismatch | Add missing version to `schemas` array | 2 min | +| "Failed to fulfill faulting for [relationship]" | Relationship not prefetched | Add `relationshipKeyPathsForPrefetching` | 3 min | +| App crashes after schema change | Missing model in VersionedSchema | Include ALL models in `models` array | 2 min | +| Data lost after migration | Transformation in wrong closure | Move logic from didMigrate to willMigrate | 5 min | +| Simulator works, device crashes | Untested migration path | Test on real device with real data | 15 min | +| Relationships nil after migration | Inverse relationship wrong | Fix `@Relationship(inverse:)` keypath | 3 min | + +--- + +## Debugging Checklist + +When migration fails, verify ALL of these: + +- [ ] All models included in `VersionedSchema.models` array +- [ ] All schema versions included in `SchemaMigrationPlan.schemas` array +- [ ] Migration stages match schema transitions (V1→V2, V2→V3) +- [ ] Many-to-many relationships have explicit `inverse:` on both sides +- [ ] Container initialized with correct latest schema version +- [ ] Migration plan registered in `ModelContainer` initialization +- [ ] Tested on real device (not just simulator) +- [ ] Tested upgrade path (v1 → v2), not just fresh install +- [ ] SwiftData debug logging enabled (`-com.apple.coredata.swiftdata.debug 1`) +- [ ] Data transformation logic in `willMigrate` (not `didMigrate`) + +--- + +## When You're Stuck After 30 Minutes + +If you've spent >30 minutes and the migration issue persists: + +#### STOP. You either +1. Skipped mandatory diagnostics (most common) +2. Misidentified the actual problem +3. Applied wrong pattern for your symptom +4. Haven't tested on real device/real data +5. Have complex edge case requiring two-stage migration + +#### MANDATORY checklist before claiming "skill didn't work" + +- [ ] I ran all Mandatory First Steps diagnostics +- [ ] I identified the problem type (relationship, schema mismatch, data loss, testing gap) +- [ ] I enabled SwiftData debug logging and examined SQL output +- [ ] I tested on real device with real data (not simulator) +- [ ] I applied the FIRST matching pattern from Decision Tree +- [ ] I verified all models included in VersionedSchema +- [ ] I checked relationship inverse declarations + +#### If ALL boxes are checked and still broken +- You need two-stage migration (covered in `axiom-swiftdata-migration` skill) +- Time cost: 30-60 minutes for complex type change migration +- Ask: "What data transformation is actually needed?" and implement two-stage pattern + +--- + +## Time Cost Transparency + +- Pattern 1 (relationship inverse): 2-3 minutes +- Pattern 2 (schema version): 2-5 minutes +- Pattern 3 (willMigrate fix): 5-10 minutes +- Pattern 4 (real device testing): 15-30 minutes +- Pattern 5 (relationship prefetching): 3-5 minutes + +--- + +## Real-World Impact + +**Before** SwiftData migration debugging 2-8 hours per issue +- App crashes on launch in production +- Data loss for existing users +- Relationships broken after migration +- Simulator success, device failure +- Customer trust damaged + +**After** 15-45 minutes with systematic diagnosis +- Identify problem type with diagnostics (5 min) +- Apply correct pattern (5-10 min) +- Test on real device (15-30 min) +- Deploy with confidence + +**Key insight** SwiftData has well-established patterns for every common migration issue. The problem is developers don't know which diagnostic applies to their error. + +--- + +## Resources + +**WWDC**: 2025-291, 2023-10195 + +**Docs**: /swiftdata + +**Skills**: axiom-swiftdata-migration, axiom-swiftdata, axiom-database-migration + +--- + +**Created** 2025-12-09 +**Status** Production-ready diagnostic patterns +**Framework** SwiftData (Apple) +**Swift** 5.9+ diff --git a/.claude/skills/axiom-swiftdata-migration-diag/agents/openai.yaml b/.claude/skills/axiom-swiftdata-migration-diag/agents/openai.yaml new file mode 100644 index 0000000..d7101ca --- /dev/null +++ b/.claude/skills/axiom-swiftdata-migration-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftData Migration Diagnostics" + short_description: "SwiftData migrations crash, fail to preserve relationships, lose data, or work in simulator but fail on device" diff --git a/.claude/skills/axiom-swiftdata-migration/.openskills.json b/.claude/skills/axiom-swiftdata-migration/.openskills.json new file mode 100644 index 0000000..26b85f8 --- /dev/null +++ b/.claude/skills/axiom-swiftdata-migration/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftdata-migration", + "installedAt": "2026-04-12T08:06:45.644Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftdata-migration/SKILL.md b/.claude/skills/axiom-swiftdata-migration/SKILL.md new file mode 100644 index 0000000..c99b6ec --- /dev/null +++ b/.claude/skills/axiom-swiftdata-migration/SKILL.md @@ -0,0 +1,961 @@ +--- +name: axiom-swiftdata-migration +description: Use when creating SwiftData custom schema migrations with VersionedSchema and SchemaMigrationPlan - property type changes, relationship preservation (one-to-many, many-to-many), the willMigrate/didMigrate limitation, two-stage migration patterns, and testing migrations on real devices +license: MIT +metadata: + version: "1.0.0" +--- + +# SwiftData Custom Schema Migrations + +## Overview + +SwiftData schema migrations move your data safely when models change. **Core principle** SwiftData's `willMigrate` sees only OLD models, `didMigrate` sees only NEW models—you can never access both simultaneously. This limitation shapes all migration strategies. + +**Requires** iOS 17+, Swift 5.9+ +**Target** iOS 26+ (features like `propertiesToFetch`) + +## When Custom Migrations Are Required + +### Lightweight Migrations (Automatic) + +SwiftData can migrate automatically for: +- ✅ Adding new optional properties +- ✅ Adding new required properties with default values +- ✅ Removing properties +- ✅ Renaming properties (with `@Attribute(originalName:)`) +- ✅ Changing relationship delete rules +- ✅ Adding new models + +### Custom Migrations (This Skill) + +You need custom migrations for: +- ❌ Changing property types (`String` → `AttributedString`, `Int` → `String`) +- ❌ Making optional properties required (must populate existing nulls) +- ❌ Complex relationship restructuring +- ❌ Data transformations (splitting/merging fields) +- ❌ Deduplication when adding unique constraints + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "I need to change a property from String to AttributedString. How do I migrate existing data with relationships intact?" +→ The skill shows the two-stage migration pattern that works around the willMigrate/didMigrate limitation + +#### 2. "My model has a one-to-many relationship with cascade delete. How do I preserve this during a type change migration?" +→ The skill explains relationship prefetching and maintaining inverse relationships across schema versions + +#### 3. "I have a many-to-many relationship between Tags and Notes. The migration is failing with 'Expected only Arrays for Relationships'. What's wrong?" +→ The skill covers explicit inverse relationship requirements and iOS 17.0 alphabetical naming bug + +#### 4. "I need to rename a model but keep all its relationships intact." +→ The skill shows `@Attribute(originalName:)` patterns for lightweight migration + +#### 5. "My migration works in the simulator but crashes on a real device with existing data." +→ The skill emphasizes real-device testing and explains why simulator success doesn't guarantee production safety + +#### 6. "Why do I have to copy ALL my models into each VersionedSchema, even ones that haven't changed?" +→ The skill explains SwiftData's design: each VersionedSchema is a complete snapshot, not a diff + +#### 7. "I'm getting 'The model used to open the store is incompatible with the one used to create the store' error." +→ The skill provides debugging steps for schema version mismatches + +#### 8. "How do I test my SwiftData migration before releasing to production?" +→ The skill covers migration testing workflow, real device testing requirements, and validation strategies + +--- + +## The willMigrate/didMigrate Limitation + +**CRITICAL** This is the architectural constraint that shapes all SwiftData migration patterns. + +### What You Can Access + +```swift +static let migrateV1toV2 = MigrationStage.custom( + fromVersion: SchemaV1.self, + toVersion: SchemaV2.self, + willMigrate: { context in + // ✅ CAN access: SchemaV1 models (old) + let v1Notes = try context.fetch(FetchDescriptor()) + + // ❌ CANNOT access: SchemaV2 models + // SchemaV2.Note doesn't exist yet + }, + didMigrate: { context in + // ✅ CAN access: SchemaV2 models (new) + let v2Notes = try context.fetch(FetchDescriptor()) + + // ❌ CANNOT access: SchemaV1 models + // SchemaV1.Note is gone + } +) +``` + +### Why This Matters + +You cannot directly transform data from old type to new type in a single migration stage. Example: + +```swift +// ❌ IMPOSSIBLE - you can't do this in one stage +willMigrate: { context in + let oldNotes = try context.fetch(FetchDescriptor()) + for oldNote in oldNotes { + let newNote = SchemaV2.Note() // ❌ Doesn't exist yet! + newNote.content = oldNote.contentAsAttributedString() + } +} +``` + +**Solution** Use two-stage migration pattern (covered below). + +--- + +## Core Patterns + +### Pattern 1: Basic VersionedSchema Setup + +Every distinct schema version must be defined as a `VersionedSchema`. + +```swift +import SwiftData + +enum NotesSchemaV1: VersionedSchema { + static var versionIdentifier = Schema.Version(1, 0, 0) + + static var models: [any PersistentModel.Type] { + [Note.self, Folder.self, Tag.self] // ALL models, even if unchanged + } + + @Model + final class Note { + @Attribute(.unique) var id: String + var title: String + var content: String // Original type + var createdAt: Date + + @Relationship(deleteRule: .nullify, inverse: \Folder.notes) + var folder: Folder? + + @Relationship(deleteRule: .nullify, inverse: \Tag.notes) + var tags: [Tag] = [] + + init(id: String, title: String, content: String, createdAt: Date) { + self.id = id + self.title = title + self.content = content + self.createdAt = createdAt + } + } + + @Model + final class Folder { + @Attribute(.unique) var id: String + var name: String + + @Relationship(deleteRule: .cascade) + var notes: [Note] = [] + + init(id: String, name: String) { + self.id = id + self.name = name + } + } + + @Model + final class Tag { + @Attribute(.unique) var id: String + var name: String + + @Relationship(deleteRule: .nullify) + var notes: [Note] = [] + + init(id: String, name: String) { + self.id = id + self.name = name + } + } +} +``` + +#### Key patterns +- **Complete snapshot** All models included, even unchanged ones +- **Semantic versioning** Use Schema.Version(major, minor, patch) +- **Explicit init** SwiftData doesn't synthesize initializers +- **Inverse relationships** Specify on both sides for bidirectional + +--- + +### Pattern 2: Two-Stage Migration for Type Changes + +**Use when** Changing property type (String → AttributedString, Int → String, etc.) + +#### Problem + +We want to change `Note.content` from `String` to `AttributedString`, but we can't access both old and new types simultaneously. + +#### Solution + +Use an intermediate schema version (V1.1) that has BOTH properties. + +```swift +// Stage 1: V1 → V1.1 (Add new property alongside old) +enum NotesSchemaV1_1: VersionedSchema { + static var versionIdentifier = Schema.Version(1, 1, 0) + + static var models: [any PersistentModel.Type] { + [Note.self, Folder.self, Tag.self] + } + + @Model + final class Note { + @Attribute(.unique) var id: String + var title: String + + // OLD property (to be deprecated) + @Attribute(originalName: "content") + var contentOld: String = "" + + // NEW property (target type) + var contentNew: AttributedString? + + var createdAt: Date + + @Relationship(deleteRule: .nullify, inverse: \Folder.notes) + var folder: Folder? + + @Relationship(deleteRule: .nullify, inverse: \Tag.notes) + var tags: [Tag] = [] + + init(id: String, title: String, contentOld: String, createdAt: Date) { + self.id = id + self.title = title + self.contentOld = contentOld + self.createdAt = createdAt + } + } + + // Folder and Tag unchanged (copy from V1) + @Model final class Folder { /* same as V1 */ } + @Model final class Tag { /* same as V1 */ } +} + +// Stage 2: V1.1 → V2 (Transform data, remove old property) +enum NotesSchemaV2: VersionedSchema { + static var versionIdentifier = Schema.Version(2, 0, 0) + + static var models: [any PersistentModel.Type] { + [Note.self, Folder.self, Tag.self] + } + + @Model + final class Note { + @Attribute(.unique) var id: String + var title: String + + // Renamed from contentNew + @Attribute(originalName: "contentNew") + var content: AttributedString? + + var createdAt: Date + + @Relationship(deleteRule: .nullify, inverse: \Folder.notes) + var folder: Folder? + + @Relationship(deleteRule: .nullify, inverse: \Tag.notes) + var tags: [Tag] = [] + + init(id: String, title: String, content: AttributedString?, createdAt: Date) { + self.id = id + self.title = title + self.content = content + self.createdAt = createdAt + } + } + + @Model final class Folder { /* same as V1 */ } + @Model final class Tag { /* same as V1 */ } +} +``` + +#### Migration Plan + +```swift +enum NotesMigrationPlan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { + [NotesSchemaV1.self, NotesSchemaV1_1.self, NotesSchemaV2.self] + } + + static var stages: [MigrationStage] { + [migrateV1toV1_1, migrateV1_1toV2] + } + + // Stage 1: Lightweight migration (adds contentNew) + static let migrateV1toV1_1 = MigrationStage.lightweight( + fromVersion: NotesSchemaV1.self, + toVersion: NotesSchemaV1_1.self + ) + + // Stage 2: Custom migration (transform String → AttributedString) + static let migrateV1_1toV2 = MigrationStage.custom( + fromVersion: NotesSchemaV1_1.self, + toVersion: NotesSchemaV2.self, + willMigrate: { context in + // Transform data while we still have access to V1.1 models + var fetchDesc = FetchDescriptor() + + // Prefetch relationships to preserve them + fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags] + + let notes = try context.fetch(fetchDesc) + + for note in notes { + // Convert String → AttributedString + note.contentNew = try? AttributedString(markdown: note.contentOld) + } + + try context.save() + }, + didMigrate: nil + ) +} +``` + +#### Apply Migration Plan + +```swift +@main +struct NotesApp: App { + let container: ModelContainer = { + do { + let schema = Schema(versionedSchema: NotesSchemaV2.self) + return try ModelContainer( + for: schema, + migrationPlan: NotesMigrationPlan.self + ) + } catch { + fatalError("Failed to create container: \(error)") + } + }() + + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(container) + } +} +``` + +--- + +### Pattern 3: Many-to-Many Relationship Migration + +**Use when** You have many-to-many relationships (Tags ↔ Notes) + +#### Critical Requirements + +1. **Explicit inverse relationships** SwiftData won't infer many-to-many +2. **Arrays on both sides** Not optional, must be arrays +3. **iOS 17.0 bug workaround** Alphabetical naming issue + +```swift +enum SchemaV1: VersionedSchema { + static var versionIdentifier = Schema.Version(1, 0, 0) + static var models: [any PersistentModel.Type] { + [Note.self, Tag.self] + } + + @Model + final class Note { + @Attribute(.unique) var id: String + var title: String + + // Many-to-many: MUST specify inverse + @Relationship(deleteRule: .nullify, inverse: \Tag.notes) + var tags: [Tag] = [] // ✅ Array with default value + + init(id: String, title: String) { + self.id = id + self.title = title + } + } + + @Model + final class Tag { + @Attribute(.unique) var id: String + var name: String + + // Many-to-many: MUST specify inverse + @Relationship(deleteRule: .nullify, inverse: \Note.tags) + var notes: [Note] = [] // ✅ Array with default value + + init(id: String, name: String) { + self.id = id + self.name = name + } + } +} +``` + +#### iOS 17.0 Alphabetical Bug Workaround + +In iOS 17.0, many-to-many relationships could fail if model names were in alphabetical order (e.g., Actor ↔ Movie works, but Movie ↔ Person fails). + +**Workaround** Provide default values for relationship arrays: + +```swift +@Relationship(deleteRule: .nullify, inverse: \Movie.actors) +var actors: [Actor] = [] // ✅ Default value prevents bug +``` + +**Fixed in** iOS 17.1+ + +#### Adding Junction Table Metadata + +If you need additional fields on the relationship (e.g., "when was this tag added?"), use an explicit junction model: + +```swift +@Model +final class NoteTag { + @Attribute(.unique) var id: String + var addedAt: Date // Metadata on relationship + + @Relationship(deleteRule: .cascade) + var note: Note? + + @Relationship(deleteRule: .cascade) + var tag: Tag? + + init(id: String, note: Note, tag: Tag, addedAt: Date) { + self.id = id + self.note = note + self.tag = tag + self.addedAt = addedAt + } +} + +@Model +final class Note { + @Attribute(.unique) var id: String + var title: String + + @Relationship(deleteRule: .cascade) + var noteTags: [NoteTag] = [] // One-to-many to junction + + var tags: [Tag] { + noteTags.compactMap { $0.tag } + } +} + +@Model +final class Tag { + @Attribute(.unique) var id: String + var name: String + + @Relationship(deleteRule: .cascade) + var noteTags: [NoteTag] = [] // One-to-many to junction + + var notes: [Note] { + noteTags.compactMap { $0.note } + } +} +``` + +--- + +### Pattern 4: Relationship Prefetching During Migration + +**Use when** Migrating models with relationships to avoid N+1 queries + +```swift +static let migrateV1toV2 = MigrationStage.custom( + fromVersion: SchemaV1.self, + toVersion: SchemaV2.self, + willMigrate: { context in + var fetchDesc = FetchDescriptor() + + // Prefetch relationships (iOS 26+) + fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags] + + // Only fetch properties you need (iOS 26+) + fetchDesc.propertiesToFetch = [\.title, \.content] + + let notes = try context.fetch(fetchDesc) + + // Relationships are already loaded - no N+1 + for note in notes { + let folderName = note.folder?.name // ✅ Already in memory + let tagCount = note.tags.count // ✅ Already in memory + } + + try context.save() + }, + didMigrate: nil +) +``` + +#### Performance Impact + +``` +Without prefetching: +- 1 query to fetch notes +- N queries to fetch each note's folder +- N queries to fetch each note's tags += 1 + N + N queries + +With prefetching: +- 1 query to fetch notes +- 1 query to fetch all folders +- 1 query to fetch all tags += 3 queries total +``` + +--- + +### Pattern 5: Renaming Properties + +**Use when** You want to rename a property without data loss + +```swift +enum SchemaV1: VersionedSchema { + static var versionIdentifier = Schema.Version(1, 0, 0) + static var models: [any PersistentModel.Type] { + [Note.self] + } + + @Model + final class Note { + @Attribute(.unique) var id: String + var title: String // Original name + } +} + +enum SchemaV2: VersionedSchema { + static var versionIdentifier = Schema.Version(2, 0, 0) + static var models: [any PersistentModel.Type] { + [Note.self] + } + + @Model + final class Note { + @Attribute(.unique) var id: String + + // Renamed from "title" to "heading" + @Attribute(originalName: "title") + var heading: String + } +} + +// Migration plan (lightweight migration) +enum MigrationPlan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { + [SchemaV1.self, SchemaV2.self] + } + + static var stages: [MigrationStage] { + [migrateV1toV2] + } + + static let migrateV1toV2 = MigrationStage.lightweight( + fromVersion: SchemaV1.self, + toVersion: SchemaV2.self + ) +} +``` + +**Why this works** SwiftData sees `originalName` and preserves data during lightweight migration. + +--- + +### Pattern 6: Deduplication for Unique Constraints + +**Use when** Adding `@Attribute(.unique)` to a field that has duplicates + +```swift +enum SchemaV1: VersionedSchema { + static var versionIdentifier = Schema.Version(1, 0, 0) + static var models: [any PersistentModel.Type] { + [Trip.self] + } + + @Model + final class Trip { + @Attribute(.unique) var id: String + var name: String // ❌ Not unique, has duplicates + + init(id: String, name: String) { + self.id = id + self.name = name + } + } +} + +enum SchemaV2: VersionedSchema { + static var versionIdentifier = Schema.Version(2, 0, 0) + static var models: [any PersistentModel.Type] { + [Trip.self] + } + + @Model + final class Trip { + @Attribute(.unique) var id: String + @Attribute(.unique) var name: String // ✅ Now unique + + init(id: String, name: String) { + self.id = id + self.name = name + } + } +} + +enum TripMigrationPlan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { + [SchemaV1.self, SchemaV2.self] + } + + static var stages: [MigrationStage] { + [migrateV1toV2] + } + + static let migrateV1toV2 = MigrationStage.custom( + fromVersion: SchemaV1.self, + toVersion: SchemaV2.self, + willMigrate: { context in + // Deduplicate before adding unique constraint + let trips = try context.fetch(FetchDescriptor()) + + var seenNames = Set() + for trip in trips { + if seenNames.contains(trip.name) { + // Duplicate - delete or rename + context.delete(trip) + } else { + seenNames.insert(trip.name) + } + } + + try context.save() + }, + didMigrate: nil + ) +} +``` + +--- + +## Testing Migrations + +### Mandatory Testing Checklist + +- [ ] Test fresh install (all migrations run from V1 → latest) +- [ ] Test upgrade from each previous version +- [ ] Test on REAL device (not just simulator) +- [ ] Verify relationship integrity after migration +- [ ] Check for data loss (count records before/after) +- [ ] Test with production-sized dataset + +### Why Simulator Testing Is Insufficient + +**Simulator behavior** Deletes database on rebuild, always sees fresh schema + +**Real device behavior** Keeps persistent database across updates, schema must match + +```swift +// ❌ WRONG - only testing in simulator +// You rebuild → simulator deletes database → fresh install +// Migration code never runs! + +// ✅ CORRECT - test on real device +// 1. Install v1 build on device +// 2. Create sample data +// 3. Install v2 build (with migration) +// 4. Verify data preserved +``` + +### Testing Workflow + +**Before deploying any migration to production:** + +#### 1. Create Test Data Sets + +Prepare test data representing pre-migration state: +- **Minimal dataset** - 10-20 records with all relationship types +- **Realistic dataset** - 1,000+ records matching production scale +- **Edge cases** - Empty relationships, max relationship counts, optional fields + +#### 2. Test in Simulator + +Run migration with test data: +```swift +// Create test data in V1 schema +let v1Container = try ModelContainer(for: Schema(versionedSchema: SchemaV1.self)) +// ... populate test data ... + +// Run migration +let v2Container = try ModelContainer( + for: Schema(versionedSchema: SchemaV2.self), + migrationPlan: MigrationPlan.self +) +``` + +Verify: +- All relationships preserved +- No data loss (count records before/after) +- New fields populated correctly +- Performance acceptable with realistic dataset size + +#### 3. Test on Real Device + +**CRITICAL** - Simulator success does not guarantee production safety. + +```bash +# Workflow: +1. Install v1 build on real device +2. Create 100+ records with relationships +3. Verify data exists +4. Install v2 build (over existing app, don't delete) +5. Launch app +6. Verify: + - App launches without crash + - All 100+ records still exist + - Relationships intact + - New fields populated +``` + +#### 4. Validate with Production Data (If Possible) + +If you have access to production data: +- Copy production database to development environment +- Run migration against copy +- Verify no data corruption +- Check performance with production-sized dataset + +See `axiom-swiftdata-migration-diag` for debugging tools if migration fails. + +### Migration Test Pattern + +```swift +import Testing +import SwiftData + +@Test func testMigrationFromV1ToV2() throws { + // 1. Create V1 data + let v1Schema = Schema(versionedSchema: SchemaV1.self) + let v1Config = ModelConfiguration(isStoredInMemoryOnly: true) + let v1Container = try ModelContainer(for: v1Schema, configurations: v1Config) + + let context = v1Container.mainContext + let note = SchemaV1.Note(id: "1", title: "Test", content: "Original") + context.insert(note) + try context.save() + + // 2. Run migration to V2 + let v2Schema = Schema(versionedSchema: SchemaV2.self) + let v2Container = try ModelContainer( + for: v2Schema, + migrationPlan: MigrationPlan.self, + configurations: v1Config + ) + + // 3. Verify data migrated + let v2Context = v2Container.mainContext + let notes = try v2Context.fetch(FetchDescriptor()) + + #expect(notes.count == 1) + #expect(notes.first?.content != nil) // String → AttributedString +} +``` + +--- + +## Decision Tree: Lightweight vs Custom Migration + +``` +What change are you making? +├─ Adding optional property → Lightweight ✓ +├─ Adding required property with default → Lightweight ✓ +├─ Renaming property (with originalName) → Lightweight ✓ +├─ Removing property → Lightweight ✓ +├─ Changing relationship delete rule → Lightweight ✓ +├─ Adding new model → Lightweight ✓ +├─ Changing property type → Custom (two-stage) ✗ +├─ Making optional → required → Custom (populate nulls first) ✗ +├─ Adding unique constraint (duplicates exist) → Custom (deduplicate first) ✗ +└─ Complex relationship restructure → Custom ✗ +``` + +--- + +## Common Mistakes + +### ❌ Forgetting to include ALL models in VersionedSchema + +```swift +enum SchemaV1: VersionedSchema { + static var models: [any PersistentModel.Type] { + [Note.self] // ❌ WRONG: Missing Folder and Tag + } +} + +// ✅ CORRECT: Include ALL models +enum SchemaV1: VersionedSchema { + static var models: [any PersistentModel.Type] { + [Note.self, Folder.self, Tag.self] // ✅ Even if unchanged + } +} +``` + +**Why** Each VersionedSchema is a complete snapshot of the data model, not a diff. + +--- + +### ❌ Trying to access old models in didMigrate + +```swift +static let migrate = MigrationStage.custom( + fromVersion: SchemaV1.self, + toVersion: SchemaV2.self, + willMigrate: nil, + didMigrate: { context in + // ❌ CRASH: SchemaV1.Note doesn't exist here + let oldNotes = try context.fetch(FetchDescriptor()) + } +) + +// ✅ CORRECT: Use willMigrate for old models +static let migrate = MigrationStage.custom( + fromVersion: SchemaV1.self, + toVersion: SchemaV2.self, + willMigrate: { context in + // ✅ SchemaV1.Note exists here + let oldNotes = try context.fetch(FetchDescriptor()) + }, + didMigrate: nil +) +``` + +--- + +### ❌ Not testing on real device with real data + +```swift +// ❌ WRONG: Simulator success ≠ production safety +// Rebuild simulator → database deleted → fresh install +// Migration never actually runs! + +// ✅ CORRECT: Test migration path +// 1. Install v1 on real device +// 2. Create data (100+ records) +// 3. Install v2 with migration +// 4. Verify data preserved +``` + +--- + +### ❌ Many-to-many without explicit inverse + +```swift +// ❌ WRONG: SwiftData can't infer many-to-many +@Model +final class Note { + var tags: [Tag] = [] // ❌ Missing inverse +} + +// ✅ CORRECT: Explicit inverse +@Model +final class Note { + @Relationship(deleteRule: .nullify, inverse: \Tag.notes) + var tags: [Tag] = [] // ✅ Inverse specified +} +``` + +--- + +### ❌ Assuming simulator success = production success + +Simulator deletes database on rebuild. Real devices keep persistent databases across updates. + +**Impact** Migration bugs hidden in simulator, crash 100% of production users. + +**Fix** ALWAYS test on real device before shipping. + +--- + +## Debugging Failed Migrations + +### Enable Core Data SQL Debug + +```bash +# In Xcode scheme, add argument: +-com.apple.coredata.swiftdata.debug 1 +``` + +**Output** Shows actual SQL queries during migration + +``` +CoreData: sql: SELECT Z_PK, Z_ENT, Z_OPT, ZID, ZTITLE FROM ZNOTE +CoreData: sql: ALTER TABLE ZNOTE ADD COLUMN ZCONTENT TEXT +``` + +### Common Error Messages + +| Error | Likely Cause | Fix | +|-------|--------------|-----| +| "Expected only Arrays for Relationships" | Many-to-many inverse missing | Add `@Relationship(inverse:)` | +| "The model used to open the store is incompatible" | Schema version mismatch | Verify migration plan schemas array | +| "Failed to fulfill faulting for..." | Relationship integrity broken | Prefetch relationships during migration | +| App crashes on launch after schema change | Missing model in VersionedSchema | Include ALL models | + +--- + +## Quick Reference + +### Basic Migration Setup + +```swift +// 1. Define versioned schemas +enum SchemaV1: VersionedSchema { /* models */ } +enum SchemaV2: VersionedSchema { /* models */ } + +// 2. Create migration plan +enum MigrationPlan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { + [SchemaV1.self, SchemaV2.self] + } + + static var stages: [MigrationStage] { + [migrateV1toV2] + } + + static let migrateV1toV2 = MigrationStage.lightweight( + fromVersion: SchemaV1.self, + toVersion: SchemaV2.self + ) +} + +// 3. Apply to container +let schema = Schema(versionedSchema: SchemaV2.self) +let container = try ModelContainer( + for: schema, + migrationPlan: MigrationPlan.self +) +``` + +--- + +## Resources + +**WWDC**: 2025-291, 2023-10195 + +**Docs**: /swiftdata + +**Skills**: axiom-swiftdata, axiom-swiftdata-migration-diag, axiom-database-migration + +--- + +**Created** 2025-12-09 +**Targets** iOS 17+ (focus on iOS 26+ features) +**Framework** SwiftData (Apple) +**Swift** 5.9+ diff --git a/.claude/skills/axiom-swiftdata-migration/agents/openai.yaml b/.claude/skills/axiom-swiftdata-migration/agents/openai.yaml new file mode 100644 index 0000000..7c1ca56 --- /dev/null +++ b/.claude/skills/axiom-swiftdata-migration/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftData Migration" + short_description: "Creating SwiftData custom schema migrations with VersionedSchema and SchemaMigrationPlan" diff --git a/.claude/skills/axiom-swiftdata/.openskills.json b/.claude/skills/axiom-swiftdata/.openskills.json new file mode 100644 index 0000000..ccd367c --- /dev/null +++ b/.claude/skills/axiom-swiftdata/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftdata", + "installedAt": "2026-04-12T08:06:45.264Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftdata/SKILL.md b/.claude/skills/axiom-swiftdata/SKILL.md new file mode 100644 index 0000000..929525d --- /dev/null +++ b/.claude/skills/axiom-swiftdata/SKILL.md @@ -0,0 +1,1109 @@ +--- +name: axiom-swiftdata +description: Use when working with SwiftData - @Model definitions, @Query in SwiftUI, @Relationship macros, ModelContext patterns, CloudKit integration, iOS 26+ features, and Swift 6 concurrency with @MainActor — Apple's native persistence framework +license: MIT +metadata: + version: "1.0.0" +--- + +# SwiftData + +## Overview + +Apple's native persistence framework using `@Model` classes and declarative queries. Built on Core Data, designed for SwiftUI. + +**Core principle** Reference types (`class`) + `@Model` macro + declarative `@Query` for reactive SwiftUI integration. + +**Requires** iOS 17+, Swift 5.9+ +**Target** iOS 26+ (this skill focuses on latest features) +**License** Proprietary (Apple) + +## When to Use SwiftData + +#### Choose SwiftData when you need +- ✅ Native Apple integration with SwiftUI +- ✅ Simple CRUD operations +- ✅ Automatic UI updates with `@Query` +- ✅ CloudKit sync (iOS 17+) +- ✅ Reference types (classes) with relationships + +#### Use SQLiteData instead when +- Need value types (structs) +- CloudKit record sharing (not just sync) +- Large datasets (50k+ records) with specific performance needs + +#### Use GRDB when +- Complex raw SQL required +- Fine-grained migration control needed + +**For migrations** See the `axiom-swiftdata-migration` skill for custom schema migrations with VersionedSchema and SchemaMigrationPlan. For migration debugging, see `axiom-swiftdata-migration-diag`. + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### Basic Operations + +#### 1. "I have a notes app with folders. I need to filter notes by folder and sort by last modified. How do I set up the @Query?" +→ The skill shows how to use `@Query` with predicates, sorting, and automatic view updates + +#### 2. "When a user deletes a task list, all tasks should auto-delete too. How do I set up the relationship?" +→ The skill explains `@Relationship` with `deleteRule: .cascade` and inverse relationships + +#### 3. "I have a relationship between User → Messages → Attachments. How do I prevent orphaned data when deleting?" +→ The skill shows cascading deletes, inverse relationships, and safe deletion patterns + +#### CloudKit & Sync + +#### 4. "My chat app syncs messages to other devices via CloudKit. Sometimes messages conflict. How do I handle sync conflicts?" +→ The skill covers CloudKit integration, conflict resolution strategies (last-write-wins, custom resolution), and sync patterns + +#### 5. "I'm adding CloudKit sync to my app, but I get 'Property must have a default value' error. What's wrong?" +→ The skill explains CloudKit constraints: all properties must be optional or have defaults, explains why (network timing), and shows fixes + +#### 6. "I want to show users when their data is syncing to iCloud and what happens when they're offline." +→ The skill shows monitoring sync status with notifications, detecting network connectivity, and offline-aware UI patterns + +#### 7. "I need to share a playlist with other users. How do I implement CloudKit record sharing?" +→ The skill covers CloudKit record sharing patterns (iOS 26+) with owner/permission tracking and sharing metadata + +#### Performance & Optimization + +#### 8. "I need to query 50,000 messages but only display 20 at a time. How do I paginate efficiently?" +→ The skill covers performance patterns, batch fetching, limiting queries, and preventing memory bloat with chunked imports + +#### 9. "My app loads 100 tasks with relationships, and displaying them is slow. I think it's N+1 queries." +→ The skill shows how to identify N+1 problems without prefetching, provides prefetching pattern, and shows 100x performance improvement + +#### 10. "I'm importing 1 million records from an API. What's the best way to batch them without running out of memory?" +→ The skill shows chunk-based importing with periodic saves, memory cleanup patterns, and batch operation optimization + +#### 11. "Which properties should I add indexes to? I'm worried about over-indexing slowing down writes." +→ The skill explains index optimization patterns: when to index (frequently filtered/sorted properties), when to avoid (rarely used, frequently changing), maintenance costs + +#### Migration from Legacy Frameworks + +#### 12. "We're migrating from Realm/Core Data to SwiftData" +→ See the comparison table in Migration section below, then follow `realm-to-swiftdata-migration` or `axiom-swiftdata-migration` for detailed guides + +--- + +## @Model Definitions + +### Basic Model + +```swift +import SwiftData + +@Model +final class Track { + @Attribute(.unique) var id: String + var title: String + var artist: String + var duration: TimeInterval + var genre: String? + + init(id: String, title: String, artist: String, duration: TimeInterval, genre: String? = nil) { + self.id = id + self.title = title + self.artist = artist + self.duration = duration + self.genre = genre + } +} +``` + +#### Key patterns +- Use `final class`, not `struct` (omit `final` if you need subclasses — see Class Inheritance below) +- Use `@Attribute(.unique)` for primary key-like behavior (not supported with CloudKit sync — see CloudKit Constraints below) +- Provide explicit `init` (SwiftData doesn't synthesize) +- Optional properties (`String?`) are nullable +- Use `@Attribute(.preserveValueOnDeletion)` on properties whose values should survive even after the object is deleted (useful for analytics, audit trails) + +### Relationships + +```swift +@Model +final class Track { + @Attribute(.unique) var id: String + var title: String + + @Relationship(deleteRule: .cascade, inverse: \Album.tracks) + var album: Album? + + init(id: String, title: String, album: Album? = nil) { + self.id = id + self.title = title + self.album = album + } +} + +@Model +final class Album { + @Attribute(.unique) var id: String + var title: String + + @Relationship(deleteRule: .cascade) + var tracks: [Track] = [] + + init(id: String, title: String) { + self.id = id + self.title = title + } +} +``` + +### Many-to-Many Self-Referential Relationships + +```swift +@MainActor // Required for Swift 6 strict concurrency +@Model +final class User { + @Attribute(.unique) var id: String + var name: String + + // Users following this user (inverse relationship) + @Relationship(deleteRule: .nullify, inverse: \User.following) + var followers: [User] = [] + + // Users this user is following + @Relationship(deleteRule: .nullify) + var following: [User] = [] + + init(id: String, name: String) { + self.id = id + self.name = name + } +} +``` + +#### CRITICAL: SwiftData automatically manages BOTH sides when you modify ONE side. + +✅ **Correct — Only modify ONE side** +```swift +// user1 follows user2 (modifying ONE side) +user1.following.append(user2) +try modelContext.save() + +// SwiftData AUTOMATICALLY updates user2.followers +// Don't manually append to both sides - causes duplicates! +``` + +❌ **Wrong — Don't manually update both sides** +```swift +user1.following.append(user2) +user2.followers.append(user1) // Redundant! Creates duplicates in CloudKit sync +``` + +#### Unfollowing (remove from ONE side only) +```swift +user1.following.removeAll { $0.id == user2.id } +try modelContext.save() +// user2.followers automatically updated +``` + +#### Verifying relationship integrity (for debugging) +```swift +// Check if relationship is truly bidirectional +let user1FollowsUser2 = user1.following.contains { $0.id == user2.id } +let user2FollowedByUser1 = user2.followers.contains { $0.id == user1.id } + +// These MUST always match after save() +assert(user1FollowsUser2 == user2FollowedByUser1, "Relationship corrupted!") +``` + +#### CloudKit Sync Recovery (if relationships become corrupted) +```swift +// If CloudKit sync creates duplicate/orphaned relationships: + +// 1. Backup current state +let backup = user.following.map { $0.id } + +// 2. Clear relationships +user.following.removeAll() +user.followers.removeAll() +try modelContext.save() + +// 3. Rebuild from source of truth (e.g., API) +for followingId in backup { + if let followingUser = fetchUser(id: followingId) { + user.following.append(followingUser) + } +} +try modelContext.save() + +// 4. Force CloudKit resync (in ModelConfiguration) +// Re-create ModelContainer to force full sync after corruption recovery +``` + +#### Delete rules +- `.cascade` - Delete related objects +- `.nullify` - Set relationship to nil +- `.deny` - Prevent deletion if relationship exists +- `.noAction` - Leave relationship as-is (careful!) + +## Class Inheritance + +SwiftData supports class inheritance for hierarchical models. Use when you have a clear IS-A relationship (e.g., `BusinessTrip` IS-A `Trip`) and need both broad queries (all trips) and type-specific queries. + +### Base and Subclass Pattern + +Apply `@Model` to both base class and subclasses. Omit `final` on the base class. + +```swift +@Model class Trip { + @Attribute(.preserveValueOnDeletion) + var name: String + var destination: String + var startDate: Date + var endDate: Date + + @Relationship(deleteRule: .cascade, inverse: \Accommodation.trip) + var accommodation: Accommodation? + + init(name: String, destination: String, startDate: Date, endDate: Date) { + self.name = name + self.destination = destination + self.startDate = startDate + self.endDate = endDate + } +} + +@Model class BusinessTrip: Trip { + var purpose: String + var expenseCode: String + + @Relationship(deleteRule: .cascade, inverse: \BusinessMeal.trip) + var businessMeals: [BusinessMeal] = [] + + init(name: String, destination: String, startDate: Date, endDate: Date, + purpose: String, expenseCode: String) { + self.purpose = purpose + self.expenseCode = expenseCode + super.init(name: name, destination: destination, startDate: startDate, endDate: endDate) + } +} +``` + +### Type-Based Queries with #Predicate + +Query all base class instances (includes subclasses), or filter by type: + +```swift +// All trips (includes BusinessTrip, PersonalTrip, etc.) +@Query(sort: \Trip.startDate) var allTrips: [Trip] + +// Only business trips — use `is` in #Predicate +@Query(filter: #Predicate { $0 is BusinessTrip }) var businessTrips: [Trip] + +// Filter on subclass-specific properties — use `as?` cast +let vacationPredicate = #Predicate { + if let personal = $0 as? PersonalTrip { + return personal.reason == .vacation + } + return false +} +@Query(filter: vacationPredicate) var vacationTrips: [Trip] +``` + +### Polymorphic Relationships + +Relationships typed to the base class can hold mixed subclass instances: + +```swift +@Model class TravelPlanner { + var name: String + + @Relationship(deleteRule: .cascade) + var upcomingTrips: [Trip] = [] // Can contain BusinessTrip and PersonalTrip + + init(name: String) { self.name = name } +} +``` + +Cast to access subclass-specific properties: + +```swift +for trip in planner.upcomingTrips { + if let business = trip as? BusinessTrip { + print(business.expenseCode) + } +} +``` + +### When to Use Inheritance vs Alternatives + +| Signal | Use Inheritance | Use Enum/Flag Instead | +|--------|----------------|----------------------| +| Subclasses share many base properties | Yes | — | +| Need type-based queries across all models | Yes | — | +| Subclasses have their own relationships | Yes | — | +| Only 1-2 distinguishing properties | — | Yes | +| Query only on specialized properties | — | Yes | +| Protocol conformance suffices | — | Yes | + +**Keep hierarchies shallow** (1-2 levels). Deep chains complicate schema migrations and queries. + +## ModelContainer Setup + +### SwiftUI App + +```swift +import SwiftUI +import SwiftData + +@main +struct MusicApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(for: [Track.self, Album.self]) + } +} +``` + +### Custom Configuration + +```swift +let schema = Schema([Track.self, Album.self]) + +let config = ModelConfiguration( + schema: schema, + url: URL(fileURLWithPath: "/path/to/database.sqlite"), + cloudKitDatabase: .private("iCloud.com.example.app") +) + +let container = try ModelContainer( + for: schema, + configurations: config +) +``` + +### In-Memory (Tests) + +```swift +let config = ModelConfiguration(isStoredInMemoryOnly: true) +let container = try ModelContainer( + for: schema, + configurations: config +) +``` + +## Queries in SwiftUI + +### Basic @Query + +```swift +import SwiftUI +import SwiftData + +struct TracksView: View { + @Query var tracks: [Track] + + var body: some View { + List(tracks) { track in + Text(track.title) + } + } +} +``` + +**Automatic updates** View refreshes when data changes. + +### Filtered, Sorted, Combined + +```swift +// Filtered +@Query(filter: #Predicate { $0.genre == "Rock" }) var rockTracks: [Track] + +// Sorted (single) +@Query(sort: \.title, order: .forward) var tracks: [Track] + +// Sorted (multiple descriptors) +@Query(sort: [SortDescriptor(\.artist), SortDescriptor(\.title)]) var tracks: [Track] + +// Combined filter + sort +@Query(filter: #Predicate { $0.duration > 180 }, sort: \.title) var longTracks: [Track] +``` + +## ModelContext Operations + +### Accessing ModelContext + +```swift +struct ContentView: View { + @Environment(\.modelContext) private var modelContext + // ... +} +``` + +### CRUD Operations + +```swift +// Insert +let track = Track(id: "1", title: "Song", artist: "Artist", duration: 240) +modelContext.insert(track) + +// Fetch +let descriptor = FetchDescriptor( + predicate: #Predicate { $0.genre == "Rock" }, + sortBy: [SortDescriptor(\.title)] +) +let rockTracks = try modelContext.fetch(descriptor) + +// Update — just modify properties, SwiftData tracks changes +track.title = "Updated Title" + +// Delete +modelContext.delete(track) + +// Batch delete +try modelContext.delete(model: Track.self, where: #Predicate { $0.genre == "Classical" }) + +// Save (optional — auto-saves on view disappear) +try modelContext.save() +``` + +## Predicates + +### Basic Comparisons + +```swift +#Predicate { $0.duration > 180 } +#Predicate { $0.artist == "Artist Name" } +#Predicate { $0.genre != nil } +``` + +### Compound Predicates + +```swift +#Predicate { track in + track.genre == "Rock" && track.duration > 180 +} + +#Predicate { track in + track.artist == "Artist" || track.artist == "Other Artist" +} +``` + +### String Matching + +```swift +// Contains +#Predicate { track in + track.title.contains("Love") +} + +// Case-insensitive contains +#Predicate { track in + track.title.localizedStandardContains("love") +} + +// Starts with +#Predicate { track in + track.artist.hasPrefix("The ") +} +``` + +### Relationship Predicates + +```swift +#Predicate { track in + track.album?.title == "Album Name" +} + +#Predicate { album in + album.tracks.count > 10 +} +``` + +## Swift 6 Concurrency + +### @MainActor Isolation + +```swift +import SwiftData + +@MainActor +@Model +final class Track { + var id: String + var title: String + + init(id: String, title: String) { + self.id = id + self.title = title + } +} +``` + +**Why** SwiftData models are not `Sendable`. Use `@MainActor` to ensure safe access from SwiftUI. + +### Background Context + +```swift +import SwiftData + +actor DataImporter { + let modelContainer: ModelContainer + + init(container: ModelContainer) { + self.modelContainer = container + } + + func importTracks(_ tracks: [TrackData]) async throws { + // Create background context + let context = ModelContext(modelContainer) + + for track in tracks { + let model = Track( + id: track.id, + title: track.title, + artist: track.artist, + duration: track.duration + ) + context.insert(model) + } + + try context.save() + } +} +``` + +**Pattern** Use `ModelContext(modelContainer)` for background operations, not `@Environment(\.modelContext)` which is main-actor bound. + +#### Calling from SwiftUI + +```swift +struct ContentView: View { + @Environment(\.modelContext) private var modelContext + + var body: some View { + Button("Import") { + Task { + let importer = DataImporter(container: modelContext.container) + try await importer.importTracks(data) + } + } + } +} +``` + +## CloudKit Integration + +### Enable CloudKit Sync + +```swift +let schema = Schema([Track.self]) + +let config = ModelConfiguration( + schema: schema, + cloudKitDatabase: .private("iCloud.com.example.MusicApp") +) + +let container = try ModelContainer( + for: schema, + configurations: config +) +``` + +### Capabilities Required + +1. Enable iCloud in Xcode (Signing & Capabilities) +2. Select CloudKit +3. Add iCloud container: `iCloud.com.example.MusicApp` + +**Note** SwiftData CloudKit sync is automatic - no manual conflict resolution needed. + +### CloudKit Constraints (CRITICAL) + +#### When using CloudKit sync, ALL properties must be optional or have default values + +```swift +@Model +final class Track { + var id: String = UUID().uuidString // ✅ Has default (don't use .unique — CloudKit can't enforce it) + var title: String = "" // ✅ Has default + var duration: TimeInterval = 0 // ✅ Has default + var genre: String? = nil // ✅ Optional + + // ❌ These don't work with CloudKit: + // var requiredField: String // No default, not optional +} +``` + +**Why** CloudKit only syncs to private zones, and network delays mean new records may not have all fields populated yet. + +**Relationship Constraint** All relationships must be optional +```swift +@Model +final class Track { + @Relationship(deleteRule: .cascade, inverse: \Album.tracks) + var album: Album? // ✅ Must be optional for CloudKit +} +``` + +### Sync Status, Conflicts, Offline Handling + +SwiftData CloudKit sync uses **last-write-wins** by default. For sync status monitoring, custom conflict resolution, and offline-aware UI patterns, see `axiom-cloud-sync`. For CKShare-based record sharing, see `axiom-cloudkit-ref`. + +### Resolving "Property must be optional or have default value" Error + +**Problem** You get this error when trying to use CloudKit sync: +``` +Property 'title' must be optional or have a default value for CloudKit synchronization +``` + +#### Solution +```swift +// ❌ Wrong - required property +@Model +final class Track { + var title: String +} + +// ✅ Correct - has default +@Model +final class Track { + var title: String = "" +} + +// ✅ Also correct - optional +@Model +final class Track { + var title: String? +} +``` + +### Testing CloudKit Sync (Without iCloud) + +```swift +let schema = Schema([Track.self]) + +// Test configuration (no CloudKit sync) +let testConfig = ModelConfiguration(isStoredInMemoryOnly: true) + +let container = try ModelContainer(for: schema, configurations: testConfig) +``` + +#### For real CloudKit testing +1. Sign in to iCloud on test device +2. Enable CloudKit in Capabilities +3. Use real device (simulator CloudKit is unreliable) +4. Check iCloud status in Settings → [Your Name] → iCloud + +## iOS 26+ Features + +### Enhanced Relationship Handling + +```swift +@Model +final class Track { + @Relationship( + deleteRule: .cascade, + inverse: \Album.tracks, + minimum: 0, + maximum: 1 // Track belongs to at most one album + ) var album: Album? +} +``` + +### Transient Properties + +```swift +@Model +final class Track { + var id: String + var duration: TimeInterval + + @Transient + var formattedDuration: String { + let minutes = Int(duration) / 60 + let seconds = Int(duration) % 60 + return String(format: "%d:%02d", minutes, seconds) + } +} +``` + +**Transient** Computed property, not persisted. + +### History Tracking + +```swift +// Enable history tracking +let config = ModelConfiguration( + schema: schema, + cloudKitDatabase: .private("iCloud.com.example.app"), + allowsSave: true, + isHistoryEnabled: true // iOS 26+ +) +``` + +## Performance Patterns + +### Batch Fetching + +```swift +let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.title)] +) +descriptor.fetchLimit = 100 // Paginate results + +let tracks = try modelContext.fetch(descriptor) +``` + +### Prefetch Relationships (Prevent N+1 Queries) + +```swift +let descriptor = FetchDescriptor() +descriptor.relationshipKeyPathsForPrefetching = [\.album] // Eager load album + +let tracks = try modelContext.fetch(descriptor) +// No N+1 queries - albums already loaded +``` + +**CRITICAL** Without prefetching, accessing `track.album.title` in a loop triggers individual queries for EACH track: + +```swift +// ❌ SLOW: N+1 queries (1 fetch tracks + 100 fetch albums) +let tracks = try modelContext.fetch(FetchDescriptor()) +for track in tracks { + print(track.album?.title) // 100 separate queries! +} + +// ✅ FAST: 2 queries total (1 fetch tracks + 1 fetch all albums) +let descriptor = FetchDescriptor() +descriptor.relationshipKeyPathsForPrefetching = [\.album] +let tracks = try modelContext.fetch(descriptor) +for track in tracks { + print(track.album?.title) // Already loaded +} +``` + +### Faulting (Lazy Loading) + +SwiftData uses faulting (lazy loading) by default: + +```swift +let track = tracks.first +// Album is a fault - not loaded yet + +let albumTitle = track.album?.title +// Album loaded on access (separate query) +``` + +#### Use faulting strategically +- ✅ Good when you access relationships in only 10-20% of cases +- ✅ Good for large relationship graphs you partially use +- ❌ Bad when you access relationships in loops → use prefetching instead + +### Batch Operations (Performance for Large Datasets) + +```swift +// ❌ SLOW: 1000 individual saves +for track in largeDataset { + track.genre = "Updated" + try modelContext.save() // Expensive - 1000 times +} + +// ✅ FAST: Single save operation +for track in largeDataset { + track.genre = "Updated" +} +try modelContext.save() // Once for entire batch +``` + +### Index Optimization (iOS 26+) + +Create indexes on frequently queried properties: + +```swift +@Model +final class Track { + @Attribute(.unique) var id: String = UUID().uuidString + + @Attribute(.indexed) // ✅ Add index + var genre: String = "" + + @Attribute(.indexed) + var releaseDate: Date = Date() + + var title: String = "" + var duration: TimeInterval = 0 +} + +// Now these queries are faster: +@Query(filter: #Predicate { $0.genre == "Rock" }) var rockTracks: [Track] +@Query(filter: #Predicate { $0.releaseDate > Date() }) var upcomingTracks: [Track] +``` + +#### When to add indexes +- ✅ Properties used in `@Query` filters frequently +- ✅ Properties used in sort operations +- ✅ Properties used in relationships +- ❌ NOT properties that are rarely filtered +- ❌ NOT properties that change frequently (maintenance cost) + +### Memory Optimization: Fetch Chunks + +For very large datasets (100k+ records), fetch in chunks: + +```swift +actor DataImporter { + let modelContainer: ModelContainer + + func importLargeDataset(_ items: [Item]) async throws { + let chunkSize = 1000 + let context = ModelContext(modelContainer) + + for chunk in items.chunked(into: chunkSize) { + for item in chunk { + let track = Track( + id: item.id, + title: item.title, + artist: item.artist, + duration: item.duration + ) + context.insert(track) + } + + try context.save() // Save after each chunk + + // Prevent memory bloat + context.delete(model: Track.self, where: #Predicate { _ in true }) + } + } +} + +extension Array { + func chunked(into size: Int) -> [[Element]] { + stride(from: 0, to: count, by: size).map { + Array(self[$0..` | Entity editor + `@NSManaged` | `@Relationship` with automatic inverses | +| Background work | `DispatchQueue` + thread-local Realm | `newBackgroundContext()` | `actor` + `ModelContext(modelContainer)` | +| Batch delete | Loop + `realm.delete()` | `NSBatchDeleteRequest` | `context.delete(model:where:)` | +| CloudKit sync | Realm Sync (deprecated Sept 2025) | `NSPersistentCloudKitContainer` | `ModelConfiguration(cloudKitDatabase:)` | + +### Detailed Migration Guides + +- **`realm-to-swiftdata-migration`** — Complete Realm migration: pattern equivalents, thread safety conversion, relationship migration, CloudKit sync transition, timeline planning +- **`axiom-swiftdata-migration`** — SwiftData schema evolution: VersionedSchema, SchemaMigrationPlan, lightweight vs custom migrations +- **`axiom-database-migration`** — Safe additive migration patterns applicable to any persistence framework + +## Testing + +### Test Setup + +```swift +import XCTest +import SwiftData +@testable import MusicApp + +final class TrackTests: XCTestCase { + var modelContext: ModelContext! + + override func setUp() async throws { + let schema = Schema([Track.self]) + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: config) + modelContext = ModelContext(container) + } + + func testInsertTrack() throws { + let track = Track(id: "1", title: "Test", artist: "Artist", duration: 240) + modelContext.insert(track) + + let descriptor = FetchDescriptor() + let tracks = try modelContext.fetch(descriptor) + + XCTAssertEqual(tracks.count, 1) + XCTAssertEqual(tracks.first?.title, "Test") + } +} +``` + +## Comparison: SwiftData vs SQLiteData + +| Feature | SwiftData | SQLiteData | +|---------|-----------|------------| +| **Type** | Reference (class) | Value (struct) | +| **Macro** | `@Model` | `@Table` | +| **Queries** | `@Query` in SwiftUI | `@FetchAll` / `@FetchOne` | +| **Relationships** | `@Relationship` macro | Explicit foreign keys | +| **CloudKit** | Automatic sync | Manual SyncEngine + sharing | +| **Backend** | Core Data | GRDB + SQLite | +| **Learning Curve** | Easy (native) | Moderate | +| **Performance** | Good | Excellent (raw SQL) | + +## tvOS + +**SwiftData on tvOS has no persistent local storage.** tvOS has no Document directory, and Application Support maps to Caches — the system deletes files under storage pressure. A local-only SwiftData store will lose all data. + +**You must use CloudKit sync** (`cloudKitDatabase: .private(...)`) for tvOS SwiftData apps. Without iCloud, user data does not survive between app launches. See `axiom-tvos` for full tvOS storage constraints. + +--- + +## Resources + +**Docs**: /swiftdata, /swiftdata/adopting-inheritance-in-swiftdata + +**Skills**: axiom-swiftdata-migration, axiom-swiftdata-migration-diag, axiom-database-migration, axiom-sqlitedata, axiom-grdb, axiom-swift-concurrency + +## Common Mistakes + +### ❌ Forgetting explicit init +```swift +@Model +final class Track { + var id: String + var title: String + // No init - won't compile +} +``` +**Fix** Always provide `init` for `@Model` classes + +### ❌ Using structs +```swift +@Model +struct Track { } // Won't work - must be class +``` +**Fix** Use `final class` not `struct` + +### ❌ Background operations on main context +```swift +@Environment(\.modelContext) var context // Main actor only + +Task { + // ❌ Crash - crossing actor boundaries + context.insert(track) +} +``` +**Fix** Use `ModelContext(modelContainer)` for background work + +### ❌ Not saving when needed +```swift +modelContext.insert(track) +// Might not persist immediately +``` +**Fix** Call `try modelContext.save()` for immediate persistence + +--- + +**Created** 2025-11-28 +**Targets** iOS 17+ (focus on iOS 26+ features) +**Framework** SwiftData (Apple) +**Swift** 5.9+ (Swift 6 concurrency patterns) diff --git a/.claude/skills/axiom-swiftdata/agents/openai.yaml b/.claude/skills/axiom-swiftdata/agents/openai.yaml new file mode 100644 index 0000000..c857416 --- /dev/null +++ b/.claude/skills/axiom-swiftdata/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftData" + short_description: "Working with SwiftData" diff --git a/.claude/skills/axiom-swiftui-26-ref/.openskills.json b/.claude/skills/axiom-swiftui-26-ref/.openskills.json new file mode 100644 index 0000000..804c928 --- /dev/null +++ b/.claude/skills/axiom-swiftui-26-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftui-26-ref", + "installedAt": "2026-04-12T08:06:46.409Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftui-26-ref/SKILL.md b/.claude/skills/axiom-swiftui-26-ref/SKILL.md new file mode 100644 index 0000000..4d5d1e0 --- /dev/null +++ b/.claude/skills/axiom-swiftui-26-ref/SKILL.md @@ -0,0 +1,1204 @@ +--- +name: axiom-swiftui-26-ref +description: Use when implementing iOS 26 SwiftUI features - covers Liquid Glass design system, performance improvements, @Animatable macro, 3D spatial layout, scene bridging, WebView/WebPage, AttributedString rich text editing, drag and drop enhancements, and visionOS integration for iOS 26+ +license: MIT +metadata: + version: "1.0.0" +--- + +# SwiftUI 26 Features + +## Overview + +Comprehensive guide to new SwiftUI features in iOS 26, iPadOS 26, macOS Tahoe, watchOS 26, and visionOS 26. From the Liquid Glass design system to rich text editing, these enhancements make SwiftUI more powerful across all Apple platforms. + +**Core principle** From low level performance improvements all the way up through the buttons in your user interface, there are some major improvements across the system. + +## When to Use This Skill + +- Adopting the Liquid Glass design system +- Implementing rich text editing with AttributedString +- Embedding web content with WebView +- Optimizing list and scrolling performance +- Using the @Animatable macro for custom animations +- Building 3D spatial layouts on visionOS +- Bridging SwiftUI scenes to UIKit/AppKit apps +- Implementing drag and drop with multiple items +- Creating 3D charts with Chart3D +- Adding widgets to visionOS or CarPlay +- Adding custom tick marks to sliders (chapter markers, value indicators) +- Constraining slider selection ranges with `enabledBounds` +- Customizing slider appearance (thumb visibility, current value labels) +- Creating sticky safe area bars with blur effects +- Opening URLs in in-app browser +- Using system-styled close and confirm buttons +- Applying glass button styles (iOS 26.1+) +- Controlling button sizing behavior +- Implementing compact search toolbars +- Adjusting line height or baseline spacing for text + +## System Requirements + +#### iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, visionOS 26+ + +--- + +## Liquid Glass Design System + +**For comprehensive coverage**, see `axiom-liquid-glass` (design principles, variants, review pressure) and `axiom-liquid-glass-ref` (app-wide adoption guide). This section covers WWDC 256-specific APIs only. + +### Automatic Adoption + +Recompile with iOS 26 SDK — navigation containers, tab bars, toolbars, toggles, segmented pickers, and sliders automatically adopt the new design. Bordered buttons default to capsule shape. Sheets get Liquid Glass background (remove any `presentationBackground` customizations). + +### Toolbar APIs (iOS 26) + +#### ToolbarSpacer + +```swift +.toolbar { + ToolbarItem(placement: .bottomBar) { Button("Archive", systemImage: "archivebox") { } } + ToolbarSpacer(.flexible, placement: .bottomBar) // Push items apart + ToolbarItem(placement: .bottomBar) { Button("Compose", systemImage: "square.and.pencil") { } } +} +// .fixed separates groups visually; .flexible pushes apart (like Spacer in HStack) +``` + +#### ToolbarItemGroup (Visual Grouping) + +Items in a `ToolbarItemGroup` share a single glass background "pill". `ToolbarItemPlacement` controls visual appearance: `confirmationAction` → `glassProminent` styling, `cancellationAction` → standard glass. Use `.sharedBackgroundVisibility(.hidden)` to exclude items (e.g., avatars) from group background. + +#### Toolbar Morphing + +Attach `.toolbar {}` to individual views inside NavigationStack (not to NavigationStack itself). iOS 26 morphs between per-view toolbars during push/pop. Use `toolbar(id:)` with matching `ToolbarItem(id:)` across screens for items that should stay stable (no bounce): + +```swift +// MailboxList +.toolbar(id: "main") { + ToolbarItem(id: "filter", placement: .bottomBar) { Button("Filter") { } } + ToolbarSpacer(.flexible, placement: .bottomBar) + ToolbarItem(id: "compose", placement: .bottomBar) { Button("New Message") { } } +} +// MessageList — "filter" absent (animates out), "compose" stays stable +.toolbar(id: "main") { + ToolbarSpacer(.flexible, placement: .bottomBar) + ToolbarItem(id: "compose", placement: .bottomBar) { Button("New Message") { } } +} +``` + +**#1 gotcha**: Toolbar on NavigationStack = nothing to morph between. + +#### DefaultToolbarItem + +Reposition system-provided items (like search) within your toolbar layout: + +```swift +DefaultToolbarItem(kind: .search, placement: .bottomBar) +// Replaces system's default placement of matching kind +``` + +Use in collapsed `NavigationSplitView` sidebar to specify which column shows search on iPhone. Wrap in `if #available(iOS 26.0, *)` for backward compatibility. + +#### User-Customizable Toolbars + +`toolbar(id:)` enables user customization (rearrange, show/hide). Only `.secondaryAction` items support customization on iPadOS. Use `showsByDefault: false` for optional items. Add `ToolbarCommands()` for macOS menu item. + +#### Other Toolbar Features + +- `.navigationSubtitle("3 unread")` — Secondary line below title +- `.badge(3)` on toolbar items — Notification counts +- Monochrome icon rendering — Reduces visual noise; tint for meaning, not decoration +- Scroll edge blur — Automatic, no code required + +### Bottom-Aligned Search + +**Foundational search APIs**: See `axiom-swiftui-search-ref`. This section covers iOS 26 refinements only. + +```swift +NavigationSplitView { + List { }.searchable(text: $searchText) +} +// Bottom-aligned on iPhone, top trailing on iPad (automatic) +// Use placement: .sidebar to restore sidebar-embedded search on iPad +``` + +- `searchToolbarBehavior(.minimize)` — Compact search that expands on tap +- `Tab(role: .search)` — Dedicated search tab; search field replaces tab bar. See swiftui-nav-ref Section 5.7 + +### Glass Effect for Custom Views + +```swift +Button("To Top", systemImage: "chevron.up") { scrollToTop() } + .padding() + .glassEffect() // Add .interactive for custom controls on iOS +``` + +- `GlassEffectContainer` — Required when multiple glass elements are nearby (glass can't sample glass) +- `glassEffectID(_:in:)` — Fluid morphing transitions between glass elements using a namespace +- Sheet morphing — Use `.matchedTransitionSource` + `.navigationTransition(.zoom(...))` to morph sheets from buttons + +### Button & Control Changes + +- Capsule shape default for bordered buttons (override with `.buttonBorderShape(.roundedRectangle)`) +- `.controlSize(.extraLarge)` — New extra-large button size +- `.controlSize(.small)` on containers — Preserve pre-iOS 26 density +- `GlassButtonStyle(.clear/.glass/.tint)` — Glass button variants (iOS 26.1+) +- `.buttonSizing(.fit/.stretch/.flexible)` — Control button layout behavior +- `Button(role: .close)` / `Button(role: .confirm)` — System-styled close/confirm +- `.clipShape(.rect(cornerRadius: 12, style: .containerConcentric))` — Corner concentricity +- Menus: icons on leading edge, consistent iOS/macOS + +--- + +## Slider Enhancements + +iOS 26 adds custom tick marks, constrained selection ranges, current value labels, and thumb visibility control. + +### Slider Ticks + +Core types: `SliderTick`, `SliderTickContentForEach`, `SliderTickBuilder` + +```swift +// Static ticks with labels +Slider(value: $value, in: 0...10) { + Text("Rating") +} ticks: { + SliderTick(0) { Text("Min") } + SliderTick(5) { Text("Mid") } + SliderTick(10) { Text("Max") } +} + +// Dynamic ticks from collection +SliderTickContentForEach(stops, id: \.self) { value in + SliderTick(value) { Text("\(Int(value))°").font(.caption2) } +} + +// Step-based ticks (called for each step value) +Slider(value: $volume, in: 0...10, step: 2, label: { Text("Volume") }, tick: { value in + SliderTick(value) { Text("\(Int(value))") } +}) +``` + +**API constraint**: `SliderTickContentForEach` requires `Data.Element` to match `SliderTick` value type. For custom structs, extract numeric values: `chapters.map(\.time)` then look up labels via `chapters.first(where: { $0.time == time })`. + +### Full-Featured Slider + +```swift +Slider( + value: $rating, in: 0...100, + neutralValue: 50, // Starting point / center value + enabledBounds: 20...80, // Restrict selectable range + label: { Text("Rating") }, + currentValueLabel: { Text("\(Int(rating))") }, + minimumValueLabel: { Text("0") }, + maximumValueLabel: { Text("100") }, + ticks: { SliderTick(50) { Text("Mid") } }, + onEditingChanged: { editing in print(editing ? "Started" : "Ended") } +) +``` + +### sliderThumbVisibility + +`.sliderThumbVisibility(.hidden)` — Hide thumb for media progress indicators and minimal UI. Options: `.automatic`, `.visible`, `.hidden`. Always visible on watchOS. + +--- + +## New View Modifiers + +### safeAreaBar + +Sticky bars with integrated progressive blur: + +```swift +List { ForEach(1...20, id: \.self) { Text("\($0). Item") } } + .safeAreaBar(edge: .bottom) { + Text("Bottom Action Bar").padding(.vertical, 15) + } + .scrollEdgeEffectStyle(.soft, for: .bottom) // or .hard +``` + +Works like `safeAreaInset` but with blur. Bar remains fixed while content scrolls beneath. + +### onOpenURL Enhancement + +```swift +@Environment(\.openURL) var openURL +// openURL(url, prefersInApp: true) — Opens in SFSafariViewController-style in-app browser +// Default Link opens in Safari; prefersInApp keeps users in your app +``` + +### searchToolbarBehavior + +See `axiom-swiftui-search-ref` for foundational `.searchable` APIs. iOS 26 adds: + +```swift +.searchable(text: $searchText) +.searchToolbarBehavior(.minimize) // Compact button, expands on tap +``` + +Also: `.searchPresentationToolbarBehavior(.avoidHidingContent)` (iOS 17.1+) keeps title visible during search. + +**Backward-compatible wrapper** for apps targeting iOS 18+26: + +```swift +extension View { + @ViewBuilder func minimizedSearch() -> some View { + if #available(iOS 26.0, *) { + self.searchToolbarBehavior(.minimize) + } else { self } + } +} + +// Usage +.searchable(text: $searchText) +.minimizedSearch() +``` + +**Availability pattern for toolbar items**: + +```swift +.toolbar { + if #available(iOS 26.0, *) { + DefaultToolbarItem(kind: .search, placement: .bottomBar) + ToolbarSpacer(.flexible, placement: .bottomBar) + } + ToolbarItem(placement: .bottomBar) { + NewNoteButton() + } +} +.searchable(text: $searchText) +``` + +**Button roles, GlassButtonStyle, buttonSizing** — See Liquid Glass Design System section above. + +### lineHeight (iOS 26) + +Sets the baseline-to-baseline distance between text lines. More intuitive than `.lineSpacing()` which measures bottom-of-line to top-of-next-line. + +#### Presets + +```swift +Text("Lorem ipsum...") + .lineHeight(.loose) // Increased spacing for open layouts + .lineHeight(.tight) // Reduced spacing for compact layouts + .lineHeight(.normal) // Constant height based on point size multiple + .lineHeight(.variable) // Uses font metrics for height calculation +``` + +#### Precise Control + +```swift +// Scale proportionally to font size +Text("Scales with text size") + .lineHeight(.multiple(factor: 2)) + +// Relative to point size with fixed increase +Text("Point-size relative") + .lineHeight(.leading(increase: 30)) + +// Absolute fixed value — does NOT scale with Dynamic Type +Text("Fixed height") + .lineHeight(.exact(points: 30)) +``` + +#### AttributedString Support + +```swift +var s = AttributedString("Paragraph\nwith multiple\nlines.") +s.lineHeight = .exact(points: 32) +s.lineHeight = .multiple(factor: 2.5) +s.lineHeight = .loose +``` + +#### Comparison with Existing APIs + +| API | Measures | Available | +|-----|----------|-----------| +| `.lineHeight()` | Baseline to baseline | iOS 26+ | +| `.lineSpacing()` | Bottom of line to top of next | iOS 13+ | +| `.font(.body.leading(.tight))` | Font-level leading preset | iOS 14+ | + +**Cross-reference** `axiom-typography-ref` — Full typography system including Dynamic Type, tracking, and internationalization + +--- + +## iPad Enhancements + +### Menu Bar + +#### Access common actions via swipe-down menu + +```swift +.commands { + TextEditingCommands() // Same API as macOS menu bar + + CommandGroup(after: .newItem) { + Button("Add Note") { + addNote() + } + .keyboardShortcut("n", modifiers: [.command, .shift]) + } +} +// Creates menu bar on iPad when people swipe down +``` + +### Resizable Windows + +#### Fluid resizing on iPad + +```swift +// MIGRATION REQUIRED: +// Remove deprecated property list key in iPadOS 26: +// UIRequiresFullscreen (entire key deprecated, all values) + +// For split view navigation, system automatically shows/hides columns +// based on available space during resize +NavigationSplitView { + Sidebar() +} detail: { + Detail() +} +// Adapts to resizing automatically +``` + +**Reference** "Elevate the design of your iPad app" (WWDC 2025) + +--- + +## macOS Window Enhancements + +### Synchronized Window Resize Animations + +```swift +.windowResizeAnchor(.topLeading) // Tailor where animation originates + +// SwiftUI now synchronizes animation between content view size changes +// and window resizing - great for preserving continuity when switching tabs +``` + +--- + +## Performance Improvements + +### List Performance (macOS Focus) + +#### Massive gains for large lists + +- **6x faster loading** for lists of 100,000+ items on macOS +- **16x faster updates** for large lists +- Even bigger gains for larger lists +- Improvements benefit all platforms (iOS, iPadOS, watchOS) + +```swift +List(trips) { trip in // 100k+ items + TripRow(trip: trip) +} +// Loads 6x faster, updates 16x faster on macOS (iOS 26+) +``` + +### Scrolling Performance + +#### Reduced dropped frames + +SwiftUI has improved scheduling of user interface updates on iOS and macOS. This improves responsiveness and lets SwiftUI do even more work to prepare for upcoming frames. All in all, it reduces the chance of your app dropping a frame while scrolling quickly at high frame rates. + +### Nested ScrollViews with Lazy Stacks + +#### Photo carousels and multi-axis scrolling + +```swift +ScrollView(.horizontal) { + LazyHStack { + ForEach(photoSets) { photoSet in + ScrollView(.vertical) { + LazyVStack { + ForEach(photoSet.photos) { photo in + PhotoView(photo: photo) + } + } + } + } + } +} +// Nested scrollviews now properly delay loading with lazy stacks +// Great for building photo carousels +``` + +### SwiftUI Performance Instrument + +#### New profiling tool in Xcode + +Available lanes: +- **Long view body updates** — Identify expensive body computations +- **Platform view updates** — Track UIKit/AppKit bridging performance +- Other performance problem areas + +**Reference** "Optimize SwiftUI performance with instruments" (WWDC 2025) + +**Cross-reference** [SwiftUI Performance](/skills/ui-design/swiftui-performance) — Master the SwiftUI Instrument + +--- + +## Swift Concurrency Integration + +### Compile-Time Data Race Safety + +```swift +@Observable +class TripStore { + var trips: [Trip] = [] + + func loadTrips() async { + trips = await TripService.fetchTrips() + // Swift 6 verifies data race safety at compile time + } +} +``` + +**Benefits** Find bugs in concurrent code before they affect your app + +#### References +- "Embracing Swift concurrency" (WWDC 2025) +- "Explore concurrency in SwiftUI" (WWDC 2025) + +**Cross-reference** [Swift Concurrency](/skills/concurrency/swift-concurrency) — Swift 6 strict concurrency patterns + +--- + +## @Animatable Macro + +### Overview + +Simplifies custom animations by automatically synthesizing `animatableData` property. + +#### Before (@Animatable macro) + +```swift +struct HikingRouteShape: Shape { + var startPoint: CGPoint + var endPoint: CGPoint + var elevation: Double + var drawingDirection: Bool // Don't want to animate this + + // Tedious manual animatableData declaration + var animatableData: AnimatablePair> { + get { + AnimatablePair(startPoint.animatableData, + AnimatablePair(elevation, endPoint.animatableData)) + } + set { + startPoint.animatableData = newValue.first + elevation = newValue.second.first + endPoint.animatableData = newValue.second.second + } + } +} +``` + +#### After (@Animatable macro) + +```swift +@Animatable +struct HikingRouteShape: Shape { + var startPoint: CGPoint + var endPoint: CGPoint + var elevation: Double + + @AnimatableIgnored + var drawingDirection: Bool // Excluded from animation + + // animatableData automatically synthesized! +} +``` + +#### Key benefits +- Delete manual `animatableData` property +- Use `@AnimatableIgnored` for properties to exclude +- SwiftUI automatically synthesizes animation data + +**Cross-reference** SwiftUI Animation (swiftui-animation-ref skill) — Comprehensive animation guide covering VectorArithmetic, Animatable protocol, @Animatable macro, animation types, Transaction system, and performance optimization + +--- + +## 3D Spatial Layout (visionOS) + +### Alignment3D + +#### Depth-based layout + +```swift +struct SunPositionView: View { + @State private var timeOfDay: Double = 12.0 + + var body: some View { + HikingRouteView() + .overlay(alignment: sunAlignment) { + SunView() + .spatialOverlay(alignment: sunAlignment) + } + } + + var sunAlignment: Alignment3D { + // Align sun in 3D space based on time of day + Alignment3D( + horizontal: .center, + vertical: .top, + depth: .back + ) + } +} +``` + +### Manipulable Modifier + +#### Interactive 3D objects + +```swift +Model3D(named: "WaterBottle") + .manipulable() // People can pick up and move the object +``` + +### Surface Snapping APIs + +```swift +@Environment(\.surfaceSnappingInfo) var snappingInfo: SurfaceSnappingInfo + +var body: some View { + VStackLayout().depthAlignment(.center) { + Model3D(named: "waterBottle") + .manipulable() + + Pedestal() + .opacity(snappingInfo.classification == .table ? 1.0 : 0.0) + } +} +``` + +#### References +- "Meet SwiftUI spatial layout" (WWDC 2025) +- "Set the scene with SwiftUI in visionOS" (WWDC 2025) +- "What's new in visionOS" (WWDC 2025) + +--- + +## Scene Bridging + +### Overview + +Scene bridging allows your UIKit and AppKit lifecycle apps to interoperate with SwiftUI scenes. Apps can use it to open SwiftUI-only scene types or use SwiftUI-exclusive features right from UIKit or AppKit code. + +### Supported Scene Types + +#### From UIKit/AppKit apps, you can now use + +- `MenuBarExtra` (macOS) +- `ImmersiveSpace` (visionOS) +- `RemoteImmersiveSpace` (macOS → Vision Pro) +- `AssistiveAccess` (iOS 26) + +### Scene Modifiers + +Works with scene modifiers like: +- `.windowStyle()` +- `.immersiveEnvironmentBehavior()` + +### RemoteImmersiveSpace + +#### Mac app renders stereo content on Vision Pro + +```swift +// In your macOS app +@main +struct MyMacApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + + RemoteImmersiveSpace(id: "stereoView") { + // Render stereo content on Apple Vision Pro + // Uses CompositorServices + } + } +} +``` + +#### Features +- Mac app renders stereo content on Vision Pro +- Hover effects and input events supported +- Uses CompositorServices and Metal + +**Reference** "What's new in Metal rendering for immersive apps" (WWDC 2025) + +### AssistiveAccess Scene + +#### Special mode for users with cognitive disabilities + +```swift +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + + AssistiveAccessScene { + SimplifiedUI() // UI shown when iPhone is in AssistiveAccess mode + } + } +} +``` + +**Reference** "Customize your app for Assistive Access" (WWDC 2025) + +--- + +## AppKit Integration Enhancements + +### SwiftUI Sheets in AppKit + +```swift +// Show SwiftUI view in AppKit sheet +let hostingController = NSHostingController(rootView: SwiftUISettingsView()) +presentAsSheet(hostingController) +// Great for incremental SwiftUI adoption +``` + +### NSGestureRecognizerRepresentable + +```swift +// Bridge AppKit gestures to SwiftUI +struct AppKitPanGesture: NSGestureRecognizerRepresentable { + func makeNSGestureRecognizer(context: Context) -> NSPanGestureRecognizer { + NSPanGestureRecognizer() + } + + func updateNSGestureRecognizer(_ recognizer: NSPanGestureRecognizer, context: Context) { + // Update configuration + } +} +``` + +### NSHostingView in Interface Builder + +NSHostingView can now be used directly in Interface Builder for gradual SwiftUI adoption. + +--- + +## RealityKit Integration + +### Observable Entities + +```swift +@Observable +class RealityEntity { + var position: SIMD3 + var rotation: simd_quatf +} + +struct MyView: View { + @State private var entity = RealityEntity() + + var body: some View { + // SwiftUI views automatically observe changes + Text("Position: \(entity.position.x)") + } +} +``` + +### PresentationComponent + +Present SwiftUI popovers, alerts, and sheets directly from RealityKit entities. + +```swift +// Present SwiftUI popovers from RealityKit entities +let popover = Entity() +mapEntity.addChild(popover) +popover.components[PresentationComponent.self] = PresentationComponent( + isPresented: $popoverPresented, + configuration: .popover(arrowEdge: .bottom), + content: DetailsView() +) +``` + +### Additional Improvements + +- `ViewAttachmentComponent` — add SwiftUI views to entities +- `GestureComponent` — entity touch and gesture responsiveness +- Enhanced coordinate conversion API +- Synchronizing animations, binding to components +- New sizing behaviors for RealityView + +**Reference** "Better Together: SwiftUI & RealityKit" (WWDC 2025) + +--- + +## WebView & WebPage + +### Overview + +WebKit now provides full SwiftUI APIs for embedding web content, eliminating the need to drop down to UIKit. + +### WebView + +#### Display web content + +```swift +import WebKit + +struct ArticleView: View { + let articleURL: URL + + var body: some View { + WebView(url: articleURL) + } +} +``` + +### WebPage (Observable Model) + +#### Rich interaction with web content + +```swift +import WebKit + +struct InAppBrowser: View { + @State private var page = WebPage() + + var body: some View { + VStack { + Text(page.title ?? "Loading...") + + WebView(page) + .ignoresSafeArea() + .onAppear { + page.load(URLRequest(url: articleURL)) + } + + HStack { + Button("Back") { page.goBack() } + .disabled(!page.canGoBack) + Button("Forward") { page.goForward() } + .disabled(!page.canGoForward) + } + } + } +} +``` + +#### WebPage features +- Programmatic navigation (`goBack()`, `goForward()`) +- Access page properties (`title`, `url`, `canGoBack`, `canGoForward`) +- Observable — SwiftUI views update automatically + +**tvOS**: WebView and WebPage are **not available on tvOS**. tvOS has no WKWebView at all. For web content parsing on tvOS, use JavaScriptCore. See `axiom-tvos` for alternatives. + +### Advanced WebKit Features + +- Custom user agents +- JavaScript execution +- Custom URL schemes +- And more + +**Reference** "Meet WebKit for SwiftUI" (WWDC 2025) + +--- + +## TextEditor with AttributedString + +### Overview + +SwiftUI's new support for rich text editing is great for experiences like commenting on photos. TextView now supports AttributedString! + +**Note** The WWDC transcript uses "TextView" as editorial language. The actual SwiftUI API is `TextEditor` which now supports `AttributedString` binding for rich text editing. + +#### Plain Text vs Rich Text + +- **For plain text**: Prefer `TextField("Label", text: $text, axis: .vertical)` over `TextEditor` — supports placeholder text, consistent styling, and automatic vertical expansion (iOS 16+) +- **For rich text**: Use `TextEditor` with `AttributedString` binding (iOS 26+) — `TextField` does not support `AttributedString` + +### Rich Text Editing + +```swift +struct CommentView: View { + @State private var comment = AttributedString("Enter your comment") + + var body: some View { + TextEditor(text: $comment) + // Built-in text formatting controls included + // Users can apply bold, italic, underline, etc. + } +} +``` + +#### Features +- Built-in text formatting controls (bold, italic, underline, colors, etc.) +- Binding to `AttributedString` preserves formatting +- Automatic toolbar with formatting options + +### Advanced AttributedString Features + +#### Customization options +- Paragraph styles +- Attribute transformations +- Constrain which attributes users can apply + +**Reference** "Cook up a rich text experience in SwiftUI with AttributedString" (WWDC 2025) + +**Cross-reference** App Intents Integration (app-intents-ref skill) — AttributedString for Apple Intelligence Use Model action + +--- + +## Drag and Drop Enhancements + +### Multiple Item Dragging + +#### Drag multiple items based on selection + +```swift +struct PhotoGrid: View { + @State private var selectedPhotos: [Photo.ID] = [] + + var body: some View { + ScrollView { + LazyVGrid(columns: gridColumns) { + ForEach(model.photos) { photo in + view(photo: photo) + .draggable(containerItemID: photo.id) + } + } + } + .dragContainer(for: Photo.self, selection: selectedPhotos) { draggedIDs in + photos(ids: draggedIDs) + } + } +} +``` + +**Key APIs**: +- `.draggable(containerItemID:containerNamespace:)` marks each item as part of a drag container (namespace defaults to `nil`) +- `.dragContainer(for:selection:)` provides the typed items lazily when a drop occurs + +### DragConfiguration + +#### Customize supported operations + +```swift +.dragConfiguration(DragConfiguration(allowMove: false, allowDelete: true)) +``` + +### Observing Drag Events + +```swift +.onDragSessionUpdated { session in + let ids = session.draggedItemIDs(for: Photo.ID.self) + if session.phase == .ended(.delete) { + trash(ids) + deletePhotos(ids) + } +} +``` + +### Drag Preview Formations + +```swift +.dragPreviewsFormation(.stack) // Items stack nicely on top of one another + +// Other formations: +// - .default +// - .grid +// - .stack +``` + +Combine all modifiers (`.dragContainer`, `.dragConfiguration`, `.dragPreviewsFormation`, `.onDragSessionUpdated`) on the same scroll view for a complete multi-item drag experience. + +--- + +## 3D Charts + +### Overview + +Swift Charts supports three-dimensional plotting with `Chart3D`. Key components: `Chart3D` (container), `SurfacePlot` (continuous surfaces), `Chart3DPose` (camera control), `Chart3DSurfaceStyle` (surface appearance). + +### Chart3D Container + +```swift +import Charts + +Chart3D { + SurfacePlot(x: "x", y: "y", z: "z") { x, y in + sin(x) * cos(y) + } + .foregroundStyle(Gradient(colors: [.orange, .pink])) +} +.chartXScale(domain: -3...3) +.chartYScale(domain: -3...3) +.chartZScale(domain: -3...3) +``` + +`Chart3D` also accepts data collections: + +```swift +Chart3D(dataPoints) { point in + // 3D mark for each data point +} +``` + +### SurfacePlot + +Renders continuous surfaces from a mathematical function mapping (x, y) to z values. + +```swift +SurfacePlot(x: "X Axis", y: "Y Axis", z: "Z Axis") { x, y in + sin(sqrt(x * x + y * y)) +} +``` + +#### Surface Styling + +```swift +SurfacePlot(x: "X", y: "Y", z: "Z") { x, y in sin(x) * cos(y) } + .foregroundStyle(.blue) // Solid color + .roughness(0.3) // 0 = smooth, 1 = rough + +// Height-based coloring (color maps to z-value) + .foregroundStyle(Chart3DSurfaceStyle.heightBased(yRange: -1.0...1.0)) + +// Custom gradient mapped to height + .foregroundStyle(Chart3DSurfaceStyle.heightBased( + Gradient(colors: [.blue, .green, .yellow, .red]), + yRange: -1.0...1.0 + )) +``` + +Available surface styles: `.heightBased` (color by z-value), `.normalBased` (color by surface normal direction). + +#### Multiple Surfaces + +```swift +Chart3D { + SurfacePlot(x: "X", y: "Y", z: "Z") { x, y in sin(x) * cos(y) } + SurfacePlot(x: "X", y: "Y", z: "Z") { x, y in cos(x) * sin(y) + 2 } +} +``` + +### Chart3DPose (Camera Control) + +Controls the viewing angle. Pass as value for static positioning, or bind for interactive rotation. + +```swift +@State private var chartPose: Chart3DPose = .default + +Chart3D { /* ... */ } + .chart3DPose(chartPose) // Static — read-only + .chart3DPose($chartPose) // Binding — enables drag-to-rotate +``` + +Predefined poses: `.default`, `.front`, `.back`, `.top`, `.bottom`, `.left`, `.right` + +Custom pose with specific angles: + +```swift +Chart3DPose(azimuth: .degrees(45), inclination: .degrees(30)) +``` + +Animate between poses: + +```swift +Button("Top View") { withAnimation { chartPose = .top } } +``` + +### Chart3DCameraProjection + +Controls how 3D depth is projected to 2D. + +```swift +Chart3D { /* ... */ } + .chart3DCameraProjection(.perspective) // Objects shrink with distance + .chart3DCameraProjection(.orthographic) // Objects maintain size regardless of depth + .chart3DCameraProjection(.automatic) // System decides +``` + +### Z-Axis Modifiers + +All existing chart axis modifiers have z-axis equivalents: +- `.chartZScale(domain:)` — Set z-axis range +- `.chartZAxis()` — Configure z-axis labels and grid lines + +**Reference** "Bring Swift Charts to the third dimension" (WWDC 2025) + +--- + +## Widgets & Controls + +### Controls on watchOS and macOS + +#### watchOS 26 + +```swift +struct FavoriteLocationControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: "FavoriteLocation") { + ControlWidgetButton(action: MarkFavoriteIntent()) { + Label("Mark Favorite", systemImage: "star") + } + } + } +} +// Access from watch face or Shortcuts +``` + +#### macOS + +Controls now appear in Control Center on Mac. + +### Widgets on visionOS + +#### Level of detail customization + +```swift +struct CountdownWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "Countdown") { entry in + CountdownView(entry: entry) + } + } +} + +struct PhotoCountdownView: View { + @Environment(\.levelOfDetail) var levelOfDetail: LevelOfDetail + + var body: some View { + switch levelOfDetail { + case .default: + RecentPhotosView() // Full detail when close + case .simplified: + CountdownView() // Simplified when further away + default: + CountdownView() + } + } +} +``` + +### Widgets on CarPlay + +#### Live Activities on CarPlay + +Live Activities now appear on CarPlay displays for glanceable information while driving. + +### Additional Widget Features + +- Push-based updating API +- New relevance APIs for watchOS + +**Reference** "What's new in widgets" (WWDC 2025) + +--- + +## Migration Checklist + +### Deprecated APIs + +#### ❌ Remove in iPadOS 26 +```xml +UIRequiresFullscreen + +``` + +Apps must support resizable windows on iPad. + +### Automatic Adoptions (Recompile Only) + +✅ Liquid Glass design for navigation, tab bars, toolbars +✅ Bottom-aligned search on iPhone +✅ List performance improvements (6x loading, 16x updating) +✅ Scrolling performance improvements +✅ System controls (toggles, pickers, sliders) new appearance +✅ Bordered buttons default to capsule shape +✅ Updated control heights (slightly taller on macOS) +✅ Monochrome icon rendering in toolbars +✅ Menus: icons on leading edge, consistent across iOS and macOS +✅ Sheets morph out of dialogs automatically +✅ Scroll edge blur/fade under system toolbars + +### Audit Items (Remove Old Customizations) + +⚠️ Remove `presentationBackground` from sheets (let Liquid Glass material shine) +⚠️ Remove extra backgrounds/darkening effects behind toolbar areas +⚠️ Remove hard-coded control heights (use automatic sizing) +⚠️ Update section headers to title-style capitalization (no longer auto-uppercased) + +### Manual Adoptions (Code Changes) + +🔧 Toolbar spacers (`.fixed`) +🔧 Tinted prominent buttons in toolbars +🔧 Glass effect for custom views (`.glassEffect()`) +🔧 `glassEffectID` for morphing transitions between glass elements +🔧 `GlassEffectContainer` for multiple nearby glass elements +🔧 `sharedBackgroundVisibility(.hidden)` to remove toolbar item from group background +🔧 Sheet morphing from buttons (`navigationZoomTransition`) +🔧 Search tab role (`Tab(role: .search)`) +🔧 Compact search toolbar (`.searchToolbarBehavior(.minimize)`) +🔧 Extra large buttons (`.controlSize(.extraLarge)`) +🔧 Concentric rectangle shape (`.containerConcentric`) +🔧 iPad menu bar (`.commands`) +🔧 Window resize anchor (`.windowResizeAnchor()`) +🔧 @Animatable macro for custom shapes/modifiers +🔧 WebView for web content +🔧 TextEditor with AttributedString binding +🔧 Enhanced drag and drop with `.dragContainer` +🔧 Slider ticks (`SliderTick`, `SliderTickContentForEach`) +🔧 Slider thumb visibility (`.sliderThumbVisibility()`) +🔧 Safe area bars with blur (`.safeAreaBar()` + `.scrollEdgeEffectStyle()`) +🔧 In-app URL opening (`openURL(url, prefersInApp: true)`) +🔧 Close and confirm button roles (`Button(role: .close)`) +🔧 Glass button styles (`GlassButtonStyle` — iOS 26.1+) +🔧 Button sizing control (`.buttonSizing()`) +🔧 Toolbar morphing transitions (per-view `.toolbar {}` inside NavigationStack) +🔧 DefaultToolbarItem for system components in toolbars +🔧 Stable toolbar items (`toolbar(id:)` with matched IDs across screens) +🔧 User-customizable toolbars (`toolbar(id:)` with `CustomizableToolbarContent`) +🔧 Line height control (`.lineHeight()` — baseline-to-baseline distance) +🔧 Tab bar minimization (`.tabBarMinimizeBehavior(.onScrollDown)`) +🔧 Tab view bottom accessory (`.tabViewBottomAccessory(isEnabled:content:)` — iOS 26.1+) + +--- + +## Best Practices + +- **Performance**: Profile with new SwiftUI Instrument; use lazy stacks in nested ScrollViews; trust automatic list performance improvements +- **Liquid Glass**: Recompile and test first; use toolbar spacers; attach `.toolbar {}` to individual views (not NavigationStack); remove `presentationBackground` from sheets; use `GlassEffectContainer` for nearby glass elements +- **Layout**: Use `.safeAreaPadding()` for edge-to-edge (not `.padding()`). See `axiom-swiftui-layout-ref` for full guide +- **Rich Text**: Bind `AttributedString` to `TextEditor`; constrain attributes for your UX +- **Spatial (visionOS)**: Use `Alignment3D` for depth; `.manipulable()` only where it makes sense + +--- + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| Old design after updating to iOS 26 SDK | Clean build (Shift-Cmd-K), rebuild targeting iOS 26 SDK, check deployment target | +| Search remains at top on iPhone | Place `.searchable` on `NavigationSplitView`, not on `List` directly | +| @Animatable "does not conform" | All properties must be `VectorArithmetic` or marked `@AnimatableIgnored` | +| Rich text formatting lost in TextEditor | Bind `AttributedString`, not `String` | +| Drag delete not working | Enable `.dragConfiguration(allowDelete: true)` AND observe `.onDragSessionUpdated` | +| SliderTickContentForEach won't compile | Iterate over numeric values (`chapters.map(\.time)`), not custom structs — see Slider section | +| Toolbar not morphing during navigation | Move `.toolbar {}` from NavigationStack to each view inside it — see Liquid Glass section | + +--- + +## Resources + +**WWDC**: 2025-256, 2025-278 (What's new in widgets), 2025-287 (Meet WebKit for SwiftUI), 2025-310 (Optimize SwiftUI performance with instruments), 2025-323 (Build a SwiftUI app with the new design), 2025-325 (Bring Swift Charts to the third dimension), 2025-341 (Cook up a rich text experience in SwiftUI with AttributedString) + +**Docs**: /swiftui, /swiftui/defaulttoolbaritem, /swiftui/toolbarspacer, /swiftui/searchtoolbarbehavior, /swiftui/view/toolbar(id:content:), /swiftui/view/tabbarminimizebehavior(_:), /swiftui/view/tabviewbottomaccessory(isenabled:content:), /swiftui/slider, /swiftui/slidertick, /swiftui/slidertickcontentforeach, /webkit, /foundation/attributedstring, /charts, /charts/chart3d, /charts/surfaceplot, /charts/chart3dpose, /charts/chart3dcameraprojection, /charts/chart3dsurfacestyle, /realitykit/presentationcomponent + +**Skills**: axiom-swiftui-performance, axiom-liquid-glass, axiom-swift-concurrency, axiom-app-intents-ref, axiom-swiftui-search-ref + +--- + +**Primary source** WWDC 2025-256 "What's new in SwiftUI". Additional content from 2025-323 (Build a SwiftUI app with the new design), 2025-287 (Meet WebKit for SwiftUI), and Apple documentation. +**Version** iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, visionOS 26+ diff --git a/.claude/skills/axiom-swiftui-26-ref/agents/openai.yaml b/.claude/skills/axiom-swiftui-26-ref/agents/openai.yaml new file mode 100644 index 0000000..42b18c5 --- /dev/null +++ b/.claude/skills/axiom-swiftui-26-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftUI 26 Reference" + short_description: "Implementing iOS 26 SwiftUI features" diff --git a/.claude/skills/axiom-swiftui-animation-ref/.openskills.json b/.claude/skills/axiom-swiftui-animation-ref/.openskills.json new file mode 100644 index 0000000..3829722 --- /dev/null +++ b/.claude/skills/axiom-swiftui-animation-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftui-animation-ref", + "installedAt": "2026-04-12T08:06:46.782Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftui-animation-ref/SKILL.md b/.claude/skills/axiom-swiftui-animation-ref/SKILL.md new file mode 100644 index 0000000..a32bcbb --- /dev/null +++ b/.claude/skills/axiom-swiftui-animation-ref/SKILL.md @@ -0,0 +1,1013 @@ +--- +name: axiom-swiftui-animation-ref +description: Use when implementing SwiftUI animations, understanding VectorArithmetic, using @Animatable macro, zoom transitions, UIKit/AppKit animation bridging, choosing between spring and timing curve animations, or debugging animation behavior - comprehensive animation reference from iOS 13 through iOS 26 +license: MIT +metadata: + version: "1.1.0" +--- + +# SwiftUI Animation + +## Overview + +Comprehensive guide to SwiftUI's animation system, from foundational concepts to advanced techniques. This skill covers the Animatable protocol, the iOS 26 @Animatable macro, animation types, and the Transaction system. + +**Core principle** Animation in SwiftUI is mathematical interpolation over time, powered by the VectorArithmetic protocol. Understanding this foundation unlocks the full power of SwiftUI's declarative animation system. + +## System Requirements + +- iOS 13+: Animatable protocol, timing/spring animations +- iOS 17+: Default spring animations, scoped animations, PhaseAnimator, KeyframeAnimator +- iOS 18+: Zoom transitions, UIKit/AppKit animation bridging +- iOS 26+: @Animatable macro + +--- + +## Part 1: Understanding Animation + +### What Is Interpolation + +Animation is the process of generating intermediate values between a start and end state. + +#### Example: Opacity animation + +```swift +.opacity(0) → .opacity(1) +``` + +While this animation runs, SwiftUI computes intermediate values: + +``` +0.0 → 0.02 → 0.05 → 0.1 → 0.25 → 0.4 → 0.6 → 0.8 → 1.0 +``` + +**How values are distributed** +- Determined by the animation's timing curve or velocity function +- Spring animations use physics simulation +- Timing curves use bezier curves +- Each animation type calculates values differently + +### VectorArithmetic Protocol + +SwiftUI requires animated data to conform to `VectorArithmetic` — providing subtraction, scaling, addition, and a zero value. This enables SwiftUI to interpolate between any two values. + +**Built-in conforming types**: `CGFloat`, `Double`, `Float`, `Angle` (1D), `CGPoint`, `CGSize` (2D), `CGRect` (4D). + +**Key insight** Vector arithmetic abstracts over dimensionality. SwiftUI animates all these types with a single generic implementation. + +### Why Int Can't Be Animated + +`Int` doesn't conform to VectorArithmetic — no fractional intermediates exist between 3 and 4. SwiftUI simply snaps the value. + +**Solution**: Use `Float`/`Double` and display as `Int`: + +```swift +@State private var count: Float = 0 +// ... +Text("\(Int(count))") + .animation(.spring, value: count) +``` + +### Model vs Presentation Values + +Animatable attributes conceptually have two values: + +#### Model Value +- The target value set by your code +- Updated immediately when state changes +- What you write in your view's body + +#### Presentation Value +- The current interpolated value being rendered +- Updates frame-by-frame during animation +- What the user actually sees + +**Example** + +```swift +.scaleEffect(selected ? 1.5 : 1.0) +``` + +When `selected` becomes `true`: +- **Model value**: Immediately becomes `1.5` +- **Presentation value**: Interpolates `1.0 → 1.1 → 1.2 → 1.3 → 1.4 → 1.5` over time + +--- + +## Part 2: Animatable Protocol + +### Overview + +The `Animatable` protocol allows views to animate their properties by defining which data should be interpolated. + +```swift +protocol Animatable { + associatedtype AnimatableData: VectorArithmetic + + var animatableData: AnimatableData { get set } +} +``` + +SwiftUI builds an animatable attribute for any view conforming to this protocol. + +### Built-in Animatable Views + +Many SwiftUI modifiers conform to Animatable: + +#### Visual Effects +- `.scaleEffect()` — Animates scale transform +- `.rotationEffect()` — Animates rotation +- `.offset()` — Animates position offset +- `.opacity()` — Animates transparency +- `.blur()` — Animates blur radius +- `.shadow()` — Animates shadow properties + +#### All Shape types +- `Circle`, `Rectangle`, `RoundedRectangle` +- `Capsule`, `Ellipse`, `Path` +- Custom `Shape` implementations + +### AnimatablePair for Multi-Dimensional Data + +When animating multiple properties, use `AnimatablePair` to combine vectors. For example, `scaleEffect` combines `CGSize` (2D) and `UnitPoint` (2D) into a 4D vector via `AnimatablePair`. Access components via `.first` and `.second`. The `@Animatable` macro (iOS 26+) eliminates this boilerplate entirely. + +### Custom Animatable Conformance + +#### When to use +- Animating custom layout (like RadialLayout) +- Animating custom drawing code +- Animating properties that affect shape paths + +#### Example: Animated number view + +```swift +struct AnimatableNumberView: View, Animatable { + var number: Double + + var animatableData: Double { + get { number } + set { number = newValue } + } + + var body: some View { + Text("\(Int(number))") + .font(.largeTitle) + } +} + +// Usage +AnimatableNumberView(number: value) + .animation(.spring, value: value) +``` + +**How it works** +1. `number` changes from 0 to 100 +2. SwiftUI calls `body` for every frame of the animation +3. Each frame gets a new `number` value: 0 → 5 → 15 → 30 → 55 → 80 → 100 +4. Text updates to show the interpolated integer + +### Performance Warning + +**Custom Animatable conformance is expensive** — SwiftUI calls `body` for every frame on the main thread. Built-in effects (`.scaleEffect()`, `.opacity()`) run off-main-thread and don't call `body`. Use custom conformance only when built-in modifiers can't achieve the effect (e.g., animating a custom `Layout` that repositions subviews per-frame). + +--- + +## Part 3: @Animatable Macro (iOS 26+) + +### Overview + +The `@Animatable` macro eliminates the boilerplate of manually conforming to the Animatable protocol. + +**Before iOS 26**, you had to: +1. Manually conform to `Animatable` +2. Write `animatableData` getter and setter +3. Use `AnimatablePair` for multiple properties +4. Exclude non-animatable properties manually + +**iOS 26+**, you just add `@Animatable`: + +```swift +@MainActor +@Animatable +struct MyView: View { + var scale: CGFloat + var opacity: Double + + var body: some View { + // ... + } +} +``` + +The macro automatically: +- Generates `Animatable` conformance +- Inspects all stored properties +- Creates `animatableData` from VectorArithmetic-conforming properties +- Handles multi-dimensional data with `AnimatablePair` + +### Before/After Comparison + +#### Before @Animatable macro + +```swift +struct HikingRouteShape: Shape { + var startPoint: CGPoint + var endPoint: CGPoint + var elevation: Double + var drawingDirection: Bool // Don't want to animate this + + // Tedious manual animatableData declaration + var animatableData: AnimatablePair, + AnimatablePair>> { + get { + AnimatablePair( + AnimatablePair(startPoint.x, startPoint.y), + AnimatablePair(elevation, AnimatablePair(endPoint.x, endPoint.y)) + ) + } + set { + startPoint = CGPoint(x: newValue.first.first, y: newValue.first.second) + elevation = newValue.second.first + endPoint = CGPoint(x: newValue.second.second.first, y: newValue.second.second.second) + } + } + + func path(in rect: CGRect) -> Path { + // Drawing code + } +} +``` + +#### After @Animatable macro + +```swift +@Animatable +struct HikingRouteShape: Shape { + var startPoint: CGPoint + var endPoint: CGPoint + var elevation: Double + + @AnimatableIgnored + var drawingDirection: Bool // Excluded from animation + + func path(in rect: CGRect) -> Path { + // Drawing code + } +} +``` + +**Lines of code**: 20 → 12 (40% reduction) + +### @AnimatableIgnored + +Use `@AnimatableIgnored` to exclude properties from animation. + +#### When to use +- **Debug values** — Flags for development only +- **IDs** — Identifiers that shouldn't animate +- **Timestamps** — When the view was created/updated +- **Internal state** — Non-visual bookkeeping +- **Non-VectorArithmetic types** — Colors, strings, booleans + +#### Example + +```swift +@MainActor +@Animatable +struct ProgressView: View { + var progress: Double // Animated + var totalItems: Int // Animated (if Float, not if Int) + + @AnimatableIgnored + var title: String // Not animated + + @AnimatableIgnored + var startTime: Date // Not animated + + @AnimatableIgnored + var debugEnabled: Bool // Not animated + + var body: some View { + VStack { + Text(title) + ProgressBar(value: progress) + if debugEnabled { + Text("Started: \(startTime.formatted())") + } + } + } +} +``` + +### Real-World Use Case + +@Animatable works for any numeric display — stock prices, heart rate, scores, timers, progress bars: + +```swift +@MainActor +@Animatable +struct AnimatedValueView: View { + var value: Double + var changePercent: Double + + @AnimatableIgnored + var label: String + + var body: some View { + VStack(alignment: .trailing) { + Text("\(value, format: .number.precision(.fractionLength(2)))") + .font(.title) + Text("\(changePercent > 0 ? "+" : "")\(changePercent, format: .percent)") + .foregroundStyle(changePercent > 0 ? .green : .red) + } + } +} + +// Usage +AnimatedValueView(value: currentPrice, changePercent: 0.025, label: "Price") + .animation(.spring(duration: 0.8), value: currentPrice) +``` + +--- + +## Part 4: Animation Types + +### Timing Curve Animations + +Timing curve animations use bezier curves to control the speed of animation over time. + +#### Built-in presets + +```swift +.animation(.linear) // Constant speed +.animation(.easeIn) // Starts slow, ends fast +.animation(.easeOut) // Starts fast, ends slow +.animation(.easeInOut) // Slow start and end, fast middle +``` + +#### Custom timing curves + +```swift +let customCurve = UnitCurve( + startControlPoint: CGPoint(x: 0.2, y: 0), + endControlPoint: CGPoint(x: 0.8, y: 1) +) + +.animation(.timingCurve(customCurve, duration: 0.5)) +``` + +#### Duration + +All timing curve animations accept an optional duration: + +```swift +.animation(.easeInOut(duration: 0.3)) +.animation(.linear(duration: 1.0)) +``` + +**Default**: 0.35 seconds + +### Spring Animations + +Spring animations use physics simulation to create natural, organic motion. + +#### Built-in presets + +```swift +.animation(.smooth) // No bounce (default since iOS 17) +.animation(.snappy) // Small amount of bounce +.animation(.bouncy) // Larger amount of bounce +``` + +#### Custom springs + +```swift +.animation(.spring(duration: 0.6, bounce: 0.3)) +``` + +**Parameters** +- `duration` — Perceived animation duration +- `bounce` — Amount of bounce (0 = no bounce, 1 = very bouncy) + +**Much more intuitive** than traditional spring parameters (mass, stiffness, damping). + +### Higher-Order Animations + +Modify base animations to create complex effects. + +#### Delay + +```swift +.animation(.spring.delay(0.5)) +``` + +Waits 0.5 seconds before starting the animation. + +#### Repeat + +```swift +.animation(.easeInOut.repeatCount(3, autoreverses: true)) +.animation(.linear.repeatForever(autoreverses: false)) +``` + +Repeats the animation multiple times or infinitely. + +#### Speed + +```swift +.animation(.spring.speed(2.0)) // 2x faster +.animation(.spring.speed(0.5)) // 2x slower +``` + +Multiplies the animation speed. + +### Default Animation Changes (iOS 17+) + +**Before iOS 17** +```swift +withAnimation { + // Used timing curve by default +} +``` + +**iOS 17+** +```swift +withAnimation { + // Uses .smooth spring by default +} +``` + +**Why the change**: Spring animations feel more natural and preserve velocity when interrupted. + +**Recommendation**: Embrace springs. They make your UI feel more responsive and polished. + +--- + +## Part 5: Transaction System + +### withAnimation + +The most common way to trigger an animation. + +```swift +Button("Scale Up") { + withAnimation(.spring) { + scale = 1.5 + } +} +``` + +**How it works** +1. `withAnimation` opens a transaction +2. Sets the animation in the transaction dictionary +3. Executes the closure (state changes) +4. Transaction propagates down the view hierarchy +5. Animatable attributes check for animation and interpolate + +#### Explicit animation + +```swift +withAnimation(.spring(duration: 0.6, bounce: 0.4)) { + isExpanded.toggle() +} +``` + +#### No animation + +```swift +withAnimation(nil) { + // Changes happen immediately, no animation + resetState() +} +``` + +### animation() View Modifier + +Apply animations to specific values within a view. + +#### Basic usage + +```swift +Circle() + .fill(isActive ? .blue : .gray) + .animation(.spring, value: isActive) +``` + +**How it works**: Animation only applies when `isActive` changes. Other state changes won't trigger this animation. + +#### Multiple animations on same view + +```swift +Circle() + .scaleEffect(scale) + .animation(.bouncy, value: scale) + .opacity(opacity) + .animation(.easeInOut, value: opacity) +``` + +Different animations for different properties. + +### Scoped Animations (iOS 17+) + +Narrowly scope animations to specific animatable attributes. + +#### Problem with old approach + +```swift +struct AvatarView: View { + var selected: Bool + + var body: some View { + Image("avatar") + .scaleEffect(selected ? 1.5 : 1.0) + .animation(.spring, value: selected) + // ⚠️ If image also changes when selected changes, + // image transition gets animated too (accidental) + } +} +``` + +#### Solution: Scoped animation + +```swift +struct AvatarView: View { + var selected: Bool + + var body: some View { + Image("avatar") + .animation(.spring, value: selected) { + $0.scaleEffect(selected ? 1.5 : 1.0) + } + // ✅ Only scaleEffect animates, image transition doesn't + } +} +``` + +**How it works** +- Animation only applies to attributes in the closure +- Other attributes are unaffected +- Prevents accidental animations + +### Custom Transaction Keys + +Define custom `TransactionKey` types to propagate context through the transaction system. Use `withTransaction` to set values and `.transaction` modifier to read them. This enables applying different animations based on how a state change was triggered (tap vs programmatic). + +--- + +## Part 6: Advanced Topics + +### CustomAnimation Protocol + +Implement your own animation algorithms. + +```swift +protocol CustomAnimation { + // Calculate current value + func animate( + value: V, + time: TimeInterval, + context: inout AnimationContext + ) -> V? + + // Optional: Should this animation merge with previous? + func shouldMerge(previous: Animation, value: V, time: TimeInterval, context: inout AnimationContext) -> Bool + + // Optional: Current velocity + func velocity( + value: V, + time: TimeInterval, + context: AnimationContext + ) -> V? +} +``` + +#### Example: Linear timing curve + +```swift +struct LinearAnimation: CustomAnimation { + let duration: TimeInterval + + func animate( + value: V, // Delta vector: target - current + time: TimeInterval, + context: inout AnimationContext + ) -> V? { + if time >= duration { return nil } + return value.scaled(by: time / duration) + } +} +``` + +**Critical understanding**: `value` is the **delta vector** (target - current), not the target. Return `nil` when done. SwiftUI adds the scaled delta to the current value automatically. + +### Animation Merging Behavior + +What happens when a new animation starts before the previous one finishes? + +#### Timing curve animations (default: don't merge) + +```swift +func shouldMerge(...) -> Bool { + return false // Default implementation +} +``` + +**Behavior**: Both animations run together, results are combined additively. + +**Example** +- First tap: animate 1.0 → 1.5 (running) +- Second tap (before finish): animate 1.5 → 1.0 +- Result: Both animations run, values combine + +#### Spring animations (merge and retarget) + +```swift +func shouldMerge(...) -> Bool { + return true // Springs override this +} +``` + +**Behavior**: New animation incorporates state of previous animation, preserving velocity. + +**Example** +- First tap: animate 1.0 → 1.5 with velocity V +- Second tap (before finish): retarget to 1.0, preserving current velocity V +- Result: Smooth transition, no sudden velocity change + +**Why springs feel more natural**: They preserve momentum when interrupted. + +--- + +## Part 7: Multi-Step Animations (iOS 17+) + +### PhaseAnimator + +Cycles through a sequence of phases, applying different modifiers at each phase. Each phase transition is independently animated. + +```swift +PhaseAnimator([false, true]) { phase in + Image(systemName: "star.fill") + .scaleEffect(phase ? 1.5 : 1.0) + .opacity(phase ? 1.0 : 0.5) + .rotationEffect(.degrees(phase ? 360 : 0)) +} animation: { phase in + phase ? .spring(duration: 0.8, bounce: 0.3) : .easeInOut(duration: 0.4) +} +``` + +**How it works**: Begins at first phase, animates to second, then loops. The `animation` closure returns the animation used to transition INTO that phase. Phases can be any `Equatable` type — use an enum for complex multi-step sequences: + +```swift +enum PulsePhase: CaseIterable { case idle, expand, contract } + +PhaseAnimator(PulsePhase.allCases) { phase in + Circle() + .scaleEffect(phase == .expand ? 1.3 : phase == .contract ? 0.9 : 1.0) +} +``` + +**Trigger**: Add a `trigger` parameter to run the animation only when a value changes (instead of looping continuously). + +### KeyframeAnimator + +Provides per-property keyframe tracks for precise, timeline-based animations. More control than PhaseAnimator. + +```swift +struct AnimationValues { + var scale: Double = 1.0 + var rotation: Angle = .zero + var yOffset: Double = 0 +} + +KeyframeAnimator(initialValue: AnimationValues()) { values in + Image(systemName: "heart.fill") + .scaleEffect(values.scale) + .rotationEffect(values.rotation) + .offset(y: values.yOffset) +} keyframes: { _ in + KeyframeTrack(\.scale) { + SpringKeyframe(1.5, duration: 0.3) + SpringKeyframe(1.0, duration: 0.3) + } + KeyframeTrack(\.rotation) { + LinearKeyframe(.degrees(15), duration: 0.15) + LinearKeyframe(.degrees(-15), duration: 0.3) + LinearKeyframe(.zero, duration: 0.15) + } + KeyframeTrack(\.yOffset) { + CubicKeyframe(-20, duration: 0.3) + CubicKeyframe(0, duration: 0.3) + } +} +``` + +**Keyframe types**: `LinearKeyframe` (constant velocity), `SpringKeyframe` (spring physics), `CubicKeyframe` (bezier curves), `MoveKeyframe` (instant jump, no interpolation). + +**vs PhaseAnimator**: Use PhaseAnimator for simple state cycling. Use KeyframeAnimator when different properties need independent timing. + +### .transition() + +Defines how a view animates when inserted/removed from the view hierarchy. + +```swift +if showDetail { + DetailView() + .transition(.slide) // Slide in/out + .transition(.scale.combined(with: .opacity)) // Combine transitions + .transition(.move(edge: .bottom)) // Move from edge + .transition(.asymmetric( // Different in/out + insertion: .scale.combined(with: .opacity), + removal: .opacity + )) +} +``` + +**Requires animation context** — wrap the state change in `withAnimation` or use `.animation()` modifier. Without animation, the view appears/disappears instantly. + +### matchedGeometryEffect + +Smoothly animate a view's frame between two positions in the hierarchy. Commonly used for hero transitions and shared element animations. + +```swift +@Namespace private var animation + +// Source +if !isExpanded { + RoundedRectangle(cornerRadius: 10) + .matchedGeometryEffect(id: "card", in: animation) + .frame(width: 100, height: 100) +} + +// Destination +if isExpanded { + RoundedRectangle(cornerRadius: 20) + .matchedGeometryEffect(id: "card", in: animation) + .frame(width: 300, height: 400) +} +``` + +**Key rules**: Same `id` + same `Namespace` = matched pair. Only one view with a given ID should be `isSource: true` (default) at a time. Wrap state change in `withAnimation` for smooth interpolation. + +### contentTransition + +Animates changes to text and symbol content within a view (iOS 16+). + +```swift +Text(value, format: .number) + .contentTransition(.numericText(countsDown: value < previous)) + +Image(systemName: isFavorite ? "heart.fill" : "heart") + .contentTransition(.symbolEffect(.replace)) +``` + +--- + +## Part 8: Zoom Transitions (iOS 18+) + +### Overview + +iOS 18 introduces the zoom transition, where a tapped cell morphs into the incoming view. This transition is continuously interactive—users can grab and drag the view during or after the transition begins. + +**Key benefit** In parts of your app where you transition from a large cell, zoom transitions increase visual continuity by keeping the same UI elements on screen across the transition. + +### SwiftUI Implementation + +Two steps to adopt zoom transitions: + +#### Step 1: Declare the transition style on the destination + +```swift +NavigationLink { + BraceletEditor(bracelet) + .navigationTransition(.zoom(sourceID: bracelet.id, in: namespace)) +} label: { + BraceletPreview(bracelet) +} +``` + +#### Step 2: Mark the source view + +```swift +BraceletPreview(bracelet) + .matchedTransitionSource(id: bracelet.id, in: namespace) +``` + +#### Complete example + +```swift +struct BraceletListView: View { + @Namespace private var braceletList + let bracelets: [Bracelet] + + var body: some View { + NavigationStack { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))]) { + ForEach(bracelets) { bracelet in + NavigationLink { + BraceletEditor(bracelet: bracelet) + .navigationTransition( + .zoom(sourceID: bracelet.id, in: braceletList) + ) + } label: { + BraceletPreview(bracelet: bracelet) + } + .matchedTransitionSource(id: bracelet.id, in: braceletList) + } + } + } + } + } +} +``` + +### UIKit Implementation + +Set `preferredTransition = .zoom { context in ... }` on the pushed view controller. The closure returns the source view and is called on both zoom in and zoom out — capture a stable identifier (model object), not a view directly. + +### Presentations + +Zoom transitions also work with `fullScreenCover` and `sheet`: + +```swift +.fullScreenCover(item: $selectedBracelet) { bracelet in + BraceletEditor(bracelet: bracelet) + .navigationTransition(.zoom(sourceID: bracelet.id, in: namespace)) +} +``` + +### Styling the Source View + +```swift +.matchedTransitionSource(id: bracelet.id, in: namespace) { source in + source.cornerRadius(8.0).shadow(radius: 4) +} +``` + +### Fluid Transition Lifecycle + +Push transitions cannot be cancelled — when interrupted, they convert to pop transitions. The view controller always reaches the Appeared state. Don't guard against overlapping transitions; let the system handle them. + +--- + +## Part 9: UIKit/AppKit Animation Bridging (iOS 18+) + +### Overview + +iOS 18 enables using SwiftUI `Animation` types to animate UIKit and AppKit views. This provides access to the full suite of SwiftUI animations, including custom animations. + +### Basic Usage + +```swift +// Old way +UIView.animate(withDuration: 0.5, delay: 0, + usingSpringWithDamping: 0.7, initialSpringVelocity: 0.5) { + bead.center = endOfBracelet +} + +// New way: Use SwiftUI Animation type +UIView.animate(.spring(duration: 0.5)) { + bead.center = endOfBracelet +} +``` + +All SwiftUI animations work: `.linear`, `.easeIn/Out`, `.spring`, `.smooth`, `.snappy`, `.bouncy`, `.repeatForever()`, and custom animations. + +**Architecture note**: Unlike old UIKit APIs, no `CAAnimation` is generated — presentation values are animated directly. + +--- + +## Part 10: UIViewRepresentable Animation Bridging (iOS 18+) + +### The Problem + +When wrapping UIKit views in SwiftUI, animations don't automatically bridge: + +```swift +struct BeadBoxWrapper: UIViewRepresentable { + @Binding var isOpen: Bool + + func updateUIView(_ box: BeadBox, context: Context) { + // ❌ Animation on binding doesn't affect UIKit + box.lid.center.y = isOpen ? -100 : 100 + } +} + +// Usage +BeadBoxWrapper(isOpen: $isOpen) + .animation(.spring, value: isOpen) // No effect on UIKit view +``` + +### The Solution: context.animate() + +Use `context.animate()` to bridge SwiftUI animations: + +```swift +struct BeadBoxWrapper: UIViewRepresentable { + @Binding var isOpen: Bool + + func makeUIView(context: Context) -> BeadBox { + BeadBox() + } + + func updateUIView(_ box: BeadBox, context: Context) { + // ✅ Bridges animation from Transaction to UIKit + context.animate { + box.lid.center.y = isOpen ? -100 : 100 + } + } +} +``` + +### How It Works + +1. SwiftUI stores animation info in the current `Transaction` +2. `context.animate()` reads the Transaction's animation +3. Applies that animation to UIView changes in the closure +4. If no animation in Transaction, changes happen immediately (no animation) + +### Key Behavior + +```swift +context.animate { + // Changes here +} completion: { + // Called when animation completes + // If not animated, called immediately inline +} +``` + +**Works whether animated or not** — safe to always use this pattern. + +### Perfect Synchronization + +A single animation running across SwiftUI Views and UIViews runs **perfectly in sync**. This enables seamless mixed hierarchies. + +--- + +## Part 11: Gesture-Driven Animations (iOS 18+) + +### Automatic Velocity Preservation + +SwiftUI animations automatically preserve velocity through animation merging — no manual velocity calculation needed: + +```swift +// UIKit with SwiftUI animations +func handlePan(_ gesture: UIPanGestureRecognizer) { + switch gesture.state { + case .changed: + UIView.animate(.interactiveSpring) { + bead.center = gesture.location(in: view) + } + case .ended: + UIView.animate(.spring) { // Inherits velocity automatically + bead.center = endOfBracelet + } + default: break + } +} + +// Pure SwiftUI equivalent +DragGesture() + .onChanged { value in + withAnimation(.interactiveSpring) { position = value.location } + } + .onEnded { _ in + withAnimation(.spring) { position = targetPosition } + } +``` + +Each `.interactiveSpring` retargets the previous animation, and the final `.spring` inherits the accumulated velocity for smooth deceleration. + +--- + +--- + +## Troubleshooting + +### Property Not Animating + +Check in order: +1. **Type conforms to VectorArithmetic?** — `Int` can't animate; use `Double`/`Float` +2. **Animation modifier present?** — Need `.animation(.spring, value: x)` or `withAnimation` +3. **Correct value tracked?** — `.animation(.spring, value: progress)` not `.animation(.spring, value: title)` +4. **View conforms to Animatable?** — Custom views need `@Animatable` (iOS 26+) or manual `animatableData` + +### Animation Stuttering + +Custom `Animatable` conformance calls `body` every frame on main thread. Use built-in effects (`.opacity()`, `.scaleEffect()`) when possible — they run off-main-thread. Profile with Instruments for complex cases. + +### Unexpected Animation Merging + +Spring animations merge by default, preserving velocity. Use timing curve animations (`.easeInOut`) if you don't want merging behavior. See **Animation Merging Behavior** section above. + +--- + +## Resources + +**WWDC**: 2023-10156, 2023-10157, 2023-10158, 2024-10145, 2025-256 + +**Docs**: /swiftui/animatable, /swiftui/animation, /swiftui/vectorarithmetic, /swiftui/transaction, /swiftui/view/navigationtransition(_:), /swiftui/view/matchedtransitionsource(id:in:configuration:), /uikit/uiview/animate(_:changes:completion:) + +**Skills**: axiom-swiftui-26-ref, axiom-swiftui-nav-ref, axiom-swiftui-performance, axiom-swiftui-debugging, axiom-sf-symbols-ref + diff --git a/.claude/skills/axiom-swiftui-animation-ref/agents/openai.yaml b/.claude/skills/axiom-swiftui-animation-ref/agents/openai.yaml new file mode 100644 index 0000000..494e15d --- /dev/null +++ b/.claude/skills/axiom-swiftui-animation-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftUI Animation Reference" + short_description: "Implementing SwiftUI animations, understanding VectorArithmetic, using @Animatable macro, zoom transitions, UIKit/App..." diff --git a/.claude/skills/axiom-swiftui-architecture/.openskills.json b/.claude/skills/axiom-swiftui-architecture/.openskills.json new file mode 100644 index 0000000..e1bb1db --- /dev/null +++ b/.claude/skills/axiom-swiftui-architecture/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftui-architecture", + "installedAt": "2026-04-12T08:06:47.139Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftui-architecture/SKILL.md b/.claude/skills/axiom-swiftui-architecture/SKILL.md new file mode 100644 index 0000000..65e2ba6 --- /dev/null +++ b/.claude/skills/axiom-swiftui-architecture/SKILL.md @@ -0,0 +1,1404 @@ +--- +name: axiom-swiftui-architecture +description: Use when separating logic from SwiftUI views, choosing architecture patterns, refactoring view files, or asking 'where should this code go', 'how do I organize my SwiftUI app', 'MVVM vs TCA vs vanilla SwiftUI', 'how do I make SwiftUI testable' - comprehensive architecture patterns with refactoring workflows for iOS 26+ +license: MIT +compatibility: iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, axiom-visionOS 26+. Xcode 26+ +metadata: + version: "1.0" +--- + +# SwiftUI Architecture + +## When to Use This Skill + +Use this skill when: +- You have logic in your SwiftUI view files and want to extract it +- Choosing between MVVM, TCA, vanilla SwiftUI patterns, or Coordinator +- Refactoring views to separate concerns +- Making SwiftUI code testable +- Asking "where should this code go?" +- Deciding which property wrapper to use (@State, @Environment, @Bindable) +- Organizing a SwiftUI codebase for team development + +## Example Prompts + +| What You Might Ask | Why This Skill Helps | +|--------------------|----------------------| +| "There's quite a bit of code in my model view files about logic things. How do I extract it?" | Provides refactoring workflow with decision trees for where logic belongs | +| "Should I use MVVM, TCA, or Apple's vanilla patterns?" | Decision criteria based on app complexity, team size, testability needs | +| "How do I make my SwiftUI code testable?" | Shows separation patterns that enable testing without SwiftUI imports | +| "Where should formatters and calculations go?" | Anti-patterns section prevents logic in view bodies | +| "Which property wrapper do I use?" | Decision tree for @State, @Environment, @Bindable, or plain properties | + +## Quick Architecture Decision Tree + +``` +What's driving your architecture choice? +│ +├─ Starting fresh, small/medium app, want Apple's patterns? +│ └─ Use Apple's Native Patterns (Part 1) +│ - @Observable models for business logic +│ - State-as-Bridge for async boundaries +│ - Property wrapper decision tree +│ +├─ Familiar with MVVM from UIKit? +│ └─ Use MVVM Pattern (Part 2) +│ - ViewModels as presentation adapters +│ - Clear View/ViewModel/Model separation +│ - Works well with @Observable +│ +├─ Complex app, need rigorous testability, team consistency? +│ └─ Consider TCA (Part 3) +│ - State/Action/Reducer/Store architecture +│ - Excellent testing story +│ - Learning curve + boilerplate trade-off +│ +└─ Complex navigation, deep linking, multiple entry points? + └─ Add Coordinator Pattern (Part 4) + - Can combine with any of the above + - Extracts navigation logic from views + - NavigationPath + Coordinator objects +``` + +--- + +# Part 1: Apple's Native Patterns (iOS 26+) + +## Core Principle + +> "A data model provides separation between the data and the views that interact with the data. This separation promotes modularity, improves testability, and helps make it easier to reason about how the app works." +> — Apple Developer Documentation + +Apple's modern SwiftUI patterns (WWDC 2023-2025) center on: +1. **@Observable** for data models (replaces ObservableObject) +2. **State-as-Bridge** for async boundaries (WWDC 2025) +3. **Three property wrappers**: @State, @Environment, @Bindable +4. **Synchronous UI updates** for animations + +## The State-as-Bridge Pattern + +### Problem + +Async functions create suspension points that can break animations: + +```swift +// ❌ Problematic: Animation might miss frame deadline +struct ColorExtractorView: View { + @State private var isLoading = false + + var body: some View { + Button("Extract Colors") { + Task { + isLoading = true // Synchronous ✅ + await extractColors() // ⚠️ Suspension point! + isLoading = false // ❌ Might happen too late + } + } + .scaleEffect(isLoading ? 1.5 : 1.0) // ⚠️ Animation timing uncertain + } +} +``` + +### Solution: Use State as a Bridge + +"Find the boundaries between UI code that requires time-sensitive changes, and long-running async logic." + +```swift +// ✅ Correct: State bridges UI and async code +@Observable +class ColorExtractor { + var isLoading = false + var colors: [Color] = [] + + func extract(from image: UIImage) async { + // This method is async and can live in the model + let extracted = await heavyComputation(image) + // Synchronous mutation for UI update + self.colors = extracted + } +} + +struct ColorExtractorView: View { + let extractor: ColorExtractor + + var body: some View { + Button("Extract Colors") { + // Synchronous state change for animation + withAnimation { + extractor.isLoading = true + } + + // Launch async work + Task { + await extractor.extract(from: currentImage) + + // Synchronous state change for animation + withAnimation { + extractor.isLoading = false + } + } + } + .scaleEffect(extractor.isLoading ? 1.5 : 1.0) + } +} +``` + +**Benefits**: +- UI logic stays synchronous (animations work correctly) +- Async code lives in the model (testable without SwiftUI) +- Clear boundary between time-sensitive UI and long-running work + +## Property Wrapper Decision Tree + +There are only **3 questions** to answer: + +``` +Which property wrapper should I use? +│ +├─ Does this model need to be STATE OF THE VIEW ITSELF? +│ └─ YES → Use @State +│ Examples: Form inputs, local toggles, sheet presentations +│ Lifetime: Managed by the view's lifetime +│ +├─ Does this model need to be part of the GLOBAL ENVIRONMENT? +│ └─ YES → Use @Environment +│ Examples: User account, app settings, dependency injection +│ Lifetime: Lives at app/scene level +│ +├─ Does this model JUST NEED BINDINGS? +│ └─ YES → Use @Bindable +│ Examples: Editing a model passed from parent +│ Lightweight: Only enables $ syntax for bindings +│ +└─ NONE OF THE ABOVE? + └─ Use as plain property + Examples: Immutable data, parent-owned models + No wrapper needed: @Observable handles observation +``` + +### Examples + +```swift +// ✅ @State — View owns the model +struct DonutEditor: View { + @State private var donutToAdd = Donut() // View's own state + + var body: some View { + TextField("Name", text: $donutToAdd.name) + } +} + +// ✅ @Environment — App-wide model +struct MenuView: View { + @Environment(Account.self) private var account // Global + + var body: some View { + Text("Welcome, \(account.userName)") + } +} + +// ✅ @Bindable — Need bindings to parent-owned model +struct DonutRow: View { + @Bindable var donut: Donut // Parent owns it + + var body: some View { + TextField("Name", text: $donut.name) // Need binding + } +} + +// ✅ Plain property — Just reading +struct DonutRow: View { + let donut: Donut // Parent owns, no binding needed + + var body: some View { + Text(donut.name) // Just reading + } +} +``` + +## @Observable Model Pattern + +Use `@Observable` for business logic that needs to trigger UI updates: + +```swift +// ✅ Domain model with business logic +@Observable +class FoodTruckModel { + var orders: [Order] = [] + var donuts = Donut.all + + var orderCount: Int { + orders.count // Computed properties work automatically + } + + func addDonut() { + donuts.append(Donut()) + } +} + +// ✅ View automatically tracks accessed properties +struct DonutMenu: View { + let model: FoodTruckModel // No wrapper needed! + + var body: some View { + List { + Section("Donuts") { + ForEach(model.donuts) { donut in + Text(donut.name) // Tracks model.donuts + } + Button("Add") { + model.addDonut() + } + } + Section("Orders") { + Text("Count: \(model.orderCount)") // Tracks model.orders + } + } + } +} +``` + +**How it works** (WWDC 2023/10149): +- SwiftUI tracks which properties are accessed during `body` execution +- Only those properties trigger view updates when changed +- Granular dependency tracking = better performance + +## ViewModel Adapter Pattern + +Use ViewModels as **presentation adapters** when you need filtering, sorting, or view-specific logic: + +```swift +// ✅ ViewModel as presentation adapter +@Observable +class PetStoreViewModel { + let petStore: PetStore // Domain model + var searchText: String = "" + + // View-specific computed property + var filteredPets: [Pet] { + guard !searchText.isEmpty else { return petStore.myPets } + return petStore.myPets.filter { $0.name.contains(searchText) } + } +} + +struct PetListView: View { + @Bindable var viewModel: PetStoreViewModel + + var body: some View { + List { + ForEach(viewModel.filteredPets) { pet in + PetRowView(pet: pet) + } + } + .searchable(text: $viewModel.searchText) + } +} +``` + +**When to use a ViewModel adapter**: +- Filtering, sorting, grouping for display +- Formatting for presentation (but NOT heavy computation) +- View-specific state that doesn't belong in domain model +- Bridging between domain model and SwiftUI conventions + +**When NOT to use a ViewModel**: +- Simple views that just display model data +- Logic that belongs in the domain model +- Over-extraction just for "pattern purity" + +--- + +# Part 2: MVVM Pattern + +## When to Use MVVM + +MVVM (Model-View-ViewModel) is appropriate when: + +✅ **You're familiar with it from UIKit** — Easier onboarding for team +✅ **You want explicit View/ViewModel separation** — Clear contracts +✅ **You have complex presentation logic** — Multiple filtering/sorting operations +✅ **You're migrating from UIKit** — Familiar mental model + +❌ **Avoid MVVM when**: +- Views are simple (just displaying data) +- You're starting fresh with SwiftUI (Apple's patterns are simpler) +- You're creating unnecessary abstraction layers + +## MVVM Structure for SwiftUI + +```swift +// Model — Domain data and business logic +struct Pet: Identifiable { + let id: UUID + var name: String + var kind: Kind + var trick: String + var hasAward: Bool = false + + mutating func giveAward() { + hasAward = true + } +} + +// ViewModel — Presentation logic +@Observable +class PetListViewModel { + private let petStore: PetStore + + var pets: [Pet] { petStore.myPets } + var searchText: String = "" + var selectedSort: SortOption = .name + + var filteredSortedPets: [Pet] { + let filtered = pets.filter { pet in + searchText.isEmpty || pet.name.contains(searchText) + } + return filtered.sorted { lhs, rhs in + switch selectedSort { + case .name: lhs.name < rhs.name + case .kind: lhs.kind.rawValue < rhs.kind.rawValue + } + } + } + + init(petStore: PetStore) { + self.petStore = petStore + } + + func awardPet(_ pet: Pet) { + petStore.awardPet(pet.id) + } +} + +// View — UI only +struct PetListView: View { + @Bindable var viewModel: PetListViewModel + + var body: some View { + List { + ForEach(viewModel.filteredSortedPets) { pet in + PetRow(pet: pet) { + viewModel.awardPet(pet) + } + } + } + .searchable(text: $viewModel.searchText) + } +} +``` + +## Common MVVM Mistakes in SwiftUI + +### ❌ Mistake 1: Duplicating @Observable in View and ViewModel + +```swift +// ❌ @State + @Observable is redundant — @State creates its own storage +struct MyView: View { + @State private var viewModel = MyViewModel() // ❌ Redundant wrapper +} + +// ✅ Pass @Observable directly — use @State only if the view OWNS the lifecycle +struct MyView: View { + let viewModel: MyViewModel // ✅ Or @State if view creates it +} +``` + +### ❌ Mistake 2: God ViewModel + +```swift +// ❌ Don't do this +@Observable +class AppViewModel { + // Settings + var isDarkMode = false + var notificationsEnabled = true + + // User + var userName = "" + var userEmail = "" + + // Content + var posts: [Post] = [] + var comments: [Comment] = [] + + // ... 50 more properties +} +``` + +```swift +// ✅ Correct: Separate concerns +@Observable +class SettingsViewModel { + var isDarkMode = false + var notificationsEnabled = true +} + +@Observable +class UserProfileViewModel { + var user: User +} + +@Observable +class FeedViewModel { + var posts: [Post] = [] +} +``` + +### ❌ Mistake 3: Business Logic in ViewModel + +```swift +// ❌ Business rules belong in the Model, not the ViewModel +@Observable class OrderViewModel { + func calculateDiscount(for order: Order) -> Double { /* ... */ } // ❌ Business logic +} + +// ✅ Model owns business logic; ViewModel only formats for display +struct Order { + func calculateDiscount() -> Double { /* business rules */ } +} +@Observable class OrderViewModel { + let order: Order + var displayDiscount: String { + "$\(order.calculateDiscount(), specifier: "%.2f")" // ✅ Just formatting + } +} +``` + +--- + +# Part 3: TCA (Composable Architecture) + +## When to Consider TCA + +TCA is a third-party architecture from Point-Free. Consider it when: + +✅ **Rigorous testability is critical** — TestStore makes testing deterministic +✅ **Large team needs consistency** — Strict patterns reduce variation +✅ **Complex state management** — Side effects, dependencies, composition +✅ **You value Redux-like patterns** — Unidirectional data flow + +❌ **Avoid TCA when**: +- Small app or prototype (too much overhead) +- Team unfamiliar with functional programming +- You need rapid iteration (boilerplate slows development) +- You want minimal dependencies + +## TCA Core Concepts + +TCA has 4 building blocks — **State** (data), **Action** (events), **Reducer** (state evolution), and **Store** (runtime engine). Here they are in a single feature: + +```swift +// STATE — Data your feature needs +@ObservableState +struct CounterFeature { + var count = 0 + var fact: String? + var isLoading = false +} + +// ACTION — All possible events +enum Action { + case incrementButtonTapped + case decrementButtonTapped + case factButtonTapped + case factResponse(String) +} + +// REDUCER — How state evolves in response to actions +struct CounterFeature: Reducer { + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .incrementButtonTapped: + state.count += 1 + return .none + case .decrementButtonTapped: + state.count -= 1 + return .none + case .factButtonTapped: + state.isLoading = true + return .run { [count = state.count] send in + let fact = try await numberFact(count) + await send(.factResponse(fact)) + } + case let .factResponse(fact): + state.isLoading = false + state.fact = fact + return .none + } + } + } +} + +// STORE — Runtime that receives actions and executes reducer +struct CounterView: View { + let store: StoreOf + + var body: some View { + VStack { + Text("\(store.count)") + Button("Increment") { store.send(.incrementButtonTapped) } + } + } +} +``` + +## TCA Trade-offs + +### ✅ Benefits + +| Benefit | Description | +|---------|-------------| +| **Testability** | TestStore makes testing deterministic and exhaustive | +| **Consistency** | One pattern for all features reduces cognitive load | +| **Composition** | Small reducers combine into larger features | +| **Side effects** | Structured effect management (networking, timers, etc.) | + +### ❌ Costs + +| Cost | Description | +|------|-------------| +| **Boilerplate** | State/Action/Reducer for every feature | +| **Learning curve** | Concepts from functional programming (effects, dependencies) | +| **Dependency** | Third-party library, not Apple-supported | +| **Iteration speed** | More code to write for simple features | + +## When to Choose TCA Over Apple Patterns + +| Scenario | Recommendation | +|----------|----------------| +| Small app (< 10 screens) | Apple patterns (simpler) | +| Medium app, experienced team | TCA if testability is priority | +| Large app, multiple teams | TCA for consistency | +| Rapid prototyping | Apple patterns (faster) | +| Mission-critical (banking, health) | TCA for rigorous testing | + +--- + +# Part 4: Coordinator Pattern + +## When to Use Coordinators + +Coordinators extract navigation logic from views. Use when: + +✅ **Complex navigation** — Multiple paths, conditional flows +✅ **Deep linking** — URL-driven navigation to any screen +✅ **Multiple entry points** — Same screen from different contexts +✅ **Testable navigation** — Isolate navigation from UI + +## SwiftUI Coordinator Implementation + +```swift +// Minimal coordinator — Route enum + @Observable coordinator + NavigationStack binding +enum Route: Hashable { + case detail(Pet) + case settings +} + +@Observable +class AppCoordinator { + var path: [Route] = [] + + func showDetail(for pet: Pet) { path.append(.detail(pet)) } + func popToRoot() { path.removeAll() } +} + +// Root view binds NavigationStack to coordinator's path +struct AppView: View { + @State private var coordinator = AppCoordinator() + + var body: some View { + NavigationStack(path: $coordinator.path) { + PetListView(coordinator: coordinator) + .navigationDestination(for: Route.self) { route in + switch route { + case .detail(let pet): PetDetailView(pet: pet, coordinator: coordinator) + case .settings: SettingsView(coordinator: coordinator) + } + } + } + } +} +``` + +Add deep linking with `.onOpenURL` and URL-to-route parsing: + +```swift +// Add to AppCoordinator +func handleDeepLink(_ url: URL) { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return } + // Parse URL path into routes + if components.path.hasPrefix("/pets/"), let id = components.path.split(separator: "/").last { + path = [.detail(loadPet(id: String(id)))] + } +} + +// Add to AppView's body +.onOpenURL { url in coordinator.handleDeepLink(url) } +``` + +Coordinators are testable without SwiftUI — assert path state directly: + +```swift +func testDeepLink() { + let coordinator = AppCoordinator() + coordinator.handleDeepLink(URL(string: "myapp://pets/123")!) + XCTAssertEqual(coordinator.path.count, 1) // Navigated to detail +} +``` + +For state restoration, advanced URL routing, and tab-based coordination, see `axiom-swiftui-nav` — Pattern 7 (Coordinator) for structure, Pattern 1b for URL-based deep linking. + +## Coordinator + Architecture Combinations + +You can combine Coordinators with any architecture: + +| Pattern | Coordinator Role | +|---------|------------------| +| **Apple Native** | Coordinator manages path, @Observable models for data | +| **MVVM** | Coordinator manages path, ViewModels for presentation | +| **TCA** | Coordinator manages path, Reducers for features | + +--- + +# Part 5: Refactoring Workflow + +## Step 1: Identify Logic in Views + +Run this checklist on your views: + +**View body contains:** +- DateFormatter, NumberFormatter creation +- Calculations or data transformations +- API calls or async operations +- Business rules (discounts, validation, etc.) +- Data filtering or sorting +- Heavy string manipulation +- Task { } with complex logic inside + +If ANY of these are present, that logic should likely move out. + +## Step 2: Extract to Appropriate Layer + +Use this decision tree: + +``` +Where does this logic belong? +│ +├─ Pure domain logic (discounts, validation, business rules)? +│ └─ Extract to Model +│ Example: Order.calculateDiscount() +│ +├─ Presentation logic (filtering, sorting, formatting)? +│ └─ Extract to ViewModel or computed property +│ Example: filteredItems, displayPrice +│ +├─ External side effects (API, database, file system)? +│ └─ Extract to Service +│ Example: APIClient, DatabaseManager +│ +└─ Just expensive computation? + └─ Cache with @State or create once + Example: let formatter = DateFormatter() +``` + +### Example: Refactoring Logic from View + +```swift +// ❌ Before: Logic in view body +struct OrderListView: View { + let orders: [Order] + + var body: some View { + let formatter = NumberFormatter() // ❌ Created every render + formatter.numberStyle = .currency + + let discounted = orders.filter { order in // ❌ Computed every render + let discount = order.total * 0.1 // ❌ Business logic in view + return discount > 10.0 + } + + return List(discounted) { order in + Text(formatter.string(from: order.total)!) // ❌ Force unwrap + } + } +} +``` + +```swift +// ✅ After: Logic extracted + +// Model — Business logic +struct Order { + let id: UUID + let total: Decimal + + var discount: Decimal { + total * 0.1 + } + + var qualifiesForDiscount: Bool { + discount > 10.0 + } +} + +// ViewModel — Presentation logic +@Observable +class OrderListViewModel { + let orders: [Order] + private let formatter: NumberFormatter // ✅ Created once + + var discountedOrders: [Order] { // ✅ Computed property + orders.filter { $0.qualifiesForDiscount } + } + + init(orders: [Order]) { + self.orders = orders + self.formatter = NumberFormatter() + formatter.numberStyle = .currency + } + + func formattedTotal(_ order: Order) -> String { + formatter.string(from: order.total as NSNumber) ?? "$0.00" + } +} + +// View — UI only +struct OrderListView: View { + let viewModel: OrderListViewModel + + var body: some View { + List(viewModel.discountedOrders) { order in + Text(viewModel.formattedTotal(order)) + } + } +} +``` + +## Step 3: Verify Testability + +Your refactoring succeeded if: + +```swift +// ✅ Can test without importing SwiftUI +import XCTest + +final class OrderTests: XCTestCase { + func testDiscountCalculation() { + let order = Order(id: UUID(), total: 100) + XCTAssertEqual(order.discount, 10) + } + + func testQualifiesForDiscount() { + let order = Order(id: UUID(), total: 100) + XCTAssertTrue(order.qualifiesForDiscount) + } +} + +final class OrderViewModelTests: XCTestCase { + func testFilteredOrders() { + let orders = [ + Order(id: UUID(), total: 50), // Discount: 5 ❌ + Order(id: UUID(), total: 200), // Discount: 20 ✅ + ] + let viewModel = OrderListViewModel(orders: orders) + + XCTAssertEqual(viewModel.discountedOrders.count, 1) + } +} +``` + +## Step 4: Update View Bindings + +After extraction, update property wrappers: + +```swift +// Before refactoring +struct OrderListView: View { + @State private var orders: [Order] = [] // View owned + // ... logic in body +} + +// After refactoring +struct OrderListView: View { + @State private var viewModel: OrderListViewModel // View owns ViewModel + + init(orders: [Order]) { + _viewModel = State(initialValue: OrderListViewModel(orders: orders)) + } +} + +// Or if parent owns it +struct OrderListView: View { + let viewModel: OrderListViewModel // Parent owns, just reading +} + +// Or if need bindings +struct OrderListView: View { + @Bindable var viewModel: OrderListViewModel // Parent owns, need $ +} +``` + +--- + +# Anti-Patterns (DO NOT DO THIS) + +## ❌ Anti-Pattern 1: Logic in View Body + +```swift +// ❌ Don't do this +struct ProductListView: View { + let products: [Product] + + var body: some View { + let formatter = NumberFormatter() // ❌ Created every render! + formatter.numberStyle = .currency + + let sorted = products.sorted { $0.price > $1.price } // ❌ Sorted every render! + + return List(sorted) { product in + Text("\(product.name): \(formatter.string(from: product.price)!)") + } + } +} +``` + +**Why it's wrong**: +- `formatter` created on every render (performance) +- `sorted` computed on every render (performance) +- Business logic (`sorted`) lives in view (not testable) +- Force unwrap ('!') can crash + +```swift +// ✅ Correct +@Observable +class ProductListViewModel { + let products: [Product] + private let formatter = NumberFormatter() + + var sortedProducts: [Product] { + products.sorted { $0.price > $1.price } + } + + init(products: [Product]) { + self.products = products + formatter.numberStyle = .currency + } + + func formattedPrice(_ product: Product) -> String { + formatter.string(from: product.price as NSNumber) ?? "$0.00" + } +} + +struct ProductListView: View { + let viewModel: ProductListViewModel + + var body: some View { + List(viewModel.sortedProducts) { product in + Text("\(product.name): \(viewModel.formattedPrice(product))") + } + } +} +``` + +## ❌ Anti-Pattern 2: Async Code Without Boundaries + +See the State-as-Bridge pattern in Part 1 above — keep UI state changes synchronous (inside `withAnimation`), launch async work separately via `Task`. + +## ❌ Anti-Pattern 3: Wrong Property Wrapper + +```swift +// ❌ Don't use @State for passed-in models +struct DetailView: View { + @State var item: Item // ❌ Creates a copy, loses parent changes +} + +// ✅ Correct: No wrapper for passed-in models +struct DetailView: View { + let item: Item // ✅ Or @Bindable if you need $item +} +``` + +```swift +// ❌ Don't use @Environment for view-local state +struct FormView: View { + @Environment(FormData.self) var formData // ❌ Overkill for local form +} + +// ✅ Correct: @State for view-local +struct FormView: View { + @State private var formData = FormData() // ✅ View owns it +} +``` + +## ❌ Anti-Pattern 4: God ViewModel + +See MVVM Mistake 2 in Part 2 above — split by concern into separate ViewModels. + +## ❌ Anti-Pattern 5: @AppStorage Inside @Observable + +**Never use `@AppStorage` inside an `@Observable` class** — it silently breaks observation. `@AppStorage` is a property wrapper designed for SwiftUI views, not model classes. + +```swift +// ❌ BROKEN — @AppStorage silently breaks @Observable +@Observable +class Settings { + @AppStorage("theme") var theme = "light" // Changes won't trigger view updates +} + +// ✅ Read @AppStorage in view, pass to model +struct SettingsView: View { + @AppStorage("theme") private var theme = "light" + // ... +} +``` + +## ❌ Anti-Pattern 6: Binding(get:set:) in View Body + +Creating `Binding(get:set:)` in the view body creates a new binding on every evaluation, breaking SwiftUI's identity tracking. + +```swift +// ❌ New Binding created every body evaluation +var body: some View { + TextField("Name", text: Binding( + get: { model.name }, + set: { model.name = $0 } + )) +} + +// ✅ Use @Bindable or computed binding +var body: some View { + @Bindable var model = model + TextField("Name", text: $model.name) +} +``` + +--- + +# Code Review Checklist + +Before merging SwiftUI code, verify: + +### Views +- View bodies contain ONLY UI code (Text, Button, List, etc.) +- No formatters created in view body +- No calculations or transformations in view body +- No API calls or database queries in view body +- No business rules in view body + +### Logic Separation +- Business logic is in models (testable without SwiftUI) +- Presentation logic is in ViewModels or computed properties +- Side effects are in services or model methods +- Heavy computations are cached or computed once + +### Property Wrappers +- @State for view-owned models +- @Environment for app-wide models +- @Bindable when bindings are needed +- No wrapper when just reading + +### Animations & Async +- State changes for animations are synchronous +- Async boundaries use State-as-Bridge pattern +- No `await` between `withAnimation { }` blocks + +### Testability +- Can test business logic without importing SwiftUI +- Can test ViewModels without rendering views +- Navigation logic is isolated (if using Coordinators) + +--- + +# Pressure Scenarios + +## Scenario 1: "Just put it in the view for now" + +### The Pressure + +**Manager**: "We need this feature by Friday. Just put the logic in the view for now, we'll refactor later." + +### Red Flags + +If you hear: +- ❌ "We'll refactor later" (tech debt that never gets paid) +- ❌ "It's just one view" (views multiply) +- ❌ "We don't have time for architecture" (costs more later) + +### Time Cost Comparison + +**Option A: Put logic in view** +- Write feature in view: 2 hours +- Realize it's untestable: 1 hour +- Try to test it anyway: 2 hours +- Give up, ship with manual testing: 0 hours +- **Total: 5 hours, 0 tests** + +**Option B: Extract logic properly** +- Create model/ViewModel: 30 min +- Write feature with separation: 2 hours +- Write tests: 1 hour +- **Total: 3.5 hours, full test coverage** + +### How to Push Back Professionally + +**Step 1**: Acknowledge the deadline +> "I understand Friday is the deadline. Let me show you why proper separation is actually faster." + +**Step 2**: Show the time comparison +> "Putting logic in views takes 5 hours with no tests. Extracting it properly takes 3.5 hours with full tests. We save 1.5 hours AND get tests." + +**Step 3**: Offer the compromise +> "If we're truly out of time, I can extract 80% now and mark the remaining 20% as tech debt with a ticket. But let's not skip extraction entirely." + +**Step 4**: Document if pressured to proceed +```swift +// TODO: TECH DEBT - Extract business logic to ViewModel +// Ticket: PROJ-123 +// Added: 2025-12-14 +// Reason: Deadline pressure from manager +// Estimated refactor time: 2 hours +``` + +### When to Accept + +Only skip extraction if: +1. This is a throwaway prototype (deleted next week) +2. You have explicit time budget for refactoring (scheduled ticket) +3. The view will never grow beyond 20 lines + +## Scenario 2: "TCA is overkill, just use vanilla SwiftUI" + +### The Pressure + +**Tech Lead**: "TCA is too complex for this project. Just use vanilla SwiftUI with @Observable." + +### Decision Criteria + +Ask these questions: + +| Question | TCA | Vanilla | +|----------|-----|---------| +| Is testability critical (medical, financial)? | ✅ | ❌ | +| Do you have < 5 screens? | ❌ | ✅ | +| Is team experienced with functional programming? | ✅ | ❌ | +| Do you need rapid prototyping? | ❌ | ✅ | +| Is consistency across large team critical? | ✅ | ❌ | +| Do you have complex side effects (sockets, timers)? | ✅ | ~ | + +**Recommendation matrix**: +- 4+ checks for TCA → Use TCA +- 4+ checks for Vanilla → Use Vanilla +- Tie → Start with Vanilla, migrate to TCA if needed + +### How to Push Back + +**If arguing FOR TCA**: +> "I understand TCA feels heavy. But we're building a banking app. The TestStore gives us exhaustive testing that catches bugs before production. The 2-week learning curve is worth it for 2 years of maintenance." + +**If arguing AGAINST TCA**: +> "I agree TCA is powerful, but we're prototyping features weekly. The boilerplate will slow us down. Let's use @Observable now and migrate to TCA if we prove the features are worth building." + +## Scenario 3: "Refactoring will take too long" + +### The Pressure + +**PM**: "We have 3 features to ship this month. We can't spend 2 weeks refactoring existing views." + +### Incremental Extraction Strategy + +You don't have to refactor everything at once: + +**Week 1**: Extract 1 view +- Pick the most painful view (lots of logic) +- Extract to ViewModel +- Write tests +- **Time**: 4 hours + +**Week 2**: Extract 2 views +- Now you have a pattern to follow +- Faster than week 1 +- **Time**: 6 hours + +**Week 3**: New features use proper architecture +- Don't refactor old code yet +- All NEW code follows the pattern +- **Time**: 0 hours (same as before) + +**Month 2**: Gradually refactor as you touch files +- Refactor when fixing bugs in old views +- Refactor when adding features to old views +- **Time**: Amortized over feature work + +### How to Push Back + +> "I'm not proposing we stop feature work for 2 weeks. I'm proposing: +> 1. Week 1: Extract our worst view (the OrdersView with 500 lines) +> 2. Week 2: Extract 2 more problematic views +> 3. Going forward: All NEW features use proper architecture +> 4. We refactor old views when we touch them anyway +> +> This costs 10 hours upfront and saves us 2+ hours per feature going forward." + +--- + +# Real-World Impact + +## Before: Logic in View + +```swift +// 😰 200 lines of pain +struct OrderListView: View { + @State private var orders: [Order] = [] + @State private var searchText = "" + @State private var selectedFilter: FilterType = .all + + var body: some View { + // ❌ Formatters created every render + let currencyFormatter = NumberFormatter() + currencyFormatter.numberStyle = .currency + + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + + // ❌ Business logic in view + let filtered = orders.filter { order in + if !searchText.isEmpty && !order.customerName.contains(searchText) { + return false + } + + switch selectedFilter { + case .all: return true + case .pending: return !order.isCompleted + case .completed: return order.isCompleted + case .highValue: return order.total > 1000 + } + } + + // ❌ More business logic + let sorted = filtered.sorted { lhs, rhs in + if selectedFilter == .highValue { + return lhs.total > rhs.total + } else { + return lhs.date > rhs.date + } + } + + return List(sorted) { order in + VStack(alignment: .leading) { + Text(order.customerName) + Text(currencyFormatter.string(from: order.total as NSNumber)!) + Text(dateFormatter.string(from: order.date)) + + if order.isCompleted { + Image(systemName: "checkmark.circle.fill") + } else { + Button("Complete") { + // ❌ Async logic in view + Task { + do { + try await completeOrder(order) + await loadOrders() + } catch { + print(error) // ❌ No error handling + } + } + } + } + } + } + .searchable(text: $searchText) + .task { + await loadOrders() + } + } + + func loadOrders() async { + // ❌ API call in view + // ... 50 more lines + } + + func completeOrder(_ order: Order) async throws { + // ❌ API call in view + // ... 30 more lines + } +} +``` + +**Problems**: +- 200+ lines in one file +- Formatters created every render (performance) +- Business logic untestable +- No error handling +- Hard to reason about + +## After: Proper Architecture + +```swift +// Model — 30 lines +struct Order { + let id: UUID + let customerName: String + let total: Decimal + let date: Date + var isCompleted: Bool + + var isHighValue: Bool { + total > 1000 + } +} + +// ViewModel — 60 lines +@Observable +class OrderListViewModel { + private let orderService: OrderService + private let currencyFormatter = NumberFormatter() + private let dateFormatter = DateFormatter() + + var orders: [Order] = [] + var searchText = "" + var selectedFilter: FilterType = .all + var error: Error? + + var filteredOrders: [Order] { + orders + .filter(matchesSearch) + .filter(matchesFilter) + .sorted(by: sortComparator) + } + + init(orderService: OrderService) { + self.orderService = orderService + currencyFormatter.numberStyle = .currency + dateFormatter.dateStyle = .medium + } + + func loadOrders() async { + do { + orders = try await orderService.fetchOrders() + } catch { + self.error = error + } + } + + func completeOrder(_ order: Order) async { + do { + try await orderService.complete(order.id) + await loadOrders() + } catch { + self.error = error + } + } + + func formattedTotal(_ order: Order) -> String { + currencyFormatter.string(from: order.total as NSNumber) ?? "$0.00" + } + + func formattedDate(_ order: Order) -> String { + dateFormatter.string(from: order.date) + } + + private func matchesSearch(_ order: Order) -> Bool { + searchText.isEmpty || order.customerName.contains(searchText) + } + + private func matchesFilter(_ order: Order) -> Bool { + switch selectedFilter { + case .all: true + case .pending: !order.isCompleted + case .completed: order.isCompleted + case .highValue: order.isHighValue + } + } + + private func sortComparator(_ lhs: Order, _ rhs: Order) -> Bool { + selectedFilter == .highValue + ? lhs.total > rhs.total + : lhs.date > rhs.date + } +} + +// View — 40 lines +struct OrderListView: View { + @Bindable var viewModel: OrderListViewModel + + var body: some View { + List(viewModel.filteredOrders) { order in + OrderRow(order: order, viewModel: viewModel) + } + .searchable(text: $viewModel.searchText) + .task { + await viewModel.loadOrders() + } + .alert("Error", error: $viewModel.error) { } + } +} + +struct OrderRow: View { + let order: Order + let viewModel: OrderListViewModel + + var body: some View { + VStack(alignment: .leading) { + Text(order.customerName) + Text(viewModel.formattedTotal(order)) + Text(viewModel.formattedDate(order)) + + if order.isCompleted { + Image(systemName: "checkmark.circle.fill") + } else { + Button("Complete") { + Task { + await viewModel.completeOrder(order) + } + } + } + } + } +} + +// Tests — 100 lines +final class OrderViewModelTests: XCTestCase { + func testFilterBySearch() async { + let viewModel = OrderListViewModel(orderService: MockOrderService()) + await viewModel.loadOrders() + + viewModel.searchText = "John" + XCTAssertEqual(viewModel.filteredOrders.count, 1) + } + + func testFilterByHighValue() async { + let viewModel = OrderListViewModel(orderService: MockOrderService()) + await viewModel.loadOrders() + + viewModel.selectedFilter = .highValue + XCTAssertTrue(viewModel.filteredOrders.allSatisfy { $0.isHighValue }) + } + + // ... 10 more tests +} +``` + +**Benefits**: +- View: 40 lines (was 200) +- ViewModel: Fully testable without SwiftUI +- Model: Pure business logic +- Formatters: Created once, not every render +- Error handling: Proper with alerts +- Tests: 10+ tests covering all logic + +--- + +## Resources + +**WWDC**: 2025-266, 2024-10150, 2023-10149, 2023-10160 + +**Docs**: /swiftui/managing-model-data-in-your-app + +**External**: github.com/pointfreeco/swift-composable-architecture + +--- + +**Platforms**: iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, axiom-visionOS 26+ +**Xcode**: 26+ +**Status**: Production-ready (v1.0) diff --git a/.claude/skills/axiom-swiftui-architecture/agents/openai.yaml b/.claude/skills/axiom-swiftui-architecture/agents/openai.yaml new file mode 100644 index 0000000..f44a433 --- /dev/null +++ b/.claude/skills/axiom-swiftui-architecture/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftUI Architecture" + short_description: "Separating logic from SwiftUI views, choosing architecture patterns, refactoring view files, or asking 'where should ..." diff --git a/.claude/skills/axiom-swiftui-containers-ref/.openskills.json b/.claude/skills/axiom-swiftui-containers-ref/.openskills.json new file mode 100644 index 0000000..3b41ba9 --- /dev/null +++ b/.claude/skills/axiom-swiftui-containers-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftui-containers-ref", + "installedAt": "2026-04-12T08:06:47.496Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftui-containers-ref/SKILL.md b/.claude/skills/axiom-swiftui-containers-ref/SKILL.md new file mode 100644 index 0000000..cac1f95 --- /dev/null +++ b/.claude/skills/axiom-swiftui-containers-ref/SKILL.md @@ -0,0 +1,416 @@ +--- +name: axiom-swiftui-containers-ref +description: Reference — SwiftUI stacks, grids, outlines, and scroll enhancements through iOS 26 +license: MIT +metadata: + version: "1.2.0" +--- + +# SwiftUI Containers Reference + +Stacks, grids, outlines, and scroll enhancements. iOS 14 through iOS 26. + +**Sources**: WWDC 2020-10031, 2022-10056, 2023-10148, 2024-10144, 2025-256 + +## Quick Decision + +| Use Case | Container | iOS | +|----------|-----------|-----| +| Fixed views vertical/horizontal | VStack / HStack | 13+ | +| Overlapping views | ZStack | 13+ | +| Large scrollable list | LazyVStack / LazyHStack | 14+ | +| Multi-column grid | LazyVGrid | 14+ | +| Multi-row grid (horizontal) | LazyHGrid | 14+ | +| Static grid, precise alignment | Grid | 16+ | +| Hierarchical data (tree) | List with `children:` | 14+ | +| Custom hierarchies | OutlineGroup | 14+ | +| Show/hide content | DisclosureGroup | 14+ | + +--- + +## Part 1: Stacks + +### VStack, HStack, ZStack + +```swift +VStack(alignment: .leading, spacing: 12) { + Text("Title") + Text("Subtitle") +} + +HStack(alignment: .top, spacing: 8) { + Image(systemName: "star") + Text("Rating") +} + +ZStack(alignment: .bottomTrailing) { + Image("photo") + Badge() +} +``` + +**ZStack alignments**: `.center` (default), `.top`, `.bottom`, `.leading`, `.trailing`, `.topLeading`, `.topTrailing`, `.bottomLeading`, `.bottomTrailing` + +### Spacer + +```swift +HStack { + Text("Left") + Spacer() + Text("Right") +} + +Spacer(minLength: 20) // Minimum size +``` + +--- + +### LazyVStack, LazyHStack (iOS 14+) + +Render children only when visible. Use inside ScrollView. + +```swift +ScrollView { + LazyVStack(spacing: 0) { + ForEach(items) { item in + ItemRow(item: item) + } + } +} +``` + +### Pinned Section Headers + +```swift +ScrollView { + LazyVStack(pinnedViews: [.sectionHeaders]) { + ForEach(sections) { section in + Section(header: SectionHeader(section)) { + ForEach(section.items) { item in + ItemRow(item: item) + } + } + } + } +} +``` + +--- + +## Part 2: Grids + +### Grid (iOS 16+) + +Non-lazy grid with precise alignment. Loads all views at once. + +```swift +Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) { + GridRow { + Text("Name") + TextField("Enter name", text: $name) + } + GridRow { + Text("Email") + TextField("Enter email", text: $email) + } +} +``` + +**Modifiers**: +- `gridCellColumns(_:)` — Span multiple columns +- `gridColumnAlignment(_:)` — Override column alignment + +```swift +Grid { + GridRow { + Text("Header").gridCellColumns(2) + } + GridRow { + Text("Left") + Text("Right").gridColumnAlignment(.trailing) + } +} +``` + +--- + +### LazyVGrid (iOS 14+) + +Vertical-scrolling grid. Define **columns**; rows grow unbounded. + +```swift +let columns = [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) +] + +ScrollView { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(items) { item in + ItemCard(item: item) + } + } +} +``` + +### LazyHGrid (iOS 14+) + +Horizontal-scrolling grid. Define **rows**; columns grow unbounded. + +```swift +let rows = [GridItem(.fixed(100)), GridItem(.fixed(100))] + +ScrollView(.horizontal) { + LazyHGrid(rows: rows, spacing: 16) { + ForEach(items) { item in + ItemCard(item: item) + } + } +} +``` + +### GridItem.Size + +| Size | Behavior | +|------|----------| +| `.fixed(CGFloat)` | Exact width/height | +| `.flexible(minimum:maximum:)` | Fills space equally | +| `.adaptive(minimum:maximum:)` | Creates as many as fit | + +```swift +// Adaptive: responsive column count +let columns = [GridItem(.adaptive(minimum: 150))] +``` + +--- + +## Part 3: Outlines + +### List with Hierarchical Data (iOS 14+) + +```swift +struct FileItem: Identifiable { + let id = UUID() + var name: String + var children: [FileItem]? // nil = leaf +} + +List(files, children: \.children) { file in + Label(file.name, systemImage: file.children != nil ? "folder" : "doc") +} +.listStyle(.sidebar) +``` + +### OutlineGroup (iOS 14+) + +For custom hierarchical layouts outside List. + +```swift +List { + ForEach(canvases) { canvas in + Section(header: Text(canvas.name)) { + OutlineGroup(canvas.graphics, children: \.children) { graphic in + GraphicRow(graphic: graphic) + } + } + } +} +``` + +### DisclosureGroup (iOS 14+) + +```swift +@State private var isExpanded = false + +DisclosureGroup("Advanced Options", isExpanded: $isExpanded) { + Toggle("Enable Feature", isOn: $feature) + Slider(value: $intensity) +} +``` + +--- + +## Part 4: Common Patterns + +### Photo Grid + +```swift +let columns = [GridItem(.adaptive(minimum: 100), spacing: 2)] + +ScrollView { + LazyVGrid(columns: columns, spacing: 2) { + ForEach(photos) { photo in + AsyncImage(url: photo.thumbnailURL) { image in + image.resizable().aspectRatio(1, contentMode: .fill) + } placeholder: { Color.gray } + .aspectRatio(1, contentMode: .fill) + .clipped() + } + } +} +``` + +### Horizontal Carousel + +```swift +ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 16) { + ForEach(items) { item in + CarouselCard(item: item).frame(width: 280) + } + } + .padding(.horizontal) +} +``` + +### File Browser + +```swift +List(selection: $selection) { + OutlineGroup(rootItems, children: \.children) { item in + Label { + Text(item.name) + } icon: { + Image(systemName: item.children != nil ? "folder.fill" : "doc.fill") + } + } +} +.listStyle(.sidebar) +``` + +--- + +## Part 5: Performance + +### When to Use Lazy + +| Size | Scrollable? | Use | +|------|-------------|-----| +| 1-20 | No | VStack/HStack | +| 1-20 | Yes | VStack/HStack in ScrollView | +| 20-100 | Yes | LazyVStack/LazyHStack | +| 100+ | Yes | LazyVStack/LazyHStack or List | +| Grid <50 | No | Grid | +| Grid 50+ | Yes | LazyVGrid/LazyHGrid | + +**Cache GridItem arrays** — define outside body: + +```swift +struct ContentView: View { + let columns = [GridItem(.adaptive(minimum: 150))] // ✅ + var body: some View { + LazyVGrid(columns: columns) { ... } + } +} +``` + +### iOS 26 Performance + +- **6x faster list loading** for 100k+ items +- **16x faster list updates** +- **Reduced dropped frames** in scrolling +- **Nested ScrollViews with lazy stacks** now properly defer loading: + +```swift +ScrollView(.horizontal) { + LazyHStack { + ForEach(photoSets) { set in + ScrollView(.vertical) { + LazyVStack { + ForEach(set.photos) { PhotoView(photo: $0) } + } + } + } + } +} +``` + +--- + +## Part 6: Scroll Enhancements + +### containerRelativeFrame (iOS 17+) + +Size views relative to scroll container. + +```swift +ScrollView(.horizontal) { + LazyHStack { + ForEach(cards) { card in + CardView(card: card) + .containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 16) + } + } +} +``` + +### scrollTargetLayout (iOS 17+) + +Enable snapping. + +```swift +ScrollView(.horizontal) { + LazyHStack { + ForEach(items) { ItemCard(item: $0) } + } + .scrollTargetLayout() +} +.scrollTargetBehavior(.viewAligned) +``` + +### scrollPosition (iOS 17+) + +Track topmost visible item. **Requires `.id()` on each item.** + +```swift +@State private var position: Item.ID? + +ScrollView { + LazyVStack { + ForEach(items) { item in + ItemRow(item: item).id(item.id) + } + } +} +.scrollPosition(id: $position) +``` + +### scrollTransition (iOS 17+) + +```swift +.scrollTransition { content, phase in + content + .opacity(1 - abs(phase.value) * 0.5) + .scaleEffect(phase.isIdentity ? 1.0 : 0.75) +} +``` + +### onScrollGeometryChange (iOS 18+) + +```swift +.onScrollGeometryChange(for: Bool.self) { geo in + geo.contentOffset.y < geo.contentInsets.top +} action: { _, isTop in + showBackButton = !isTop +} +``` + +### onScrollVisibilityChange (iOS 18+) + +```swift +VideoPlayer(player: player) + .onScrollVisibilityChange(threshold: 0.2) { visible in + visible ? player.play() : player.pause() + } +``` + +--- + +## Resources + +**WWDC**: 2020-10031, 2022-10056, 2023-10148, 2024-10144, 2025-256 + +**Docs**: /swiftui/lazyvstack, /swiftui/lazyvgrid, /swiftui/lazyhgrid, /swiftui/grid, /swiftui/outlinegroup, /swiftui/disclosuregroup + +**Skills**: axiom-swiftui-layout, axiom-swiftui-layout-ref, axiom-swiftui-nav, axiom-swiftui-26-ref diff --git a/.claude/skills/axiom-swiftui-containers-ref/agents/openai.yaml b/.claude/skills/axiom-swiftui-containers-ref/agents/openai.yaml new file mode 100644 index 0000000..8723323 --- /dev/null +++ b/.claude/skills/axiom-swiftui-containers-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftUI Containers Reference" + short_description: "Reference — SwiftUI stacks, grids, outlines, and scroll enhancements through iOS 26" diff --git a/.claude/skills/axiom-swiftui-debugging-diag/.openskills.json b/.claude/skills/axiom-swiftui-debugging-diag/.openskills.json new file mode 100644 index 0000000..5cc300e --- /dev/null +++ b/.claude/skills/axiom-swiftui-debugging-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftui-debugging-diag", + "installedAt": "2026-04-12T08:06:48.155Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftui-debugging-diag/SKILL.md b/.claude/skills/axiom-swiftui-debugging-diag/SKILL.md new file mode 100644 index 0000000..fd9b065 --- /dev/null +++ b/.claude/skills/axiom-swiftui-debugging-diag/SKILL.md @@ -0,0 +1,854 @@ +--- +name: axiom-swiftui-debugging-diag +description: Use when SwiftUI view debugging requires systematic investigation - view updates not working after basic troubleshooting, intermittent UI issues, complex state dependencies, or when Self._printChanges() shows unexpected update patterns - systematic diagnostic workflows with Instruments integration +license: MIT +metadata: + version: "1.0.0" + last-updated: "Initial release with 5 diagnostic patterns, SwiftUI Instrument workflows, and production crisis protocols" +--- + +# SwiftUI Debugging Diagnostics + +## When to Use This Diagnostic Skill + +Use this skill when: +- **Basic troubleshooting failed** — Applied `axiom-swiftui-debugging` skill patterns but issue persists +- **Self._printChanges() shows unexpected patterns** — View updating when it shouldn't, or not updating when it should +- **Intermittent issues** — Works sometimes, fails other times ("heisenbug") +- **Complex dependency chains** — Need to trace data flow through multiple views/models +- **Performance investigation** — Views updating too often or taking too long +- **Preview mysteries** — Crashes or failures that aren't immediately obvious + +## FORBIDDEN Actions + +Under pressure, you'll be tempted to shortcuts that hide problems instead of diagnosing them. **NEVER do these**: + +❌ **Guessing with random @State/@Observable changes** +- "Let me try adding @Observable here and see if it works" +- "Maybe if I change this to @StateObject it'll fix it" + +❌ **Adding .id(UUID()) to force updates** +- Creates new view identity every render +- Destroys state preservation +- Masks root cause + +❌ **Using ObservableObject when @Observable would work** (iOS 17+) +- Adds unnecessary complexity +- Miss out on automatic dependency tracking + +❌ **Ignoring intermittent issues** ("works sometimes") +- "I'll just merge and hope it doesn't happen in production" +- Intermittent = systematic bug, not randomness + +❌ **Shipping without understanding** +- "The fix works, I don't know why" +- Production is too expensive for trial-and-error + +## Mandatory First Steps + +Before diving into diagnostic patterns, establish baseline environment: + +```bash +# 1. Verify Instruments setup +xcodebuild -version # Must be Xcode 26+ for SwiftUI Instrument + +# 2. Build in Release mode for profiling +xcodebuild build -scheme YourScheme -configuration Release + +# 3. Clear derived data if investigating preview issues +rm -rf ~/Library/Developer/Xcode/DerivedData +``` + +**Time cost**: 5 minutes +**Why**: Wrong Xcode version or Debug mode produces misleading profiling data + +--- + +## Diagnostic Decision Tree + +``` +SwiftUI view issue after basic troubleshooting? +│ +├─ View not updating? +│ ├─ Basic check: Add Self._printChanges() temporarily +│ │ ├─ Shows "@self changed" → View value changed +│ │ │ └─ Pattern D1: Analyze what caused view recreation +│ │ ├─ Shows specific state property → That state triggered update +│ │ │ └─ Verify: Should that state trigger update? +│ │ └─ Nothing logged → Body not being called at all +│ │ └─ Pattern D3: View Identity Investigation +│ └─ Advanced: Use SwiftUI Instrument +│ └─ Pattern D2: SwiftUI Instrument Investigation +│ +├─ View updating too often? +│ ├─ Pattern D1: Self._printChanges() Analysis +│ │ └─ Identify unnecessary state dependencies +│ └─ Pattern D2: SwiftUI Instrument → Cause & Effect Graph +│ └─ Trace data flow, find broad dependencies +│ +├─ Intermittent issues (works sometimes)? +│ ├─ Pattern D3: View Identity Investigation +│ │ └─ Check: Does identity change unexpectedly? +│ ├─ Pattern D4: Environment Dependency Check +│ │ └─ Check: Environment values changing frequently? +│ └─ Reproduce in preview 30+ times +│ └─ If can't reproduce: Likely timing/race condition +│ +└─ Preview crashes (after basic fixes)? + ├─ Pattern D5: Preview Diagnostics (Xcode 26) + │ └─ Check diagnostics button, crash logs + └─ If still fails: Pattern D2 (profile preview build) +``` + +--- + +## Diagnostic Patterns + +### Pattern D1: Self._printChanges() Analysis + +**Time cost**: 5 minutes + +**Symptom**: Need to understand exactly why view body runs + +**When to use**: +- View updating more often than expected +- View not updating when it should +- Verifying dependencies after refactoring + +**Technique**: + +```swift +struct MyView: View { + @State private var count = 0 + @Environment(AppModel.self) private var model + + var body: some View { + let _ = Self._printChanges() // Add temporarily + + VStack { + Text("Count: \(count)") + Text("Model value: \(model.value)") + } + } +} +``` + +**Output interpretation**: + +``` +# Scenario 1: View parameter changed +MyView: @self changed +→ Parent passed new MyView instance +→ Check parent code - what triggered recreation? + +# Scenario 2: State property changed +MyView: count changed +→ Local @State triggered update +→ Expected if you modified count + +# Scenario 3: Environment property changed +MyView: @self changed # Environment is part of @self +→ Environment value changed (color scheme, locale, custom value) +→ Pattern D4: Check environment dependencies + +# Scenario 4: Nothing logged +→ Body not being called +→ Pattern D3: View identity investigation +``` + +**Common discoveries**: + +1. **"@self changed" when you don't expect** + - Parent recreating view unnecessarily + - Check parent's state management + +2. **Property shows changed but you didn't change it** + - Indirect dependency (reading from object that changed) + - Pattern D2: Use Instruments to trace + +3. **Multiple properties changing together** + - Broad dependency (e.g., reading entire array when only need one item) + - Fix: Extract specific dependency + +**Verification**: +- Remove `Self._printChanges()` call before committing +- Never ship to production with this code + +**Cross-reference**: For complex cases, use Pattern D2 (SwiftUI Instrument) + +--- + +### Pattern D2: SwiftUI Instrument Investigation + +**Time cost**: 25 minutes + +**Symptom**: Complex update patterns that Self._printChanges() can't fully explain + +**When to use**: +- Multiple views updating when one should +- Need to trace data flow through app +- Views updating but don't know which data triggered it +- Long view body updates (performance issue) + +**Prerequisites**: +- Xcode 26+ installed +- Device updated to iOS 26+ / macOS Tahoe+ +- Build in Release mode + +**Steps**: + +#### 1. Launch Instruments (5 min) +```bash +# Build Release +xcodebuild build -scheme YourScheme -configuration Release + +# Launch Instruments +# Press Command-I in Xcode +# Choose "SwiftUI" template +``` + +#### 2. Record Trace (3 min) +- Click Record button +- Perform the action that triggers unexpected updates +- Stop recording (10-30 seconds of interaction is enough) + +#### 3. Analyze Long View Body Updates (5 min) +- Look at **Long View Body Updates lane** +- Any orange/red bars? Those are expensive views +- Click on a long update → Detail pane shows view name +- Right-click → "Set Inspection Range and Zoom" +- Switch to **Time Profiler** track +- Find your view in call stack +- Identify expensive operation (formatter creation, calculation, etc.) + +**Fix**: Move expensive operation to model layer, cache result + +#### 4. Analyze Unnecessary Updates (7 min) +- Highlight time range of user action (e.g., tapping favorite button) +- Expand hierarchy in detail pane +- **Count updates** — more than expected? +- Hover over view → Click arrow → "Show Cause & Effect Graph" + +#### 5. Interpret Cause & Effect Graph (5 min) + +**Graph nodes**: +``` +[Blue node] = Your code (gesture, state change, view body) +[System node] = SwiftUI/system work +[Arrow labeled "update"] = Caused this update +[Arrow labeled "creation"] = Caused view to appear +``` + +**Common patterns**: + +``` +# Pattern A: Single view updates (GOOD) +[Gesture] → [State Change in ViewModelA] → [ViewA body] + +# Pattern B: All views update (BAD - broad dependency) +[Gesture] → [Array change] → [All list item views update] +└─ Fix: Use granular view models, one per item + +# Pattern C: Cascade through environment (CHECK) +[State Change] → [Environment write] → [Many view bodies check] +└─ If environment value changes frequently → Pattern D4 fix +``` + +**Click on nodes**: +- **State change node** → See backtrace of where value was set +- **View body node** → See which properties it read (dependencies) + +**Verification**: +- Record new trace after fix +- Compare before/after update counts +- Verify red/orange bars reduced or eliminated + +**Cross-reference**: `axiom-swiftui-performance` skill for detailed Instruments workflows + +--- + +### Pattern D3: View Identity Investigation + +**Time cost**: 15 minutes + +**Symptom**: @State values reset unexpectedly, or views don't animate + +**When to use**: +- Counter resets to 0 when it shouldn't +- Animations don't work (view pops instead of animates) +- ForEach items jump around +- Text field loses focus + +**Root cause**: View identity changed unexpectedly + +**Investigation steps**: + +#### 1. Check for conditional placement (5 min) + +```swift +// ❌ PROBLEM: Identity changes with condition +if showDetails { + CounterView() // Gets new identity each time showDetails toggles +} + +// ✅ FIX: Use .opacity() +CounterView() + .opacity(showDetails ? 1 : 0) // Same identity always +``` + +**Find**: Search codebase for views inside `if/else` that hold state + +#### 2. Check .id() modifiers (5 min) + +```swift +// ❌ PROBLEM: .id() changes when data changes +DetailView() + .id(item.id + "-\(isEditing)") // ID changes with isEditing + +// ✅ FIX: Stable ID +DetailView() + .id(item.id) // Stable ID +``` + +**Find**: Search codebase for `.id(` — check if ID values change + +#### 3. Check ForEach identifiers (5 min) + +```swift +// ❌ WRONG: Index-based ID +ForEach(Array(items.enumerated()), id: \.offset) { index, item in + Text(item.name) +} + +// ❌ WRONG: Non-unique ID +ForEach(items, id: \.category) { item in // Multiple items per category + Text(item.name) +} + +// ✅ RIGHT: Unique, stable ID +ForEach(items, id: \.id) { item in + Text(item.name) +} +``` + +**Find**: Search for `ForEach` — verify unique, stable IDs + +**Fix patterns**: + +| Issue | Fix | +|-------|-----| +| View in conditional | Use `.opacity()` instead | +| .id() changes too often | Use stable identifier | +| ForEach jumping | Use unique, stable IDs (UUID or server ID) | +| State resets on navigation | Check NavigationStack path management | + +**Verification**: +- Add Self._printChanges() — should NOT see "@self changed" repeatedly +- Animations should now work smoothly +- @State values should persist + +--- + +### Pattern D4: Environment Dependency Check + +**Time cost**: 10 minutes + +**Symptom**: Many views updating when unrelated data changes + +**When to use**: +- Cause & Effect Graph shows "Environment" node triggering many updates +- Slow scrolling or animation performance +- Unexpected cascading updates + +**Root cause**: Frequently-changing value in environment OR too many views reading environment + +**Investigation steps**: + +#### 1. Find environment writes (3 min) + +```bash +# Search for environment modifiers in current project +grep -r "\.environment(" --include="*.swift" . +``` + +**Look for**: +```swift +// ❌ BAD: Frequently changing values +.environment(\.scrollOffset, scrollOffset) // Updates 60+ times/second +.environment(model) // If model updates frequently + +// ✅ GOOD: Stable values +.environment(\.colorScheme, .dark) +.environment(appModel) // If appModel changes rarely +``` + +#### 2. Check what's in environment (3 min) + +Using Pattern D2 (Instruments), check Cause & Effect Graph: +- Click on "Environment" node +- See which properties changed +- Count how many views checked for updates + +**Questions**: +- Is this value changing every scroll/animation frame? +- Do all these views actually need this value? + +#### 3. Apply fix (4 min) + +**Fix A: Remove from environment** (if frequently changing): +```swift +// ❌ Before: Environment +.environment(\.scrollOffset, scrollOffset) + +// ✅ After: Direct parameter +ChildView(scrollOffset: scrollOffset) +``` + +**Fix B: Use @Observable model** (if needed by many views): +```swift +// Instead of storing primitive in environment: +@Observable class ScrollViewModel { + var offset: CGFloat = 0 +} + +// Views depend on specific properties: +@Environment(ScrollViewModel.self) private var viewModel + +var body: some View { + Text("\(viewModel.offset)") // Only updates when offset changes +} +``` + +**Verification**: +- Record new trace in Instruments +- Check Cause & Effect Graph — fewer views should update +- Performance should improve (smoother scrolling/animations) + +--- + +### Pattern D5: Preview Diagnostics (Xcode 26) + +**Time cost**: 10 minutes + +**Symptom**: Preview won't load or crashes with unclear error + +**When to use**: +- Preview fails after basic fixes (swiftui-debugging skill) +- Error message unclear or generic +- Preview worked before, stopped suddenly + +**Investigation steps**: + +#### 1. Use Preview Diagnostics Button (2 min) + +**Location**: Editor menu → Canvas → Diagnostics + +**What it shows**: +- Detailed error messages +- Missing dependencies +- State initialization issues +- Preview-specific problems + +#### 2. Check crash logs (3 min) + +```bash +# Open crash logs directory +open ~/Library/Logs/DiagnosticReports/ + +# Look for recent .crash files containing "Preview" +ls -lt ~/Library/Logs/DiagnosticReports/ | grep -i preview | head -5 +``` + +**What to look for**: +- Fatal errors (array out of bounds, force unwrap nil) +- Missing module imports +- Framework initialization failures + +#### 3. Isolate the problem (5 min) + +**Create minimal preview**: +```swift +// Start with empty preview +#Preview { + Text("Test") +} + +// If this works, gradually add: +#Preview { + MyView() // Your actual view, but with mock data + .environment(MockModel()) // Provide all dependencies +} + +// Find which dependency causes crash +``` + +**Common issues**: + +| Error | Cause | Fix | +|-------|-------|-----| +| "Cannot find in scope" | Missing dependency | Add to preview (see example below) | +| "Fatal error: Unexpectedly found nil" | Optional unwrap failed | Provide non-nil value in preview | +| "No such module" | Import missing | Add import statement | +| Silent crash (no error) | State init with invalid value | Use safe defaults | + +**Fix patterns**: + +```swift +// Missing @Environment +#Preview { + ContentView() + .environment(AppModel()) // Provide dependency +} + +// Missing @EnvironmentObject (pre-iOS 17) +#Preview { + ContentView() + .environmentObject(AppModel()) +} + +// Missing ModelContainer (SwiftData) +#Preview { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try! ModelContainer(for: Item.self, configurations: config) + + return ContentView() + .modelContainer(container) +} + +// State with invalid defaults +@State var selectedIndex = 10 // ❌ Out of bounds +let items = ["a", "b", "c"] + +// Fix: Safe default +@State var selectedIndex = 0 // ✅ Valid index +``` + +**Verification**: +- Preview loads without errors +- Can interact with preview normally +- Changes reflect immediately + +--- + +## Production Crisis Scenario + +### The Situation + +**Context**: +- iOS 26 build shipped 2 days ago +- Users report "settings screen freezes when toggling features" +- 15% of users affected (reported via App Store reviews) +- VP asking for updates every 2 hours +- 8 hours until next deployment window closes +- Junior engineer suggests: "Let me try switching to @ObservedObject" + +### Red Flags — Resist These + +If you hear ANY of these under deadline pressure, **STOP and use diagnostic patterns**: + +❌ **"Let me try different property wrappers and see what works"** +- Random changes = guessing +- 80% chance of making it worse + +❌ **"It works on my device, must be iOS 26 bug"** +- User reports are real +- 15% = systematic issue, not edge case + +❌ **"We can roll back if the fix doesn't work"** +- App Store review takes 24 hours +- Rollback isn't instant + +❌ **"Add .id(UUID()) to force refresh"** +- Destroys state preservation +- Hides root cause + +❌ **"Users will accept degraded performance for now"** +- Once shipped, you're committed for 24 hours +- Bad reviews persist + +### Mandatory Protocol (No Shortcuts) + +**Total time budget**: 90 minutes + +#### Phase 1: Reproduce (15 min) + +```bash +# 1. Get exact steps from user report +# 2. Build Release mode +xcodebuild build -scheme YourApp -configuration Release + +# 3. Test on device (not simulator) +# 4. Reproduce freeze 3+ times +``` + +**If can't reproduce**: Ask for video recording or device logs from affected users + +#### Phase 2: Diagnose with Pattern D2 (30 min) + +```bash +# Launch Instruments with SwiftUI template +# Command-I in Xcode + +# Record while reproducing freeze +# Look for: +# - Long View Body Updates (red bars) +# - Cause & Effect Graph showing update cascade +``` + +**Find**: +- Which view is expensive? +- What data change triggered it? +- How many views updated? + +#### Phase 3: Apply Targeted Fix (20 min) + +Based on diagnostic findings: + +**If Long View Body Update**: +```swift +// Example finding: Formatter creation in body +// Fix: Move to cached formatter +``` + +**If Cascade Update**: +```swift +// Example finding: All toggle views reading entire settings array +// Fix: Per-toggle view models with granular dependencies +``` + +**If Environment Issue**: +```swift +// Example finding: Environment value updating every frame +// Fix: Remove from environment, use direct parameter +``` + +#### Phase 4: Verify (15 min) + +```bash +# Record new Instruments trace +# Compare before/after: +# - Long updates eliminated? +# - Update count reduced? +# - Freeze gone? + +# Test on device 10+ times +``` + +#### Phase 5: Deploy with Evidence (10 min) + +``` +Slack to VP + team: + +"Diagnostic complete: Settings screen freeze caused by formatter creation +in ToggleRow body (confirmed via SwiftUI Instrument, Long View Body Updates). + +Each toggle tap recreated NumberFormatter + DateFormatter for all visible +toggles (20+ formatters per tap). + +Fix: Cached formatters in SettingsViewModel, pre-formatted strings. +Verified: Settings screen now responds in <16ms (was 200ms+). + +Deploying build 2.1.1 now. Will monitor for next 24 hours." +``` + +**This shows**: +- You diagnosed with evidence (not guessed) +- You understand the root cause +- You verified the fix +- You're shipping with confidence + +### Time Cost Comparison + +#### Option A: Guess and Pray +- Time to try random fixes: 30 min +- Time to deploy: 20 min +- Time to learn it failed: 24 hours (next App Store review) +- Total delay: 24+ hours +- User suffering: Continues through deployment window +- Risk: Made it worse, now TWO bugs + +#### Option B: Diagnostic Protocol (This Skill) +- Time to diagnose: 45 min +- Time to apply targeted fix: 20 min +- Time to verify: 15 min +- Time to deploy: 10 min +- Total time: 90 minutes +- User suffering: Stopped after 2 hours +- Confidence: High (evidence-based fix) + +**Savings**: 22 hours + avoid making it worse + +### When Pressure is Legitimate + +Sometimes managers are right to push for speed. Accept the pressure IF: + +✅ You've completed diagnostic protocol (90 minutes) +✅ You know exact view/operation causing issue +✅ You have targeted fix, not a guess +✅ You've verified in Instruments before shipping +✅ You're shipping WITH evidence, not hoping + +**Document your decision** (same as above Slack template) + +### Professional Script for Pushback + +If pressured to skip diagnostics: + +> "I understand the urgency. Skipping diagnostics means 80% chance of shipping the wrong fix, committing us to 24 more hours of user suffering. The diagnostic protocol takes 90 minutes total and gives us evidence-based confidence. We'll have the fix deployed in under 2 hours, verified, with no risk of making it worse. The math says diagnostics is the fastest path to resolution." + +--- + +## Quick Reference Table + +| Symptom | Likely Cause | First Check | Pattern | Fix Time | +|---------|--------------|-------------|---------|----------| +| View doesn't update | Missing observer / Wrong state | Self._printChanges() | D1 | 10 min | +| View updates too often | Broad dependencies | Self._printChanges() → Instruments | D1 → D2 | 30 min | +| State resets | Identity change | .id() modifiers, conditionals | D3 | 15 min | +| Cascade updates | Environment issue | Environment modifiers | D4 | 20 min | +| Preview crashes | Missing deps / Bad init | Diagnostics button | D5 | 10 min | +| Intermittent issues | Identity or timing | Reproduce 30+ times | D3 | 30 min | +| Long updates (performance) | Expensive body operation | Instruments (SwiftUI + Time Profiler) | D2 | 30 min | + +--- + +## Decision Framework + +Before shipping ANY fix: + +| Question | Answer Yes? | Action | +|----------|-------------|--------| +| Have you used Self._printChanges()? | No | STOP - Pattern D1 (5 min) | +| Have you run SwiftUI Instrument? | No | STOP - Pattern D2 (25 min) | +| Can you explain in one sentence what caused the issue? | No | STOP - you're guessing | +| Have you verified the fix in Instruments? | No | STOP - test before shipping | +| Did you check for simpler explanations? | No | STOP - review diagnostic patterns | + +**Answer YES to all five** → Ship with confidence + +--- + +## Common Mistakes + +### Mistake 1: "I added @Observable and it fixed it" + +**Why it's wrong**: You don't know WHY it fixed it +- Might work now, break later +- Might have hidden another bug + +**Right approach**: +- Use Pattern D1 (Self._printChanges()) to see BEFORE state +- Apply @Observable +- Use Pattern D1 again to see AFTER state +- Understand exactly what changed + +### Mistake 2: "Instruments is too slow for quick fixes" + +**Why it's wrong**: Guessing is slower when you're wrong +- 25 min diagnostic = certain fix +- 5 min guess × 3 failed attempts = 15 min + still broken + +**Right approach**: +- Always profile for production issues +- Use Self._printChanges() for simple cases + +### Mistake 3: "The fix works, I don't need to verify" + +**Why it's wrong**: Manual testing ≠ verification +- Might work for your specific test +- Might fail for edge cases +- Might have introduced performance regression + +**Right approach**: +- Always verify in Instruments after fix +- Compare before/after traces +- Test edge cases (empty data, large data, etc.) + +--- + +## Quick Command Reference + +### Instruments Commands + +```bash +# Launch Instruments with SwiftUI template +# 1. In Xcode: Command-I +# 2. Or from command line: +open -a Instruments + +# Build in Release mode (required for accurate profiling) +xcodebuild build -scheme YourScheme -configuration Release + +# Clean derived data if needed +rm -rf ~/Library/Developer/Xcode/DerivedData +``` + +### Self._printChanges() Debug Pattern + +```swift +// Add temporarily to view body +var body: some View { + let _ = Self._printChanges() // Shows update reason + + // Your view code +} +``` + +**Remember**: Remove before committing! + +### Preview Diagnostics + +```bash +# Check preview crash logs +open ~/Library/Logs/DiagnosticReports/ + +# Filter for recent preview crashes +ls -lt ~/Library/Logs/DiagnosticReports/ | grep -i preview | head -5 + +# Xcode menu path: +# Editor → Canvas → Diagnostics +``` + +### Environment Search + +```bash +# Find environment modifiers +grep -r "\.environment(" --include="*.swift" . + +# Find environment object usage +grep -r "@Environment" --include="*.swift" . + +# Find view identity modifiers +grep -r "\.id(" --include="*.swift" . +``` + +### Instruments Navigation + +**In Instruments (after recording)**: +1. Select **SwiftUI** track +2. Expand to see: + - Update Groups lane + - Long View Body Updates lane + - Long Representable Updates lane +3. Click **Long View Body Updates** summary +4. Right-click update → "Set Inspection Range and Zoom" +5. Switch to **Time Profiler** track +6. Find your view in call stack (Command-F) + +**Cause & Effect Graph**: +1. Expand hierarchy in detail pane +2. Hover over view name → Click arrow +3. Choose "Show Cause & Effect Graph" +4. Click nodes to see: + - State change node → Backtrace + - View body node → Dependencies + +--- + +## Resources + +**WWDC**: 2025-306, 2023-10160, 2023-10149, 2021-10022 + +**Docs**: /xcode/understanding-hitches-in-your-app, /xcode/analyzing-hangs-in-your-app, /swiftui/managing-model-data-in-your-app + +**Skills**: axiom-swiftui-debugging, axiom-swiftui-performance, axiom-swiftui-layout, axiom-xcode-debugging diff --git a/.claude/skills/axiom-swiftui-debugging-diag/agents/openai.yaml b/.claude/skills/axiom-swiftui-debugging-diag/agents/openai.yaml new file mode 100644 index 0000000..7a827c3 --- /dev/null +++ b/.claude/skills/axiom-swiftui-debugging-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftUI Debugging Diagnostics" + short_description: "SwiftUI view debugging requires systematic investigation" diff --git a/.claude/skills/axiom-swiftui-debugging/.openskills.json b/.claude/skills/axiom-swiftui-debugging/.openskills.json new file mode 100644 index 0000000..87584eb --- /dev/null +++ b/.claude/skills/axiom-swiftui-debugging/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftui-debugging", + "installedAt": "2026-04-12T08:06:47.828Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftui-debugging/SKILL.md b/.claude/skills/axiom-swiftui-debugging/SKILL.md new file mode 100644 index 0000000..1a443c3 --- /dev/null +++ b/.claude/skills/axiom-swiftui-debugging/SKILL.md @@ -0,0 +1,1300 @@ +--- +name: axiom-swiftui-debugging +description: Use when debugging SwiftUI view updates, preview crashes, or layout issues - diagnostic decision trees to identify root causes quickly and avoid misdiagnosis under pressure +license: MIT +metadata: + version: "1.3.0" + last-updated: "Added Self._printChanges() debugging, @Observable patterns (iOS 17+), @Bindable, view identity section, and cross-references to swiftui-performance" +--- + +# SwiftUI Debugging + +## Overview + +SwiftUI debugging falls into three categories, each with a different diagnostic approach: + +1. **View Not Updating** – You changed something but the view didn't redraw. Decision tree to identify whether it's struct mutation, lost binding identity, accidental view recreation, or missing observer pattern. +2. **Preview Crashes** – Your preview won't compile or crashes immediately. Decision tree to distinguish between missing dependencies, state initialization failures, and Xcode cache corruption. +3. **Layout Issues** – Views appearing in wrong positions, wrong sizes, overlapping unexpectedly. Quick reference patterns for common scenarios. + +**Core principle**: Start with observable symptoms, test systematically, eliminate causes one by one. Don't guess. + +**Requires**: Xcode 26+, iOS 17+ (iOS 14-16 patterns still valid, see notes) +**Related skills**: `axiom-xcode-debugging` (cache corruption diagnosis), `axiom-swift-concurrency` (observer patterns), `axiom-swiftui-performance` (profiling with Instruments), `axiom-swiftui-layout` (adaptive layout patterns) + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "My list item doesn't update when I tap the favorite button, even though the data changed" +→ The skill walks through the decision tree to identify struct mutation vs lost binding vs missing observer + +#### 2. "Preview crashes with 'Cannot find AppModel in scope' but it compiles fine" +→ The skill shows how to provide missing dependencies with `.environment()` or `.environmentObject()` + +#### 3. "My counter resets to 0 every time I toggle a boolean, why?" +→ The skill identifies accidental view recreation from conditionals and shows `.opacity()` fix + +#### 4. "I'm using @Observable but the view still doesn't update when I change the property" +→ The skill explains when to use @State vs plain properties with @Observable objects + +#### 5. "Text field loses focus when I start typing, very frustrating" +→ The skill identifies ForEach identity issues and shows how to use stable IDs + +## When to Use SwiftUI Debugging + +#### Use this skill when +- ✅ A view isn't updating when you expect it to +- ✅ Preview crashes or won't load +- ✅ Layout looks wrong on specific devices +- ✅ You're tempted to bandaid with @ObservedObject everywhere + +#### Use `axiom-xcode-debugging` instead when +- App crashes at runtime (not preview) +- Build fails completely +- You need environment diagnostics + +#### Use `axiom-swift-concurrency` instead when +- Questions about async/await or MainActor +- Data race warnings + +## Debugging Tools + +### Self._printChanges() + +SwiftUI provides a debug-only method to understand why a view's body was called. + +**Usage in LLDB**: +```swift +// Set breakpoint in view's body +// In LLDB console: +(lldb) expression Self._printChanges() +``` + +**Temporary in code** (remove before shipping): +```swift +var body: some View { + let _ = Self._printChanges() // Debug only + + Text("Hello") +} +``` + +**Output interpretation**: +``` +MyView: @self changed + - Means the view value itself changed (parameters passed to view) + +MyView: count changed + - Means @State property "count" triggered the update + +MyView: (no output) + - Body not being called; view not updating at all +``` + +**⚠️ Important**: +- Prefixed with underscore → May be removed in future releases +- **NEVER submit to App Store** with _printChanges calls +- Performance impact → Use only during debugging + +**When to use**: +- Need to understand exact trigger for view update +- Investigating unexpected updates +- Verifying dependencies after refactoring + +**Cross-reference**: For complex update patterns, use SwiftUI Instrument → see `axiom-swiftui-performance` skill + +--- + +## View Not Updating Decision Tree + +The most common frustration: you changed @State but the view didn't redraw. The root cause is always one of four things. + +### Step 1: Can You Reproduce in a Minimal Preview? + +```swift +#Preview { + YourView() +} +``` + +**YES** → The problem is in your code. Continue to Step 2. + +**NO** → It's likely Xcode state or cache corruption. Skip to Preview Crashes section. + +### Step 2: Diagnose the Root Cause + +#### Root Cause 1: Struct Mutation + +**Symptom**: You modify a @State value directly, but the view doesn't update. + +**Why it happens**: SwiftUI doesn't see direct mutations on structs. You need to reassign the entire value. + +```swift +// ❌ WRONG: Direct mutation doesn't trigger update +@State var items: [String] = [] + +func addItem(_ item: String) { + items.append(item) // SwiftUI doesn't see this change +} + +// ✅ RIGHT: Reassignment triggers update +@State var items: [String] = [] + +func addItem(_ item: String) { + var newItems = items + newItems.append(item) + self.items = newItems // Full reassignment +} + +// ✅ ALSO RIGHT: Use a binding +@State var items: [String] = [] + +var itemsBinding: Binding<[String]> { + Binding( + get: { items }, + set: { items = $0 } + ) +} +``` + +**Fix it**: Always reassign the entire struct value, not pieces of it. + +--- + +#### Root Cause 2: Lost Binding Identity + +**Symptom**: You pass a binding to a child view, but changes in the child don't update the parent. + +**Why it happens**: You're passing `.constant()` or creating a new binding each time, breaking the two-way connection. + +```swift +// ❌ WRONG: Constant binding is read-only +@State var isOn = false + +ToggleChild(value: .constant(isOn)) // Changes ignored + +// ❌ WRONG: New binding created each render +@State var name = "" + +TextField("Name", text: Binding( + get: { name }, + set: { name = $0 } +)) // New binding object each time parent renders + +// ✅ RIGHT: Pass the actual binding +@State var isOn = false + +ToggleChild(value: $isOn) + +// ✅ RIGHT (iOS 17+): Use @Bindable for @Observable objects +@Observable class Book { + var title = "Sample" + var isAvailable = true +} + +struct EditView: View { + @Bindable var book: Book // Enables $book.title syntax + + var body: some View { + TextField("Title", text: $book.title) + Toggle("Available", isOn: $book.isAvailable) + } +} + +// ✅ ALSO RIGHT (iOS 17+): @Bindable as local variable +struct ListView: View { + @State private var books = [Book(), Book()] + + var body: some View { + List(books) { book in + @Bindable var book = book // Inline binding + TextField("Title", text: $book.title) + } + } +} + +// ✅ RIGHT (pre-iOS 17): Create binding once, not in body +@State var name = "" +@State var nameBinding: Binding? + +var body: some View { + if nameBinding == nil { + nameBinding = Binding( + get: { name }, + set: { name = $0 } + ) + } + return TextField("Name", text: nameBinding!) +} +``` + +**Fix it**: Pass `$state` directly when possible. For @Observable objects (iOS 17+), use `@Bindable`. If creating custom bindings (pre-iOS 17), create them in `init` or cache them, not in `body`. + +--- + +#### Root Cause 3: Accidental View Recreation + +**Symptom**: The view updates, but @State values reset to initial state. You see brief flashes of initial values. + +**Why it happens**: The view got a new identity (removed from a conditional, moved in a container, or the container itself was recreated), causing SwiftUI to treat it as a new view. + +```swift +// ❌ WRONG: View identity changes when condition flips +@State var count = 0 + +var body: some View { + VStack { + if showCounter { + Counter() // Gets new identity each time showCounter changes + } + Button("Toggle") { + showCounter.toggle() + } + } +} + +// Counter gets recreated, @State count resets to 0 + +// ✅ RIGHT: Preserve identity with opacity or hidden +@State var count = 0 + +var body: some View { + VStack { + Counter() + .opacity(showCounter ? 1 : 0) + Button("Toggle") { + showCounter.toggle() + } + } +} + +// ✅ ALSO RIGHT: Use id() if you must conditionally show +@State var count = 0 + +var body: some View { + VStack { + if showCounter { + Counter() + .id("counter") // Stable identity + } + Button("Toggle") { + showCounter.toggle() + } + } +} +``` + +**Fix it**: Preserve view identity by using `.opacity()` instead of conditionals, or apply `.id()` with a stable identifier. + +--- + +#### Root Cause 4: Missing Observer Pattern + +**Symptom**: An object changed, but views observing it didn't update. + +**Why it happens**: SwiftUI doesn't know to watch for changes in the object. + +```swift +// ❌ WRONG: Property changes don't trigger update +class Model { + var count = 0 // Not observable +} + +struct ContentView: View { + let model = Model() // New instance each render, not observable + + var body: some View { + Text("\(model.count)") + Button("Increment") { + model.count += 1 // View doesn't update + } + } +} + +// ✅ RIGHT (iOS 17+): Use @Observable with @State +@Observable class Model { + var count = 0 // No @Published needed +} + +struct ContentView: View { + @State private var model = Model() // @State, not @StateObject + + var body: some View { + Text("\(model.count)") + Button("Increment") { + model.count += 1 // View updates + } + } +} + +// ✅ RIGHT (iOS 17+): Injected @Observable objects +struct ContentView: View { + var model: Model // Just a plain property + + var body: some View { + Text("\(model.count)") // View updates when count changes + } +} + +// ✅ RIGHT (iOS 17+): @Observable with environment +@Observable class AppModel { + var count = 0 +} + +@main +struct MyApp: App { + @State private var model = AppModel() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(model) // Add to environment + } + } +} + +struct ContentView: View { + @Environment(AppModel.self) private var model // Read from environment + + var body: some View { + Text("\(model.count)") + } +} + +// ✅ RIGHT (pre-iOS 17): Use @StateObject/ObservableObject +class Model: ObservableObject { + @Published var count = 0 +} + +struct ContentView: View { + @StateObject var model = Model() // For owned instances + + var body: some View { + Text("\(model.count)") + Button("Increment") { + model.count += 1 // View updates + } + } +} + +// ✅ RIGHT (pre-iOS 17): Use @ObservedObject for injected instances +struct ContentView: View { + @ObservedObject var model: Model // Passed in from parent + + var body: some View { + Text("\(model.count)") + } +} +``` + +**Fix it (iOS 17+)**: Use `@Observable` macro on your class, then `@State` to store it. Views automatically track dependencies on properties they read. + +**Fix it (pre-iOS 17)**: Use `@StateObject` if you own the object, `@ObservedObject` if it's injected, or `@EnvironmentObject` if it's shared across the tree. + +**Why @Observable is better** (iOS 17+): +- Automatic dependency tracking (only reads trigger updates) +- No `@Published` wrapper needed +- Works with `@State` instead of `@StateObject` +- Can pass as plain property instead of `@ObservedObject` + +**See also**: [Managing model data in your app](https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app) + +--- + +### Decision Tree Summary + +```dot +digraph view_not_updating { + start [label="View not updating?" shape=diamond]; + reproduce [label="Can reproduce in preview?" shape=diamond]; + cause [label="What changed?" shape=diamond]; + + start -> reproduce; + reproduce -> cause [label="yes: bug in code"]; + reproduce -> "Cache/Xcode state → Preview Crashes" [label="no"]; + + cause -> "Struct Mutation" [label="modified struct directly"]; + cause -> "Lost Binding Identity" [label="passed binding to child"]; + cause -> "Accidental Recreation" [label="view inside conditional"]; + cause -> "Missing Observer" [label="object changed, view didn't"]; +} +``` + +## Preview Crashes Decision Tree + +When your preview won't load or crashes immediately, the three root causes are distinct. + +### Step 1: What's the Error? + +#### Error Type 1: "Cannot find in scope" or "No such module" + +**Root cause**: Preview missing a required dependency (@EnvironmentObject, @Environment, imported module). + +```swift +// ❌ WRONG: ContentView needs a model, preview doesn't provide it +struct ContentView: View { + @EnvironmentObject var model: AppModel + + var body: some View { + Text(model.title) + } +} + +#Preview { + ContentView() // Crashes: model not found +} + +// ✅ RIGHT: Provide the dependency +#Preview { + ContentView() + .environmentObject(AppModel()) +} + +// ✅ ALSO RIGHT: Check for missing imports +// If using custom types, make sure they're imported in preview file + +#Preview { + MyCustomView() // Make sure MyCustomView is defined or imported +} +``` + +**Fix it**: Trace the error, find what's missing, provide it to the preview. + +--- + +#### Error Type 2: Fatal error or Silent crash (no error message) + +**Root cause**: State initialization failed at runtime. The view tried to access data that doesn't exist. + +```swift +// ❌ WRONG: Index out of bounds at runtime +struct ListView: View { + @State var selectedIndex = 10 + let items = ["a", "b", "c"] + + var body: some View { + Text(items[selectedIndex]) // Crashes: index 10 doesn't exist + } +} + +// ❌ WRONG: Optional forced unwrap fails +struct DetailView: View { + @State var data: Data? + + var body: some View { + Text(data!.title) // Crashes if data is nil + } +} + +// ✅ RIGHT: Safe defaults +struct ListView: View { + @State var selectedIndex = 0 // Valid index + let items = ["a", "b", "c"] + + var body: some View { + if selectedIndex < items.count { + Text(items[selectedIndex]) + } + } +} + +// ✅ RIGHT: Handle optionals +struct DetailView: View { + @State var data: Data? + + var body: some View { + if let data = data { + Text(data.title) + } else { + Text("No data") + } + } +} +``` + +**Fix it**: Review your @State initializers. Check array bounds, optional unwraps, and default values. + +--- + +#### Error Type 3: Works fine locally but preview won't load + +**Root cause**: Xcode cache corruption. The preview process has stale information about your code. + +**Diagnostic checklist**: +- Preview worked yesterday, code hasn't changed → Likely cache +- Restarting Xcode fixes it temporarily but returns → Definitely cache +- Same code builds in simulator fine but preview fails → Cache +- Multiple unrelated previews fail at once → Cache + +**Fix it** (in order): +1. Restart Preview Canvas: `Cmd+Option+P` +2. Restart Xcode completely (File → Close Window, then reopen project) +3. Nuke derived data: `rm -rf ~/Library/Developer/Xcode/DerivedData` +4. Rebuild: `Cmd+B` + +If still broken after all four steps: It's not cache, see Error Types 1 or 2. + +--- + +### Decision Tree Summary + +```dot +digraph preview_crashes { + start [label="Preview crashes?" shape=diamond]; + error [label="Error message visible?" shape=diamond]; + + start -> error; + error -> "Missing Dependency" [label="'Cannot find in scope'"]; + error -> "State Init Failure" [label="'Fatal error' or silent crash"]; + error -> "Cache Corruption" [label="no error"]; + "Cache Corruption" -> "Restart Preview → Restart Xcode → Nuke DerivedData"; +} +``` + +## Layout Issues Quick Reference + +Layout problems are usually visually obvious. Match your symptom to the pattern. + +### Pattern 1: Views Overlapping in ZStack + +**Symptom**: Views stacked on top of each other, some invisible. + +**Root cause**: Z-order is wrong or you're not controlling visibility. + +```swift +// ❌ WRONG: Can't see the blue view +ZStack { + Rectangle().fill(.blue) + Rectangle().fill(.red) +} + +// ✅ RIGHT: Use zIndex to control layer order +ZStack { + Rectangle().fill(.blue).zIndex(0) + Rectangle().fill(.red).zIndex(1) +} + +// ✅ ALSO RIGHT: Hide instead of removing from hierarchy +ZStack { + Rectangle().fill(.blue) + Rectangle().fill(.red).opacity(0.5) +} +``` + +--- + +### Pattern 2: GeometryReader Sizing Weirdness + +**Symptom**: View is tiny or taking up the entire screen unexpectedly. + +**Root cause**: GeometryReader sizes itself to available space; parent doesn't constrain it. + +```swift +// ❌ WRONG: GeometryReader expands to fill all available space +VStack { + GeometryReader { geo in + Text("Size: \(geo.size)") + } + Button("Next") { } +} +// Text takes entire remaining space + +// ✅ RIGHT: Constrain the geometry reader +VStack { + GeometryReader { geo in + Text("Size: \(geo.size)") + } + .frame(height: 100) + + Button("Next") { } +} +``` + +--- + +### Pattern 3: SafeArea Complications + +**Symptom**: Content hidden behind notch, or not using full screen space. + +**Root cause**: `.ignoresSafeArea()` applied to wrong view. + +```swift +// ❌ WRONG: Only the background ignores safe area +ZStack { + Color.blue.ignoresSafeArea() + VStack { + Text("Still respects safe area") + } +} + +// ✅ RIGHT: Container ignores, children position themselves +ZStack { + Color.blue + VStack { + Text("Can now use full space") + } +} +.ignoresSafeArea() + +// ✅ ALSO RIGHT: Be selective about which edges +ZStack { + Color.blue + VStack { ... } +} +.ignoresSafeArea(edges: .horizontal) // Only horizontal +``` + +--- + +### Pattern 4: frame() vs fixedSize() Confusion + +**Symptom**: Text truncated, buttons larger than text, sizing behavior unpredictable. + +**Root cause**: Mixing `frame()` (constrains) with `fixedSize()` (expands to content). + +```swift +// ❌ WRONG: fixedSize() overrides frame() +Text("Long text here") + .frame(width: 100) + .fixedSize() // Overrides the frame constraint + +// ✅ RIGHT: Use frame() to constrain +Text("Long text here") + .frame(width: 100, alignment: .leading) + .lineLimit(1) + +// ✅ RIGHT: Use fixedSize() only for natural sizing +VStack(spacing: 0) { + Text("Small") + .fixedSize() // Sizes to text + Text("Large") + .fixedSize() +} +``` + +--- + +### Pattern 5: Modifier Order Matters + +**Symptom**: Padding, corners, or shadows appearing in wrong place. + +**Root cause**: Applying modifiers in wrong order. SwiftUI applies bottom-to-top. + +```swift +// ❌ WRONG: Corners applied after padding +Text("Hello") + .padding() + .cornerRadius(8) // Corners are too large + +// ✅ RIGHT: Corners first, then padding +Text("Hello") + .cornerRadius(8) + .padding() + +// ❌ WRONG: Shadow after frame +Text("Hello") + .frame(width: 100) + .shadow(radius: 4) // Shadow only on frame bounds + +// ✅ RIGHT: Shadow includes all content +Text("Hello") + .shadow(radius: 4) + .frame(width: 100) +``` + +## View Identity + +### Understanding View Identity + +SwiftUI uses view identity to track views over time, preserve state, and animate transitions. Understanding identity is critical for debugging state preservation and animation issues. + +### Two Types of Identity + +#### 1. Structural Identity (Implicit) +Position in view hierarchy determines identity: + +```swift +VStack { + Text("First") // Identity: VStack.child[0] + Text("Second") // Identity: VStack.child[1] +} +``` + +**When structural identity changes**: +```swift +if showDetails { + DetailView() // Identity changes when condition changes + SummaryView() +} else { + SummaryView() // Same type, different position = different identity +} +``` + +**Problem**: `SummaryView` gets recreated each time, losing @State values. + +#### 2. Explicit Identity +You control identity with `.id()` modifier: + +```swift +DetailView() + .id(item.id) // Explicit identity tied to item + +// When item.id changes → SwiftUI treats as different view +// → @State resets +// → Animates transition +``` + +### Common Identity Issues + +#### Issue 1: State Resets Unexpectedly +**Symptom**: @State values reset to initial values when you don't expect. + +**Cause**: View identity changed (position in hierarchy or .id() value changed). + +```swift +// ❌ PROBLEM: Identity changes when showDetails toggles +@State private var count = 0 + +var body: some View { + VStack { + if showDetails { + CounterView(count: $count) // Position changes + } + Button("Toggle") { + showDetails.toggle() + } + } +} + +// ✅ FIX: Stable identity with .opacity() +var body: some View { + VStack { + CounterView(count: $count) + .opacity(showDetails ? 1 : 0) // Same identity always + Button("Toggle") { + showDetails.toggle() + } + } +} + +// ✅ ALSO FIX: Explicit stable ID +var body: some View { + VStack { + if showDetails { + CounterView(count: $count) + .id("counter") // Stable ID + } + Button("Toggle") { + showDetails.toggle() + } + } +} +``` + +#### Issue 2: Animations Don't Work +**Symptom**: View changes but doesn't animate. + +**Cause**: Identity changed, SwiftUI treats as remove + add instead of update. + +```swift +// ❌ PROBLEM: Identity changes with selection +ForEach(items) { item in + ItemView(item: item) + .id(item.id + "-\(selectedID)") // ID changes when selection changes +} + +// ✅ FIX: Stable identity +ForEach(items) { item in + ItemView(item: item, isSelected: item.id == selectedID) + .id(item.id) // Stable ID +} +``` + +#### Issue 3: ForEach with Changing Data +**Symptom**: List items jump around or animate incorrectly. + +**Cause**: Non-unique or changing identifiers. + +```swift +// ❌ WRONG: Index-based ID changes when array changes +ForEach(Array(items.enumerated()), id: \.offset) { index, item in + Text(item.name) +} + +// ❌ WRONG: Non-unique IDs +ForEach(items, id: \.category) { item in // Multiple items per category + Text(item.name) +} + +// ✅ RIGHT: Stable, unique IDs +ForEach(items, id: \.id) { item in + Text(item.name) +} + +// ✅ RIGHT: Make type Identifiable +struct Item: Identifiable { + let id = UUID() + var name: String +} + +ForEach(items) { item in // id: \.id implicit + Text(item.name) +} +``` + +### When to Use .id() + +**Use .id() to**: +- Force view recreation when data changes fundamentally +- Animate transitions between distinct states +- Reset @State when external dependency changes + +**Example: Force recreation on data change**: +```swift +DetailView(item: item) + .id(item.id) // New item → new view → @State resets +``` + +**Don't use .id() when**: +- You just need to update view content (use bindings instead) +- Trying to fix update issues (investigate root cause instead) +- Identity is already stable + +### Debugging Identity Issues + +#### 1. Self._printChanges() +```swift +var body: some View { + let _ = Self._printChanges() + // Check if "@self changed" appears when you don't expect +} +``` + +#### 2. Check .id() modifiers +Search codebase for `.id()` - are IDs changing unexpectedly? + +#### 3. Check conditionals +Views in `if/else` change position → different identity. + +**Fix**: Use `.opacity()` or stable `.id()` instead. + +### Identity Quick Reference + +| Symptom | Likely Cause | Fix | +|---------|--------------|-----| +| State resets | Identity change | Use `.opacity()` instead of `if` | +| No animation | Identity change | Remove `.id()` or use stable ID | +| ForEach jumps | Non-unique ID | Use unique, stable IDs | +| Unexpected recreation | Conditional position | Add explicit `.id()` | + +**See also**: [WWDC21: Demystify SwiftUI](https://developer.apple.com/videos/play/wwdc2021/10022/) + +--- + +## Pressure Scenarios and Real-World Constraints + +When you're under deadline pressure, you'll be tempted to shortcuts that hide problems instead of fixing them. + +### Scenario 1: "Preview keeps crashing, we ship tomorrow" + +#### Red flags you might hear +- "Just rebuild everything" +- "Delete derived data and don't worry about it" +- "Ship without validating in preview" +- "It works on my machine, good enough" + +**The danger**: You skip diagnosis, cache issue recurs after 2 weeks in production, you're debugging while users hit crashes. + +**What to do instead** (5-minute protocol, total): +1. Restart Preview Canvas: `Cmd+Option+P` (30 seconds) +2. Restart Xcode (2 minutes) +3. Nuke derived data: `rm -rf ~/Library/Developer/Xcode/DerivedData` (30 seconds) +4. Rebuild: `Cmd+B` (2 minutes) +5. Still broken? Use the dependency or initialization decision trees above + +**Time cost**: 5 minutes diagnosis + 2 minutes fix = **7 minutes total** + +**Cost of skipping**: 30 min shipping + 24 hours debug cycle = **24+ hours total** + +--- + +### Scenario 2: "View won't update, let me just wrap it in @ObservedObject" + +#### Red flags you might think +- "Adding @ObservedObject everywhere will fix it" +- "Use ObservableObject as a band-aid" +- "Add @Published to random properties" +- "It's probably a binding issue, I'll just create a custom binding" + +**The danger**: You're treating symptoms, not diagnosing. Same view won't update in other contexts. You've just hidden the bug. + +**What to do instead** (2-minute diagnosis): +1. Can you reproduce in a minimal preview? If NO → cache corruption (see Scenario 1) +2. If YES: Test each root cause in order: + - Does the view have @State that you're modifying directly? → Struct Mutation + - Did the view move into a conditional recently? → View Recreation + - Are you passing bindings to children that have changed? → Lost Binding Identity + - Only if none of above: Missing Observer +3. Fix the actual root cause, not with @ObservedObject band-aid + +**Decision principle**: If you can't name the specific root cause, you haven't diagnosed yet. Don't code until you can answer "the problem is struct mutation because...". + +--- + +### Scenario 2b: "Intermittent updates - it works sometimes, not always" + +#### Red flags you might think +- "It must be a threading issue, let me add @MainActor everywhere" +- "Let me try @ObservedObject, @State, and custom Binding until something works" +- "Delete DerivedData and hope cache corruption fixes it" +- "This is unfixable, let me ship without this feature" + +**The danger**: You're exhausted after 2 hours of guessing. You're 17 hours from App Store submission. You're panicking. Every minute feels urgent, so you stop diagnosing and start flailing. + +Intermittent bugs are the MOST important to diagnose correctly. One wrong guess now creates a new bug. You ship with a broken view AND a new bug. App Store rejects you. You miss launch. + +**What to do instead** (60-minute systematic diagnosis): + +**Step 1: Reproduce in preview** (15 min) +- Create minimal preview of just the broken view +- Tap/interact 20 times +- Does it fail intermittently, consistently, or never? + - **Fails in preview**: Real bug in your code, use decision tree above + - **Works in preview but fails in app**: Cache or environment issue, use Preview Crashes decision tree + - **Can't reproduce at all**: Intermittent race condition, investigate further + +**Step 2: Isolate the variable** (15 min) +- If it's intermittent in preview: Likely view recreation + - Did the view recently move into a conditional? Remove it and test + - Did you add `if` logic that might recreate the parent? Remove it and test +- If it works in preview but fails in app: Likely environment/cache issue + - Try on different device/simulator + - Try after clearing DerivedData + +**Step 3: Apply the specific fix** (30 min) +- Once you've identified view recreation: Use `.opacity()` instead of conditionals +- Once you've identified struct mutation: Use full reassignment +- Once you've verified it's cache: Nuke DerivedData properly + +**Step 4: Verify 100% reliability** (until submission) +- Run the same interaction 30+ times +- Test on multiple devices/simulators +- Get QA to verify +- Only ship when it's 100% reproducible (not the bug, the FIX) + +**Time cost**: 60 minutes diagnosis + 30 minutes fix + confidence = **submit at 9am** + +**Cost of guessing**: 2 hours already + 3 more hours guessing + new bug introduced + crash reports post-launch + emergency patch + reputation damage = **miss launch + post-launch chaos** + +**The decision principle**: Intermittent bugs require SYSTEMATIC diagnosis. The slower you go in diagnosis, the faster you get to the fix. Guessing is the fastest way to disaster. + +#### Professional script for co-leads who suggest guessing + +> "I appreciate the suggestion. Adding @ObservedObject everywhere is treating the symptom, not the root cause. The skill says intermittent bugs create NEW bugs when we guess. I need 60 minutes for systematic diagnosis. If I can't find the root cause by then, we'll disable the feature and ship a clean v1.1. The math shows we have time—I can complete diagnosis, fix, AND verification before the deadline." + +--- + +### Scenario 3: "Layout looks wrong on iPad, we're out of time" + +#### Red flags you might think +- "Add some padding and magic numbers" +- "It's probably a safe area thing, let me just ignore it" +- "Let's lock this to iPhone only" +- "GeometryReader will solve this" + +**The danger**: Magic numbers break on other sizes. SafeArea ignoring is often wrong. Locking to iPhone means you ship a broken iPad experience. + +**What to do instead** (3-minute diagnosis): +1. Run in simulator or device +2. Use Debug View Hierarchy: Debug menu → View Hierarchy (takes 30 seconds to load) +3. Check: Is the problem SafeArea, ZStack ordering, or GeometryReader sizing? +4. Use the correct pattern from the Quick Reference above + +**Time cost**: 3 minutes diagnosis + 5 minutes fix = **8 minutes total** + +**Cost of magic numbers**: Ship wrong, report 2 weeks later, debug 4 hours, patch in update = **2+ weeks delay** + +--- + +## Quick Reference + +### Common View Update Fixes + +```swift +// Fix 1: Reassign the full struct +@State var items: [String] = [] +var newItems = items +newItems.append("new") +self.items = newItems + +// Fix 2: Pass binding correctly +@State var value = "" +ChildView(text: $value) // Pass binding, not value + +// Fix 3: Preserve view identity +View().opacity(isVisible ? 1 : 0) // Not: if isVisible { View() } + +// Fix 4: Observe the object +@StateObject var model = MyModel() +@ObservedObject var model: MyModel +``` + +### Common Preview Fixes + +```swift +// Fix 1: Provide dependencies +#Preview { + ContentView() + .environmentObject(AppModel()) +} + +// Fix 2: Safe defaults +@State var index = 0 // Not 10, if array has 3 items + +// Fix 3: Nuke cache +// Terminal: rm -rf ~/Library/Developer/Xcode/DerivedData +``` + +### Common Layout Fixes + +```swift +// Fix 1: Z-order +Rectangle().zIndex(1) + +// Fix 2: Constrain GeometryReader +GeometryReader { geo in ... }.frame(height: 100) + +// Fix 3: SafeArea +ZStack { ... }.ignoresSafeArea() + +// Fix 4: Modifier order +Text().cornerRadius(8).padding() // Corners first +``` + +## Real-World Examples + +### Example 1: List Item Doesn't Update When Tapped + +**Scenario**: You have a list of tasks. When you tap a task to mark it complete, the checkmark should appear, but it doesn't. + +**Code**: +```swift +struct TaskListView: View { + @State var tasks: [Task] = [...] + + var body: some View { + List { + ForEach(tasks, id: \.id) { task in + HStack { + Image(systemName: task.isComplete ? "checkmark.circle.fill" : "circle") + Text(task.title) + Spacer() + Button("Done") { + // ❌ WRONG: Direct mutation + task.isComplete.toggle() + } + } + } + } + } +} +``` + +**Diagnosis using the skill**: +1. Can you reproduce in preview? YES +2. Are you modifying the struct directly? YES → **Struct Mutation** (Root Cause 1) + +**Fix**: +```swift +Button("Done") { + // ✅ RIGHT: Full reassignment + if let index = tasks.firstIndex(where: { $0.id == task.id }) { + tasks[index].isComplete.toggle() + } +} +``` + +**Why this works**: SwiftUI detects the array reassignment, triggering a redraw. The task in the List updates. + +--- + +### Example 2: Preview Crashes with "No Such Module" + +**Scenario**: You created a custom data model. It works fine in the app, but the preview crashes with "Cannot find 'CustomModel' in scope". + +**Code**: +```swift +import SwiftUI + +// ❌ WRONG: Preview missing the dependency +#Preview { + TaskDetailView(task: Task(...)) +} + +struct TaskDetailView: View { + @Environment(\.modelContext) var modelContext + let task: Task // Custom model + + var body: some View { + Text(task.title) + } +} +``` + +**Diagnosis using the skill**: +1. What's the error? "Cannot find in scope" → **Missing Dependency** (Error Type 1) +2. What does TaskDetailView need? The Task model and modelContext + +**Fix**: +```swift +#Preview { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try! ModelContainer(for: Task.self, configurations: config) + + return TaskDetailView(task: Task(title: "Sample")) + .modelContainer(container) +} +``` + +**Why this works**: Providing the environment object and model container satisfies the view's dependencies. Preview loads successfully. + +--- + +### Example 3: Text Field Value Changes Don't Appear + +**Scenario**: You have a search field. You type characters, but the text doesn't appear in the UI. However, the search results DO update. + +**Code**: +```swift +struct SearchView: View { + @State var searchText = "" + + var body: some View { + VStack { + // ❌ WRONG: Passing constant binding + TextField("Search", text: .constant(searchText)) + + Text("Results for: \(searchText)") // This updates + List { + ForEach(results(for: searchText), id: \.self) { result in + Text(result) + } + } + } + } + + func results(for text: String) -> [String] { + // Returns filtered results + } +} +``` + +**Diagnosis using the skill**: +1. Can you reproduce in preview? YES +2. Are you passing a binding to a child view? YES (TextField) +3. Is it a constant binding? YES → **Lost Binding Identity** (Root Cause 2) + +**Fix**: +```swift +// ✅ RIGHT: Pass the actual binding +TextField("Search", text: $searchText) +``` + +**Why this works**: `$searchText` passes a two-way binding. TextField writes changes back to @State, triggering a redraw. Text field now shows typed characters. + +--- + +## Simulator Verification + +After fixing SwiftUI issues, verify with visual confirmation in the simulator. + +### Why Simulator Verification Matters + +SwiftUI previews don't always match simulator behavior: +- **Different rendering** — Some visual effects only work on device/simulator +- **Different timing** — Animations may behave differently +- **Different state** — Full app lifecycle vs isolated preview + +**Use simulator verification for**: +- Layout fixes (spacing, alignment, sizing) +- View update fixes (state changes, bindings) +- Animation and gesture issues +- Before/after visual comparison + +### Quick Verification Workflow + +```bash +# 1. Take "before" screenshot +/axiom:screenshot + +# 2. Apply your fix + +# 3. Rebuild and relaunch +xcodebuild build -scheme YourScheme + +# 4. Take "after" screenshot +/axiom:screenshot + +# 5. Compare screenshots to verify fix +``` + +### Navigating to Problem Screens + +If the bug is deep in your app, use debug deep links to navigate directly: + +```bash +# 1. Add debug deep links (see deep-link-debugging skill) +# Example: debug://settings, debug://recipe-detail?id=123 + +# 2. Navigate and capture +xcrun simctl openurl booted "debug://problem-screen" +sleep 1 +/axiom:screenshot +``` + +### Full Simulator Testing + +For complex scenarios (state setup, multiple steps, log analysis): + +```bash +/axiom:test-simulator +``` + +Then describe what you want to test: +- "Navigate to the recipe editor and verify the layout fix" +- "Test the profile screen with empty state" +- "Verify the animation doesn't stutter anymore" + +### Before/After Example + +**Before fix** (view not updating): +```bash +# 1. Reproduce bug +xcrun simctl openurl booted "debug://recipe-list" +sleep 1 +xcrun simctl io booted screenshot /tmp/before-fix.png +# Screenshot shows: Tapping star doesn't update UI +``` + +**After fix** (added @State binding): +```bash +# 2. Test fix +xcrun simctl openurl booted "debug://recipe-list" +sleep 1 +xcrun simctl io booted screenshot /tmp/after-fix.png +# Screenshot shows: Star updates immediately when tapped +``` + +**Time saved**: 60%+ faster iteration with visual verification vs manual navigation + +--- + +## Resources + +**WWDC**: 2025-256, 2025-306, 2023-10160, 2023-10149, 2021-10022 + +**Docs**: /swiftui/managing-model-data-in-your-app, /swiftui, /swiftui/state-and-data-flow, /xcode/previews, /observation + +**Skills**: axiom-swiftui-performance, axiom-swiftui-debugging-diag, axiom-xcode-debugging, axiom-swift-concurrency, axiom-lldb (LLDB debugging workflows beyond Self._printChanges) + diff --git a/.claude/skills/axiom-swiftui-debugging/agents/openai.yaml b/.claude/skills/axiom-swiftui-debugging/agents/openai.yaml new file mode 100644 index 0000000..e9c8bc0 --- /dev/null +++ b/.claude/skills/axiom-swiftui-debugging/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftUI Debugging" + short_description: "Debugging SwiftUI view updates, preview crashes, or layout issues" diff --git a/.claude/skills/axiom-swiftui-gestures/.openskills.json b/.claude/skills/axiom-swiftui-gestures/.openskills.json new file mode 100644 index 0000000..fee07ca --- /dev/null +++ b/.claude/skills/axiom-swiftui-gestures/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftui-gestures", + "installedAt": "2026-04-12T08:06:48.509Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftui-gestures/SKILL.md b/.claude/skills/axiom-swiftui-gestures/SKILL.md new file mode 100644 index 0000000..264094f --- /dev/null +++ b/.claude/skills/axiom-swiftui-gestures/SKILL.md @@ -0,0 +1,955 @@ +--- +name: axiom-swiftui-gestures +description: Use when implementing SwiftUI gestures (tap, drag, long press, magnification, rotation), composing gestures, managing gesture state, or debugging gesture conflicts - comprehensive patterns for gesture recognition, composition, accessibility, and cross-platform support +license: MIT +compatibility: iOS 13+, macOS 10.15+, iPadOS 13+, axiom-visionOS 1.0+. Xcode 16+ +metadata: + version: "1.0.0" + last-updated: "2025-12-07" +--- + +# SwiftUI Gestures + +Comprehensive guide to SwiftUI gesture recognition with composition patterns, state management, and accessibility integration. + +## When to Use This Skill + +- Implementing tap, drag, long press, magnification, or rotation gestures +- Composing multiple gestures (simultaneously, sequenced, exclusively) +- Managing gesture state with GestureState +- Creating custom gesture recognizers +- Debugging gesture conflicts or unresponsive gestures +- Making gestures accessible with VoiceOver +- Cross-platform gesture handling (iOS, macOS, axiom-visionOS) + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "My drag gesture isn't working - the view doesn't move when I drag it. How do I debug this?" +→ The skill covers DragGesture state management patterns and shows how to properly update view offset with @GestureState + +#### 2. "I have both a tap gesture and a drag gesture on the same view. The tap works but the drag doesn't. How do I fix this?" +→ The skill demonstrates gesture composition with .simultaneously, .sequenced, and .exclusively to resolve gesture conflicts + +#### 3. "I want users to long press before they can drag an item. How do I chain gestures together?" +→ The skill shows the .sequenced pattern for combining LongPressGesture with DragGesture in the correct order + +#### 4. "My gesture state isn't resetting when the gesture ends. The view stays in the wrong position." +→ The skill covers @GestureState automatic reset behavior and the updating parameter for proper state management + +#### 5. "VoiceOver users can't access features that require gestures. How do I make gestures accessible?" +→ The skill demonstrates .accessibilityAction patterns and providing alternative interactions for VoiceOver users + +--- + +## Choosing the Right Gesture (Decision Tree) + +``` +What interaction do you need? + +├─ Single tap/click? +│ └─ Use Button (preferred) or TapGesture +│ +├─ Drag/pan movement? +│ └─ Use DragGesture +│ +├─ Hold before action? +│ └─ Use LongPressGesture +│ +├─ Pinch to zoom? +│ └─ Use MagnificationGesture +│ +├─ Two-finger rotation? +│ └─ Use RotationGesture +│ +├─ Multiple gestures together? +│ ├─ Both at same time? → .simultaneously +│ ├─ One after another? → .sequenced +│ └─ One OR the other? → .exclusively +│ +└─ Complex custom behavior? + └─ Create custom Gesture conforming to Gesture protocol +``` + +--- + +## Pattern 1: Basic Gesture Recognition + +### TapGesture + +#### ❌ WRONG (Custom tap on non-semantic view) +```swift +Text("Submit") + .onTapGesture { + submitForm() + } +``` + +**Problems**: +- Not announced as button to VoiceOver +- No visual press feedback +- Doesn't respect accessibility settings + +#### ✅ CORRECT (Use Button for tap actions) +```swift +Button("Submit") { + submitForm() +} +.buttonStyle(.bordered) +``` + +**When to use TapGesture**: Only when you need tap *data* (location, count) or non-standard tap behavior: + +```swift +Image("map") + .onTapGesture(count: 2) { // Double-tap for details + showDetails() + } + .onTapGesture { location in // Single tap to pin + addPin(at: location) + } +``` + +--- + +### DragGesture + +#### ❌ WRONG (Direct state mutation in gesture) +```swift +@State private var offset = CGSize.zero + +var body: some View { + Circle() + .offset(offset) + .gesture( + DragGesture() + .onChanged { value in + offset = value.translation // ❌ Updates every frame, causes jank + } + ) +} +``` + +**Problems**: +- View updates on every drag event (60-120 times per second) +- No way to reset to original position +- Loses intermediate state if drag cancelled + +#### ✅ CORRECT (Use GestureState for temporary state) +```swift +@GestureState private var dragOffset = CGSize.zero +@State private var position = CGSize.zero + +var body: some View { + Circle() + .offset(x: position.width + dragOffset.width, + y: position.height + dragOffset.height) + .gesture( + DragGesture() + .updating($dragOffset) { value, state, _ in + state = value.translation // Temporary during drag + } + .onEnded { value in + position.width += value.translation.width // Commit final + position.height += value.translation.height + } + ) +} +``` + +**Why**: GestureState automatically resets to initial value when gesture ends, preventing state corruption. + +--- + +### LongPressGesture + +```swift +@GestureState private var isDetectingLongPress = false +@State private var completedLongPress = false + +var body: some View { + Text("Press and hold") + .foregroundStyle(isDetectingLongPress ? .red : .blue) + .gesture( + LongPressGesture(minimumDuration: 1.0) + .updating($isDetectingLongPress) { currentState, gestureState, _ in + gestureState = currentState // Visual feedback during press + } + .onEnded { _ in + completedLongPress = true // Action after hold + } + ) +} +``` + +**Key parameters**: +- `minimumDuration`: How long to hold (default 0.5 seconds) +- `maximumDistance`: How far finger can move before cancelling (default 10 points) + +--- + +### MagnificationGesture + +```swift +@GestureState private var magnificationAmount = 1.0 +@State private var currentZoom = 1.0 + +var body: some View { + Image("photo") + .scaleEffect(currentZoom * magnificationAmount) + .gesture( + MagnificationGesture() + .updating($magnificationAmount) { value, state, _ in + state = value.magnification + } + .onEnded { value in + currentZoom *= value.magnification + } + ) +} +``` + +**Platform notes**: +- iOS: Pinch gesture with two fingers +- macOS: Trackpad pinch +- visionOS: Pinch gesture in 3D space + +--- + +### RotationGesture + +```swift +@GestureState private var rotationAngle = Angle.zero +@State private var currentRotation = Angle.zero + +var body: some View { + Rectangle() + .fill(.blue) + .frame(width: 200, height: 200) + .rotationEffect(currentRotation + rotationAngle) + .gesture( + RotationGesture() + .updating($rotationAngle) { value, state, _ in + state = value.rotation + } + .onEnded { value in + currentRotation += value.rotation + } + ) +} +``` + +--- + +## Pattern 2: Gesture Composition + +### Simultaneous Gestures + +#### Use when: Two gestures should work *at the same time* + +```swift +@GestureState private var dragOffset = CGSize.zero +@GestureState private var magnificationAmount = 1.0 + +var body: some View { + Image("photo") + .offset(dragOffset) + .scaleEffect(magnificationAmount) + .gesture( + DragGesture() + .updating($dragOffset) { value, state, _ in + state = value.translation + } + .simultaneously(with: + MagnificationGesture() + .updating($magnificationAmount) { value, state, _ in + state = value.magnification + } + ) + ) +} +``` + +**Use case**: Photo viewer where you can drag AND pinch-zoom at the same time. + +--- + +### Sequenced Gestures + +#### Use when: One gesture must *complete* before the next starts + +```swift +@State private var isLongPressing = false +@GestureState private var dragOffset = CGSize.zero + +var body: some View { + Circle() + .offset(dragOffset) + .gesture( + LongPressGesture(minimumDuration: 0.5) + .onEnded { _ in + isLongPressing = true + } + .sequenced(before: + DragGesture() + .updating($dragOffset) { value, state, _ in + state = value.translation + } + .onEnded { _ in + isLongPressing = false + } + ) + ) +} +``` + +**Use case**: iOS Home Screen — long press to enter edit mode, *then* drag to reorder. + +--- + +### Exclusive Gestures + +#### Use when: Only *one* gesture should win, not both + +```swift +var body: some View { + Rectangle() + .gesture( + TapGesture(count: 2) // Double-tap + .onEnded { _ in + zoom() + } + .exclusively(before: + TapGesture(count: 1) // Single tap + .onEnded { _ in + select() + } + ) + ) +} +``` + +**Why**: Without `.exclusively`, double-tap triggers *both* single and double tap handlers. + +**How it works**: SwiftUI waits to see if second tap comes. If yes → double tap wins. If no → single tap wins. + +--- + +## Pattern 3: GestureState vs State + +### When to Use Each + +| Use Case | State Type | Why | +|----------|-----------|-----| +| Temporary feedback during gesture | `@GestureState` | Auto-resets when gesture ends | +| Final committed value | `@State` | Persists after gesture | +| Animation during gesture | `@GestureState` | Smooth transitions | +| Data persistence | `@State` | Survives view updates | + +### Full Example: Draggable Card + +```swift +struct DraggableCard: View { + @GestureState private var dragOffset = CGSize.zero // Temporary + @State private var position = CGSize.zero // Permanent + + var body: some View { + RoundedRectangle(cornerRadius: 12) + .fill(.blue) + .frame(width: 300, height: 200) + .offset( + x: position.width + dragOffset.width, + y: position.height + dragOffset.height + ) + .gesture( + DragGesture() + .updating($dragOffset) { value, state, transaction in + state = value.translation + + // Enable animation for smooth feedback + transaction.animation = .interactiveSpring() + } + .onEnded { value in + // Commit final position with animation + withAnimation(.spring()) { + position.width += value.translation.width + position.height += value.translation.height + } + } + ) + } +} +``` + +**Key insight**: GestureState's third parameter `transaction` lets you customize animation during the gesture. + +--- + +## Pattern 4: Custom Gestures + +### When to Create Custom Gestures + +- Need gesture behavior not provided by built-in gestures +- Want to encapsulate complex gesture logic +- Reusing gesture across multiple views + +### Example: Swipe Gesture with Direction + +```swift +struct SwipeGesture: Gesture { + enum Direction { + case left, right, up, down + } + + let minimumDistance: CGFloat + let coordinateSpace: CoordinateSpace + + init(minimumDistance: CGFloat = 50, coordinateSpace: CoordinateSpace = .local) { + self.minimumDistance = minimumDistance + self.coordinateSpace = coordinateSpace + } + + // Value is the direction + typealias Value = Direction + + // Body builds on DragGesture + var body: AnyGesture { + DragGesture(minimumDistance: minimumDistance, coordinateSpace: coordinateSpace) + .map { value in + let horizontal = value.translation.width + let vertical = value.translation.height + + if abs(horizontal) > abs(vertical) { + return horizontal < 0 ? .left : .right + } else { + return vertical < 0 ? .up : .down + } + } + .eraseToAnyGesture() + } +} + +// Usage +Text("Swipe me") + .gesture( + SwipeGesture() + .onEnded { direction in + switch direction { + case .left: deleteItem() + case .right: archiveItem() + default: break + } + } + ) +``` + +--- + +## Pattern 5: Gesture Velocity and Prediction + +### Accessing Velocity + +```swift +@State private var velocity: CGSize = .zero + +var body: some View { + Circle() + .gesture( + DragGesture() + .onEnded { value in + // value.velocity is deprecated in iOS 18+ + // Use value.predictedEndLocation and time + + let timeDelta = value.time.timeIntervalSince(value.startLocation.time) + let distance = value.translation + + velocity = CGSize( + width: distance.width / timeDelta, + height: distance.height / timeDelta + ) + + // Animate with momentum + withAnimation(.interpolatingSpring(stiffness: 100, damping: 15)) { + applyMomentum(velocity: velocity) + } + } + ) +} +``` + +### Predicted End Location (iOS 16+) + +```swift +DragGesture() + .onChanged { value in + // Where gesture will likely end based on velocity + let predicted = value.predictedEndLocation + + // Show preview of where item will land + showPreview(at: predicted) + } +``` + +**Use case**: Springy physics, momentum scrolling, throw animations. + +--- + +## Pattern 6: Accessibility Integration + +### Making Custom Gestures Accessible + +#### ❌ WRONG (Gesture-only, no VoiceOver support) +```swift +Image("slider") + .gesture( + DragGesture() + .onChanged { value in + updateVolume(value.translation.width) + } + ) +``` + +**Problem**: VoiceOver users can't adjust the slider. + +#### ✅ CORRECT (Add accessibility actions) +```swift +@State private var volume: Double = 50 + +var body: some View { + Image("slider") + .gesture( + DragGesture() + .onChanged { value in + volume = calculateVolume(from: value.translation.width) + } + ) + .accessibilityElement() + .accessibilityLabel("Volume") + .accessibilityValue("\(Int(volume))%") + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: + volume = min(100, volume + 5) + case .decrement: + volume = max(0, volume - 5) + @unknown default: + break + } + } +} +``` + +**Why**: VoiceOver users can now swipe up/down to adjust volume without seeing or using the gesture. + +### Keyboard Alternatives (macOS) + +```swift +Rectangle() + .gesture( + DragGesture() + .onChanged { value in + move(by: value.translation) + } + ) + .onKeyPress(.upArrow) { + move(by: CGSize(width: 0, height: -10)) + return .handled + } + .onKeyPress(.downArrow) { + move(by: CGSize(width: 0, height: 10)) + return .handled + } + .onKeyPress(.leftArrow) { + move(by: CGSize(width: -10, height: 0)) + return .handled + } + .onKeyPress(.rightArrow) { + move(by: CGSize(width: 10, height: 0)) + return .handled + } +``` + +--- + +## Pattern 7: Cross-Platform Gestures + +### iOS vs macOS vs visionOS + +| Gesture | iOS | macOS | visionOS | +|---------|-----|-------|----------| +| TapGesture | Tap with finger | Click with mouse/trackpad | Look + pinch | +| DragGesture | Drag with finger | Click and drag | Pinch and move | +| LongPressGesture | Long press | Click and hold | Long pinch | +| MagnificationGesture | Two-finger pinch | Trackpad pinch | Pinch with both hands | +| RotationGesture | Two-finger rotate | Trackpad rotate | Rotate with both hands | + +### Platform-Specific Gestures + +```swift +var body: some View { + Image("photo") + .gesture( + #if os(iOS) + DragGesture(minimumDistance: 10) // Smaller threshold for touch + #elseif os(macOS) + DragGesture(minimumDistance: 1) // Precise mouse control + #else + DragGesture(minimumDistance: 20) // Larger for spatial gestures + #endif + .onChanged { value in + updatePosition(value.translation) + } + ) +} +``` + +--- + +## Common Pitfalls + +### Pitfall 1: Forgetting to Reset GestureState + +#### ❌ WRONG +```swift +@State private var offset = CGSize.zero // Should be GestureState + +var body: some View { + Circle() + .offset(offset) + .gesture( + DragGesture() + .onChanged { value in + offset = value.translation + } + ) +} +``` + +**Problem**: When drag ends, offset stays at last value instead of resetting. + +**Fix**: Use `@GestureState` for temporary state, or manually reset in `.onEnded`. + +--- + +### Pitfall 2: Gesture Conflicts with ScrollView + +#### ❌ WRONG (Drag gesture blocks scrolling) +```swift +ScrollView { + ForEach(items) { item in + ItemView(item) + .gesture( + DragGesture() + .onChanged { _ in + // Prevents scroll! + } + ) + } +} +``` + +**Fix**: Use `.highPriorityGesture()` or `.simultaneousGesture()` appropriately: + +```swift +ScrollView { + ForEach(items) { item in + ItemView(item) + .simultaneousGesture( // Allows both scroll and drag + DragGesture() + .onChanged { value in + // Only trigger if horizontal swipe + if abs(value.translation.width) > abs(value.translation.height) { + handleSwipe(value) + } + } + ) + } +} +``` + +--- + +### Pitfall 3: Using .gesture() Instead of Button + +#### ❌ WRONG (Reimplementing button) +```swift +Text("Submit") + .padding() + .background(.blue) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .onTapGesture { + submit() + } +``` + +**Problems**: +- No press animation +- No accessibility traits +- Doesn't respect system button styling +- More code + +#### ✅ CORRECT +```swift +Button("Submit") { + submit() +} +.buttonStyle(.borderedProminent) +``` + +**When TapGesture is OK**: When you need tap *location* or multiple tap counts: +```swift +Canvas { context, size in + // Draw canvas +} +.onTapGesture { location in + addShape(at: location) // Need location data +} +``` + +--- + +### Pitfall 4: Not Handling Gesture Cancellation + +#### ❌ WRONG (Assumes gesture always completes) +```swift +DragGesture() + .onChanged { value in + showPreview(at: value.location) + } + .onEnded { value in + hidePreview() + commitChange(at: value.location) + } +``` + +**Problem**: If user drags outside bounds and gesture cancels, preview stays visible. + +#### ✅ CORRECT (GestureState auto-resets) +```swift +@GestureState private var isDragging = false + +var body: some View { + content + .gesture( + DragGesture() + .updating($isDragging) { _, state, _ in + state = true + } + .onChanged { value in + if isDragging { + showPreview(at: value.location) + } + } + .onEnded { value in + commitChange(at: value.location) + } + ) + .onChange(of: isDragging) { _, newValue in + if !newValue { + hidePreview() // Cleanup when cancelled + } + } +} +``` + +--- + +### Pitfall 5: Forgetting coordinateSpace + +#### ❌ WRONG (Location relative to view, not screen) +```swift +DragGesture() + .onChanged { value in + // value.location is relative to the gesture's view + addAnnotation(at: value.location) + } +``` + +**Problem**: If view is offset/scrolled, coordinates are wrong. + +#### ✅ CORRECT (Specify coordinate space) +```swift +DragGesture(coordinateSpace: .named("container")) + .onChanged { value in + addAnnotation(at: value.location) // Relative to "container" + } + +// In parent: +ScrollView { + content +} +.coordinateSpace(name: "container") +``` + +**Options**: +- `.local` — Relative to gesture's view (default) +- `.global` — Relative to screen +- `.named("name")` — Relative to named coordinate space + +--- + +## Performance Considerations + +### Minimize Work in .onChanged + +#### ❌ SLOW +```swift +DragGesture() + .onChanged { value in + // Called 60-120 times per second! + let position = complexCalculation(value.translation) + updateDatabase(position) // ❌ I/O in gesture + reloadAllViews() // ❌ Heavy work + } +``` + +#### ✅ FAST +```swift +@GestureState private var dragOffset = CGSize.zero + +var body: some View { + content + .offset(dragOffset) // Cheap - just layout + .gesture( + DragGesture() + .updating($dragOffset) { value, state, _ in + state = value.translation // Minimal work + } + .onEnded { value in + // Heavy work once, not 120 times/second + let finalPosition = complexCalculation(value.translation) + updateDatabase(finalPosition) + } + ) +} +``` + +### Use Transaction for Smooth Animations + +```swift +DragGesture() + .updating($dragOffset) { value, state, transaction in + state = value.translation + + // Disable implicit animations during drag + transaction.animation = nil + } + .onEnded { value in + // Enable spring animation for final position + withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + commitPosition(value.translation) + } + } +``` + +**Why**: Animations during gesture can feel sluggish. Disable during drag, enable for final snap. + +--- + +## Troubleshooting + +### Gesture Not Recognizing + +**Check**: +1. Is view interactive? (Some views like `Text` ignore gestures unless wrapped) +2. Is another gesture taking priority? (Use `.highPriorityGesture()` or `.simultaneousGesture()`) +3. Is view clipped? (Use `.contentShape()` to define tap area) +4. Is gesture too restrictive? (Check `minimumDistance`, `minimumDuration`) + +```swift +// Fix unresponsive gesture +Text("Tap me") + .frame(width: 100, height: 100) + .contentShape(Rectangle()) // Define full tap area + .onTapGesture { + handleTap() + } +``` + +### Gesture Conflicts with Navigation + +```swift +NavigationLink(destination: DetailView()) { + ItemRow(item) + .simultaneousGesture( // Don't block navigation + LongPressGesture() + .onEnded { _ in + showContextMenu() + } + ) +} +``` + +### Gesture Breaking ScrollView + +**Use horizontal-only gesture detection**: +```swift +ScrollView { + ForEach(items) { item in + ItemView(item) + .simultaneousGesture( + DragGesture() + .onEnded { value in + // Only trigger on horizontal swipe + if abs(value.translation.width) > abs(value.translation.height) * 2 { + if value.translation.width < 0 { + deleteItem(item) + } + } + } + ) + } +} +``` + +--- + +## Testing Gestures + +### UI Testing with Gestures + +```swift +func testDragGesture() throws { + let app = XCUIApplication() + app.launch() + + let element = app.otherElements["draggable"] + + // Get start and end coordinates + let start = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + let finish = element.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)) + + // Perform drag + start.press(forDuration: 0.1, thenDragTo: finish) + + // Verify result + XCTAssertTrue(app.staticTexts["Dragged"].exists) +} +``` + +### Manual Testing Checklist + +- [ ] Gesture works on first interaction (no "warmup" needed) +- [ ] Gesture can be cancelled (drag outside bounds) +- [ ] Multiple rapid gestures work correctly +- [ ] Gesture works with VoiceOver enabled +- [ ] Gesture works on all target platforms (iOS/macOS/visionOS) +- [ ] Gesture doesn't block scrolling or navigation +- [ ] Gesture provides visual feedback during interaction +- [ ] Gesture respects accessibility settings (Reduce Motion) + +--- + +## Resources + +**WWDC**: 2019-237, 2020-10043, 2021-10018 + +**Docs**: /swiftui/composing-swiftui-gestures, /swiftui/gesturestate, /swiftui/gesture + +**Skills**: axiom-accessibility-diag, axiom-swiftui-performance, axiom-ui-testing + +--- + +**Remember**: Prefer built-in controls (Button, Slider) over custom gestures whenever possible. Gestures should enhance interaction, not replace standard controls. diff --git a/.claude/skills/axiom-swiftui-gestures/agents/openai.yaml b/.claude/skills/axiom-swiftui-gestures/agents/openai.yaml new file mode 100644 index 0000000..20551fb --- /dev/null +++ b/.claude/skills/axiom-swiftui-gestures/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftUI Gestures" + short_description: "Implementing SwiftUI gestures (tap, drag, long press, magnification, rotation), composing gestures, managing gesture ..." diff --git a/.claude/skills/axiom-swiftui-layout-ref/.openskills.json b/.claude/skills/axiom-swiftui-layout-ref/.openskills.json new file mode 100644 index 0000000..510816e --- /dev/null +++ b/.claude/skills/axiom-swiftui-layout-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftui-layout-ref", + "installedAt": "2026-04-12T08:06:49.197Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftui-layout-ref/SKILL.md b/.claude/skills/axiom-swiftui-layout-ref/SKILL.md new file mode 100644 index 0000000..284edd6 --- /dev/null +++ b/.claude/skills/axiom-swiftui-layout-ref/SKILL.md @@ -0,0 +1,939 @@ +--- +name: axiom-swiftui-layout-ref +description: Reference — Complete SwiftUI adaptive layout API guide covering ViewThatFits, AnyLayout, Layout protocol, onGeometryChange, GeometryReader, size classes, and iOS 26 window APIs +license: MIT +metadata: + version: "1.0.0" +--- + +# SwiftUI Layout API Reference + +Comprehensive API reference for SwiftUI adaptive layout tools. For decision guidance and anti-patterns, see the `axiom-swiftui-layout` skill. + +## Overview + +This reference covers all SwiftUI layout APIs for building adaptive interfaces: + +- **ViewThatFits** — Automatic variant selection (iOS 16+) +- **AnyLayout** — Type-erased animated layout switching (iOS 16+) +- **Layout Protocol** — Custom layout algorithms (iOS 16+) +- **onGeometryChange** — Efficient geometry reading (iOS 16+ backported) +- **GeometryReader** — Layout-phase geometry access (iOS 13+) +- **Safe Area Padding** — .safeAreaPadding() vs .padding() (iOS 17+) +- **Size Classes** — Trait-based adaptation +- **iOS 26 Window APIs** — Free-form windows, menu bar, resize anchors + +--- + +## ViewThatFits + +Evaluates child views in order and displays the first one that fits in the available space. + +### Basic Usage + +```swift +ViewThatFits { + // First choice + HStack { + icon + title + Spacer() + button + } + + // Second choice + HStack { + icon + title + button + } + + // Fallback + VStack { + HStack { icon; title } + button + } +} +``` + +### With Axis Constraint + +```swift +// Only consider horizontal fit +ViewThatFits(in: .horizontal) { + wideVersion + narrowVersion +} + +// Only consider vertical fit +ViewThatFits(in: .vertical) { + tallVersion + shortVersion +} +``` + +### How It Works + +1. Applies `fixedSize()` to each child +2. Measures ideal size against available space +3. Returns first child that fits +4. Falls back to last child if none fit + +### Limitations + +- Does not expose which variant was selected +- Cannot animate between variants (use AnyLayout instead) +- Measures all variants (performance consideration for complex views) + +--- + +## AnyLayout + +Type-erased layout container enabling animated transitions between layouts. + +### Basic Usage + +```swift +struct AdaptiveView: View { + @Environment(\.horizontalSizeClass) var sizeClass + + var layout: AnyLayout { + sizeClass == .compact + ? AnyLayout(VStackLayout(spacing: 12)) + : AnyLayout(HStackLayout(spacing: 20)) + } + + var body: some View { + layout { + ForEach(items) { item in + ItemView(item: item) + } + } + .animation(.default, value: sizeClass) + } +} +``` + +### Available Layout Types + +```swift +AnyLayout(HStackLayout(alignment: .top, spacing: 10)) +AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) +AnyLayout(ZStackLayout(alignment: .center)) +AnyLayout(GridLayout(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10)) +``` + +### Custom Conditions + +```swift +// Based on Dynamic Type +@Environment(\.dynamicTypeSize) var typeSize + +var layout: AnyLayout { + typeSize.isAccessibilitySize + ? AnyLayout(VStackLayout()) + : AnyLayout(HStackLayout()) +} + +// Based on geometry +@State private var isWide = true + +var layout: AnyLayout { + isWide + ? AnyLayout(HStackLayout()) + : AnyLayout(VStackLayout()) +} +``` + +### Why Use Over Conditional Views + +```swift +// ❌ Loses view identity, no animation +if isCompact { + VStack { content } +} else { + HStack { content } +} + +// ✅ Preserves identity, smooth animation +let layout = isCompact ? AnyLayout(VStackLayout()) : AnyLayout(HStackLayout()) +layout { content } +``` + +--- + +## Layout Protocol + +Create custom layout containers with full control over positioning. + +### Basic Custom Layout + +```swift +struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + return calculateSize(for: sizes, in: proposal.width ?? .infinity) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var point = bounds.origin + var lineHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + + if point.x + size.width > bounds.maxX { + point.x = bounds.origin.x + point.y += lineHeight + spacing + lineHeight = 0 + } + + subview.place(at: point, proposal: .unspecified) + point.x += size.width + spacing + lineHeight = max(lineHeight, size.height) + } + } +} + +// Usage +FlowLayout(spacing: 12) { + ForEach(tags) { tag in + TagView(tag: tag) + } +} +``` + +### With Cache + +```swift +struct CachedLayout: Layout { + struct CacheData { + var sizes: [CGSize] = [] + } + + func makeCache(subviews: Subviews) -> CacheData { + CacheData(sizes: subviews.map { $0.sizeThatFits(.unspecified) }) + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) -> CGSize { + // Use cache.sizes instead of measuring again + } +} +``` + +### Layout Values + +```swift +// Define custom layout value +struct Rank: LayoutValueKey { + static let defaultValue: Int = 0 +} + +extension View { + func rank(_ value: Int) -> some View { + layoutValue(key: Rank.self, value: value) + } +} + +// Read in layout +func placeSubviews(...) { + let sorted = subviews.sorted { $0[Rank.self] < $1[Rank.self] } +} +``` + +--- + +## onGeometryChange + +Efficient geometry reading without layout side effects. Backported to iOS 16+. + +### Basic Usage + +```swift +@State private var size: CGSize = .zero + +var body: some View { + content + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { newSize in + size = newSize + } +} +``` + +### Reading Specific Values + +```swift +// Width only +.onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.width +} action: { width in + columnCount = max(1, Int(width / 150)) +} + +// Frame in coordinate space +.onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .global) +} action: { frame in + globalFrame = frame +} + +// Aspect ratio +.onGeometryChange(for: Bool.self) { proxy in + proxy.size.width > proxy.size.height +} action: { isWide in + self.isWide = isWide +} +``` + +### Coordinate Spaces + +```swift +// Named coordinate space +ScrollView { + content + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.frame(in: .named("scroll")).minY + } action: { offset in + scrollOffset = offset + } +} +.coordinateSpace(name: "scroll") +``` + +### Comparison with GeometryReader + +| Aspect | onGeometryChange | GeometryReader | +|--------|------------------|----------------| +| Layout impact | None | Greedy (fills space) | +| When evaluated | After layout | During layout | +| Use case | Side effects | Layout calculations | +| iOS version | 16+ (backported) | 13+ | + +--- + +## GeometryReader + +Provides geometry information during layout phase. Use sparingly due to greedy sizing. + +### Basic Usage (Constrained) + +```swift +// ✅ Always constrain GeometryReader +GeometryReader { proxy in + let width = proxy.size.width + HStack(spacing: 0) { + Rectangle().frame(width: width * 0.3) + Rectangle().frame(width: width * 0.7) + } +} +.frame(height: 100) // Required constraint +``` + +### GeometryProxy Properties + +```swift +GeometryReader { proxy in + // Container size + let size = proxy.size // CGSize + + // Safe area insets + let insets = proxy.safeAreaInsets // EdgeInsets + + // Frame in coordinate space + let globalFrame = proxy.frame(in: .global) + let localFrame = proxy.frame(in: .local) + let namedFrame = proxy.frame(in: .named("container")) +} +``` + +### Common Patterns + +```swift +// Proportional sizing +GeometryReader { geo in + VStack { + header.frame(height: geo.size.height * 0.2) + content.frame(height: geo.size.height * 0.8) + } +} + +// Centering with offset +GeometryReader { geo in + content + .position(x: geo.size.width / 2, y: geo.size.height / 2) +} +``` + +### Avoiding Common Mistakes + +```swift +// ❌ Unconstrained in VStack +VStack { + GeometryReader { ... } // Takes ALL space + Button("Next") { } // Invisible +} + +// ✅ Constrained +VStack { + GeometryReader { ... } + .frame(height: 200) + Button("Next") { } +} + +// ❌ Causing layout loops +GeometryReader { geo in + content + .frame(width: geo.size.width) // Can cause infinite loop +} +``` + +--- + +## Safe Area Padding + +SwiftUI provides two primary approaches for handling spacing around content: `.padding()` and `.safeAreaPadding()`. Understanding when to use each is critical for proper layout on devices with safe areas (notch, Dynamic Island, home indicator). + +### The Critical Difference + +```swift +// ❌ WRONG - Ignores safe areas, content hits notch/home indicator +ScrollView { + content +} +.padding(.horizontal, 20) + +// ✅ CORRECT - Respects safe areas, adds padding beyond them +ScrollView { + content +} +.safeAreaPadding(.horizontal, 20) +``` + +**Key insight**: `.padding()` adds fixed spacing from the view's edges. `.safeAreaPadding()` adds spacing beyond the safe area insets. + +### When to Use Each + +#### Use `.padding()` when + +- Adding spacing between sibling views within a container +- Creating internal spacing that should be consistent everywhere +- Working with views that already respect safe areas (like List, Form) +- Adding decorative spacing on macOS (no safe area concerns) + +```swift +VStack(spacing: 0) { + header + .padding(.horizontal, 16) // ✅ Internal spacing + + Divider() + + content + .padding(.horizontal, 16) // ✅ Internal spacing +} +``` + +#### Use `.safeAreaPadding()` when (iOS 17+) + +- Adding margin to full-width content that extends to screen edges +- Implementing edge-to-edge scrolling with proper insets +- Creating custom containers that need safe area awareness +- Working with Liquid Glass or full-screen materials + +```swift +// ✅ Edge-to-edge list with custom padding +List(items) { item in + ItemRow(item) +} +.listStyle(.plain) +.safeAreaPadding(.horizontal, 20) // Adds 20pt beyond safe areas + +// ✅ Full-screen content with proper margins +ZStack { + Color.blue.ignoresSafeArea() + + VStack { + content + } + .safeAreaPadding(.all, 16) // Respects notch, home indicator +} +``` + +### Platform Availability + +**iOS 17+, iPadOS 17+, macOS 14+, axiom-visionOS 1.0+** + +For earlier iOS versions, use manual safe area handling: + +```swift +// iOS 13-16 fallback +GeometryReader { geo in + content + .padding(.horizontal, 20 + geo.safeAreaInsets.leading) +} +``` + +Or conditional compilation: + +```swift +if #available(iOS 17, *) { + content.safeAreaPadding(.horizontal, 20) +} else { + content.padding(.horizontal, 20) + .padding(.leading, safeAreaInsets.leading) +} +``` + +### Edge-Specific Usage + +```swift +// Top only (below status bar/notch) +.safeAreaPadding(.top, 8) + +// Bottom only (above home indicator) +.safeAreaPadding(.bottom, 16) + +// Horizontal (left/right of safe areas) +.safeAreaPadding(.horizontal, 20) + +// All edges +.safeAreaPadding(.all, 16) + +// Individual edges +.safeAreaPadding(EdgeInsets(top: 8, leading: 20, bottom: 16, trailing: 20)) +``` + +### Common Patterns + +#### Edge-to-Edge ScrollView + +```swift +ScrollView { + LazyVStack(spacing: 12) { + ForEach(items) { item in + ItemCard(item) + } + } +} +.safeAreaPadding(.horizontal, 16) // Content inset from edges + safe areas +.safeAreaPadding(.vertical, 8) +``` + +#### Full-Screen Background with Safe Content + +```swift +ZStack { + // Background extends edge-to-edge + LinearGradient(...) + .ignoresSafeArea() + + // Content respects safe areas + custom padding + VStack { + header + Spacer() + content + Spacer() + footer + } + .safeAreaPadding(.all, 20) +} +``` + +#### Nested Padding (Combined Approach) + +```swift +// Outer: Safe area padding for device insets +VStack(spacing: 0) { + content +} +.safeAreaPadding(.horizontal, 16) // Beyond safe areas + +// Inner: Regular padding for internal spacing +VStack { + Text("Title") + .padding(.bottom, 8) // Internal spacing + Text("Subtitle") +} +``` + +### Decision Tree + +``` +Does your content extend to screen edges? +├─ YES → Use .safeAreaPadding() +│ ├─ Is it scrollable? → .safeAreaPadding(.horizontal/.vertical) +│ └─ Is it full-screen? → .safeAreaPadding(.all) +│ +└─ NO (contained within a safe container like List/Form) + └─ Use .padding() for internal spacing +``` + +### Visual Debugging + +```swift +// Visualize safe area padding (iOS 17+) +content + .safeAreaPadding(.horizontal, 20) + .background(.red.opacity(0.2)) // Shows padding area + .border(.blue) // Shows content bounds +``` + +### Migration from Manual Safe Area Handling + +```swift +// ❌ OLD: Manual calculation (iOS 13-16) +GeometryReader { geo in + content + .padding(.top, geo.safeAreaInsets.top + 16) + .padding(.bottom, geo.safeAreaInsets.bottom + 16) + .padding(.horizontal, 20) +} + +// ✅ NEW: .safeAreaPadding() (iOS 17+) +content + .safeAreaPadding(.vertical, 16) + .safeAreaPadding(.horizontal, 20) +``` + +### Related APIs + +**`.safeAreaInset(edge:)`** - Adds persistent content that shrinks the safe area: +```swift +ScrollView { + content +} +.safeAreaInset(edge: .bottom) { + // This REDUCES the safe area, content scrolls under it + toolbarButtons + .padding() + .background(.ultraThinMaterial) +} +``` + +**`.ignoresSafeArea()`** - Opts out of safe area completely: +```swift +Color.blue + .ignoresSafeArea() // Extends to absolute screen edges +``` + +### Why It Matters + +**Before iOS 17**: Developers had to manually calculate safe area insets with GeometryReader, leading to: +- Verbose code +- Performance overhead (GeometryReader forces extra layout pass) +- Easy mistakes (forgetting to check all edges) + +**iOS 17+**: `.safeAreaPadding()` provides: +- Declarative API (matches SwiftUI philosophy) +- Automatic safe area awareness +- Better performance (no extra layout passes) +- Type-safe edge specification + +**Real-world impact**: Using `.padding()` instead of `.safeAreaPadding()` on iPhone 15 Pro causes content to: +- Hit the Dynamic Island (top) +- Overlap the home indicator (bottom) +- Get cut off by screen corners (rounded edges) + +--- + +## Size Classes + +Environment values indicating horizontal and vertical size characteristics. + +### Reading Size Classes + +```swift +struct AdaptiveView: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @Environment(\.verticalSizeClass) var verticalSizeClass + + var body: some View { + if horizontalSizeClass == .compact { + compactLayout + } else { + regularLayout + } + } +} +``` + +### Size Class Values + +```swift +enum UserInterfaceSizeClass { + case compact // Constrained space + case regular // Ample space +} +``` + +### Platform Behavior + +**iPhone:** +| Orientation | Horizontal | Vertical | +|-------------|------------|----------| +| Portrait | `.compact` | `.regular` | +| Landscape (small) | `.compact` | `.compact` | +| Landscape (Plus/Max) | `.regular` | `.compact` | + +**iPad:** +| Configuration | Horizontal | Vertical | +|--------------|------------|----------| +| Any full screen | `.regular` | `.regular` | +| 70% Split View | `.regular` | `.regular` | +| 50% Split View | `.regular` | `.regular` | +| 33% Split View | `.compact` | `.regular` | +| Slide Over | `.compact` | `.regular` | + +### Overriding Size Classes + +```swift +content + .environment(\.horizontalSizeClass, .compact) +``` + +--- + +## Dynamic Type Size + +Environment value for user's preferred text size. + +### Reading Dynamic Type + +```swift +@Environment(\.dynamicTypeSize) var dynamicTypeSize + +var body: some View { + if dynamicTypeSize.isAccessibilitySize { + accessibleLayout + } else { + standardLayout + } +} +``` + +### Size Categories + +```swift +enum DynamicTypeSize: Comparable { + case xSmall + case small + case medium + case large // Default + case xLarge + case xxLarge + case xxxLarge + case accessibility1 // isAccessibilitySize = true + case accessibility2 + case accessibility3 + case accessibility4 + case accessibility5 +} +``` + +### Scaled Metric + +```swift +@ScaledMetric var iconSize: CGFloat = 24 +@ScaledMetric(relativeTo: .largeTitle) var headerSize: CGFloat = 44 + +Image(systemName: "star") + .frame(width: iconSize, height: iconSize) +``` + +--- + +## iOS 26 Window APIs + +### Window Resize Anchor + +```swift +WindowGroup { + ContentView() +} +.windowResizeAnchor(.topLeading) // Resize originates from top-left +.windowResizeAnchor(.center) // Resize from center +``` + +### Menu Bar Commands (iPad) + +```swift +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + .commands { + CommandMenu("View") { + Button("Show Sidebar") { + showSidebar.toggle() + } + .keyboardShortcut("s", modifiers: [.command, .option]) + + Divider() + + Button("Zoom In") { zoom += 0.1 } + .keyboardShortcut("+") + Button("Zoom Out") { zoom -= 0.1 } + .keyboardShortcut("-") + } + } + } +} +``` + +### NavigationSplitView Column Control + +```swift +// iOS 26: Automatic column visibility +NavigationSplitView { + Sidebar() +} content: { + ContentList() +} detail: { + DetailView() +} +// Columns auto-hide/show based on available width + +// Manual control (when needed) +@State private var columnVisibility: NavigationSplitViewVisibility = .all + +NavigationSplitView(columnVisibility: $columnVisibility) { + Sidebar() +} detail: { + DetailView() +} +``` + +### Scene Phase + +```swift +@Environment(\.scenePhase) var scenePhase + +var body: some View { + content + .onChange(of: scenePhase) { oldPhase, newPhase in + switch newPhase { + case .active: + // Window is visible and interactive + case .inactive: + // Window is visible but not interactive + case .background: + // Window is not visible + } + } +} +``` + +--- + +## Coordinate Spaces + +### Built-in Coordinate Spaces + +```swift +// Global (screen coordinates) +proxy.frame(in: .global) + +// Local (view's own bounds) +proxy.frame(in: .local) + +// Named (custom) +proxy.frame(in: .named("mySpace")) +``` + +### Creating Named Spaces + +```swift +ScrollView { + content + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.frame(in: .named("scroll")).minY + } action: { offset in + scrollOffset = offset + } +} +.coordinateSpace(name: "scroll") + +// iOS 17+ typed coordinate space +extension CoordinateSpaceProtocol where Self == NamedCoordinateSpace { + static var scroll: Self { .named("scroll") } +} +``` + +--- + +## ScrollView Geometry (iOS 18+) + +### onScrollGeometryChange + +```swift +ScrollView { + content +} +.onScrollGeometryChange(for: CGFloat.self) { geometry in + geometry.contentOffset.y +} action: { offset in + scrollOffset = offset +} +``` + +### ScrollGeometry Properties + +```swift +.onScrollGeometryChange(for: ScrollGeometry.self) { $0 } action: { geo in + let offset = geo.contentOffset // Current scroll position + let size = geo.contentSize // Total content size + let visible = geo.visibleRect // Currently visible rect + let insets = geo.contentInsets // Content insets +} +``` + +--- + +## Lazy Container Gotchas + +### Recycling Behavior + +`LazyVStack` and `LazyHStack` create views **on demand** and recycle them when off-screen. This means: + +- **View identity matters**: If cells flash/disappear during fast scrolling, the view identity is unstable. Use explicit `.id()` on items. +- **onAppear/onDisappear fire repeatedly**: Views are created and destroyed as you scroll. Don't use these for one-time setup. +- **State resets on recycle**: `@State` in lazy items resets when recycled. Lift state to the model layer. + +```swift +// ❌ Items flash during fast scroll — unstable identity +LazyVStack { + ForEach(Array(items.enumerated()), id: \.offset) { index, item in + ItemRow(item: item) // Identity changes when array mutates + } +} + +// ✅ Stable identity prevents flash/disappear +LazyVStack { + ForEach(items) { item in // Uses item.id (Identifiable) + ItemRow(item: item) + } +} +``` + +### When NOT to Use Lazy Containers + +| Scenario | Use Instead | Why | +|----------|-------------|-----| +| < 50 items | `VStack` / `HStack` | No recycling overhead, simpler | +| Nested in another lazy container | `VStack` (inner) | Nested lazy causes layout issues | +| Need all items measured upfront | `VStack` | Lazy containers don't know total size | + +--- + +## Resources + +**WWDC**: 2025-208, 2024-10074, 2022-10056 + +**Docs**: /swiftui/layout, /swiftui/viewthatfits + +**Skills**: axiom-swiftui-layout, axiom-swiftui-debugging diff --git a/.claude/skills/axiom-swiftui-layout-ref/agents/openai.yaml b/.claude/skills/axiom-swiftui-layout-ref/agents/openai.yaml new file mode 100644 index 0000000..028a609 --- /dev/null +++ b/.claude/skills/axiom-swiftui-layout-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftUI Layout Reference" + short_description: "Reference — Complete SwiftUI adaptive layout API guide covering ViewThatFits, AnyLayout, Layout protocol, onGeometryC..." diff --git a/.claude/skills/axiom-swiftui-layout/.openskills.json b/.claude/skills/axiom-swiftui-layout/.openskills.json new file mode 100644 index 0000000..1a77c64 --- /dev/null +++ b/.claude/skills/axiom-swiftui-layout/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftui-layout", + "installedAt": "2026-04-12T08:06:48.854Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftui-layout/SKILL.md b/.claude/skills/axiom-swiftui-layout/SKILL.md new file mode 100644 index 0000000..e772381 --- /dev/null +++ b/.claude/skills/axiom-swiftui-layout/SKILL.md @@ -0,0 +1,391 @@ +--- +name: axiom-swiftui-layout +description: Use when layouts need to adapt to different screen sizes, iPad multitasking, or iOS 26 free-form windows — decision trees for ViewThatFits vs AnyLayout vs onGeometryChange, size class limitations, and anti-patterns preventing device-based layout mistakes +license: MIT +metadata: + version: "1.0.0" +--- + +# SwiftUI Adaptive Layout + +## Overview + +Discipline-enforcing skill for building layouts that respond to available space rather than device assumptions. Covers tool selection, size class limitations, iOS 26 free-form windows, and common anti-patterns. + +**Core principle:** Your layout should work correctly if Apple ships a new device tomorrow, or if iPadOS adds a new multitasking mode next year. Respond to your container, not your assumptions about the device. + +## When to Use This Skill + +- "How do I make this layout work on iPad and iPhone?" +- "Should I use GeometryReader or ViewThatFits?" +- "My layout breaks in Split View / Stage Manager" +- "Size classes aren't giving me what I need" +- "Designer wants different layout for portrait vs landscape" +- "Preparing app for iOS 26 window resizing" + +## Decision Tree + +``` +"I need my layout to adapt..." +│ +├─ TO AVAILABLE SPACE (container-driven) +│ │ +│ ├─ "Pick best-fitting variant" +│ │ → ViewThatFits +│ │ +│ ├─ "Animated switch between H↔V" +│ │ → AnyLayout + condition +│ │ +│ ├─ "Read size for calculations" +│ │ → onGeometryChange (iOS 16+) +│ │ +│ └─ "Custom layout algorithm" +│ → Layout protocol +│ +├─ TO PLATFORM TRAITS +│ │ +│ ├─ "Compact vs Regular width" +│ │ → horizontalSizeClass (⚠️ iPad limitations) +│ │ +│ ├─ "Accessibility text size" +│ │ → dynamicTypeSize.isAccessibilitySize +│ │ +│ └─ "Platform differences" +│ → #if os() / Environment +│ +└─ TO WINDOW SHAPE (aspect ratio) + │ + ├─ "Portrait vs Landscape semantics" + │ → Geometry + custom threshold + │ + ├─ "Auto show/hide columns" + │ → NavigationSplitView (automatic in iOS 26) + │ + └─ "Window lifecycle" + → @Environment(\.scenePhase) +``` + +## Tool Selection + +### Quick Decision + +``` +Do you need a calculated value (width, height)? +├─ YES → onGeometryChange +└─ NO → Do you need animated transitions? + ├─ YES → AnyLayout + condition + └─ NO → ViewThatFits +``` + +### When to Use Each Tool + +| I need to... | Use this | Not this | +|-------------|----------|----------| +| Pick between 2-3 layout variants | `ViewThatFits` | `if size > X` | +| Switch H↔V with animation | `AnyLayout` | Conditional HStack/VStack | +| Read container size | `onGeometryChange` | `GeometryReader` | +| Adapt to accessibility text | `dynamicTypeSize` | Fixed breakpoints | +| Detect compact width | `horizontalSizeClass` | `UIDevice.idiom` | +| Detect narrow window on iPad | Geometry + threshold | Size class alone | +| Hide/show sidebar | `NavigationSplitView` | Manual column logic | +| Custom layout algorithm | `Layout` protocol | Nested GeometryReaders | + +--- + +## Pattern 1: ViewThatFits + +**Use when:** You have 2-3 layout variants and want SwiftUI to pick the first that fits. + +```swift +ViewThatFits { + // First choice: horizontal + HStack { + Image(systemName: "star") + Text("Favorite") + Spacer() + Button("Add") { } + } + + // Fallback: vertical + VStack { + HStack { + Image(systemName: "star") + Text("Favorite") + } + Button("Add") { } + } +} +``` + +**Limitation:** ViewThatFits doesn't expose which variant was chosen. If you need that state for other views, use AnyLayout instead. + +--- + +## Pattern 2: AnyLayout for Animated Switching + +**Use when:** You need animated transitions between layouts, or need to know current layout state. + +```swift +struct AdaptiveStack: View { + @Environment(\.horizontalSizeClass) var sizeClass + + let content: Content + + var layout: AnyLayout { + sizeClass == .compact + ? AnyLayout(VStackLayout(spacing: 12)) + : AnyLayout(HStackLayout(spacing: 20)) + } + + var body: some View { + layout { + content + } + .animation(.default, value: sizeClass) + } +} +``` + +**For Dynamic Type:** + +```swift +@Environment(\.dynamicTypeSize) var dynamicTypeSize + +var layout: AnyLayout { + dynamicTypeSize.isAccessibilitySize + ? AnyLayout(VStackLayout()) + : AnyLayout(HStackLayout()) +} +``` + +--- + +## Pattern 3: onGeometryChange (Preferred for Geometry) + +**Use when:** You need actual dimensions for calculations. Preferred over GeometryReader. + +```swift +struct ResponsiveGrid: View { + @State private var columnCount = 2 + + var body: some View { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: columnCount)) { + ForEach(items) { item in + ItemView(item: item) + } + } + .onGeometryChange(for: Int.self) { proxy in + max(1, Int(proxy.size.width / 150)) + } action: { newCount in + columnCount = newCount + } + } +} +``` + +**For aspect ratio detection (iPad "orientation"):** + +```swift +struct WindowShapeReader: View { + @State private var isWide = true + + var body: some View { + content + .onGeometryChange(for: Bool.self) { proxy in + proxy.size.width > proxy.size.height * 1.2 + } action: { newValue in + isWide = newValue + } + } +} +``` + +--- + +## Pattern 4: GeometryReader (When Necessary) + +**Use when:** You need geometry AND are on iOS 15 or earlier, OR need geometry during layout phase (not just as side effect). + +```swift +// ✅ CORRECT: Constrained GeometryReader +VStack { + GeometryReader { geo in + Text("Width: \(geo.size.width)") + } + .frame(height: 44) // MUST constrain! + + Button("Next") { } +} + +// ❌ WRONG: Unconstrained (greedy) +VStack { + GeometryReader { geo in + Text("Width: \(geo.size.width)") + } + // Takes all available space, crushes siblings + Button("Next") { } +} +``` + +--- + +## Size Class Truth Table (iPad) + +| Configuration | Horizontal | Vertical | +|--------------|------------|----------| +| Full screen portrait | `.regular` | `.regular` | +| Full screen landscape | `.regular` | `.regular` | +| 70% Split View | `.regular` | `.regular` | +| 50% Split View | `.regular` | `.regular` | +| 33% Split View | `.compact` | `.regular` | +| Slide Over | `.compact` | `.regular` | +| With keyboard | (unchanged) | (unchanged) | + +**Key insight:** Size class only goes `.compact` on iPad at ~33% width or Slide Over. For finer control, use geometry. + +--- + +## iOS 26 Free-Form Windows + +### What Changed + +| Before iOS 26 | iOS 26+ | +|---------------|---------| +| Fixed Split View sizes | Free-form drag-to-resize | +| `UIRequiresFullScreen` allowed | **Deprecated** | +| No menu bar on iPad | Menu bar via `.commands` | +| Manual column visibility | `NavigationSplitView` auto-adapts | + +### Apple's Guideline + +> "Resizing an app should not permanently alter its layout. Be opportunistic about reverting back to the starting state whenever possible." + +**Translation:** Don't save layout state based on window size. When window returns to original size, layout should too. + +### NavigationSplitView Auto-Adaptation + +```swift +// iOS 26: Columns automatically show/hide +NavigationSplitView { + Sidebar() +} content: { + ContentList() +} detail: { + DetailView() +} +// No manual columnVisibility management needed +``` + +### Migration Checklist + +- [ ] Remove `UIRequiresFullScreen` from Info.plist +- [ ] Test at arbitrary window sizes (not just 33/50/66%) +- [ ] Verify layout doesn't "stick" after resize +- [ ] Add menu bar commands for common actions +- [ ] Test Window Controls don't overlap toolbar items + +--- + +## Anti-Patterns + +### ❌ Device Orientation Observer + +```swift +// ❌ WRONG: Reports device, not window +NotificationCenter.default.addObserver( + forName: UIDevice.orientationDidChangeNotification, ... +) + +let orientation = UIDevice.current.orientation +if orientation.isLandscape { ... } +``` + +**Why it fails:** Reports physical device orientation, not window shape. Wrong in Split View, Stage Manager, iOS 26. + +**Fix:** Use `onGeometryChange` to read actual window dimensions. + +### ❌ Screen Bounds + +```swift +// ❌ WRONG: Returns full screen, not your window +let width = UIScreen.main.bounds.width +if width > 700 { useWideLayout() } +``` + +**Why it fails:** In multitasking, your app may only have 40% of the screen. + +**Fix:** Read your view's actual container size. + +### ❌ Device Model Checks + +```swift +// ❌ WRONG: Breaks on new devices, wrong in multitasking +if UIDevice.current.userInterfaceIdiom == .pad { + useWideLayout() +} +``` + +**Why it fails:** iPad in 1/3 Split View is narrower than iPhone 14 Pro Max landscape. + +**Fix:** Respond to available space, not device identity. + +### ❌ Unconstrained GeometryReader + +```swift +// ❌ WRONG: GeometryReader is greedy +VStack { + GeometryReader { geo in + Text("Size: \(geo.size)") + } + Button("Next") { } // Crushed +} +``` + +**Fix:** Constrain with `.frame()` or use `onGeometryChange`. + +### ❌ Size Class as Orientation Proxy + +```swift +// ❌ WRONG: iPad is .regular in both orientations +var isLandscape: Bool { + horizontalSizeClass == .regular // Always true on iPad! +} +``` + +**Fix:** Calculate from actual geometry if you need aspect ratio. + +--- + +## Pressure Scenarios + +### "Designer wants iPhone-specific layout" + +**Temptation:** `if UIDevice.current.userInterfaceIdiom == .phone` + +**Response:** "I'll implement these as 'compact' and 'regular' layouts that switch based on available space. The iPhone layout will appear on iPad when the window is narrow. This future-proofs us for Stage Manager and iOS 26." + +### "Just use GeometryReader, it's fine" + +**Temptation:** Wrap everything in GeometryReader. + +**Response:** "GeometryReader has known layout side effects — it expands greedily. `onGeometryChange` reads the same data without affecting layout. It's backported to iOS 16." + +### "Size classes worked before" + +**Temptation:** Force everything through size class. + +**Response:** "Size classes are coarse. iPad is `.regular` in both orientations. I'll use size class for broad categories and geometry for precise thresholds." + +### "We don't support iPad multitasking" + +**Temptation:** `UIRequiresFullScreen = true` + +**Response:** "Apple deprecated full-screen-only in iOS 26. Even without active Split View support, the app can't break when resized. Space-based layout costs the same." + +--- + +## Resources + +**WWDC**: 2025-208, 2024-10074, 2022-10056 + +**Skills**: axiom-swiftui-layout-ref, axiom-swiftui-debugging, axiom-liquid-glass diff --git a/.claude/skills/axiom-swiftui-layout/agents/openai.yaml b/.claude/skills/axiom-swiftui-layout/agents/openai.yaml new file mode 100644 index 0000000..59b2bdc --- /dev/null +++ b/.claude/skills/axiom-swiftui-layout/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftUI Layout" + short_description: "Layouts need to adapt to different screen sizes, iPad multitasking, or iOS 26 free-form windows" diff --git a/.claude/skills/axiom-swiftui-nav-diag/.openskills.json b/.claude/skills/axiom-swiftui-nav-diag/.openskills.json new file mode 100644 index 0000000..5bf5dcd --- /dev/null +++ b/.claude/skills/axiom-swiftui-nav-diag/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftui-nav-diag", + "installedAt": "2026-04-12T08:06:49.875Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftui-nav-diag/SKILL.md b/.claude/skills/axiom-swiftui-nav-diag/SKILL.md new file mode 100644 index 0000000..eb1f644 --- /dev/null +++ b/.claude/skills/axiom-swiftui-nav-diag/SKILL.md @@ -0,0 +1,1230 @@ +--- +name: axiom-swiftui-nav-diag +description: Use when debugging navigation not responding, unexpected pops, deep links showing wrong screen, state lost on tab switch or background, crashes in navigationDestination, or any SwiftUI navigation failure - systematic diagnostics with production crisis defense +license: MIT +metadata: + version: "1.0.0" +--- + +# SwiftUI Navigation Diagnostics + +## Overview + +**Core principle** 85% of navigation problems stem from path state management errors, view identity issues, or placement mistakes—not SwiftUI defects. + +SwiftUI's navigation system is used by millions of apps and handles complex navigation patterns reliably. If your navigation is failing, not responding, or behaving unexpectedly, the issue is almost always in how you're managing navigation state, not the framework itself. + +This skill provides systematic diagnostics to identify root causes in minutes, not hours. + +## Red Flags — Suspect Navigation Issue + +If you see ANY of these, suspect a code issue, not framework breakage: + +- Navigation tap does nothing (link present but doesn't push) +- Back button pops to wrong screen or root +- Deep link opens app but shows wrong screen +- Navigation state lost when switching tabs +- Navigation state lost when app backgrounds +- Same NavigationLink pushes twice +- Navigation animation stuck or janky +- Crash with `navigationDestination` in stack trace + +- ❌ **FORBIDDEN** "SwiftUI navigation is broken, let's wrap UINavigationController" + - NavigationStack is used by Apple's own apps + - Wrapping UIKit adds complexity and loses SwiftUI state management benefits + - UIKit interop has its own edge cases you'll spend weeks discovering + - Your issue is almost certainly path management, not framework defect + +**Critical distinction** NavigationStack behavior is deterministic. If it's not working, you're modifying state incorrectly, have view identity issues, or navigationDestination is misplaced. + +## Mandatory First Steps + +**ALWAYS run these checks FIRST** (before changing code): + +```swift +// 1. Add NavigationPath logging +NavigationStack(path: $path) { + RootView() + .onChange(of: path.count) { oldCount, newCount in + print("📍 Path changed: \(oldCount) → \(newCount)") + // If this never fires, link isn't modifying path + // If it fires unexpectedly, something else modifies path + } +} + +// 2. Check navigationDestination is visible +// Put temporary print in destination closure +.navigationDestination(for: Recipe.self) { recipe in + let _ = print("🔗 Destination for Recipe: \(recipe.name)") + RecipeDetail(recipe: recipe) +} +// If this never prints, destination isn't being evaluated + +// 3. Check NavigationLink is inside NavigationStack +// Visual inspection: Trace from NavigationLink up view hierarchy +// Must hit NavigationStack, not another container first + +// 4. Check path state location +// @State must be in stable view (not recreated each render) +// Must be @State, @StateObject, or @Observable — not local variable + +// 5. Test basic case in isolation +// Create minimal reproduction +NavigationStack { + NavigationLink("Test", value: "test") + .navigationDestination(for: String.self) { str in + Text("Pushed: \(str)") + } +} +// If this works, problem is in your specific setup +``` + +#### What this tells you + +| Observation | Diagnosis | Next Step | +|-------------|-----------|-----------| +| onChange never fires on tap | NavigationLink not in NavigationStack hierarchy | Pattern 1a | +| onChange fires but view doesn't push | navigationDestination not found/loaded | Pattern 1b | +| onChange fires, view pushes, then immediate pop | View identity issue or path modification | Pattern 2a | +| Path changes unexpectedly (not from tap) | External code modifying path | Pattern 2b | +| Deep link path.append() doesn't navigate | Timing issue or wrong thread | Pattern 3b | +| State lost on tab switch | NavigationStack shared across tabs | Pattern 4a | +| Works first time, fails on return | View recreation issue | Pattern 5a | + +#### MANDATORY INTERPRETATION + +Before changing ANY code, identify ONE of these: + +1. If link tap does nothing AND no onChange → Link outside NavigationStack (check hierarchy) +2. If onChange fires but nothing pushes → navigationDestination not in scope (check placement) +3. If pushes then immediately pops → View identity change or path reset (check @State location) +4. If deep link fails → Timing or MainActor issue (check thread) +5. If crash → Force unwrap on path decode or missing type registration + +#### If diagnostics are contradictory or unclear +- STOP. Do NOT proceed to patterns yet +- Add print statements at every path modification point +- Create minimal reproduction case +- Test with String values first (simplest case) + +## Decision Tree + +Use this to reach the correct diagnostic pattern in 2 minutes: + +``` +Navigation problem? +├─ Navigation tap does nothing? +│ ├─ NavigationLink inside NavigationStack? +│ │ ├─ No → Pattern 1a (Link outside Stack) +│ │ └─ Yes → Check navigationDestination +│ │ +│ ├─ navigationDestination registered? +│ │ ├─ Inside lazy container? → Pattern 1b (Lazy Loading) +│ │ ├─ Type mismatch? → Pattern 1c (Type Registration) +│ │ └─ Blocked by sheet/popover? → Pattern 1d (Modal Blocking) +│ │ +│ └─ Using view-based link? +│ └─ → Pattern 1e (Deprecated API) +│ +├─ Unexpected pop back? +│ ├─ Immediate pop after push? +│ │ ├─ View body recreating path? → Pattern 2a (Path Recreation) +│ │ ├─ @State in wrong view? → Pattern 2a (State Location) +│ │ └─ ForEach id changing? → Pattern 2c (Identity Change) +│ │ +│ ├─ Pop when shouldn't? +│ │ ├─ External code calling removeLast? → Pattern 2b (Unexpected Modification) +│ │ ├─ Task cancelled? → Pattern 2b (Async Cancellation) +│ │ └─ MainActor issue? → Pattern 2d (Threading) +│ │ +│ └─ Back button behavior wrong? +│ └─ → Pattern 2e (Stack Corruption) +│ +├─ Deep link not working? +│ ├─ URL not received? +│ │ ├─ onOpenURL not called? → Check URL scheme in Info.plist +│ │ └─ Universal Links issue? → Check apple-app-site-association +│ │ +│ ├─ URL received, path not updated? +│ │ ├─ path.append not on MainActor? → Pattern 3a (Threading) +│ │ ├─ Timing issue (app not ready)? → Pattern 3b (Initialization) +│ │ └─ NavigationStack not created yet? → Pattern 3b (Lifecycle) +│ │ +│ └─ Path updated, wrong screen shown? +│ ├─ Wrong path order? → Pattern 3c (Path Construction) +│ ├─ Wrong type appended? → Pattern 3c (Type Mismatch) +│ └─ Item not found? → Pattern 3d (Data Resolution) +│ +├─ State lost? +│ ├─ Lost on tab switch? +│ │ ├─ Shared NavigationStack? → Pattern 4a (Shared State) +│ │ └─ Tab recreation? → Pattern 4a (Tab Identity) +│ │ +│ ├─ Lost on background/foreground? +│ │ ├─ No SceneStorage? → Pattern 4b (No Persistence) +│ │ └─ Decode failure? → Pattern 4c (Decode Error) +│ │ +│ └─ Lost on rotation/size change? +│ └─ → Pattern 4d (Layout Recreation) +│ +├─ NavigationSplitView issue? +│ ├─ Sidebar not visible on iPad? +│ │ ├─ columnVisibility not set? → Pattern 6a (Column Visibility) +│ │ └─ Compact size class? → Pattern 6a (Automatic Adaptation) +│ │ +│ ├─ Detail shows blank on iPad? +│ │ ├─ No default detail view? → Pattern 6b (Missing Detail) +│ │ └─ Selection binding nil? → Pattern 6b (Selection State) +│ │ +│ └─ Works on iPhone, broken on iPad? +│ └─ → Pattern 6c (Platform Adaptation) +│ +└─ Crash? + ├─ EXC_BAD_ACCESS in navigation code? + │ └─ → Pattern 5a (Memory Issue) + │ + ├─ Fatal error: type not registered? + │ └─ → Pattern 5b (Missing Destination) + │ + └─ Decode failure on restore? + └─ → Pattern 5c (Restoration Crash) +``` + +## Pattern Selection Rules (MANDATORY) + +Before proceeding to a pattern: + +1. **Navigation tap does nothing** → Add onChange logging FIRST, then Pattern 1 +2. **Unexpected pop** → Find WHAT is modifying path (logging), then Pattern 2 +3. **Deep link fails** → Verify URL received (print in onOpenURL), then Pattern 3 +4. **State lost** → Identify WHEN lost (tab switch vs background), then Pattern 4 +5. **Crash** → Get full stack trace, then Pattern 5 + +#### Apply ONE pattern at a time +- Implement the fix from one pattern +- Test thoroughly +- Only if issue persists, try next pattern +- DO NOT apply multiple patterns simultaneously (can't isolate cause) + +#### FORBIDDEN +- Guessing at solutions without diagnostics +- Changing multiple things at once +- Wrapping with UINavigationController "because SwiftUI is broken" +- Adding delays/DispatchQueue.main.async without understanding why +- Switching to view-based NavigationLink "to avoid path issues" + +--- + +## Diagnostic Patterns + +### Pattern 1a: NavigationLink Outside NavigationStack + +**Time cost** 5-10 minutes + +#### Symptom +- Tapping NavigationLink does nothing +- No navigation occurs, no errors +- onChange(of: path) never fires + +#### Diagnosis +```swift +// Check view hierarchy — NavigationLink must be INSIDE NavigationStack + +// ❌ WRONG — Link outside stack +struct ContentView: View { + var body: some View { + VStack { + NavigationLink("Go", value: "test") // Outside stack! + NavigationStack { + Text("Root") + } + } + } +} + +// Check: Add background color to NavigationStack +NavigationStack { + Color.red // If link is on red, it's inside +} +``` + +#### Fix +```swift +// ✅ CORRECT — Link inside stack +struct ContentView: View { + var body: some View { + NavigationStack { + VStack { + NavigationLink("Go", value: "test") // Inside stack + Text("Root") + } + .navigationDestination(for: String.self) { str in + Text("Pushed: \(str)") + } + } + } +} +``` + +#### Verification +- Tap link, navigation occurs +- onChange(of: path) fires when tapped + +--- + +### Pattern 1b: navigationDestination in Lazy Container + +**Time cost** 10-15 minutes + +#### Symptom +- NavigationLink tap does nothing OR works intermittently +- onChange fires (path updated) but view doesn't push +- Console may show: "A navigationDestination for [Type] was not found" + +#### Diagnosis +```swift +// ❌ WRONG — Destination inside lazy container (may not be loaded) +ScrollView { + LazyVStack { + ForEach(items) { item in + NavigationLink(item.name, value: item) + .navigationDestination(for: Item.self) { item in + ItemDetail(item: item) // May not be evaluated! + } + } + } +} +``` + +#### Fix +```swift +// ✅ CORRECT — Destination outside lazy container +ScrollView { + LazyVStack { + ForEach(items) { item in + NavigationLink(item.name, value: item) + } + } +} +.navigationDestination(for: Item.self) { item in + ItemDetail(item: item) // Always available +} +``` + +#### Verification +- Add print in destination closure — should always print on navigation +- Works regardless of scroll position + +--- + +### Pattern 1c: Type Registration Mismatch + +**Time cost** 10 minutes + +#### Symptom +- Navigation tap does nothing +- No matching navigationDestination for the value type +- May work for some links, not others + +#### Diagnosis +```swift +// Check: Value type must EXACTLY match destination type + +// Link uses Recipe +NavigationLink(recipe.name, value: recipe) // value is Recipe + +// Destination registered for... Recipe.ID? +.navigationDestination(for: Recipe.ID.self) { id in // ❌ Wrong type! + RecipeDetail(id: id) +} +``` + +#### Fix +```swift +// Match types exactly +NavigationLink(recipe.name, value: recipe) // Recipe + +.navigationDestination(for: Recipe.self) { recipe in // ✅ Recipe + RecipeDetail(recipe: recipe) +} + +// OR change link to use ID +NavigationLink(recipe.name, value: recipe.id) // Recipe.ID + +.navigationDestination(for: Recipe.ID.self) { id in // ✅ Recipe.ID + RecipeDetail(id: id) +} +``` + +#### Verification +- Print type in destination: `print(type(of: value))` +- Types must match exactly (no inheritance) + +--- + +### Pattern 2a: NavigationPath Recreated Every Render + +**Time cost** 15-20 minutes + +#### Symptom +- Navigation pushes then immediately pops back +- Appears to "flash" the destination view +- Works once, then fails, or fails immediately + +#### Diagnosis +```swift +// ❌ WRONG — Path created in view body (reset every render) +struct ContentView: View { + var body: some View { + let path = NavigationPath() // Recreated every time! + NavigationStack(path: .constant(path)) { + // ... + } + } +} + +// ❌ WRONG — @State in child view that gets recreated +struct ParentView: View { + @State var showChild = true + var body: some View { + if showChild { + ChildView() // Recreated when showChild toggles + } + } +} + +struct ChildView: View { + @State var path = NavigationPath() // Lost when ChildView recreated + // ... +} +``` + +#### Fix +```swift +// ✅ CORRECT — @State at stable level +struct ContentView: View { + @State private var path = NavigationPath() // Persists across renders + + var body: some View { + NavigationStack(path: $path) { + RootView() + } + } +} + +// ✅ CORRECT — @StateObject for ObservableObject +struct ContentView: View { + @StateObject private var navModel = NavigationModel() + + var body: some View { + NavigationStack(path: $navModel.path) { + RootView() + } + } +} +``` + +#### Verification +- Add onChange logging — path should not reset unexpectedly +- Navigate, wait, path.count stays stable + +--- + +### Pattern 2d: Path Modified Off MainActor + +**Time cost** 10-15 minutes + +#### Symptom +- Navigation works sometimes, fails others +- Swift 6 warnings about MainActor isolation +- Unexpected pops or state corruption + +#### Diagnosis +```swift +// ❌ WRONG — Modifying path from background task +func loadAndNavigate() async { + let recipe = await fetchRecipe() + path.append(recipe) // ⚠️ Not on MainActor! +} + +// Check: Search for path.append, path.removeLast outside @MainActor context +``` + +#### Fix +```swift +// ✅ CORRECT — Ensure MainActor +@MainActor +func loadAndNavigate() async { + let recipe = await fetchRecipe() + path.append(recipe) // ✅ MainActor isolated +} + +// OR explicitly dispatch +func loadAndNavigate() async { + let recipe = await fetchRecipe() + await MainActor.run { + path.append(recipe) + } +} + +// ✅ BEST — Use @Observable with @MainActor +@Observable +@MainActor +class Router { + var path = NavigationPath() + + func navigate(to value: any Hashable) { + path.append(value) + } +} +``` + +#### Verification +- No Swift 6 concurrency warnings +- Navigation consistent regardless of timing + +--- + +### Pattern 3a: Deep Link Threading Issue + +**Time cost** 15-20 minutes + +#### Symptom +- Deep link URL received (onOpenURL fires) +- path.append called but navigation doesn't happen +- Works when app is in foreground, fails from cold start + +#### Diagnosis +```swift +// ❌ WRONG — May be called before NavigationStack exists +.onOpenURL { url in + handleDeepLink(url) // NavigationStack may not be rendered yet +} + +func handleDeepLink(_ url: URL) { + path.append(parsedValue) // Modifies path that doesn't exist yet +} +``` + +#### Fix +```swift +// ✅ CORRECT — Defer deep link handling +@State private var pendingDeepLink: URL? +@State private var isReady = false + +var body: some View { + NavigationStack(path: $path) { + RootView() + .onAppear { + isReady = true + if let url = pendingDeepLink { + handleDeepLink(url) + pendingDeepLink = nil + } + } + } + .onOpenURL { url in + if isReady { + handleDeepLink(url) + } else { + pendingDeepLink = url // Queue for later + } + } +} +``` + +#### Verification +- Test deep link from cold start (app killed) +- Test deep link when app in background +- Test deep link when app in foreground + +--- + +### Pattern 3c: Deep Link Path Construction Order + +**Time cost** 10-15 minutes + +#### Symptom +- Deep link navigates but to wrong screen +- Shows intermediate screen instead of final destination +- Path appears correct but wrong view displayed + +#### Diagnosis +```swift +// ❌ WRONG — Wrong order (child before parent) +// URL: myapp://category/desserts/recipe/apple-pie +func handleDeepLink(_ url: URL) { + path.append(recipe) // Recipe pushed first + path.append(category) // Category pushed second — WRONG ORDER +} +// User sees Category screen, not Recipe screen +``` + +#### Fix +```swift +// ✅ CORRECT — Parent before child +func handleDeepLink(_ url: URL) { + path.removeLast(path.count) // Clear existing + + // Build hierarchy: parent → child + path.append(category) // First: Category + path.append(recipe) // Second: Recipe (shows this screen) +} + +// For complex paths, build array first +var newPath: [any Hashable] = [] +// Parse URL segments... +newPath.append(category) +newPath.append(subcategory) +newPath.append(item) + +// Then apply +path = NavigationPath(newPath) +``` + +#### Verification +- Print path after construction +- Final item in path should be the destination screen + +--- + +### Pattern 4a: Shared NavigationStack Across Tabs + +**Time cost** 15-20 minutes + +#### Symptom +- Navigate in Tab A, switch to Tab B +- Return to Tab A — navigation state lost (back at root) +- Or: Navigation from Tab A appears in Tab B + +#### Diagnosis +```swift +// ❌ WRONG — Single NavigationStack wrapping TabView +NavigationStack(path: $path) { + TabView { + Tab("Home") { HomeView() } + Tab("Settings") { SettingsView() } + } +} +// All tabs share same navigation — state mixed/lost + +// ❌ WRONG — Same @State used across tabs +@State var path = NavigationPath() // Shared +TabView { + Tab("Home") { + NavigationStack(path: $path) { ... } // Uses shared path + } + Tab("Settings") { + NavigationStack(path: $path) { ... } // Same path! + } +} +``` + +#### Fix +```swift +// ✅ CORRECT — Each tab has own NavigationStack +TabView { + Tab("Home", systemImage: "house") { + NavigationStack { // Own stack + HomeView() + .navigationDestination(for: HomeItem.self) { ... } + } + } + Tab("Settings", systemImage: "gear") { + NavigationStack { // Own stack + SettingsView() + .navigationDestination(for: SettingItem.self) { ... } + } + } +} + +// For per-tab path tracking: +struct HomeTab: View { + @State private var path = NavigationPath() // Tab-specific + + var body: some View { + NavigationStack(path: $path) { + HomeView() + } + } +} +``` + +#### Verification +- Navigate in Tab A, switch tabs, return — state preserved +- Each tab maintains independent navigation history + +--- + +### Pattern 4b: No State Persistence on Background + +**Time cost** 15-20 minutes + +#### Symptom +- Navigate to screen, background app +- Kill app or wait for system to terminate +- Relaunch — navigation state lost (back at root) + +#### Diagnosis +```swift +// ❌ WRONG — No persistence mechanism +@State private var path = NavigationPath() +// Path lost when app terminates +``` + +#### Fix +```swift +// ✅ CORRECT — Use SceneStorage + Codable +struct ContentView: View { + @StateObject private var navModel = NavigationModel() + @SceneStorage("navigation") private var savedData: Data? + + var body: some View { + NavigationStack(path: $navModel.path) { + RootView() + } + .task { + // Restore on appear + if let data = savedData { + navModel.restore(from: data) + } + // Save on changes + for await _ in navModel.objectWillChange.values { + savedData = navModel.encoded() + } + } + } +} + +@MainActor +class NavigationModel: ObservableObject { + @Published var path = NavigationPath() + + func encoded() -> Data? { + guard let codable = path.codable else { return nil } + return try? JSONEncoder().encode(codable) + } + + func restore(from data: Data) { + guard let codable = try? JSONDecoder().decode( + NavigationPath.CodableRepresentation.self, + from: data + ) else { return } + path = NavigationPath(codable) + } +} +``` + +#### Verification +- Navigate deep, background app +- Kill app via Xcode +- Relaunch — state restored + +--- + +### Pattern 5b: Missing navigationDestination Registration + +**Time cost** 10-15 minutes + +#### Symptom +- Crash: "No destination found for [Type]" +- Or navigation silently fails +- Happens when pushing certain types + +#### Diagnosis +```swift +// Every type pushed on path needs a destination + +// You push Recipe +path.append(recipe) // Recipe type + +// But only registered Category +.navigationDestination(for: Category.self) { ... } +// No destination for Recipe! +``` + +#### Fix +```swift +// Register ALL types you might push +NavigationStack(path: $path) { + RootView() + .navigationDestination(for: Category.self) { category in + CategoryView(category: category) + } + .navigationDestination(for: Recipe.self) { recipe in + RecipeDetail(recipe: recipe) + } + .navigationDestination(for: Chef.self) { chef in + ChefProfile(chef: chef) + } +} + +// Or use enum route type for single registration +enum AppRoute: Hashable { + case category(Category) + case recipe(Recipe) + case chef(Chef) +} + +.navigationDestination(for: AppRoute.self) { route in + switch route { + case .category(let cat): CategoryView(category: cat) + case .recipe(let recipe): RecipeDetail(recipe: recipe) + case .chef(let chef): ChefProfile(chef: chef) + } +} +``` + +#### Verification +- List all types you push on path +- Verify each has matching navigationDestination + +--- + +### Pattern 5c: State Restoration Decode Crash + +**Time cost** 15-20 minutes + +#### Symptom +- Crash on app launch +- Stack trace shows JSON decode failure +- Happens after app update or data model change + +#### Diagnosis +```swift +// ❌ WRONG — Force unwrap decode +func restore(from data: Data) { + let codable = try! JSONDecoder().decode( // 💥 Crashes! + NavigationPath.CodableRepresentation.self, + from: data + ) + path = NavigationPath(codable) +} + +// Crash reasons: +// - Saved path contains type that no longer exists +// - Codable encoding changed between versions +// - Saved item was deleted +``` + +#### Fix +```swift +// ✅ CORRECT — Graceful decode with fallback +func restore(from data: Data) { + do { + let codable = try JSONDecoder().decode( + NavigationPath.CodableRepresentation.self, + from: data + ) + path = NavigationPath(codable) + } catch { + print("Navigation restore failed: \(error)") + path = NavigationPath() // Start fresh + // Optionally clear bad saved data + } +} + +// ✅ BETTER — Store IDs, resolve to objects +class NavigationModel: ObservableObject, Codable { + var selectedIds: [String] = [] // Store IDs + + func resolvedPath(dataModel: DataModel) -> NavigationPath { + var path = NavigationPath() + for id in selectedIds { + if let item = dataModel.item(withId: id) { + path.append(item) + } + // Missing items silently skipped + } + return path + } +} +``` + +#### Verification +- Delete saved state, launch app — no crash +- Simulate bad data — graceful fallback +- Change data model, launch — handles mismatch + +--- + +## Production Crisis Scenario + +### Context: Navigation Randomly Breaks After iOS Update + +#### Situation +- iOS 18 ships on Tuesday +- By Wednesday, support tickets surge: "navigation broken" +- 20% of users report tapping links does nothing +- Some users report navigation "resets randomly" +- CTO asks: "What's the ETA on a fix?" + +#### Pressure signals +- 🚨 **Production issue** 20% of users affected +- ⏰ **Time pressure** "Users are leaving bad reviews" +- 👔 **Executive visibility** CTO personally tracking +- 📱 **Platform change** New iOS version + +#### Rationalization traps (DO NOT fall into these) + +1. *"It's an iOS 18 bug, wait for Apple to fix"* + - If 80% of users work fine, it's not iOS + - Apple's apps use same NavigationStack + - Your code has an edge case exposed by iOS changes + +2. *"Let's wrap UINavigationController"* + - 2-3 week rewrite + - Lose SwiftUI state management + - UIKit has its own iOS 18 changes + - Doesn't address root cause + +3. *"Add retry logic for navigation"* + - Navigation is synchronous — retries don't help + - Masks symptom, doesn't fix cause + - Makes debugging harder + +4. *"Roll back to pre-iOS 18 version"* + - Can't control user iOS version + - App Store version must support iOS 18 + - Doesn't fix the issue + +#### MANDATORY Diagnostic Protocol + +You have 2 hours to provide CTO with: +1. Root cause +2. Fix timeline +3. Workaround for affected users + +#### Step 1: Identify Pattern (30 minutes) + +```swift +// Release build with diagnostic logging +#if DEBUG || DIAGNOSTIC +NavigationStack(path: $path) { + // ... +} +.onChange(of: path.count) { old, new in + Analytics.log("nav_path_change", ["old": old, "new": new]) +} +#endif + +// Check analytics for: +// - path.count going to 0 unexpectedly → Path recreation +// - path.count increasing but no push → Missing destination +// - No path changes at all → Link not firing +``` + +#### Step 2: Cross-Reference with iOS 18 Changes (15 minutes) + +```swift +// iOS 18 changes that affect navigation: +// 1. Stricter MainActor enforcement +// 2. Changes to view identity in TabView +// 3. New navigation lifecycle timing + +// Most common iOS 18 issue: +// Code that worked by accident now fails + +// Check: Any path modifications in async contexts without @MainActor? +Task { + let result = await fetch() + path.append(result) // ⚠️ iOS 18 stricter about this +} +``` + +#### Step 3: Apply Targeted Fix (30 minutes) + +```swift +// Root cause found: NavigationPath modified from async context +// iOS 17 was lenient, iOS 18 enforces MainActor properly + +// ❌ Old code (worked on iOS 17, breaks on iOS 18) +func loadAndNavigate() async { + let recipe = await fetchRecipe() + path.append(recipe) // Race condition +} + +// ✅ Fix: Explicit MainActor isolation +@MainActor +func loadAndNavigate() async { + let recipe = await fetchRecipe() + path.append(recipe) // ✅ Safe +} + +// OR: Annotate entire class +@Observable +@MainActor +class Router { + var path = NavigationPath() + + func navigate(to value: any Hashable) { + path.append(value) + } +} +``` + +#### Step 4: Validate and Deploy (45 minutes) + +```swift +// 1. Test on iOS 17 device — still works +// 2. Test on iOS 18 device — now works +// 3. Test all navigation paths +// 4. Submit expedited review + +// Expedited review justification: +// "Critical bug fix for iOS 18 compatibility affecting 20% of users" +``` + +#### Professional Communication Templates + +#### To CTO (45 minutes after starting) +``` +Root cause identified: Navigation code wasn't properly isolated +to the main thread. iOS 18 enforces this more strictly than iOS 17. + +Fix: Add @MainActor annotation to navigation code. +Already tested on iOS 17 (no regression) and iOS 18 (fixes issue). + +Timeline: +- Fix ready: Now +- QA validation: 1 hour +- App Store submission: Today +- Available to users: 24-48 hours (expedited review) + +Workaround for affected users: Force quit and relaunch app +often clears the issue temporarily. +``` + +#### To Engineering Team +``` +iOS 18 Navigation Fix + +Root cause: NavigationPath modifications in async contexts +without @MainActor isolation. iOS 17 was permissive, iOS 18 enforces. + +Fix applied: +- Added @MainActor to Router class +- Updated all path.append/removeLast calls to be MainActor-isolated +- Added Swift 6 concurrency checking to catch future issues + +Files changed: Router.swift, ContentView.swift, DeepLinkHandler.swift + +Testing needed: +- All navigation flows +- Deep links from cold start +- Tab switching with navigation state +- Background/foreground with navigation state +``` + +--- + +## Pattern 6: NavigationSplitView Platform Issues + +**Time cost** 10-20 minutes + +NavigationSplitView adapts automatically between compact (iPhone) and regular (iPad) size classes. Most issues arise because developers test only on iPhone, where it collapses to a NavigationStack. + +### Pattern 6a: Sidebar/Column Not Visible + +#### Symptom +- Sidebar doesn't appear on iPad +- App shows detail view only, no way to navigate back +- Works fine on iPhone (collapses to single column) + +#### Diagnosis +```swift +// Check 1: Is columnVisibility controlling visibility? +@State private var columnVisibility: NavigationSplitViewVisibility = .all + +NavigationSplitView(columnVisibility: $columnVisibility) { + // sidebar +} detail: { + // detail +} + +// Check 2: Are you in compact size class? (iPhone or iPad slide-over) +// In compact, NavigationSplitView collapses to NavigationStack automatically +// The sidebar becomes the root of the stack +``` + +#### Fix +- Bind `columnVisibility` to control initial state (`.all`, `.doubleColumn`, `.detailOnly`) +- Test on iPad in full screen AND slide-over (compact) +- Use `navigationSplitViewStyle(.balanced)` or `.prominentDetail` to control column proportions + +### Pattern 6b: Blank Detail View on iPad + +#### Symptom +- iPad launches to blank right panel +- Sidebar shows list but detail area is empty +- iPhone works fine (no detail visible until selection) + +#### Fix — Provide Default Detail +```swift +NavigationSplitView { + List(items, selection: $selectedItem) { item in + Text(item.name) + } +} detail: { + if let selectedItem { + ItemDetailView(item: selectedItem) + } else { + ContentUnavailableView("Select an Item", + systemImage: "sidebar.left", + description: Text("Choose an item from the sidebar")) + } +} +``` + +**Key insight**: iPad shows the detail column immediately on launch. Without a default view, it's blank. iPhone doesn't show this because it starts on the sidebar. + +### Pattern 6c: Platform Adaptation Issues + +#### Symptom +- Navigation works on one platform, broken on another +- List selection behaves differently on iPhone vs iPad + +#### Diagnosis +NavigationSplitView uses different navigation models per size class: +- **Regular** (iPad full screen): Side-by-side columns, selection drives detail +- **Compact** (iPhone, iPad slide-over): Collapses to NavigationStack, selection pushes + +```swift +// Common mistake: using NavigationLink inside NavigationSplitView sidebar +// This creates DOUBLE navigation on iPad (link push + selection) +// Fix: Use List(selection:) binding, not NavigationLink +NavigationSplitView { + List(items, selection: $selectedID) { item in // ✅ selection binding + Text(item.name) + } +} detail: { + // driven by selectedID +} +``` + +**Test on both iPhone AND iPad before shipping.** Most NavigationSplitView bugs are platform-specific. + +--- + +## Quick Reference Table + +| Symptom | Likely Cause | First Check | Pattern | Fix Time | +|---------|--------------|-------------|---------|----------| +| Link tap does nothing | Link outside stack | View hierarchy | 1a | 5-10 min | +| Intermittent navigation failure | Destination in lazy container | Destination placement | 1b | 10-15 min | +| Works for some types, not others | Type mismatch | Print type(of:) | 1c | 10 min | +| Push then immediate pop | Path recreated | @State location | 2a | 15-20 min | +| Random unexpected pops | External path modification | Add logging | 2b | 15-20 min | +| Works on MainActor, fails in Task | Threading issue | Check @MainActor | 2d | 10-15 min | +| Deep link doesn't navigate | Not on MainActor | Thread check | 3a | 15-20 min | +| Deep link from cold start fails | Timing/lifecycle | Add pendingDeepLink | 3b | 15-20 min | +| Deep link shows wrong screen | Path order wrong | Print path contents | 3c | 10-15 min | +| State lost on tab switch | Shared NavigationStack | Check Tab structure | 4a | 15-20 min | +| State lost on background | No persistence | Add SceneStorage | 4b | 20-25 min | +| Crash on launch (decode) | Force unwrap decode | Error handling | 5c | 15-20 min | +| "No destination found" crash | Missing registration | List all types | 5b | 10-15 min | +| Sidebar missing on iPad | columnVisibility | Check binding | 6a | 10-15 min | +| Blank detail on iPad | No default detail | Add ContentUnavailableView | 6b | 10 min | +| Works iPhone, broken iPad | Platform adaptation | Test both size classes | 6c | 15-20 min | + +--- + +## Common Mistakes + +### Mistake 1: Putting navigationDestination Inside ForEach + +**Problem** Destination not loaded when needed (lazy evaluation). + +**Why it fails** LazyVStack/ForEach don't evaluate all children. Destination may not exist when link is tapped. + +#### Fix +```swift +// Move destination OUTSIDE lazy container +List { + ForEach(items) { item in + NavigationLink(item.name, value: item) + } +} +.navigationDestination(for: Item.self) { item in + ItemDetail(item: item) +} +``` + +### Mistake 2: Using NavigationView on iOS 16+ + +**Problem** NavigationView deprecated, different behavior across versions. + +**Why it fails** No NavigationPath support, can't programmatically navigate or deep link reliably. + +#### Fix +- Replace `NavigationView` with `NavigationStack` or `NavigationSplitView` +- Use value-based `NavigationLink(title, value:)` instead of view-based + +### Mistake 3: Creating NavigationPath in computed property + +**Problem** Path reset every access. + +**Why it fails** `var body` is called repeatedly. Creating path there means it's reset constantly. + +#### Fix +```swift +// Use @State, not computed +@State private var path = NavigationPath() // ✅ Persists + +// NOT +var path: NavigationPath { NavigationPath() } // ❌ Reset every time +``` + +### Mistake 4: Not Handling Decode Errors in Restoration + +**Problem** Crash when saved navigation data is invalid. + +**Why it fails** Data model changes, items deleted, encoding format changes between app versions. + +#### Fix +- Always use `try?` or `do/catch` for decode +- Provide fallback (empty path) +- Consider storing IDs and resolving to objects + +### Mistake 5: Assuming Deep Links Work Immediately + +**Problem** Deep link on cold start fails. + +**Why it fails** `onOpenURL` may fire before `NavigationStack` is rendered. + +#### Fix +- Queue deep link URL +- Process after `onAppear` of NavigationStack +- Use `isReady` flag pattern + +--- + +## Cross-References + +### For Preventive Patterns + +**swiftui-nav skill** — Discipline-enforcing anti-patterns: +- Red Flags: NavigationView, view-based links, path in body +- Pattern 1a-7: Correct implementation patterns +- Pressure Scenarios: How to handle architecture pressure + +### For API Reference + +**swiftui-nav-ref skill** — Complete API documentation: +- NavigationStack, NavigationSplitView, NavigationPath full API +- All WWDC code examples with timestamps +- Router/Coordinator patterns with testing +- iOS 26+ features (Liquid Glass, bottom search) + +### For Related Issues + +**swift-concurrency skill** — If MainActor issues: +- Pattern 3: @MainActor isolation patterns +- Async/await with UI updates +- Task cancellation handling + +--- + +**Last Updated** 2025-12-05 +**Status** Production-ready diagnostics +**Tested** Diagnostic patterns validated against common navigation issues diff --git a/.claude/skills/axiom-swiftui-nav-diag/agents/openai.yaml b/.claude/skills/axiom-swiftui-nav-diag/agents/openai.yaml new file mode 100644 index 0000000..a4b43c2 --- /dev/null +++ b/.claude/skills/axiom-swiftui-nav-diag/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftUI Nav Diagnostics" + short_description: "Debugging navigation not responding, unexpected pops, deep links showing wrong screen, state lost on tab switch or ba..." diff --git a/.claude/skills/axiom-swiftui-nav-ref/.openskills.json b/.claude/skills/axiom-swiftui-nav-ref/.openskills.json new file mode 100644 index 0000000..b567d1e --- /dev/null +++ b/.claude/skills/axiom-swiftui-nav-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftui-nav-ref", + "installedAt": "2026-04-12T08:06:50.214Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftui-nav-ref/SKILL.md b/.claude/skills/axiom-swiftui-nav-ref/SKILL.md new file mode 100644 index 0000000..921b403 --- /dev/null +++ b/.claude/skills/axiom-swiftui-nav-ref/SKILL.md @@ -0,0 +1,972 @@ +--- +name: axiom-swiftui-nav-ref +description: Reference — Comprehensive SwiftUI navigation guide covering NavigationStack (iOS 16+), NavigationSplitView (iOS 16+), NavigationPath, deep linking, state restoration, Tab+Navigation integration (iOS 18+), Liquid Glass navigation (iOS 26+), and coordinator patterns +license: MIT +compatibility: iOS 16+ (NavigationStack), iOS 18+ (Tab/Sidebar), iOS 26+ (Liquid Glass) +metadata: + version: "1.0.0" + last-updated: "2025-12-05" +--- + +# SwiftUI Navigation API Reference + +## Overview + +SwiftUI's navigation APIs provide data-driven, programmatic navigation that scales from simple stacks to complex multi-column layouts. Introduced in iOS 16 (2022) with NavigationStack and NavigationSplitView, evolved in iOS 18 (2024) with Tab/Sidebar unification, and refined in iOS 26 (2025) with Liquid Glass design. + +#### Evolution timeline +- **2022 (iOS 16)** NavigationStack, NavigationSplitView, NavigationPath, value-based NavigationLink +- **2024 (iOS 18)** Tab/Sidebar unification, sidebarAdaptable style, zoom navigation transition +- **2025 (iOS 26)** Liquid Glass navigation chrome, bottom-aligned search, floating tab bars, backgroundExtensionEffect + +#### Key capabilities +- **Data-driven navigation** NavigationPath represents stack state, enabling programmatic push/pop and deep linking +- **Multi-column layouts** NavigationSplitView adapts automatically (3-column on iPad → single stack on iPhone) +- **State restoration** Codable NavigationPath + SceneStorage for persistence across app launches +- **Tab integration** Per-tab NavigationStack with state preservation on tab switch (iOS 18+) +- **Liquid Glass** Automatic glass navigation bars, sidebars, and toolbars (iOS 26+) + +#### When to use vs UIKit +- **SwiftUI navigation** New apps, multiplatform, simpler navigation flows → Use NavigationStack/SplitView +- **UINavigationController** Complex coordinator patterns, legacy code, specific UIKit features → Consider UIKit + +#### Related Skills +- Use `axiom-swiftui-nav` for anti-patterns, decision trees, pressure scenarios +- Use `axiom-swiftui-nav-diag` for systematic troubleshooting of navigation issues + +--- + +## When to Use This Skill + +Use this skill when: +- **Implementing navigation APIs** NavigationStack, NavigationSplitView, NavigationPath, Tab+Navigation +- **Deep linking or state restoration** URL routing, Codable NavigationPath, SceneStorage +- **Adopting iOS 26+ features** Liquid Glass navigation, bottom-aligned search, tab bar minimization +- **Choosing navigation architecture** Stack vs SplitView vs coordinator patterns + +--- + +## API Evolution + +### Timeline + +| Year | iOS Version | Key Features | +|------|-------------|--------------| +| 2020 | iOS 14 | NavigationView (deprecated iOS 16) | +| 2022 | iOS 16 | NavigationStack, NavigationSplitView, NavigationPath, value-based NavigationLink | +| 2024 | iOS 18 | Tab/Sidebar unification, sidebarAdaptable, TabSection, zoom transitions | +| 2025 | iOS 26 | Liquid Glass navigation, backgroundExtensionEffect, tabBarMinimizeBehavior | + +### NavigationView (Deprecated) + +NavigationView is deprecated as of iOS 16. Use NavigationStack (single-column push/pop) or NavigationSplitView (multi-column) exclusively in new code. Key improvements: single NavigationPath replaces per-link `isActive` bindings, value-based type safety, built-in Codable state restoration. See "Migrating to new navigation types" documentation. + +--- + +## NavigationStack Complete Reference + +NavigationStack represents a push-pop interface like Settings on iPhone or System Settings on macOS. + +### 1.1 Creating NavigationStack + +#### Basic NavigationStack + +```swift +NavigationStack { + List(Category.allCases) { category in + NavigationLink(category.name, value: category) + } + .navigationTitle("Categories") + .navigationDestination(for: Category.self) { category in + CategoryDetail(category: category) + } +} +``` + +#### With Path Binding (WWDC 2022, 6:05) + +```swift +struct PushableStack: View { + @State private var path: [Recipe] = [] + @StateObject private var dataModel = DataModel() + + var body: some View { + NavigationStack(path: $path) { + List(Category.allCases) { category in + Section(category.localizedName) { + ForEach(dataModel.recipes(in: category)) { recipe in + NavigationLink(recipe.name, value: recipe) + } + } + } + .navigationTitle("Categories") + .navigationDestination(for: Recipe.self) { recipe in + RecipeDetail(recipe: recipe) + } + } + .environmentObject(dataModel) + } +} +``` + +Path binding + value-presenting NavigationLink + `navigationDestination(for:)` form the core data-driven navigation pattern. + +### 1.2 NavigationLink (Value-Based) + +#### Value-presenting NavigationLink + +```swift +// Correct: Value-based (iOS 16+) +NavigationLink(recipe.name, value: recipe) + +// Correct: With custom label +NavigationLink(value: recipe) { + RecipeTile(recipe: recipe) +} + +// Deprecated: View-based (iOS 13-15) +NavigationLink(recipe.name) { + RecipeDetail(recipe: recipe) // Don't use in new code +} +``` + +#### How NavigationLink works with NavigationStack + +1. NavigationStack maintains a `path` collection +2. Tapping a value-presenting link appends the value to the path +3. NavigationStack maps `navigationDestination` modifiers over path values +4. Views are pushed onto the stack based on destination mappings + +### 1.3 navigationDestination Modifier + +#### Single Type + +```swift +.navigationDestination(for: Recipe.self) { recipe in + RecipeDetail(recipe: recipe) +} +``` + +#### Multiple Types + +```swift +NavigationStack(path: $path) { + RootView() + .navigationDestination(for: Recipe.self) { recipe in + RecipeDetail(recipe: recipe) + } + .navigationDestination(for: Category.self) { category in + CategoryList(category: category) + } + .navigationDestination(for: Chef.self) { chef in + ChefProfile(chef: chef) + } +} +``` + +#### Navigation Anti-Patterns + +- **Never mix `navigationDestination(for:)` and `NavigationLink(destination:)`** in the same NavigationStack hierarchy — causes undefined behavior +- **Register `navigationDestination(for:)` once per data type** — duplicates cause the wrong view to appear + +#### Placement rules +- Place `navigationDestination` outside lazy containers (not inside ForEach) +- Place near related NavigationLinks for code organization +- Must be inside NavigationStack hierarchy + +```swift +// Correct: Outside lazy container +ScrollView { + LazyVGrid(columns: columns) { + ForEach(recipes) { recipe in + NavigationLink(value: recipe) { + RecipeTile(recipe: recipe) + } + } + } +} +.navigationDestination(for: Recipe.self) { recipe in + RecipeDetail(recipe: recipe) +} + +// Wrong: Inside ForEach (may not be loaded) +ForEach(recipes) { recipe in + NavigationLink(value: recipe) { RecipeTile(recipe: recipe) } + .navigationDestination(for: Recipe.self) { r in // Don't do this + RecipeDetail(recipe: r) + } +} +``` + +### 1.4 NavigationPath + +NavigationPath is a type-erased collection for heterogeneous navigation stacks. + +#### Typed Array vs NavigationPath + +```swift +// Typed array: All values same type +@State private var path: [Recipe] = [] + +// NavigationPath: Mixed types +@State private var path = NavigationPath() +``` + +#### NavigationPath Operations + +```swift +// Append value +path.append(recipe) + +// Pop to previous +path.removeLast() + +// Pop to root +path.removeLast(path.count) +// or +path = NavigationPath() + +// Check count +if path.count > 0 { ... } + +// Deep link: Set multiple values +path.append(category) +path.append(recipe) +``` + +#### Codable Support + +```swift +// NavigationPath is Codable when all values are Codable +@State private var path = NavigationPath() + +// Encode +let data = try JSONEncoder().encode(path.codable) + +// Decode +let codableRep = try JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data) +path = NavigationPath(codableRep) +``` + +--- + +## NavigationSplitView Complete Reference + +NavigationSplitView creates multi-column layouts that adapt to device size. + +### 2.1 Two-Column Layout + +#### Basic Two-Column (WWDC 2022, 10:40) + +```swift +struct MultipleColumns: View { + @State private var selectedCategory: Category? + @State private var selectedRecipe: Recipe? + @StateObject private var dataModel = DataModel() + + var body: some View { + NavigationSplitView { + List(Category.allCases, selection: $selectedCategory) { category in + NavigationLink(category.localizedName, value: category) + } + .navigationTitle("Categories") + } detail: { + if let recipe = selectedRecipe { + RecipeDetail(recipe: recipe) + } else { + Text("Select a recipe") + } + } + } +} +``` + +### 2.2 Three-Column Layout + +Add a `content:` closure between sidebar and detail for a middle column: + +```swift +NavigationSplitView { + List(Category.allCases, selection: $selectedCategory) { category in + NavigationLink(category.localizedName, value: category) + } +} content: { + List(dataModel.recipes(in: selectedCategory), selection: $selectedRecipe) { recipe in + NavigationLink(recipe.name, value: recipe) + } +} detail: { + RecipeDetail(recipe: selectedRecipe) +} +``` + +### 2.3 NavigationSplitView with NavigationStack + +Place a `NavigationStack(path:)` inside the detail column for grid-to-detail drill-down while preserving sidebar selection: + +```swift +NavigationSplitView { + List(Category.allCases, selection: $selectedCategory) { ... } +} detail: { + NavigationStack(path: $path) { + RecipeGrid(category: selectedCategory) + .navigationDestination(for: Recipe.self) { recipe in + RecipeDetail(recipe: recipe) + } + } +} +``` + +### 2.4 Column Visibility + +```swift +@State private var columnVisibility: NavigationSplitViewVisibility = .all + +NavigationSplitView(columnVisibility: $columnVisibility) { + Sidebar() +} content: { + Content() +} detail: { + Detail() +} + +// Programmatically control visibility +columnVisibility = .detailOnly // Hide sidebar and content +columnVisibility = .all // Show all columns +columnVisibility = .automatic // System decides +``` + +### 2.5 Automatic Adaptation + +NavigationSplitView automatically adapts: +- **iPad landscape** All columns visible (depending on configuration) +- **iPad portrait/Slide Over** Collapses to overlay or single column +- **iPhone** Single navigation stack +- **Apple Watch/TV** Single navigation stack + +Selection changes automatically translate to push/pop on iPhone. + +### 2.6 iOS 26+ Liquid Glass Sidebar (WWDC 2025, 323) + +```swift +NavigationSplitView { + List { ... } +} detail: { + DetailView() +} +// Sidebar automatically gets Liquid Glass appearance on iPad/macOS + +// Extend content behind glass sidebar +.backgroundExtensionEffect() // Mirrors and blurs content outside safe area +``` + +--- + +## Deep Linking and URL Routing + +### 3.1 Deep Link Pattern + +Use `.onOpenURL` to receive URLs, parse with `URLComponents`, then manipulate `NavigationPath`: + +```swift +.onOpenURL { url in + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let host = components.host else { return } + path.removeLast(path.count) // Pop to root first + // Parse host/path to determine destination, then path.append(value) +} +``` + +For multi-step deep links (`myapp://category/desserts/recipe/apple-pie`), iterate URL path components and append each resolved value to build the full navigation stack. + +For comprehensive deep linking examples, error diagnosis, and testing workflows, see `axiom-swiftui-nav-diag` (Pattern 3). + +--- + +## State Restoration + +### 4.1 Complete State Restoration (WWDC 2022, 18:12) + +```swift +struct UseSceneStorage: View { + @StateObject private var navModel = NavigationModel() + @SceneStorage("navigation") private var data: Data? + @StateObject private var dataModel = DataModel() + + var body: some View { + NavigationSplitView { + List(Category.allCases, selection: $navModel.selectedCategory) { category in + NavigationLink(category.localizedName, value: category) + } + .navigationTitle("Categories") + } detail: { + NavigationStack(path: $navModel.recipePath) { + RecipeGrid(category: navModel.selectedCategory) + } + } + .task { + // Restore on appear + if let data = data { + navModel.jsonData = data + } + // Save on changes + for await _ in navModel.objectWillChangeSequence { + data = navModel.jsonData + } + } + .environmentObject(dataModel) + } +} +``` + +### 4.2 Codable NavigationModel + +```swift +class NavigationModel: ObservableObject, Codable { + @Published var selectedCategory: Category? + @Published var recipePath: [Recipe] = [] + + enum CodingKeys: String, CodingKey { + case selectedCategory + case recipePathIds // Store IDs, not full objects + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory) + try container.encode(recipePath.map(\.id), forKey: .recipePathIds) + } + + init() {} + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.selectedCategory = try container.decodeIfPresent(Category.self, forKey: .selectedCategory) + let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds) + self.recipePath = recipePathIds.compactMap { DataModel.shared[$0] } // Discard deleted items + } +} +``` + +Store IDs (not full model objects) and use `compactMap` to handle deleted items gracefully. Add `jsonData` computed property and `objectWillChangeSequence` for SceneStorage integration as shown in 4.1. + +--- + +## Tab + Navigation Integration + +### 5.1 Tab Syntax (iOS 18+) (WWDC 2024, 4:27) + +```swift +TabView { + Tab("Watch Now", systemImage: "play") { + WatchNowView() + } + Tab("Library", systemImage: "books.vertical") { + LibraryView() + } + Tab(role: .search) { + NavigationStack { + SearchView() + .navigationTitle("Search") + } + .searchable(text: $searchText) + } +} +``` + +**Search tab requirement**: Contents of a search-role tab must be wrapped in `NavigationStack` with `.searchable()` applied to the stack. Without `NavigationStack`, the search field will not appear. For foundational `.searchable` patterns (suggestions, scopes, tokens, programmatic control), see `axiom-swiftui-search-ref`. + +### 5.2 TabView with NavigationStack Per Tab + +```swift +TabView { + Tab("Home", systemImage: "house") { + NavigationStack { + HomeView() + .navigationDestination(for: Item.self) { item in + ItemDetail(item: item) + } + } + } + Tab("Settings", systemImage: "gear") { + NavigationStack { + SettingsView() + } + } +} +``` + +Each tab has its own NavigationStack to preserve navigation state when switching tabs. + +### 5.3 Sidebar-Adaptable TabView (WWDC 2024, 6:41) + +```swift +TabView { + Tab("Watch Now", systemImage: "play") { + WatchNowView() + } + Tab("Library", systemImage: "books.vertical") { + LibraryView() + } + TabSection("Collections") { + Tab("Cinematic Shots", systemImage: "list.and.film") { + CinematicShotsView() + } + Tab("Forest Life", systemImage: "list.and.film") { + ForestLifeView() + } + } + TabSection("Animations") { + // More tabs... + } + Tab(role: .search) { + SearchView() + } +} +.tabViewStyle(.sidebarAdaptable) +``` + +`TabSection` creates sidebar groups. `.sidebarAdaptable` enables sidebar on iPad, tab bar on iPhone. Search tab with `.search` role gets special placement. + +### 5.4 Tab Customization (WWDC 2024, 10:45) + +```swift +@AppStorage("MyTabViewCustomization") +private var customization: TabViewCustomization + +TabView { + Tab("Watch Now", systemImage: "play", value: .watchNow) { + WatchNowView() + } + .customizationID("Tab.watchNow") + .customizationBehavior(.disabled, for: .sidebar, .tabBar) // Can't be hidden + + Tab("Optional Tab", systemImage: "star", value: .optional) { + OptionalView() + } + .customizationID("Tab.optional") + .defaultVisibility(.hidden, for: .tabBar) // Hidden by default +} +.tabViewCustomization($customization) +``` + +### 5.5 Tab Context Menus + +Use `.contextMenu(menuItems:)` on a `Tab` to add a context menu to its sidebar representation (e.g., right-click on Mac, long-press on iPad sidebar). + +```swift +Tab("Currently Reading", systemImage: "book") { + CurrentBooksList() +} +.contextMenu { + Button { + pinnedTabs.insert(.reading) + } label: { + Label("Pin", systemImage: "pin") + } + Button { + showShareSheet = true + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } +} +``` + +Return an empty closure to deactivate the context menu conditionally: + +```swift +.contextMenu { + if canPin { + Button("Pin", systemImage: "pin") { pin() } + } +} +``` + +#### iPhone Tab Bar Long-Press + +`.contextMenu` on `Tab` only applies to the sidebar representation (iPad/Mac). iPhone tab bar context menus require UIKit interop (adding `UILongPressGestureRecognizer` to `UITabBar` via Introspect or a `UITabBarController` subclass). See `axiom-swiftui-nav-diag` for workaround patterns. + +**Caveat**: Relies on private `UITabBarButton` subviews — fragile across iOS versions, not a public API guarantee. + +### 5.6 Programmatic Tab Visibility + +Use `.hidden(_:)` to show/hide tabs based on app state while preserving their navigation state. + +#### State-Driven Tab Visibility + +```swift +Tab("Libraries", systemImage: "square.stack") { LibrariesView() } + .hidden(context == .browse) // Hide based on app state +``` + +Apply `.hidden(condition)` to each tab. Tabs hidden this way preserve their navigation state (unlike conditional `if` rendering which destroys and recreates them). + +#### State Preservation + +**Key difference**: `.hidden(_:)` preserves tab state, conditional rendering does not. + +```swift +// ✅ State preserved when hidden +Tab("Settings", systemImage: "gear") { + SettingsView() // Navigation stack preserved +} +.hidden(!showSettings) + +// ❌ State lost when condition changes +if showSettings { + Tab("Settings", systemImage: "gear") { + SettingsView() // Navigation stack recreated + } +} +``` + +#### Common Patterns + +```swift +Tab("Beta Features", systemImage: "flask") { BetaView() } + .hidden(!UserDefaults.standard.bool(forKey: "enableBetaFeatures")) +``` + +Same pattern applies to authentication state, purchase status, and debug builds — bind `.hidden()` to any boolean condition. + +#### Animated Transitions + +Wrap state changes in `withAnimation` for smooth tab bar layout transitions: + +```swift +Button("Switch to Browse") { + withAnimation { + context = .browse + selection = .tracks // Switch to first visible tab + } +} +// Tab bar animates as tabs appear/disappear +// Uses system motion curves automatically +``` + +### 5.7 iOS 26+ Tab Features (WWDC 2025, 256) + +```swift +// Tab bar minimization on scroll +TabView { ... } + .tabBarMinimizeBehavior(.onScrollDown) + +// Bottom accessory view (always visible) +TabView { ... } + .tabViewBottomAccessory { + PlaybackControls() + } + +// Dynamic visibility (recommended for mini-players) +// ⚠️ Requires iOS 26.1+ (not 26.0) +TabView { ... } + .tabViewBottomAccessory(isEnabled: showMiniPlayer) { + MiniPlayerView() + .transition(.opacity) + } +// isEnabled: true = shows accessory +// isEnabled: false = hides AND removes reserved space + +// Search tab with dedicated search field +Tab(role: .search) { + NavigationStack { + SearchView() + .navigationTitle("Search") + } + .searchable(text: $searchText) +} +// Morphs into search field when selected +// ⚠️ NavigationStack wrapper required for search field to appear +// Fallback: If no tab has .search role, the tab view applies search +// to ALL tabs, resetting search state when the selected tab changes +``` + +#### Dynamic Bottom Accessory + +The accessory can switch on `activeTab` for per-tab content, though Apple's usage (Music mini-player) keeps it global. Read `@Environment(\.tabViewBottomAccessoryPlacement)` to adapt layout: `.bar` when above tab bar (full controls), other values when inline with collapsed tab bar (compact). + +Reserve `tabViewBottomAccessory` for cross-tab content (playback, status). For tab-specific actions, prefer floating glass buttons within the tab's content view. + +### 5.8 Tab API Quick Reference + +| Modifier | Target | iOS | Purpose | +|----------|--------|-----|---------| +| `Tab(_:systemImage:value:content:)` | — | 18+ | New tab syntax with selection value | +| `Tab(role: .search)` | — | 18+ | Semantic search tab with morph behavior | +| `TabSection(_:content:)` | — | 18+ | Group tabs in sidebar view | +| `.contextMenu(menuItems:)` | Tab | 18+ | Add context menu to tab's sidebar representation | +| `.customizationID(_:)` | Tab | 18+ | Enable user customization | +| `.customizationBehavior(_:for:)` | Tab | 18+ | Control hide/reorder permissions | +| `.defaultVisibility(_:for:)` | Tab | 18+ | Set initial visibility state | +| `.hidden(_:)` | Tab | 18+ | Programmatic visibility with state preservation | +| `.tabViewStyle(.sidebarAdaptable)` | TabView | 18+ | Sidebar on iPad, tabs on iPhone | +| `.tabViewCustomization($binding)` | TabView | 18+ | Persist user tab arrangement | +| `.tabBarMinimizeBehavior(_:)` | TabView | 26+ | Auto-hide on scroll | +| `.tabViewBottomAccessory(isEnabled:content:)` | TabView | 26.1+ | Dynamic content below tab bar | + +--- + +## iOS 26+ Navigation Features + +### 6.1 Liquid Glass Navigation (WWDC 2025, 323) + +Automatic adoption when building with Xcode 26: +- Navigation bars become Liquid Glass +- Sidebars float above content with glass effect +- Tab bars float with new compact appearance +- Toolbars get automatic grouping + +### 6.2 Background Extension Effect + +```swift +NavigationSplitView { + Sidebar() +} detail: { + HeroImage() + .backgroundExtensionEffect() // Content extends behind sidebar +} +``` + +### 6.3 Bottom-Aligned Search (WWDC 2025, 256) + +**Foundational search APIs** For `.searchable`, `isSearching`, suggestions, scopes, tokens, and programmatic control, see `axiom-swiftui-search-ref`. This section covers iOS 26 bottom-aligned refinement only. + +```swift +NavigationSplitView { + Sidebar() +} detail: { + DetailView() +} +.searchable(text: $query, prompt: "What are you looking for?") +// Automatically bottom-aligned on iPhone, top-trailing on iPad +``` + +### 6.4 Scroll Edge Effect + +```swift +// Automatic blur effect when content scrolls under toolbar +// Remove any custom darkening backgrounds - they interfere + +// For dense UIs, adjust sharpness +ScrollView { ... } + .scrollEdgeEffectStyle(.soft) // .sharp, .soft +``` + +### 6.5 Sheet Presentations with Zoom Transition + +In iOS 26, sheets can morph directly out of the buttons that present them. Make the presenting toolbar item a source for a navigation zoom transition, and mark the sheet content as the destination: + +```swift +@Namespace private var namespace + +// Sheet morphs out of presenting button +.toolbar { + ToolbarItem { + Button("Settings") { showSettings = true } + .matchedTransitionSource(id: "settings", in: namespace) + } +} +.sheet(isPresented: $showSettings) { + SettingsView() + .navigationTransition(.zoom(sourceID: "settings", in: namespace)) +} +``` + +Other presentations also flow smoothly out of Liquid Glass controls — menus, alerts, and popovers. Dialogs automatically morph out of the buttons that present them without additional code. + +**Audit tip**: If you've used `presentationBackground` to apply custom backgrounds to sheets, consider removing it and let the new Liquid Glass sheet material shine. Partial height sheets are now inset with glass background by default. + +### 6.6 Toolbar Morphing Transitions + +iOS 26 automatically morphs toolbars during NavigationStack push/pop when each destination view declares its own `.toolbar {}`. Items with matching `toolbar(id:)` and `ToolbarItem(id:)` IDs stay stable during the transition (no bounce), while unmatched items animate in/out. + +**Key rule**: Attach `.toolbar {}` to individual views inside NavigationStack, not to NavigationStack itself. Otherwise there is nothing to morph between. + +See `axiom-swiftui-26-ref` skill for complete toolbar morphing API including DefaultToolbarItem, `toolbar(id:)` stable items, ToolbarSpacer patterns, and troubleshooting. + +--- + +## Router/Coordinator Patterns + +### 7.1 When to Use Coordinators + +**Use coordinators when:** +- Navigation logic is complex with conditional flows +- Testing navigation in isolation +- Sharing navigation logic across multiple screens +- UIKit interop with heavy navigation requirements + +**Use built-in navigation when:** +- Simple linear or hierarchical navigation +- State restoration is primary concern +- Fewer than 5-10 navigation destinations +- No need for navigation unit testing + +### 7.2 Simple Router Pattern + +```swift +// Route enum defines all possible destinations +enum AppRoute: Hashable { + case home + case category(Category) + case recipe(Recipe) + case settings +} + +// Router class manages navigation +@Observable +class Router { + var path = NavigationPath() + + func navigate(to route: AppRoute) { + path.append(route) + } + + func popToRoot() { + path.removeLast(path.count) + } + + func pop() { + if !path.isEmpty { + path.removeLast() + } + } +} + +// Usage in views +struct ContentView: View { + @State private var router = Router() + + var body: some View { + NavigationStack(path: $router.path) { + HomeView() + .navigationDestination(for: AppRoute.self) { route in + switch route { + case .home: + HomeView() + case .category(let category): + CategoryView(category: category) + case .recipe(let recipe): + RecipeDetail(recipe: recipe) + case .settings: + SettingsView() + } + } + } + .environment(router) + } +} + +// In child views +struct RecipeCard: View { + let recipe: Recipe + @Environment(Router.self) private var router + + var body: some View { + Button(recipe.name) { + router.navigate(to: .recipe(recipe)) + } + } +} +``` + +### 7.3 Coordinator Pattern with Protocol + +For larger apps, extract a `Coordinator` protocol with `associatedtype Route: Hashable` and `var path: NavigationPath`. Each feature area gets its own coordinator conformance with domain-specific routes and convenience methods (e.g., `showRecipeOfTheDay()` that resets path and navigates). + +### 7.4 Testing Navigation + +```swift +// Router is easily testable +func testNavigateToRecipe() { + let router = Router() + let recipe = Recipe(name: "Apple Pie") + + router.navigate(to: .recipe(recipe)) + + XCTAssertEqual(router.path.count, 1) +} + +func testPopToRoot() { + let router = Router() + router.navigate(to: .category(.desserts)) + router.navigate(to: .recipe(Recipe(name: "Apple Pie"))) + + router.popToRoot() + + XCTAssertTrue(router.path.isEmpty) +} +``` + +--- + +## Testing Checklist + +- [ ] Deep links navigate correctly from cold start AND while running +- [ ] Pop to root clears entire stack +- [ ] State restores on app relaunch (SceneStorage key unique per scene) +- [ ] Deleted items handled gracefully in restoration (compactMap) +- [ ] NavigationSplitView collapses correctly on iPhone (selection pushes) +- [ ] iOS 26+: Liquid Glass appearance, bottom-aligned search, tab bar minimization + +--- + +## API Quick Reference + +### NavigationStack + +```swift +NavigationStack { content } +NavigationStack(path: $path) { content } +``` + +### NavigationSplitView + +```swift +NavigationSplitView { sidebar } detail: { detail } +NavigationSplitView { sidebar } content: { content } detail: { detail } +NavigationSplitView(columnVisibility: $visibility) { ... } +``` + +### NavigationLink + +```swift +NavigationLink(title, value: value) +NavigationLink(value: value) { label } +``` + +### NavigationPath + +```swift +path.append(value) +path.removeLast() +path.removeLast(path.count) +path.count +path.codable // For encoding +NavigationPath(codableRepresentation) // For decoding +``` + +### Modifiers + +```swift +.navigationTitle("Title") +.navigationDestination(for: Type.self) { value in View } +.searchable(text: $query) +.tabViewStyle(.sidebarAdaptable) +.tabBarMinimizeBehavior(.onScrollDown) +.backgroundExtensionEffect() +``` + +--- + +## Resources + +**WWDC**: 2022-10054, 2024-10147, 2025-256, 2025-323 (Build a SwiftUI app with the new design) + +**Docs**: /swiftui/tabrole/search, /swiftui/view/tabbarminimizebehavior(_:), /swiftui/view/tabviewbottomaccessory(isenabled:content:) + +**Skills**: axiom-swiftui-nav, axiom-swiftui-nav-diag, axiom-swiftui-26-ref, axiom-liquid-glass, axiom-swiftui-search-ref + +--- + +**Last Updated** Based on WWDC 2022-10054, WWDC 2024-10147, WWDC 2025-256, WWDC 2025-323 (Build a SwiftUI app with the new design) +**Platforms** iOS 16+, iPadOS 16+, macOS 13+, watchOS 9+, tvOS 16+ diff --git a/.claude/skills/axiom-swiftui-nav-ref/agents/openai.yaml b/.claude/skills/axiom-swiftui-nav-ref/agents/openai.yaml new file mode 100644 index 0000000..13c1099 --- /dev/null +++ b/.claude/skills/axiom-swiftui-nav-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftUI Nav Reference" + short_description: "Reference — Comprehensive SwiftUI navigation guide covering NavigationStack (iOS 16+), NavigationSplitView (iOS 16+),..." diff --git a/.claude/skills/axiom-swiftui-nav/.openskills.json b/.claude/skills/axiom-swiftui-nav/.openskills.json new file mode 100644 index 0000000..623dbb5 --- /dev/null +++ b/.claude/skills/axiom-swiftui-nav/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftui-nav", + "installedAt": "2026-04-12T08:06:49.541Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftui-nav/SKILL.md b/.claude/skills/axiom-swiftui-nav/SKILL.md new file mode 100644 index 0000000..07f6678 --- /dev/null +++ b/.claude/skills/axiom-swiftui-nav/SKILL.md @@ -0,0 +1,836 @@ +--- +name: axiom-swiftui-nav +description: Use when implementing navigation patterns, choosing between NavigationStack and NavigationSplitView, handling deep links, adopting coordinator patterns, or requesting code review of navigation implementation - prevents navigation state corruption, deep link failures, and state restoration bugs for iOS 18+ +license: MIT +compatibility: iOS 18+ (Tab/Sidebar), iOS 26+ (Liquid Glass) +metadata: + version: "1.0.0" + last-updated: "2025-12-05" +--- + +# SwiftUI Navigation + +## When to Use This Skill + +Use when: +- Choosing navigation architecture (NavigationStack vs NavigationSplitView vs TabView) +- Implementing programmatic navigation with NavigationPath +- Setting up deep linking and URL routing +- Implementing state restoration for navigation +- Adopting Tab/Sidebar patterns (iOS 18+) +- Implementing coordinator/router patterns +- Requesting code review of navigation implementation before shipping + +#### Related Skills +- Use `axiom-swiftui-nav-diag` for systematic troubleshooting of navigation failures +- Use `axiom-swiftui-nav-ref` for comprehensive API reference (including Tab customization, iOS 26+ features) with all WWDC examples + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "Should I use NavigationStack or NavigationSplitView for my app?" +-> The skill provides a decision tree based on device targets, content hierarchy depth, and multiplatform requirements + +#### 2. "How do I navigate programmatically in SwiftUI?" +-> The skill shows NavigationPath manipulation patterns for push, pop, pop-to-root, and deep linking + +#### 3. "My deep links aren't working. The app opens but shows the wrong screen." +-> The skill covers URL parsing patterns, path construction order, and timing issues with onOpenURL + +#### 4. "Navigation state is lost when my app goes to background." +-> The skill demonstrates Codable NavigationPath, SceneStorage persistence, and crash-resistant restoration + +#### 5. "How do I implement a coordinator pattern in SwiftUI?" +-> The skill provides Router pattern examples alongside guidance on when coordinators add value vs complexity + +--- + +## Red Flags — Anti-Patterns to Prevent + +If you're doing ANY of these, STOP and use the patterns in this skill: + +### ❌ CRITICAL — Never Do These + +#### 1. Using deprecated NavigationView on iOS 16+ +```swift +// ❌ WRONG — Deprecated, different behavior on iOS 16+ +NavigationView { + List { ... } +} +.navigationViewStyle(.stack) +``` +**Why this fails** NavigationView is deprecated since iOS 16. It lacks NavigationPath support, making programmatic navigation and deep linking unreliable. Different behavior across iOS versions causes bugs. + +#### 2. Using view-based NavigationLink for programmatic navigation +```swift +// ❌ WRONG — Cannot programmatically control +NavigationLink("Recipe") { + RecipeDetail(recipe: recipe) // View destination, no value +} +``` +**Why this fails** View-based links cannot be controlled programmatically. No way to deep link or pop to this destination. Deprecated since iOS 16. + +#### 3. Putting navigationDestination inside lazy containers +```swift +// ❌ WRONG — May not be loaded when needed +LazyVGrid(columns: columns) { + ForEach(items) { item in + NavigationLink(value: item) { ... } + .navigationDestination(for: Item.self) { item in // Don't do this + ItemDetail(item: item) + } + } +} +``` +**Why this fails** Lazy containers don't load all views immediately. navigationDestination may not be visible to NavigationStack, causing navigation to silently fail. + +#### 4. Storing full model objects in NavigationPath for restoration +```swift +// ❌ WRONG — Duplicates data, stale on restore +class NavigationModel: Codable { + var path: [Recipe] = [] // Full Recipe objects +} +``` +**Why this fails** Duplicates data already in your model. On restore, Recipe data may be stale (edited/deleted elsewhere). Use IDs and resolve to current data. + +#### 5. Modifying NavigationPath outside MainActor +```swift +// ❌ WRONG — UI update off main thread +Task.detached { + await viewModel.path.append(recipe) // Background thread +} +``` +**Why this fails** NavigationPath binds to UI. Modifications must happen on MainActor or navigation state becomes corrupted. Can cause crashes or silent failures. + +#### 6. Missing @MainActor isolation for navigation state +```swift +// ❌ WRONG — Not MainActor isolated +class Router: ObservableObject { + @Published var path = NavigationPath() // No @MainActor +} +``` +**Why this fails** In Swift 6 strict concurrency, @Published properties accessed from SwiftUI views require MainActor isolation. Causes data race warnings and potential crashes. + +#### 7. Not handling navigation state in multi-tab apps +```swift +// ❌ WRONG — Shared NavigationPath across tabs +TabView { + Tab("Home") { HomeView() } + Tab("Settings") { SettingsView() } +} +// All tabs share same NavigationStack — wrong! +``` +**Why this fails** Each tab should have its own NavigationStack to preserve navigation state when switching tabs. Shared state causes confusing UX. + +#### 8. Ignoring NavigationPath decoding errors +```swift +// ❌ WRONG — Crashes on invalid data +let path = NavigationPath(try! decoder.decode(NavigationPath.CodableRepresentation.self, from: data)) +``` +**Why this fails** User may have deleted items that were in the path. Schema may have changed. Force unwrap causes crash on restore. + +--- + +## Mandatory First Steps + +**ALWAYS complete these steps** before implementing navigation: + +```swift +// Step 1: Identify your navigation structure +// Ask: Single stack? Multi-column? Tab-based with per-tab navigation? +// Record answer before writing any code + +// Step 2: Choose container based on structure +// Single stack (iPhone-primary): NavigationStack +// Multi-column (iPad/Mac-primary): NavigationSplitView +// Tab-based: TabView with NavigationStack per tab + +// Step 3: Define your value types for navigation +// All values pushed on NavigationStack must be Hashable +// For deep linking/restoration, also Codable +struct Recipe: Hashable, Codable, Identifiable { ... } + +// Step 4: Plan deep link URLs (if needed) +// myapp://recipe/{id} +// myapp://category/{name}/recipe/{id} + +// Step 5: Plan state restoration (if needed) +// Will you use SceneStorage? What data must be Codable? +``` + +--- + +## Quick Decision Tree + +``` +Need navigation? +├─ Multi-column interface (iPad/Mac primary)? +│ └─ NavigationSplitView +│ ├─ Need drill-down in detail column? +│ │ └─ NavigationStack inside detail (Pattern 3) +│ └─ Selection-only detail? +│ └─ Just selection binding (Pattern 2) +├─ Tab-based app? +│ └─ TabView +│ ├─ Each tab needs drill-down? +│ │ └─ NavigationStack per tab (Pattern 4) +│ └─ iPad sidebar experience? +│ └─ .tabViewStyle(.sidebarAdaptable) (Pattern 5) +└─ Single-column stack? + └─ NavigationStack + ├─ Need deep linking? + │ └─ Use NavigationPath (Pattern 1b) + └─ Simple push/pop? + └─ Typed array path (Pattern 1a) + +Need state restoration? +└─ SceneStorage + Codable NavigationPath (Pattern 6) + +Need coordinator abstraction? +├─ Complex conditional flows? +├─ Navigation logic testing needed? +├─ Sharing navigation across many screens? +└─ YES to any → Router pattern (Pattern 7) + NO to all → Use NavigationPath directly +``` + +--- + +## Pattern 1a: Basic NavigationStack + +**When**: Simple push/pop navigation, all destinations same type + +**Time cost**: 5-10 min + +```swift +struct RecipeList: View { + @State private var path: [Recipe] = [] + + var body: some View { + NavigationStack(path: $path) { + List(recipes) { recipe in + NavigationLink(recipe.name, value: recipe) + } + .navigationTitle("Recipes") + .navigationDestination(for: Recipe.self) { recipe in + RecipeDetail(recipe: recipe) + } + } + } + + // Programmatic navigation + func showRecipe(_ recipe: Recipe) { + path.append(recipe) + } + + func popToRoot() { + path.removeAll() + } +} +``` + +**Key points:** +- Typed array `[Recipe]` when all values are same type +- Value-based `NavigationLink(title, value:)` +- `navigationDestination(for:)` outside lazy containers + +--- + +## Pattern 1b: NavigationStack with Deep Linking + +**When**: Multiple destination types, URL-based deep linking + +**Time cost**: 15-20 min + +```swift +struct ContentView: View { + @State private var path = NavigationPath() + + var body: some View { + NavigationStack(path: $path) { + HomeView() + .navigationDestination(for: Category.self) { category in + CategoryView(category: category) + } + .navigationDestination(for: Recipe.self) { recipe in + RecipeDetail(recipe: recipe) + } + } + .onOpenURL { url in + handleDeepLink(url) + } + } + + func handleDeepLink(_ url: URL) { + // URL: myapp://category/desserts/recipe/apple-pie + path.removeLast(path.count) // Pop to root first + + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return } + let segments = components.path.split(separator: "/").map(String.init) + + var index = 0 + while index < segments.count - 1 { + switch segments[index] { + case "category": + if let category = Category(rawValue: segments[index + 1]) { + path.append(category) + } + index += 2 + case "recipe": + if let recipe = dataModel.recipe(named: segments[index + 1]) { + path.append(recipe) + } + index += 2 + default: + index += 1 + } + } + } +} +``` + +**Key points:** +- `NavigationPath` for heterogeneous types +- Pop to root before building deep link path +- Build path in correct order (parent → child) + +--- + +## Pattern 2: NavigationSplitView Selection-Based + +**When**: Multi-column layout where detail shows selected item + +**Time cost**: 10-15 min + +```swift +struct MultiColumnView: View { + @State private var selectedCategory: Category? + @State private var selectedRecipe: Recipe? + + var body: some View { + NavigationSplitView { + List(Category.allCases, selection: $selectedCategory) { category in + NavigationLink(category.name, value: category) + } + .navigationTitle("Categories") + } content: { + if let category = selectedCategory { + List(recipes(in: category), selection: $selectedRecipe) { recipe in + NavigationLink(recipe.name, value: recipe) + } + .navigationTitle(category.name) + } else { + Text("Select a category") + } + } detail: { + if let recipe = selectedRecipe { + RecipeDetail(recipe: recipe) + } else { + Text("Select a recipe") + } + } + } +} +``` + +**Key points:** +- `selection: $binding` on List connects to column selection +- Value-presenting links update selection automatically +- Adapts to single stack on iPhone + +--- + +## Pattern 3: NavigationSplitView with Stack in Detail + +**When**: Multi-column with drill-down capability in detail + +**Time cost**: 20-25 min + +```swift +struct GridWithDrillDown: View { + @State private var selectedCategory: Category? + @State private var path: [Recipe] = [] + + var body: some View { + NavigationSplitView { + List(Category.allCases, selection: $selectedCategory) { category in + NavigationLink(category.name, value: category) + } + .navigationTitle("Categories") + } detail: { + NavigationStack(path: $path) { + if let category = selectedCategory { + RecipeGrid(category: category) + .navigationDestination(for: Recipe.self) { recipe in + RecipeDetail(recipe: recipe) + } + } else { + Text("Select a category") + } + } + } + } +} +``` + +**Key points:** +- NavigationStack inside detail column +- Grid → Detail drill-down while preserving sidebar +- Separate path for drill-down, selection for sidebar + +--- + +## Pattern 4: TabView with Per-Tab NavigationStack + +**When**: Tab-based app where each tab has its own navigation + +**Time cost**: 15-20 min + +```swift +struct TabBasedApp: View { + var body: some View { + TabView { + Tab("Home", systemImage: "house") { + NavigationStack { + HomeView() + .navigationDestination(for: Item.self) { item in + ItemDetail(item: item) + } + } + } + + Tab("Search", systemImage: "magnifyingglass") { + NavigationStack { + SearchView() + } + } + + Tab("Settings", systemImage: "gear") { + NavigationStack { + SettingsView() + } + } + } + } +} +``` + +**Key points:** +- Each Tab has its own NavigationStack +- Navigation state preserved when switching tabs +- iOS 18+ Tab syntax with systemImage + +--- + +## Pattern 5: Sidebar-Adaptable TabView (iOS 18+) + +**When**: Tab bar on iPhone, sidebar on iPad + +**Time cost**: 20-25 min + +```swift +struct AdaptableApp: View { + var body: some View { + TabView { + Tab("Watch Now", systemImage: "play") { + WatchNowView() + } + Tab("Library", systemImage: "books.vertical") { + LibraryView() + } + + TabSection("Collections") { + Tab("Favorites", systemImage: "star") { + FavoritesView() + } + Tab("Recently Added", systemImage: "clock") { + RecentView() + } + } + + Tab(role: .search) { + SearchView() + } + } + .tabViewStyle(.sidebarAdaptable) + } +} +``` + +**Key points:** +- `.tabViewStyle(.sidebarAdaptable)` enables sidebar on iPad +- `TabSection` creates collapsible groups in sidebar +- `Tab(role: .search)` gets special placement + +--- + +## Pattern 6: State Restoration + +**When**: Preserve navigation state across app launches + +**Time cost**: 25-30 min + +```swift +@MainActor +class NavigationModel: ObservableObject, Codable { + @Published var selectedCategory: Category? + @Published var recipePath: [Recipe.ID] = [] // Store IDs, not objects + + enum CodingKeys: String, CodingKey { + case selectedCategory, recipePath + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory) + try container.encode(recipePath, forKey: .recipePath) + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + selectedCategory = try container.decodeIfPresent(Category.self, forKey: .selectedCategory) + recipePath = try container.decode([Recipe.ID].self, forKey: .recipePath) + } + + init() {} + + var jsonData: Data? { + get { try? JSONEncoder().encode(self) } + set { + guard let data = newValue, + let model = try? JSONDecoder().decode(NavigationModel.self, from: data) + else { return } + selectedCategory = model.selectedCategory + recipePath = model.recipePath + } + } +} + +struct ContentView: View { + @StateObject private var navModel = NavigationModel() + @SceneStorage("navigation") private var data: Data? + + var body: some View { + NavigationStack(path: $navModel.recipePath) { + // Content + } + .task { + if let data { navModel.jsonData = data } + for await _ in navModel.objectWillChange.values { + data = navModel.jsonData + } + } + } +} +``` + +**Key points:** +- Store IDs, resolve to current objects +- `@MainActor` for Swift 6 concurrency safety +- SceneStorage for automatic scene-scoped persistence +- Use `compactMap` when resolving IDs to handle deleted items + +--- + +## Pattern 7: Router/Coordinator + +**When**: Complex navigation logic, need testability + +**Time cost**: 30-45 min + +```swift +enum AppRoute: Hashable { + case home + case category(Category) + case recipe(Recipe) + case settings +} + +@Observable +@MainActor +class Router { + var path = NavigationPath() + + func navigate(to route: AppRoute) { + path.append(route) + } + + func pop() { + guard !path.isEmpty else { return } + path.removeLast() + } + + func popToRoot() { + path.removeLast(path.count) + } + + func showRecipeOfTheDay() { + popToRoot() + if let recipe = DataModel.shared.recipeOfTheDay { + path.append(AppRoute.recipe(recipe)) + } + } +} + +struct ContentView: View { + @State private var router = Router() + + var body: some View { + NavigationStack(path: $router.path) { + HomeView() + .navigationDestination(for: AppRoute.self) { route in + switch route { + case .home: HomeView() + case .category(let cat): CategoryView(category: cat) + case .recipe(let recipe): RecipeDetail(recipe: recipe) + case .settings: SettingsView() + } + } + } + .environment(router) + } +} +``` + +**When coordinators add value:** +- Complex conditional navigation flows +- Navigation logic needs unit testing +- Multiple views trigger same navigation +- UIKit interop with custom transitions + +**When coordinators add complexity without value:** +- Simple linear navigation +- < 5 navigation destinations +- No need for navigation testing +- NavigationPath already handles your deep linking + +--- + +## Anti-Patterns (DO NOT DO THIS) + +### ❌ Nesting NavigationStack inside NavigationStack + +```swift +// ❌ WRONG — Nested stacks +NavigationStack { + SomeView() + .sheet(isPresented: $showSheet) { + NavigationStack { // Creates separate stack — confusing + SheetContent() + } + } +} +``` + +**Issue** Two navigation stacks create confusing UX. Back button behavior unclear. +**Fix** Use single NavigationStack, present sheets without nested navigation when possible. + +### ❌ Using NavigationLink inside Button + +```swift +// ❌ WRONG — Double navigation triggers +Button("Go") { + // Some action +} label: { + NavigationLink(value: item) { // Fires on button AND link + Text("Item") + } +} +``` + +**Issue** Both Button and NavigationLink respond to taps. +**Fix** Use only NavigationLink, put action in `.simultaneousGesture` if needed. + +### ❌ Creating NavigationPath in view body + +```swift +// ❌ WRONG — Recreated every render +var body: some View { + let path = NavigationPath() // Reset on every render! + NavigationStack(path: .constant(path)) { ... } +} +``` + +**Issue** Path recreated each render, navigation state lost. +**Fix** Use `@State` or `@StateObject` for navigation state. + +--- + +## Pressure Scenario: "Make Navigation Like Instagram" + +### The Problem + +Product/design asks for complex navigation like Instagram: +- "Tab bar with per-tab navigation stacks" +- "Smooth coordinator pattern for all flows" +- "Deep linking to any screen" +- "Profile accessible from anywhere" + +### Red Flags — Recognize Over-Engineering Pressure + +If you hear ANY of these, **STOP and evaluate**: + +- 🚩 **"Let's build a full coordinator layer before any views"** → Usually YAGNI +- 🚩 **"We need a navigation architecture that handles anything"** → Scope creep +- 🚩 **"Instagram/TikTok does it this way"** → They have 100+ engineers + +### Time Cost Comparison + +#### Option A: Over-Engineered Coordinator +- Time to build coordinator layer: 3-5 days +- Time to maintain and debug: Ongoing +- Time when requirements change: Significant refactor + +#### Option B: Built-in Navigation + Simple Router +- Time to implement Pattern 4 (TabView + NavigationStack): 2-3 hours +- Time to add Router if needed: 1-2 hours +- Time when requirements change: Incremental additions + +### How to Push Back Professionally + +#### Step 1: Quantify Current Needs +``` +"Let's list our actual navigation flows: +1. Home → Item Detail +2. Search → Results → Item Detail +3. Profile → Settings + +That's 6 destinations. NavigationPath handles this natively." +``` + +#### Step 2: Show the Built-in Solution +``` +"Here's our navigation with NavigationStack + NavigationPath: +[Show Pattern 1b code] + +This gives us: +- Programmatic navigation ✓ +- Deep linking ✓ +- State restoration ✓ +- Type safety ✓ + +Without a coordinator layer." +``` + +#### Step 3: Offer Incremental Path +``` +"If we find NavigationPath insufficient, we can add a Router +(Pattern 7) later. It's 30-45 minutes of work. + +But let's start with the simpler solution and add complexity +only when we hit a real limitation." +``` + +### Real-World Example: 48-Hour Feature Push + +**Scenario:** +- PM: "We need deep linking for the campaign launch in 2 days" +- Lead: "Let's build a proper coordinator first" +- Time available: 16 working hours + +**Wrong approach:** +- 8 hours: Build coordinator infrastructure +- 4 hours: Debug coordinator edge cases +- 4 hours: Rush deep linking on broken foundation +- Result: Buggy, deadline missed + +**Correct approach:** +- 2 hours: Implement Pattern 1b (NavigationStack with deep linking) +- 1 hour: Test all deep link URLs +- 1 hour: Add SceneStorage restoration (Pattern 6) +- Result: Working deep links in 4 hours, 12 hours for polish/testing + +--- + +## Pressure Scenario: "NavigationView Backward Compatibility" + +### The Problem + +Team lead says: "Let's use NavigationView so we support iOS 15" + +### Red Flags + +- 🚩 NavigationView deprecated since iOS 16 (2022) +- 🚩 Different behavior across iOS versions causes bugs +- 🚩 No NavigationPath support — can't deep link properly + +### Data to Share + +``` +iOS 16+ adoption: 95%+ of active devices (as of 2024) +iOS 15: < 5% and declining + +NavigationView limitations: +- No programmatic path manipulation +- No type-safe navigation +- No built-in state restoration +- Behavior varies by iOS version +``` + +### Push-Back Script + +``` +"NavigationView was deprecated in iOS 16 (2022). Here's the impact: + +1. We lose NavigationPath — can't implement deep linking reliably +2. Behavior differs between iOS 15 and 16 — more bugs to maintain +3. iOS 15 is < 5% of users — we're adding complexity for small audience + +Recommendation: Set deployment target to iOS 16, use NavigationStack. +If iOS 15 support is required, use NavigationStack with @available +checks and fallback UI for older devices." +``` + +--- + +## Code Review Checklist + +### Navigation Architecture +- [ ] Correct container for use case (Stack vs SplitView vs TabView) +- [ ] Value-based NavigationLink (not view-based) +- [ ] navigationDestination outside lazy containers +- [ ] Each tab has own NavigationStack (if tab-based) + +### State Management +- [ ] NavigationPath in @State or @StateObject (not recreated in body) +- [ ] @MainActor isolation for navigation state (Swift 6) +- [ ] IDs stored for restoration (not full objects) +- [ ] Error handling for decode failures + +### Deep Linking +- [ ] onOpenURL handler present +- [ ] Pop to root before building path +- [ ] Path built in correct order (parent → child) +- [ ] Missing data handled gracefully + +### iOS 26+ Features +- [ ] No custom backgrounds interfering with Liquid Glass +- [ ] Bottom-aligned search working on iPhone +- [ ] Tab bar minimization if appropriate + +--- + +## Troubleshooting Quick Reference + +| Symptom | Likely Cause | Pattern | +|---------|--------------|---------| +| Navigation doesn't respond to taps | NavigationLink outside NavigationStack | Check hierarchy | +| Double navigation on tap | Button wrapping NavigationLink | Remove Button wrapper | +| State lost on tab switch | Shared NavigationStack across tabs | Pattern 4 | +| State lost on background | No SceneStorage | Pattern 6 | +| Deep link shows wrong screen | Path built in wrong order | Pattern 1b | +| Crash on restore | Force unwrap decode | Handle errors gracefully | + +--- + +## Resources + +**WWDC**: 2022-10054, 2024-10147, 2025-256, 2025-323 + +**Skills**: axiom-swiftui-nav-diag, axiom-swiftui-nav-ref + +--- + +**Last Updated** Based on WWDC 2022-2025 navigation sessions +**Platforms** iOS 18+, iPadOS 18+, macOS 15+, watchOS 11+, tvOS 18+ diff --git a/.claude/skills/axiom-swiftui-nav/agents/openai.yaml b/.claude/skills/axiom-swiftui-nav/agents/openai.yaml new file mode 100644 index 0000000..0300138 --- /dev/null +++ b/.claude/skills/axiom-swiftui-nav/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftUI Nav" + short_description: "Implementing navigation patterns, choosing between NavigationStack and NavigationSplitView, handling deep links, adop..." diff --git a/.claude/skills/axiom-swiftui-performance/.openskills.json b/.claude/skills/axiom-swiftui-performance/.openskills.json new file mode 100644 index 0000000..92cd45f --- /dev/null +++ b/.claude/skills/axiom-swiftui-performance/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftui-performance", + "installedAt": "2026-04-12T08:06:50.568Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftui-performance/SKILL.md b/.claude/skills/axiom-swiftui-performance/SKILL.md new file mode 100644 index 0000000..5ceec01 --- /dev/null +++ b/.claude/skills/axiom-swiftui-performance/SKILL.md @@ -0,0 +1,1137 @@ +--- +name: axiom-swiftui-performance +description: Use when UI is slow, scrolling lags, animations stutter, or when asking 'why is my SwiftUI view slow', 'how do I optimize List performance', 'my app drops frames', 'view body is called too often', 'List is laggy' - SwiftUI performance optimization with Instruments 26 and WWDC 2025 patterns +license: MIT +compatibility: iOS 26+, iPadOS 26+, macOS Tahoe+, axiom-visionOS 3+. Xcode 26+ +metadata: + version: "1.1.0" + last-updated: "TDD-tested with production performance crisis scenarios" +--- + +# SwiftUI Performance Optimization + +## When to Use This Skill + +Use when: +- App feels less responsive (hitches, hangs, delayed scrolling) +- Animations pause or jump during execution +- Scrolling performance is poor +- Profiling reveals SwiftUI is the bottleneck +- View bodies are taking too long to run +- Views are updating more frequently than necessary +- Need to understand cause-and-effect of SwiftUI updates + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "My app has janky scrolling and animations are stuttering. How do I figure out if SwiftUI is the cause?" +→ The skill shows how to use the new SwiftUI Instrument in Instruments 26 to identify if SwiftUI is the bottleneck vs other layers + +#### 2. "I'm using the new SwiftUI Instrument and I see orange/red bars showing long updates. How do I know what's causing them?" +→ The skill covers the Cause & Effect Graph patterns that show data flow through your app and which state changes trigger expensive updates + +#### 3. "Some views are updating way too often even though their data hasn't changed. How do I find which views are the problem?" +→ The skill demonstrates unnecessary update detection and Identity troubleshooting with the visual timeline + +#### 4. "I have large data structures and complex view hierarchies. How do I optimize them for SwiftUI performance?" +→ The skill covers performance patterns: breaking down view hierarchies, minimizing body complexity, and using the @Sendable optimization checklist + +#### 5. "We have a performance deadline and I need to understand what's slow in SwiftUI. What are the critical metrics?" +→ The skill provides the decision tree for prioritizing optimizations and understands pressure scenarios with professional guidance for trade-offs + +--- + +## Overview + +**Core Principle**: Ensure your view bodies update quickly and only when needed to achieve great SwiftUI performance. + +**NEW in WWDC 2025**: Next-generation SwiftUI instrument in Instruments 26 provides comprehensive performance analysis with: +- Visual timeline of long updates (color-coded orange/red by severity) +- Cause & Effect Graph showing data flow through your app +- Integration with Time Profiler for CPU analysis +- Hangs and Hitches tracking + +**Key Performance Problems**: +1. **Long View Body Updates** — View bodies taking too long to run +2. **Unnecessary View Updates** — Views updating when data hasn't actually changed + +--- + +## iOS 26 Framework Performance Improvements + +**"Performance improvements to the framework benefit apps across all of Apple's platforms, from our app to yours."** — WWDC 2025-256 + +SwiftUI in iOS 26 includes major performance wins that benefit all apps automatically. These improvements work alongside the new profiling tools to make SwiftUI faster out of the box. + +### List Performance (macOS Focus) + +#### Massive gains for large lists + +- **6x faster loading** for lists of 100,000+ items on macOS +- **16x faster updates** for large lists +- **Even bigger gains** for larger lists +- Improvements benefit **all platforms** (iOS, iPadOS, watchOS, not just macOS) + +```swift +List(trips) { trip in // 100k+ items + TripRow(trip: trip) +} +// iOS 26: Loads 6x faster, updates 16x faster on macOS +// All platforms benefit from performance improvements +``` + +#### Impact on your app +- Large datasets (10k+ items) see noticeable improvements +- Filtering and sorting operations complete faster +- Real-time updates to lists are more responsive +- Benefits apps like file browsers, contact lists, data tables + +### Scrolling Performance + +#### Reduced dropped frames during high-speed scrolling + +SwiftUI has improved scheduling of user interface updates on iOS and macOS. This improves responsiveness and lets SwiftUI do even more work to prepare for upcoming frames. All in all, it reduces the chance of your app dropping a frame while scrolling quickly at high frame rates. + +#### Key improvements +1. **Better frame scheduling** — SwiftUI gets more time to prepare for upcoming frames +2. **Improved responsiveness** — UI updates scheduled more efficiently +3. **Fewer dropped frames** — Especially during quick scrolling at 120Hz (ProMotion) + +#### When you'll notice +- Scrolling through image-heavy content +- High frame rate devices (iPhone Pro, iPad Pro with ProMotion) +- Complex list rows with multiple views + +### Nested ScrollViews with Lazy Stacks + +#### Photo carousels and multi-axis scrolling now properly optimize + +```swift +ScrollView(.horizontal) { + LazyHStack { + ForEach(photoSets) { photoSet in + ScrollView(.vertical) { + LazyVStack { + ForEach(photoSet.photos) { photo in + PhotoView(photo: photo) + } + } + } + } + } +} +// iOS 26: Nested scrollviews now properly delay loading with lazy stacks +// Great for photo carousels, Netflix-style layouts, multi-axis content +``` + +**Before iOS 26** Nested ScrollViews didn't properly delay loading lazy stack content, causing all nested content to load immediately. + +**After iOS 26** Lazy stacks inside nested ScrollViews now delay loading until content is about to appear, matching the behavior of single-level ScrollViews. + +#### Use cases +- Photo galleries with horizontal/vertical scrolling +- Netflix-style category rows +- Multi-dimensional data browsers +- Image carousels with vertical detail scrolling + +### SwiftUI Performance Instrument Enhancements + +#### New lanes in Instruments 26 + +The SwiftUI instrument now includes dedicated lanes for: + +1. **Long View Body Updates** — Identify expensive body computations +2. **Platform View Updates** — Track UIKit/AppKit bridging performance (Long Representable Updates) +3. **Other Long Updates** — All other types of long SwiftUI work + +These lanes are covered in detail in the next section. + +### Performance Improvement Summary + +#### Automatic wins (recompile only) +- ✅ 6x faster list loading (100k+ items, macOS) +- ✅ 16x faster list updates (macOS) +- ✅ Reduced dropped frames during scrolling +- ✅ Improved frame scheduling on iOS/macOS +- ✅ Nested ScrollView lazy loading optimization + +**No code changes required** — rebuild with iOS 26 SDK to get these improvements. + +**Cross-reference** SwiftUI 26 Features (swiftui-26-ref skill) — Comprehensive guide to all iOS 26 SwiftUI changes + +--- + +## The SwiftUI Instrument (Instruments 26) + +### Getting Started + +**Requirements**: +- Install Xcode 26 +- Update devices to latest OS releases (support for recording SwiftUI traces) +- Build app in Release mode for accurate profiling + +**Launch**: +1. Open project in Xcode +2. Press **Command-I** to profile +3. Choose **SwiftUI template** from template chooser +4. Click Record button + +### Template Contents + +The SwiftUI template includes three instruments: + +1. **SwiftUI Instrument** (NEW) — Identifies performance issues in SwiftUI code +2. **Time Profiler** — Shows CPU work samples over time +3. **Hangs and Hitches** — Tracks app responsiveness + +### SwiftUI Instrument Track Lanes + +#### Lane 1: Update Groups +- Shows when SwiftUI is actively doing work +- **Empty during CPU spikes?** → Problem likely outside SwiftUI + +#### Lane 2: Long View Body Updates +- Highlights when `body` property takes too long +- **Most common performance issue** — start here + +#### Lane 3: Long Representable Updates +- Identifies slow UIViewRepresentable/NSViewRepresentable updates +- UIKit/AppKit integration performance + +#### Lane 4: Other Long Updates +- All other types of long SwiftUI work + +### Color-Coding System + +Updates shown in **orange** and **red** based on likelihood to cause hitches: + +- **Red** — Very likely to contribute to hitch/hang (investigate first) +- **Orange** — Moderately likely to cause issues +- **Gray** — Normal updates, not concerning + +**Note**: Whether updates actually result in hitches depends on device conditions, but red updates are the highest priority. + +--- + +## Understanding the Render Loop + +### Normal Frame Rendering + +``` +Frame 1: +├─ Handle events (touches, key presses) +├─ Update UI (run view bodies) +│ └─ Complete before frame deadline ✅ +├─ Hand off to system +└─ System renders → Visible on screen + +Frame 2: +├─ Handle events +├─ Update UI +│ └─ Complete before frame deadline ✅ +├─ Hand off to system +└─ System renders → Visible on screen +``` + +**Result**: Smooth, fluid animations + +### Frame with Hitch (Long View Body) + +``` +Frame 1: +├─ Handle events +├─ Update UI +│ └─ ONE VIEW BODY TOO SLOW +│ └─ Runs past frame deadline ❌ +├─ Miss deadline +└─ Previous frame stays visible (HITCH) + +Frame 2: (Delayed) +├─ Handle events (delayed by 1 frame) +├─ Update UI +├─ Hand off to system +└─ System renders → Finally visible + +Result: Previous frame visible for 2+ frames = animation stutter +``` + +### Frame with Hitch (Too Many Updates) + +``` +Frame 1: +├─ Handle events +├─ Update UI +│ ├─ Update 1 (fast) +│ ├─ Update 2 (fast) +│ ├─ Update 3 (fast) +│ ├─ ... (100 more fast updates) +│ └─ Total time exceeds deadline ❌ +├─ Miss deadline +└─ Previous frame stays visible (HITCH) +``` + +**Result**: Many small updates add up to miss deadline + +**Key Insight**: View body runtime matters because missing frame deadlines causes hitches, making animations less fluid. + +**Reference**: +- [Understanding hitches in your app](https://developer.apple.com/documentation/xcode/understanding-hitches-in-your-app) +- Tech Talk on render loop and fixing hitches + +--- + +## Problem 1: Long View Body Updates + +### Identifying Long Updates + +1. **Record trace** in Instruments with SwiftUI template +2. **Look at Long View Body Updates lane** — any orange/red bars? +3. **Expand SwiftUI track** to see subtracks +4. **Select View Body Updates subtrack** +5. **Filter to long updates**: + - Detail pane → Dropdown → Choose "Long View Body Updates summary" + +### Analyzing with Time Profiler + +**Workflow**: +1. Find long update in Long View Body Updates summary +2. Hover over view name → Click arrow → "Show Updates" +3. Right-click on long update → "Set Inspection Range and Zoom" +4. **Switch to Time Profiler instrument track** + +**What you see**: +- Call stacks for samples recorded during view body execution +- Time spent in each frame (leftmost column) +- Your view body nested in deep SwiftUI call stack + +**Finding the bottleneck**: +1. Option-click to expand main thread call stack +2. Command-F to search for your view name (e.g., "LandmarkListItemView") +3. Identify expensive operations in time column + +### Common Expensive Operations + +#### Formatter Creation (Very Expensive) + +**❌ WRONG - Creating formatters in view body**: +```swift +struct LandmarkListItemView: View { + let landmark: Landmark + @State private var userLocation: CLLocation + + var distance: String { + // ❌ Creating formatters every time body runs + let numberFormatter = NumberFormatter() + numberFormatter.maximumFractionDigits = 1 + + let measurementFormatter = MeasurementFormatter() + measurementFormatter.numberFormatter = numberFormatter + + let meters = userLocation.distance(from: landmark.location) + let measurement = Measurement(value: meters, unit: UnitLength.meters) + return measurementFormatter.string(from: measurement) + } + + var body: some View { + HStack { + Text(landmark.name) + Text(distance) // Calls expensive distance property + } + } +} +``` + +**Why it's slow**: +- Formatters are expensive to create (milliseconds each) +- Created every time view body runs +- Runs on main thread → app waits before continuing UI updates +- Multiple views → time adds up quickly + +**✅ CORRECT - Cache formatters centrally**: +```swift +@Observable +class LocationFinder { + private let formatter: MeasurementFormatter + private let landmarks: [Landmark] + private var distanceCache: [Landmark.ID: String] = [:] + + init(landmarks: [Landmark]) { + self.landmarks = landmarks + + // Create formatters ONCE during initialization + let numberFormatter = NumberFormatter() + numberFormatter.maximumFractionDigits = 1 + + self.formatter = MeasurementFormatter() + self.formatter.numberFormatter = numberFormatter + + updateDistances() + } + + func didUpdateLocations(_ locations: [CLLocation]) { + guard let location = locations.last else { return } + updateDistances(from: location) + } + + private func updateDistances(from location: CLLocation? = nil) { + guard let location else { return } + + for landmark in landmarks { + let meters = location.distance(from: landmark.location) + let measurement = Measurement(value: meters, unit: UnitLength.meters) + distanceCache[landmark.id] = formatter.string(from: measurement) + } + } + + func distanceString(for landmarkID: Landmark.ID) -> String { + distanceCache[landmarkID] ?? "Unknown" + } +} + +struct LandmarkListItemView: View { + let landmark: Landmark + @Environment(LocationFinder.self) private var locationFinder + + var body: some View { + HStack { + Text(landmark.name) + Text(locationFinder.distanceString(for: landmark.id)) // ✅ Fast lookup + } + } +} +``` + +**Benefits**: +- Formatters created once, reused for all landmarks +- Strings pre-calculated when location changes +- View body just reads cached value (instant) +- Long view body updates eliminated + +#### Other Expensive Operations + +**Complex Calculations**: +```swift +// ❌ Don't calculate in view body +var body: some View { + let result = expensiveAlgorithm(data) // Complex math, sorting, etc. + Text("\(result)") +} + +// ✅ Calculate in model, cache result +@Observable +class ViewModel { + private(set) var result: Int = 0 + + func updateData(_ data: [Int]) { + result = expensiveAlgorithm(data) // Calculate once + } +} +``` + +**Network/File I/O**: +```swift +// ❌ NEVER do I/O in view body +var body: some View { + let data = try? Data(contentsOf: fileURL) // ❌ Synchronous I/O + // ... +} + +// ✅ Load asynchronously, store in state +@State private var data: Data? + +var body: some View { + // Just read state +} +.task { + data = try? await loadData() // Async loading +} +``` + +**Image Processing**: +```swift +// ❌ Don't process images in view body +var body: some View { + let thumbnail = image.resized(to: CGSize(width: 100, height: 100)) + Image(uiImage: thumbnail) +} + +// ✅ Process images in background, cache +.task { + await processThumbnails() +} +``` + +### Verifying the Fix + +After implementing fix: + +1. Record new trace in Instruments +2. Check Long View Body Updates summary +3. **Verify your view is gone from the list** (or significantly reduced) + +**Note**: Updates at app launch may still be long (building initial view hierarchy) — this is normal and won't cause hitches during scrolling. + +--- + +## Problem 2: Unnecessary View Updates + +### Why Unnecessary Updates Matter + +Even if individual updates are fast, **too many updates add up**: + +``` +100 fast updates × 2ms each = 200ms total +→ Misses 16.67ms frame deadline +→ Hitch +``` + +### Identifying Unnecessary Updates + +**Scenario**: Tapping a favorite button on one item updates ALL items in a list. + +**Expected**: Only the tapped item updates. +**Actual**: All visible items update. + +**How to find**: +1. Record trace with user interaction in mind +2. Highlight relevant portion of timeline +3. Expand hierarchy in detail pane +4. **Count updates** — more than expected? + +### Understanding SwiftUI's Data Model + +SwiftUI uses **AttributeGraph** to define dependencies and avoid re-running views unnecessarily. + +#### Attributes & Dependencies + +```swift +struct OnOffView: View { + @State private var isOn: Bool = false + + var body: some View { + Text(isOn ? "On" : "Off") + } +} +``` + +**What SwiftUI creates**: +1. **View attribute** — Stores view struct (recreated frequently) +2. **State storage** — Keeps `isOn` value (persists entire view lifetime) +3. **Signal attribute** — Tracks when state changes +4. **View body attribute** — Depends on state signal +5. **Text attributes** — Depend on view body + +**When state changes**: +1. Create transaction (scheduled change for next frame) +2. Mark signal attribute as outdated +3. Walk dependency chain, marking dependent attributes as outdated (just set flag - fast) +4. Before rendering, update all outdated attributes +5. View body runs again, producing new Text struct +6. Continue updates until all needed attributes updated +7. Render frame + +### The Cause & Effect Graph + +**Purpose**: Visualize **what marked your view body as outdated**. + +**Example graph**: +``` +[Gesture] → [State Change] → [View Body Update] + ↓ + [Other View Bodies] +``` + +**Node types**: +- **Blue nodes** — Your code or actions (gestures, state changes, view bodies) +- **System nodes** — SwiftUI/system work +- **Arrows labeled "update"** — Caused update +- **Arrows labeled "creation"** — Caused view to appear + +**Selecting nodes**: +- Click **State change node** → See backtrace of where value was updated +- Click **View body node** → See which views updated and why + +**Accessing graph**: +1. Detail pane → Expand hierarchy to find view +2. Hover over view name → Click arrow +3. Choose **"Show Cause & Effect Graph"** + +### Example: Favorites List Problem + +**Problem**: +```swift +@Observable +class ModelData { + var favoritesCollection: Collection // Contains array of favorites + + func isFavorite(_ landmark: Landmark) -> Bool { + favoritesCollection.landmarks.contains(landmark) // ❌ Depends on whole array + } +} + +struct LandmarkListItemView: View { + let landmark: Landmark + @Environment(ModelData.self) private var modelData + + var body: some View { + HStack { + Text(landmark.name) + Button { + modelData.toggleFavorite(landmark) // Modifies array + } label: { + Image(systemName: modelData.isFavorite(landmark) ? "heart.fill" : "heart") + } + } + } +} +``` + +**What happens**: +1. Each view calls `isFavorite()`, accessing `favoritesCollection.landmarks` array +2. `@Observable` creates dependency: **Each view depends on entire array** +3. Tapping button calls `toggleFavorite()`, modifying array +4. **All views** marked as outdated (array changed) +5. **All view bodies run** (even though only one changed) + +**Cause & Effect Graph shows**: +``` +[Gesture] → [favoritesCollection.landmarks array change] → [All LandmarkListItemViews update] +``` + +**✅ Solution — Granular Dependencies**: +```swift +@Observable +class LandmarkViewModel { + var isFavorite: Bool = false + + func toggleFavorite() { + isFavorite.toggle() + } +} + +@Observable +class ModelData { + private(set) var viewModels: [Landmark.ID: LandmarkViewModel] = [:] + + init(landmarks: [Landmark]) { + for landmark in landmarks { + viewModels[landmark.id] = LandmarkViewModel() + } + } + + func viewModel(for landmarkID: Landmark.ID) -> LandmarkViewModel? { + viewModels[landmarkID] + } +} + +struct LandmarkListItemView: View { + let landmark: Landmark + @Environment(ModelData.self) private var modelData + + var body: some View { + if let viewModel = modelData.viewModel(for: landmark.id) { + HStack { + Text(landmark.name) + Button { + viewModel.toggleFavorite() // ✅ Only modifies this view model + } label: { + Image(systemName: viewModel.isFavorite ? "heart.fill" : "heart") + } + } + } + } +} +``` + +**Result**: +- Each view depends **only on its own view model** +- Tapping button updates **only that view model** +- **Only one view body runs** + +**Cause & Effect Graph shows**: +``` +[Gesture] → [Single LandmarkViewModel change] → [Single LandmarkListItemView update] +``` + +--- + +## Environment Updates + +### How Environment Works + +```swift +struct EnvironmentValues { + // Dictionary-like value type + var colorScheme: ColorScheme + var locale: Locale + // ... many more values +} +``` + +**Each view has dependency on entire EnvironmentValues struct** via `@Environment` property wrapper. + +### What Happens on Environment Change + +1. **Any environment value changes** (e.g., dark mode enabled) +2. **All views with `@Environment` dependency notified** +3. **Each view checks** if the specific value it reads changed +4. **If value changed** → View body runs +5. **If value didn't change** → SwiftUI skips running view body (already up-to-date) + +**Cost**: Even when body doesn't run, there's still cost of checking for updates. + +### Environment Update Nodes in Graph + +Two types: + +1. **External Environment** — App-level changes from outside SwiftUI (color scheme, accessibility settings) +2. **EnvironmentWriter** — Changes inside SwiftUI via `.environment()` modifier + +**Example**: +``` +View1 reads colorScheme: +[External Environment] → [View1 body runs] ✅ + +View2 reads locale (doesn't read colorScheme): +[External Environment] → [View2 body check] (body doesn't run - dimmed icon) +``` + +**Same update shows as multiple nodes**: Hover/click any node for same update → all highlight together. + +### Environment Performance Warning + +⚠️ **AVOID storing frequently-changing values in environment**: + +```swift +// ❌ DON'T DO THIS +struct ContentView: View { + @State private var scrollOffset: CGFloat = 0 + + var body: some View { + ScrollView { + // Content + } + .environment(\.scrollOffset, scrollOffset) // ❌ Updates on every scroll frame + .onPreferenceChange(ScrollOffsetKey.self) { offset in + scrollOffset = offset + } + } +} +``` + +**Why it's bad**: +- Environment change triggers checks in **all child views** +- Scrolling = 60+ updates/second +- Massive performance hit + +**✅ Better approach**: +```swift +// Pass via parameter or @Observable model +struct ContentView: View { + @State private var scrollViewModel = ScrollViewModel() + + var body: some View { + ScrollView { + ChildView(scrollViewModel: scrollViewModel) // Direct parameter + } + } +} +``` + +**Environment is great for**: +- Color scheme +- Locale +- Accessibility settings +- Other relatively stable values + +--- + +## Performance Optimization Checklist + +### Before Profiling +- [ ] Build in Release mode (Debug mode has overhead) +- [ ] Test on real devices (Simulator performance ≠ real device) +- [ ] Update device to latest OS (SwiftUI trace support) +- [ ] Identify specific slow interactions to profile + +### During Profiling +- [ ] Use SwiftUI template in Instruments 26 +- [ ] Focus on Long View Body Updates lane first +- [ ] Check Update Groups lane (empty = problem outside SwiftUI) +- [ ] Record realistic user workflows (not artificial scenarios) +- [ ] Keep profiling sessions short (easier to analyze) + +### Analyzing Long View Body Updates +- [ ] Filter detail pane to "Long View Body Updates" +- [ ] Start with red updates, then orange +- [ ] Use Time Profiler to find expensive operations +- [ ] Look for formatter creation, calculations, I/O +- [ ] Check if work can be moved to model layer + +### Analyzing Unnecessary Updates +- [ ] Count view body updates - more than expected? +- [ ] Use Cause & Effect Graph to trace data flow +- [ ] Check for whole array/collection dependencies +- [ ] Verify each view depends only on relevant data +- [ ] Avoid frequently-changing environment values + +### After Optimization +- [ ] Record new trace to verify improvements +- [ ] Compare before/after Long View Body Updates counts +- [ ] Test on slowest supported device +- [ ] Monitor in real-world usage +- [ ] Profile regularly during development + +--- + +## Production Pressure: When Performance Issues Hit Live + +### The Problem + +When performance issues appear in production, you face competing pressures: +- **Engineering manager**: "Fix it ASAP" +- **VP of Product**: "Users have been complaining for hours" +- **Deployment window**: 6 hours before next App Store review window +- **Temptation**: Quick fix (add `.compositingGroup()`, disable animation, simplify view) + +**The issue**: Quick fixes based on guesses fail 80% of the time and waste your deployment window. + +### Red Flags — Resist These Pressure Tactics + +If you hear ANY of these under deadline pressure, **STOP and use SwiftUI Instrument**: + +- ❌ **"Just add .compositingGroup()"** – Without profiling, you don't know if this helps +- ❌ **"We can roll back if it doesn't work"** – App Store review takes 24 hours; rollback isn't fast +- ❌ **"Other apps use this pattern"** – Doesn't mean it solves YOUR specific problem +- ❌ **"Users will accept degradation for now"** – Once shipped, you're committed for 24 hours +- ❌ **"We don't have time to profile"** – You have less time if you guess wrong + +### One SwiftUI Instrument Recording (30-Minute Protocol) + +Under production pressure, one good diagnostic recording beats random fixes: + +**Time Budget**: +- Build in Release mode: 5 min +- Launch and interact to trigger sluggishness: 3 min +- Record SwiftUI Instrument trace: 5 min +- Review Long View Body Updates lane: 5 min +- Check Cause & Effect Graph: 5 min +- Identify specific expensive view: 2 min + +**Total**: 25 minutes to know EXACTLY what's slow + +**Then**: +- Apply targeted fix (15-30 min) +- Test in Instruments again (5 min) +- Ship with confidence + +**Total time**: 1 hour 15 minutes for diagnosis + fix, leaving 4+ hours for edge case testing. + +### Comparing Time Costs + +#### Option A: Guess and Pray +- Time to implement: 30 min +- Time to deploy: 20 min +- Time to learn it failed: 24 hours (next App Store review) +- Total delay: 24 hours minimum +- User suffering: Continues through deployment window + +#### Option B: One SwiftUI Instrument Recording +- Time to diagnose: 25 min +- Time to apply targeted fix: 20 min +- Time to verify: 5 min +- Time to deploy: 20 min +- Total time: 1.5 hours +- User suffering: Stopped after 2 hours instead of 26+ hours + +**Time cost of being wrong**: +- A: 24-hour delay + reputational damage + users suffering +- B: 1.5 hours + you know the actual problem + confidence in the fix + +### Real-World Example: Tab Transition Sluggishness + +**Pressure scenario**: +- iOS 26 build shipped +- Users report "sluggish tab transitions" +- VP asking for updates every hour +- 6 hours until deployment window closes + +**Bad approach** (Option A): +``` +Junior suggests: "Add .compositingGroup() to TabView" +You: "Sure, let's try it" +Result: Ships without profiling +Outcome: Doesn't fix issue (compositing wasn't the problem) +Next: 24 hours until next deploy window +VP update: "Users still complaining" +``` + +**Good approach** (Option B): +``` +"Running one SwiftUI Instrument recording of tab transition" +[25 minutes later] +"SwiftUI Instrument shows Long View Body Updates in ProductGridView during transition. +Cause & Effect Graph shows ProductList rebuilding entire grid unnecessarily. +Applying view identity fix (`.id()`) to prevent unnecessary updates" +[30 minutes to implement and test] +"Deployed at 1.5 hours. Verified with Instruments. Tab transitions now smooth." +``` + +### When to Accept the Pressure (And Still be Right) + +Sometimes managers are right to push for speed. Accept the pressure IF: + +- [ ] You've run ONE SwiftUI Instrument recording (25 minutes) +- [ ] You know what specific view/operation is expensive +- [ ] You have a targeted fix, not a guess +- [ ] You've verified the fix in Instruments before shipping +- [ ] You're shipping WITH profiling data, not hoping it works + +**Document your decision**: +``` +Slack to VP + team: + +"Completed diagnostic: ProductGridView rebuilding unnecessarily during +tab transitions (confirmed in SwiftUI Instrument, Long View Body Updates). +Applied view identity fix. Verified in Instruments - transitions now 16.67ms. +Deploying now." +``` + +This shows: +- You diagnosed (not guessed) +- You solved the right problem +- You verified the fix +- You're shipping with confidence + +### If You Still Get It Wrong After Profiling + +**Honest admission**: +``` +"SwiftUI Instrument showed ProductGridView was the bottleneck. +Applied view identity fix, but performance didn't improve as expected. +Root cause is deeper than expected. Requiring architectural change. +Shipping animation disable (.animation(nil) on TabView) as mitigation. +Proper fix queued for next release cycle." +``` + +This is different from guessing: +- You have **evidence** of the root cause +- You **understand** why the quick fix didn't work +- You're **buying time** with a known mitigation +- You're **committed** to proper fix next cycle + +### Decision Framework Under Pressure + +#### Before shipping ANY fix + +| Question | Answer Yes? | Action | +|----------|-------------|--------| +| Have you run SwiftUI Instrument? | No | STOP - 25 min diagnostic | +| Do you know which view is expensive? | No | STOP - review Cause & Effect Graph | +| Can you explain in one sentence why the fix helps? | No | STOP - you're guessing | +| Have you verified the fix in Instruments? | No | STOP - test before shipping | +| Did you consider simpler explanations? | No | STOP - check documentation first | + +**Answer YES to all five** → Ship with confidence + +--- + +## Common Patterns & Solutions + +### Pattern 1: List Item Dependencies + +**Problem**: Updating one item updates entire list + +**Solution**: Per-item view models with granular dependencies + +```swift +// ❌ Shared dependency +@Observable +class ListViewModel { + var items: [Item] // All views depend on whole array +} + +// ✅ Granular dependencies +@Observable +class ListViewModel { + private(set) var itemViewModels: [Item.ID: ItemViewModel] +} + +@Observable +class ItemViewModel { + var item: Item // Each view depends only on its item +} +``` + +### Pattern 2: Computed Properties in View Bodies + +**Problem**: Expensive computation runs every render + +**Solution**: Move to model, cache result + +```swift +// ❌ Compute in view +struct MyView: View { + let data: [Int] + + var body: some View { + Text("\(data.sorted().last ?? 0)") // Sorts every render + } +} + +// ✅ Compute in model +@Observable +class ViewModel { + var data: [Int] { + didSet { + maxValue = data.max() ?? 0 // Compute once when data changes + } + } + private(set) var maxValue: Int = 0 +} + +struct MyView: View { + @Environment(ViewModel.self) private var viewModel + + var body: some View { + Text("\(viewModel.maxValue)") // Just read cached value + } +} +``` + +### Pattern 3: Formatter Reuse + +**Problem**: Creating formatters repeatedly + +**Solution**: Create once, reuse + +```swift +// ❌ Create every time +var body: some View { + let formatter = DateFormatter() + formatter.dateStyle = .short + Text(formatter.string(from: date)) +} + +// ✅ Reuse formatter +class Formatters { + static let shortDate: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .short + return f + }() +} + +var body: some View { + Text(Formatters.shortDate.string(from: date)) +} +``` + +### Pattern 4: Environment for Stable Values Only + +**Problem**: Rapidly-changing environment values + +**Solution**: Use direct parameters or models + +```swift +// ❌ Frequently changing in environment +.environment(\.scrollPosition, scrollPosition) // 60+ updates/second + +// ✅ Direct parameter or model +ChildView(scrollPosition: scrollPosition) +``` + +--- + +## iOS 26 Performance Improvements + +**Automatic improvements** when building with Xcode 26 (no code changes needed): + +### Lists +- Update up to **16× faster** +- Large lists on macOS load **6× faster** + +### SwiftUI Instrument +- Next-generation performance analysis +- Captures detailed cause-and-effect information +- Makes it easier than ever to understand when and why views update + +--- + +## Debugging Performance Issues + +### Step-by-Step Process + +1. **Reproduce issue** — Identify specific slow interaction +2. **Profile with Instruments** — SwiftUI template +3. **Check Update Groups lane** — SwiftUI doing work when slow? +4. **Identify problem type**: + - Long View Body Updates? → Section on Long Updates + - Too many updates? → Section on Unnecessary Updates +5. **Use Time Profiler** for long updates (find expensive operation) +6. **Use Cause & Effect Graph** for unnecessary updates (find dependency issue) +7. **Implement fix** +8. **Verify with new trace** + +### When SwiftUI Isn't the Problem + +#### Update Groups lane empty during performance issue? + +Problem likely elsewhere: +- Network requests +- Background processing +- Image loading +- Database queries +- Third-party frameworks + +**Next steps**: +- [Analyze hangs with Instruments](https://developer.apple.com/documentation/xcode/analyzing-hangs-in-your-app) +- [Optimize CPU performance with Instruments](https://developer.apple.com/documentation/xcode/optimizing-your-app-s-performance) + +--- + +## Real-World Impact + +#### Example: Landmarks App (from WWDC 2025) + +**Before optimization**: +- Every favorite button tap updated ALL visible landmark views +- Each view recreated formatters for distance calculation +- Scrolling felt janky + +**After optimization**: +- Only tapped view updates (granular view models) +- Formatters created once, strings cached +- Smooth 60fps scrolling + +**Improvements**: +- 100+ unnecessary view updates → 1 update per action +- Milliseconds saved per view × dozens of views = significant improvement +- Eliminated long view body updates entirely + +--- + +## Resources + +**WWDC**: 2025-306 + +**Docs**: /xcode/understanding-hitches-in-your-app, /xcode/analyzing-hangs-in-your-app, /xcode/optimizing-your-app-s-performance + +**Skills**: axiom-swiftui-debugging-diag, axiom-swiftui-debugging, axiom-memory-debugging, axiom-xcode-debugging + +--- + +## Key Takeaways + +1. **Fast view bodies** — Keep them quick so SwiftUI has time to get UI on screen without delay +2. **Update only when needed** — Design data flow to update views only when necessary +3. **Careful with environment** — Don't store frequently-changing values +4. **Profile early and often** — Use Instruments during development, not just when problems arise +5. **Greatest takeaway**: **Ensure your view bodies update quickly and only when needed to achieve great SwiftUI performance** + +--- + +**Xcode:** 26+ +**Platforms:** iOS 26+, iPadOS 26+, macOS Tahoe+, axiom-visionOS 3+ +**History:** See git log for changes diff --git a/.claude/skills/axiom-swiftui-performance/agents/openai.yaml b/.claude/skills/axiom-swiftui-performance/agents/openai.yaml new file mode 100644 index 0000000..ef2dfb8 --- /dev/null +++ b/.claude/skills/axiom-swiftui-performance/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftUI Performance" + short_description: "UI is slow, scrolling lags, animations stutter, or when asking 'why is my SwiftUI view slow', 'how do I optimize List..." diff --git a/.claude/skills/axiom-swiftui-search-ref/.openskills.json b/.claude/skills/axiom-swiftui-search-ref/.openskills.json new file mode 100644 index 0000000..bffa262 --- /dev/null +++ b/.claude/skills/axiom-swiftui-search-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-swiftui-search-ref", + "installedAt": "2026-04-12T08:06:50.929Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-swiftui-search-ref/SKILL.md b/.claude/skills/axiom-swiftui-search-ref/SKILL.md new file mode 100644 index 0000000..c82e5fe --- /dev/null +++ b/.claude/skills/axiom-swiftui-search-ref/SKILL.md @@ -0,0 +1,737 @@ +--- +name: axiom-swiftui-search-ref +description: Use when implementing SwiftUI search — .searchable, isSearching, search suggestions, scopes, tokens, programmatic search control (iOS 15-18). For iOS 26 search refinements (bottom-aligned, minimized toolbar, search tab role), see swiftui-26-ref. +license: MIT +metadata: + version: "1.0.0" +--- + +# SwiftUI Search API Reference + +## Overview + +SwiftUI search is **environment-based and navigation-consumed**. You attach `.searchable()` to a view, but a *navigation container* (NavigationStack, NavigationSplitView, or TabView) renders the actual search field. This indirection is the source of most search bugs. + +#### API Evolution + +| iOS | Key Additions | +|-----|---------------| +| 15 | `.searchable(text:)`, `isSearching`, `dismissSearch`, suggestions, `.searchCompletion()`, `onSubmit(of: .search)` | +| 16 | Search scopes (`.searchScopes`), search tokens (`.searchable(text:tokens:)`), `SearchScopeActivation` | +| 16.4 | Search scope `activation` parameter (`.onTextEntry`, `.onSearchPresentation`) | +| 17 | `isPresented` parameter, `suggestedTokens` parameter | +| 17.1 | `.searchPresentationToolbarBehavior(.avoidHidingContent)` | +| 18 | `.searchFocused($isFocused)` for programmatic focus control | +| 26 | Bottom-aligned search, `.searchToolbarBehavior(.minimize)`, `Tab(role: .search)`, `DefaultToolbarItem(kind: .search)` — see `axiom-swiftui-26-ref` | + +## When to Use This Skill + +- Adding search to a SwiftUI list or collection +- Implementing filter-as-you-type or submit-based search +- Adding search suggestions with auto-completion +- Using search scopes to narrow results by category +- Using search tokens for structured queries +- Controlling search focus programmatically +- Debugging "search field doesn't appear" issues + +For iOS 26 search features (bottom-aligned, minimized toolbar, search tab role), see `axiom-swiftui-26-ref`. + +--- + +## Part 1: The searchable Modifier + +### Core API + +```swift +.searchable( + text: Binding, + placement: SearchFieldPlacement = .automatic, + prompt: LocalizedStringKey +) +``` + +**Availability**: iOS 15+, macOS 12+, tvOS 15+, watchOS 8+ + +### How It Works + +1. You attach `.searchable(text: $query)` to a view +2. The **nearest navigation container** (NavigationStack, NavigationSplitView) renders the search field +3. The view receives `isSearching` and `dismissSearch` through the environment +4. Your view filters or queries based on the bound text + +```swift +struct RecipeListView: View { + @State private var searchText = "" + let recipes: [Recipe] + + var body: some View { + NavigationStack { + List(filteredRecipes) { recipe in + NavigationLink(recipe.name, value: recipe) + } + .navigationTitle("Recipes") + .searchable(text: $searchText, prompt: "Find a recipe") + } + } + + var filteredRecipes: [Recipe] { + if searchText.isEmpty { return recipes } + return recipes.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } +} +``` + +### Placement Options + +| Placement | Behavior | +|-----------|----------| +| `.automatic` | System decides (recommended) | +| `.navigationBarDrawer` | Below navigation bar title (iOS) | +| `.navigationBarDrawer(displayMode: .always)` | Always visible, not hidden on scroll | +| `.sidebar` | In the sidebar column (NavigationSplitView) | +| `.toolbar` | In the toolbar area | +| `.toolbarPrincipal` | In toolbar's principal section | + +**Gotcha**: SwiftUI may ignore your placement preference if the view hierarchy doesn't support it. Always test on the target platform. + +### Column Association in NavigationSplitView + +Where you attach `.searchable` determines which column displays the search field: + +```swift +NavigationSplitView { + SidebarView() + .searchable(text: $query) // Search in sidebar +} detail: { + DetailView() +} + +// vs. + +NavigationSplitView { + SidebarView() +} detail: { + DetailView() + .searchable(text: $query) // Search in detail +} + +// vs. + +NavigationSplitView { + SidebarView() +} detail: { + DetailView() +} +.searchable(text: $query) // System decides column +``` + +--- + +## Part 2: Displaying Search Results + +### isSearching Environment + +```swift +@Environment(\.isSearching) private var isSearching +``` + +**Availability**: iOS 15+ + +Becomes `true` when the user activates search (taps the field), `false` when they cancel or you call `dismissSearch`. + +**Critical rule**: `isSearching` must be read from a **child** of the view that has `.searchable`. SwiftUI sets the value in the searchable view's environment and does not propagate it upward. + +```swift +// Pattern: Overlay search results when searching +struct WeatherCityList: View { + @State private var searchText = "" + + var body: some View { + NavigationStack { + // SearchResultsOverlay reads isSearching + SearchResultsOverlay(searchText: searchText) { + List(favoriteCities) { city in + CityRow(city: city) + } + } + .searchable(text: $searchText) + .navigationTitle("Weather") + } + } +} + +struct SearchResultsOverlay: View { + let searchText: String + @ViewBuilder let content: Content + @Environment(\.isSearching) private var isSearching + + var body: some View { + if isSearching { + // Show search results + SearchResults(query: searchText) + } else { + content + } + } +} +``` + +### dismissSearch Environment + +```swift +@Environment(\.dismissSearch) private var dismissSearch +``` + +**Availability**: iOS 15+ + +Calling `dismissSearch()` clears the search text, removes focus, and sets `isSearching` to `false`. Must be called from inside the searchable view hierarchy. + +```swift +struct SearchResults: View { + @Environment(\.dismissSearch) private var dismissSearch + + var body: some View { + List(results) { result in + Button(result.name) { + selectResult(result) + dismissSearch() // Close search after selection + } + } + } +} +``` + +--- + +## Part 3: Search Suggestions + +### Adding Suggestions + +Pass a `suggestions` closure to `.searchable`: + +```swift +.searchable(text: $searchText) { + ForEach(suggestedResults) { suggestion in + Text(suggestion.name) + .searchCompletion(suggestion.name) + } +} +``` + +**Availability**: iOS 15+ + +Suggestions appear in a list below the search field when the user is typing. + +### searchCompletion Modifier + +`.searchCompletion(_:)` binds a suggestion to a completion value. When the user taps the suggestion, the search text is replaced with the completion value. + +```swift +.searchable(text: $searchText) { + ForEach(matchingColors) { color in + HStack { + Circle() + .fill(color.value) + .frame(width: 16, height: 16) + Text(color.name) + } + .searchCompletion(color.name) // Tapping fills search with color name + } +} +``` + +**Without `.searchCompletion()`**: Suggestions display but tapping them does nothing to the search field. This is the most common suggestions bug. + +### Complete Suggestion Pattern + +```swift +struct ColorSearchView: View { + @State private var searchText = "" + let allColors: [NamedColor] + + var body: some View { + NavigationStack { + List(filteredColors) { color in + ColorRow(color: color) + } + .navigationTitle("Colors") + .searchable(text: $searchText, prompt: "Search colors") { + ForEach(suggestedColors) { color in + Label(color.name, systemImage: "paintpalette") + .searchCompletion(color.name) + } + } + } + } + + var suggestedColors: [NamedColor] { + guard !searchText.isEmpty else { return [] } + return allColors.filter { + $0.name.localizedCaseInsensitiveContains(searchText) + } + .prefix(5) + .map { $0 } // Convert ArraySlice to Array + } + + var filteredColors: [NamedColor] { + if searchText.isEmpty { return allColors } + return allColors.filter { + $0.name.localizedCaseInsensitiveContains(searchText) + } + } +} +``` + +--- + +## Part 4: Search Submission + +### onSubmit(of: .search) + +Triggers when the user presses Return/Enter in the search field: + +```swift +.searchable(text: $searchText) +.onSubmit(of: .search) { + performSearch(searchText) +} +``` + +**Availability**: iOS 15+ + +### Filter vs Submit Decision + +| Pattern | Use When | Example | +|---------|----------|---------| +| Filter-as-you-type | Local data, fast filtering | Contacts, settings | +| Submit-based search | Network requests, expensive queries | App Store, web search | +| Combined | Suggestions filter locally, submit triggers server | Maps, shopping | + +### Combined Suggestions + Submit Pattern + +```swift +struct StoreSearchView: View { + @State private var searchText = "" + @State private var searchResults: [Product] = [] + let recentSearches: [String] + + var body: some View { + NavigationStack { + List(searchResults) { product in + ProductRow(product: product) + } + .navigationTitle("Store") + .searchable(text: $searchText, prompt: "Search products") { + // Local suggestions from recent searches + ForEach(matchingRecent, id: \.self) { term in + Label(term, systemImage: "clock") + .searchCompletion(term) + } + } + .onSubmit(of: .search) { + // Server search on submit + Task { + searchResults = await ProductAPI.search(searchText) + } + } + } + } + + var matchingRecent: [String] { + guard !searchText.isEmpty else { return recentSearches } + return recentSearches.filter { + $0.localizedCaseInsensitiveContains(searchText) + } + } +} +``` + +--- + +## Part 5: Search Scopes (iOS 16+) + +### Adding Scopes + +Scopes add a segmented picker below the search field for narrowing results by category: + +```swift +enum SearchScope: String, CaseIterable { + case all = "All" + case recipes = "Recipes" + case ingredients = "Ingredients" +} + +struct ScopedSearchView: View { + @State private var searchText = "" + @State private var searchScope: SearchScope = .all + + var body: some View { + NavigationStack { + List(filteredResults) { result in + ResultRow(result: result) + } + .navigationTitle("Cookbook") + .searchable(text: $searchText) + .searchScopes($searchScope) { + ForEach(SearchScope.allCases, id: \.self) { scope in + Text(scope.rawValue).tag(scope) + } + } + } + } +} +``` + +**Availability**: iOS 16+, macOS 13+ + +### Scope Activation (iOS 16.4+) + +Control when scopes appear: + +```swift +.searchScopes($searchScope, activation: .onTextEntry) { + // Scopes appear only when user starts typing + ForEach(SearchScope.allCases, id: \.self) { scope in + Text(scope.rawValue).tag(scope) + } +} +``` + +| Activation | Behavior | +|------------|----------| +| `.automatic` | System default | +| `.onTextEntry` | Scopes appear when user types text | +| `.onSearchPresentation` | Scopes appear when search is activated | + +**Platform differences**: +- **iOS/iPadOS**: Scopes appear on text entry by default, dismiss on cancel +- **macOS**: Scopes appear when search is presented, dismiss on cancel + +--- + +## Part 6: Search Tokens (iOS 16+) + +Tokens are structured search elements that appear as "pills" in the search field alongside free text. + +### Basic Tokens + +```swift +enum RecipeToken: Identifiable, Hashable { + case cuisine(String) + case difficulty(String) + + var id: Self { self } +} + +struct TokenSearchView: View { + @State private var searchText = "" + @State private var tokens: [RecipeToken] = [] + + var body: some View { + NavigationStack { + List(filteredRecipes) { recipe in + RecipeRow(recipe: recipe) + } + .navigationTitle("Recipes") + .searchable(text: $searchText, tokens: $tokens) { token in + switch token { + case .cuisine(let name): + Label(name, systemImage: "globe") + case .difficulty(let name): + Label(name, systemImage: "star") + } + } + } + } +} +``` + +**Availability**: iOS 16+ + +**Token model requirements**: Each token element must conform to `Identifiable`. + +### Suggested Tokens (iOS 17+) + +```swift +.searchable( + text: $searchText, + tokens: $tokens, + suggestedTokens: $suggestedTokens, + prompt: "Search recipes" +) { token in + Label(token.displayName, systemImage: token.icon) +} +``` + +**Availability**: iOS 17+ adds `suggestedTokens` and `isPresented` parameters. + +### Combined Tokens + Text Filtering + +```swift +var filteredRecipes: [Recipe] { + var results = allRecipes + + // Apply token filters + for token in tokens { + switch token { + case .cuisine(let cuisine): + results = results.filter { $0.cuisine == cuisine } + case .difficulty(let difficulty): + results = results.filter { $0.difficulty == difficulty } + } + } + + // Apply text filter + if !searchText.isEmpty { + results = results.filter { + $0.name.localizedCaseInsensitiveContains(searchText) + } + } + + return results +} +``` + +--- + +## Part 7: Programmatic Search Control (iOS 18+) + +### searchFocused + +Bind a `FocusState` to the search field to activate or dismiss search programmatically: + +```swift +struct ProgrammaticSearchView: View { + @State private var searchText = "" + @FocusState private var isSearchFocused: Bool + + var body: some View { + NavigationStack { + VStack { + Button("Start Search") { + isSearchFocused = true // Activate search field + } + + List(filteredItems) { item in + Text(item.name) + } + } + .navigationTitle("Items") + .searchable(text: $searchText) + .searchFocused($isSearchFocused) + } + } +} +``` + +**Availability**: iOS 18+, macOS 15+, visionOS 2+ + +**Note**: For a non-boolean variant, use `.searchFocused(_:equals:)` to match specific focus values. + +### Comparison with dismissSearch + +| API | Direction | iOS | +|-----|-----------|-----| +| `dismissSearch` | Dismiss only | 15+ | +| `.searchFocused($bool)` | Activate or dismiss | 18+ | + +Use `dismissSearch` if you only need to close search. Use `searchFocused` when you need to programmatically *open* search (e.g., a floating action button that opens search). + +--- + +## Part 8: Platform Behavior + +SwiftUI search adapts automatically per platform: + +| Platform | Default Behavior | +|----------|-----------------| +| **iOS** | Search bar in navigation bar. Scrolls out of view by default; pull down to reveal. | +| **iPadOS** | Same as iOS in compact; may appear in toolbar in regular width. | +| **macOS** | Trailing toolbar search field. Always visible. | +| **watchOS** | Dictation-first input. Search bar at top of list. | +| **tvOS** | Tab-based search with on-screen keyboard. | + +### iOS-Specific Behavior + +```swift +// Always-visible search field (doesn't scroll away) +.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) + +// Default: search field scrolls out, pull down to reveal +.searchable(text: $searchText) +``` + +### macOS-Specific Behavior + +```swift +// Search in toolbar (default on macOS) +.searchable(text: $searchText, placement: .toolbar) + +// Search in sidebar +.searchable(text: $searchText, placement: .sidebar) +``` + +--- + +## Part 9: Common Gotchas + +### 1. Search Field Doesn't Appear + +**Cause**: `.searchable` is not inside a navigation container. + +```swift +// WRONG: No navigation container +List { ... } + .searchable(text: $query) + +// CORRECT: Inside NavigationStack +NavigationStack { + List { ... } + .searchable(text: $query) +} +``` + +### 2. isSearching Always Returns false + +**Cause**: Reading `isSearching` from the wrong view level. + +```swift +// WRONG: Reading from parent of searchable view +struct ParentView: View { + @Environment(\.isSearching) var isSearching // Always false + @State private var query = "" + + var body: some View { + NavigationStack { + ChildView(isSearching: isSearching) + .searchable(text: $query) + } + } +} + +// CORRECT: Reading from child view +struct ChildView: View { + @Environment(\.isSearching) var isSearching // Works + + var body: some View { + if isSearching { + SearchResults() + } else { + DefaultContent() + } + } +} +``` + +### 3. Suggestions Don't Fill Search Field + +**Cause**: Missing `.searchCompletion()` on suggestion views. + +```swift +// WRONG: No searchCompletion +.searchable(text: $query) { + ForEach(suggestions) { s in + Text(s.name) // Displays but tapping does nothing + } +} + +// CORRECT: With searchCompletion +.searchable(text: $query) { + ForEach(suggestions) { s in + Text(s.name) + .searchCompletion(s.name) // Fills search field on tap + } +} +``` + +### 4. Placement on Wrong Navigation Level + +**Cause**: Attaching `.searchable` to the wrong column in NavigationSplitView. + +```swift +// Might not appear where expected +NavigationSplitView { + SidebarView() +} detail: { + DetailView() +} +.searchable(text: $query) // System chooses column + +// Explicit placement +NavigationSplitView { + SidebarView() + .searchable(text: $query, placement: .sidebar) // In sidebar +} detail: { + DetailView() +} +``` + +### 5. Search Scopes Don't Appear + +**Cause**: Scopes require `.searchable` on the same view. They also require a navigation container. + +```swift +// WRONG: Scopes without searchable +List { ... } + .searchScopes($scope) { ... } + +// CORRECT: Scopes alongside searchable +List { ... } + .searchable(text: $query) + .searchScopes($scope) { + Text("All").tag(Scope.all) + Text("Recent").tag(Scope.recent) + } +``` + +### 6. iOS 26 Refinements + +For bottom-aligned search, `.searchToolbarBehavior(.minimize)`, `Tab(role: .search)`, and `DefaultToolbarItem(kind: .search)`, see `axiom-swiftui-26-ref`. These build on the foundational APIs documented here. + +--- + +## Part 10: API Quick Reference + +### Modifiers + +| Modifier | iOS | Purpose | +|----------|-----|---------| +| `.searchable(text:placement:prompt:)` | 15+ | Add search field | +| `.searchable(text:tokens:token:)` | 16+ | Search with tokens | +| `.searchable(text:tokens:suggestedTokens:isPresented:token:)` | 17+ | Tokens + suggested tokens + presentation control | +| `.searchCompletion(_:)` | 15+ | Auto-fill search on suggestion tap | +| `.searchScopes(_:_:)` | 16+ | Category picker below search | +| `.searchScopes(_:activation:_:)` | 16.4+ | Scopes with activation control | +| `.searchFocused(_:)` | 18+ | Programmatic search focus | +| `.searchPresentationToolbarBehavior(_:)` | 17.1+ | Keep title visible during search | +| `.searchToolbarBehavior(_:)` | 26+ | Compact/minimize search field | +| `onSubmit(of: .search)` | 15+ | Handle search submission | + +### Environment Values + +| Value | iOS | Purpose | +|-------|-----|---------| +| `isSearching` | 15+ | Is user actively searching | +| `dismissSearch` | 15+ | Action to dismiss search | + +### Types + +| Type | iOS | Purpose | +|------|-----|---------| +| `SearchFieldPlacement` | 15+ | Where search field renders | +| `SearchScopeActivation` | 16.4+ | When scopes appear | + +--- + +## Resources + +**WWDC**: 2021-10176, 2022-10023 + +**Docs**: /swiftui/view/searchable(text:placement:prompt:), /swiftui/environmentvalues/issearching, /swiftui/view/searchscopes(_:activation:_:), /swiftui/view/searchfocused(_:), /swiftui/searchfieldplacement + +**Skills**: axiom-swiftui-26-ref, axiom-swiftui-nav-ref, axiom-swiftui-nav + +--- + +**Last Updated** Based on WWDC 2021-10176 "Searchable modifier", sosumi.ai API reference +**Platforms** iOS 15+, iPadOS 15+, macOS 12+, watchOS 8+, tvOS 15+ diff --git a/.claude/skills/axiom-swiftui-search-ref/agents/openai.yaml b/.claude/skills/axiom-swiftui-search-ref/agents/openai.yaml new file mode 100644 index 0000000..a6dbe61 --- /dev/null +++ b/.claude/skills/axiom-swiftui-search-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "SwiftUI Search Reference" + short_description: "Implementing SwiftUI search" diff --git a/.claude/skills/axiom-synchronization/.openskills.json b/.claude/skills/axiom-synchronization/.openskills.json new file mode 100644 index 0000000..3d6886a --- /dev/null +++ b/.claude/skills/axiom-synchronization/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-synchronization", + "installedAt": "2026-04-12T08:06:51.285Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-synchronization/SKILL.md b/.claude/skills/axiom-synchronization/SKILL.md new file mode 100644 index 0000000..3df97da --- /dev/null +++ b/.claude/skills/axiom-synchronization/SKILL.md @@ -0,0 +1,289 @@ +--- +name: axiom-synchronization +description: Use when needing thread-safe primitives for performance-critical code. Covers Mutex (iOS 18+), OSAllocatedUnfairLock (iOS 16+), Atomic types, when to use locks vs actors, deadlock prevention with Swift Concurrency. +license: MIT +metadata: + version: "1.0.0" +--- + +# Mutex & Synchronization — Thread-Safe Primitives + +Low-level synchronization primitives for when actors are too slow or heavyweight. + +## When to Use Mutex vs Actor + +| Need | Use | Reason | +|------|-----|--------| +| Microsecond operations | Mutex | No async hop overhead | +| Protect single property | Mutex | Simpler, faster | +| Complex async workflows | Actor | Proper suspension handling | +| Suspension points needed | Actor | Mutex can't suspend | +| Shared across modules | Mutex | Sendable, no await needed | +| High-frequency counters | Atomic | Lock-free performance | + +## API Reference + +### Mutex (iOS 18+ / Swift 6) + +```swift +import Synchronization + +let mutex = Mutex(0) + +// Read +let value = mutex.withLock { $0 } + +// Write +mutex.withLock { $0 += 1 } + +// Non-blocking attempt +if let value = mutex.withLockIfAvailable({ $0 }) { + // Got the lock +} +``` + +**Properties**: +- Generic over protected value +- `Sendable` — safe to share across concurrency boundaries +- Closure-based access only (no lock/unlock methods) + +### OSAllocatedUnfairLock (iOS 16+) + +```swift +import os + +let lock = OSAllocatedUnfairLock(initialState: 0) + +// Closure-based (recommended) +lock.withLock { state in + state += 1 +} + +// Traditional (same-thread only) +lock.lock() +defer { lock.unlock() } +// access protected state +``` + +**Properties**: +- Heap-allocated, stable memory address +- Non-recursive (can't re-lock from same thread) +- `Sendable` + +### Atomic Types (iOS 18+) + +```swift +import Synchronization + +let counter = Atomic(0) + +// Atomic increment +counter.wrappingAdd(1, ordering: .relaxed) + +// Compare-and-swap +let (exchanged, original) = counter.compareExchange( + expected: 0, + desired: 42, + ordering: .acquiringAndReleasing +) +``` + +## Patterns + +### Pattern 1: Thread-Safe Counter + +```swift +final class Counter: Sendable { + private let mutex = Mutex(0) + + var value: Int { mutex.withLock { $0 } } + func increment() { mutex.withLock { $0 += 1 } } +} +``` + +### Pattern 2: Sendable Wrapper + +```swift +final class ThreadSafeValue: @unchecked Sendable { + private let mutex: Mutex + + init(_ value: T) { mutex = Mutex(value) } + + var value: T { + get { mutex.withLock { $0 } } + set { mutex.withLock { $0 = newValue } } + } +} +``` + +### Pattern 3: Fast Sync Access in Actor + +```swift +actor ImageCache { + // Mutex for fast sync reads without actor hop + private let mutex = Mutex<[URL: Data]>([:]) + + nonisolated func cachedSync(_ url: URL) -> Data? { + mutex.withLock { $0[url] } + } + + func cacheAsync(_ url: URL, data: Data) { + mutex.withLock { $0[url] = data } + } +} +``` + +### Pattern 4: Lock-Free Counter with Atomic + +```swift +final class FastCounter: Sendable { + private let _value = Atomic(0) + + var value: Int { _value.load(ordering: .relaxed) } + + func increment() { + _value.wrappingAdd(1, ordering: .relaxed) + } +} +``` + +### Pattern 5: iOS 16 Fallback + +```swift +#if compiler(>=6.0) +import Synchronization +typealias Lock = Mutex +#else +import os +// Use OSAllocatedUnfairLock for iOS 16-17 +#endif +``` + +## Danger: Mixing with Swift Concurrency + +### Never Hold Locks Across Await + +```swift +// ❌ DEADLOCK RISK +mutex.withLock { + await someAsyncWork() // Task suspends while holding lock! +} + +// ✅ SAFE: Release before await +let value = mutex.withLock { $0 } +let result = await process(value) +mutex.withLock { $0 = result } +``` + +### Why Semaphores/RWLocks Are Unsafe + +Swift's cooperative thread pool has **limited threads**. Blocking primitives exhaust the pool: + +```swift +// ❌ DANGEROUS: Blocks cooperative thread +let semaphore = DispatchSemaphore(value: 0) +Task { + semaphore.wait() // Thread blocked, can't run other tasks! +} + +// ✅ Use async continuation instead +await withCheckedContinuation { continuation in + // Non-blocking callback + callback { continuation.resume() } +} +``` + +### os_unfair_lock Danger + +**Never use `os_unfair_lock` directly in Swift** — it can be moved in memory: + +```swift +// ❌ UNDEFINED BEHAVIOR: Lock may move +var lock = os_unfair_lock() +os_unfair_lock_lock(&lock) // Address may be invalid + +// ✅ Use OSAllocatedUnfairLock (heap-allocated, stable address) +let lock = OSAllocatedUnfairLock() +``` + +## Decision Tree + +``` +Need synchronization? +├─ Lock-free operation needed? +│ └─ Simple counter/flag? → Atomic +│ └─ Complex state? → Mutex +├─ iOS 18+ available? +│ └─ Yes → Mutex +│ └─ No, iOS 16+? → OSAllocatedUnfairLock +├─ Need suspension points? +│ └─ Yes → Actor (not lock) +├─ Cross-await access? +│ └─ Yes → Actor (not lock) +└─ Performance-critical hot path? + └─ Yes → Mutex/Atomic (not actor) +``` + +## Common Mistakes + +### Mistake 1: Using Lock for Async Coordination + +```swift +// ❌ Locks don't work with async +let mutex = Mutex(false) +Task { + await someWork() + mutex.withLock { $0 = true } // Race condition still possible +} + +// ✅ Use actor or async state +actor AsyncState { + var isComplete = false + func complete() { isComplete = true } +} +``` + +### Mistake 2: Recursive Locking Attempt + +```swift +// ❌ Deadlock — OSAllocatedUnfairLock is non-recursive +lock.withLock { + doWork() // If doWork() also calls withLock → deadlock +} + +// ✅ Refactor to avoid nested locking +let data = lock.withLock { $0.copy() } +doWork(with: data) +``` + +### Mistake 3: Mixing Lock Styles + +```swift +// ❌ Don't mix lock/unlock with withLock +lock.lock() +lock.withLock { /* ... */ } // Deadlock! +lock.unlock() + +// ✅ Pick one style +lock.withLock { /* all work here */ } +``` + +## Memory Ordering Quick Reference + +| Ordering | Read | Write | Use Case | +|----------|------|-------|----------| +| `.relaxed` | Yes | Yes | Counters, no dependencies | +| `.acquiring` | Yes | - | Load before dependent ops | +| `.releasing` | - | Yes | Store after dependent ops | +| `.acquiringAndReleasing` | Yes | Yes | Read-modify-write | +| `.sequentiallyConsistent` | Yes | Yes | Strongest guarantee | + +**Default choice**: `.relaxed` for counters, `.acquiringAndReleasing` for read-modify-write. + +## Resources + +**Docs**: /synchronization, /synchronization/mutex, /os/osallocatedunfairlock + +**Swift Evolution**: SE-0433 + +**Skills**: axiom-swift-concurrency, axiom-swift-performance diff --git a/.claude/skills/axiom-synchronization/agents/openai.yaml b/.claude/skills/axiom-synchronization/agents/openai.yaml new file mode 100644 index 0000000..7b79a36 --- /dev/null +++ b/.claude/skills/axiom-synchronization/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Synchronization" + short_description: "Needing thread-safe primitives for performance-critical code" diff --git a/.claude/skills/axiom-test-simulator/.openskills.json b/.claude/skills/axiom-test-simulator/.openskills.json new file mode 100644 index 0000000..cb331b0 --- /dev/null +++ b/.claude/skills/axiom-test-simulator/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-test-simulator", + "installedAt": "2026-04-12T08:06:51.286Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-test-simulator/SKILL.md b/.claude/skills/axiom-test-simulator/SKILL.md new file mode 100644 index 0000000..3745488 --- /dev/null +++ b/.claude/skills/axiom-test-simulator/SKILL.md @@ -0,0 +1,363 @@ +--- +name: axiom-test-simulator +description: Use when the user mentions simulator testing, visual verification, push notification testing, location simulation, or screenshot capture. +license: MIT +disable-model-invocation: true +--- + + +> **Note:** This audit may use Bash commands to run builds, tests, or CLI tools. +# Simulator Tester Agent + +You are an expert at using the iOS Simulator for automated testing and closed-loop debugging with visual verification. + +## Your Mission + +1. Check simulator state and boot if needed +2. Set up test scenario (location, permissions, deep link, etc.) +3. Capture evidence (screenshots, video, logs) +4. Analyze results and report findings + +## Mandatory First Steps + +**ALWAYS run these checks FIRST** (using JSON for reliable parsing): + +**Check for saved preferences first:** + +Read `.axiom/preferences.yaml` if it exists. If it contains a `simulator.device` and `simulator.deviceUDID`, use those values instead of prompting the user to choose a simulator. If the saved device isn't booted, boot it by UDID. If the file exists but is malformed, skip and fall back to discovery. + +If no preferences file exists, proceed with discovery below. + +```bash +# List available simulators with structured output +xcrun simctl list devices -j | jq '.devices | to_entries[] | .value[] | select(.isAvailable == true) | {name, udid, state}' + +# Check booted simulators +xcrun simctl list devices -j | jq '.devices | to_entries[] | .value[] | select(.state == "Booted") | {name, udid}' + +# Get specific device UDID for commands +UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid' | head -1) + +# Boot if needed (get UDID first, then boot) +xcrun simctl boot "iPhone 16 Pro" + +# Check for AXe (enables UI automation if available) +if command -v axe &> /dev/null; then + echo "AXe available - UI automation enabled (tap, swipe, type, describe-ui)" + AXE_AVAILABLE=true +else + echo "AXe not installed - using simctl only (install: brew install cameroncooke/axe/axe)" + AXE_AVAILABLE=false +fi +``` + +**Common fix**: "Unable to boot" → `xcrun simctl shutdown all && killall -9 Simulator` + +**Why JSON?** Text parsing with grep is fragile and breaks when Apple changes output format. JSON output (`-j`) is stable and machine-readable. + +## Capabilities + +### 1. Screenshot Capture +```bash +xcrun simctl io booted screenshot /tmp/screenshot-$(date +%s).png +``` +**Use for**: Visual fixes, layout issues, error states, documentation + +### 2. Video Recording +```bash +# Start recording in background +xcrun simctl io booted recordVideo /tmp/recording.mov & +RECORDING_PID=$! +sleep 2 # Wait for recording to start + +# ... perform test actions ... + +# Stop recording +kill -INT $RECORDING_PID +``` +**Use for**: Animation issues, complex user flows, reproducing crashes + +### 3. Location Simulation +```bash +xcrun simctl location booted set 37.7749 -122.4194 # San Francisco +xcrun simctl location booted clear # Clear location +``` +**Common coords**: SF `37.7749 -122.4194`, NYC `40.7128 -74.0060`, London `51.5074 -0.1278` + +### 4. Push Notification Testing +```bash +# Create payload +cat > /tmp/push.json << 'EOF' +{"aps":{"alert":{"title":"Test","body":"Message"},"badge":1,"sound":"default"}} +EOF + +# Send push +xcrun simctl push booted com.example.YourApp /tmp/push.json +``` + +### 5. Permission Management +```bash +# Grant permissions +xcrun simctl privacy booted grant location-always com.example.YourApp +xcrun simctl privacy booted grant photos com.example.YourApp +xcrun simctl privacy booted grant camera com.example.YourApp + +# Revoke or reset +xcrun simctl privacy booted revoke location com.example.YourApp +xcrun simctl privacy booted reset all com.example.YourApp +``` +**Available**: `location-always`, `location-when-in-use`, `photos`, `camera`, `microphone`, `contacts`, `calendar` + +### 6. Deep Link Navigation +```bash +xcrun simctl openurl booted myapp://settings/profile +xcrun simctl openurl booted "https://example.com/product/123" +``` + +### 7. App Lifecycle +```bash +xcrun simctl launch booted com.example.YourApp +xcrun simctl terminate booted com.example.YourApp +xcrun simctl install booted /path/to/YourApp.app +``` + +### 8. Status Bar Override (for screenshots) +```bash +xcrun simctl status_bar booted override --time "9:41" --batteryLevel 100 --cellularBars 4 +xcrun simctl status_bar booted clear +``` + +### 9. Log Capture +```bash +# Stream logs for specific app +xcrun simctl spawn booted log stream --predicate 'subsystem == "com.example.YourApp"' --style compact + +# Check recent crash logs +ls -lt "$HOME/Library/Logs/DiagnosticReports/"*.crash 2>/dev/null | head -5 +``` + +### 10. App Inventory & Diagnostics +```bash +# List all installed apps on booted simulator +xcrun simctl listapps booted + +# Get app container path (useful for inspecting sandbox) +xcrun simctl get_app_container booted com.example.YourApp data +xcrun simctl get_app_container booted com.example.YourApp app + +# Get detailed app info +xcrun simctl appinfo booted com.example.YourApp + +# Comprehensive system diagnostics (no archive = faster) +xcrun simctl diagnose --no-archive +``` +**Use for**: Verifying app installation, inspecting app data, deep debugging + +### 11. Simulator Management +```bash +# Clone simulator for test variants +xcrun simctl clone "Test Variant - Dark Mode" + +# List available runtimes +xcrun simctl list runtimes -j | jq '.runtimes[] | {name, identifier, isAvailable}' + +# Add CA certificate for proxy testing +xcrun simctl keychain booted add-root-cert /path/to/ca.pem +``` + +### 12. UI Automation with AXe (Optional) + +**Installation:** + +```bash +# Install AXe via Homebrew +brew install cameroncooke/axe/axe + +# Verify installation +axe --version +``` + +**Check availability:** `command -v axe` + +```bash +# Discover UI elements first (get accessibility identifiers) +axe describe-ui --udid $UDID + +# Tap by accessibility identifier (RECOMMENDED - stable) +axe tap --id "loginButton" --udid $UDID + +# Tap by label +axe tap --label "Submit" --udid $UDID + +# Tap at coordinates (less stable) +axe tap -x 200 -y 400 --udid $UDID + +# Long press +axe tap -x 200 -y 400 --duration 1.0 --udid $UDID + +# Gesture presets +axe gesture scroll-down --udid $UDID # Scroll content down +axe gesture scroll-up --udid $UDID # Scroll content up +axe gesture swipe-from-left-edge --udid $UDID # Back navigation + +# Custom swipe +axe swipe --start-x 200 --start-y 600 --end-x 200 --end-y 200 --udid $UDID + +# Type text (field must be focused first) +axe tap --id "emailTextField" --udid $UDID +axe type "user@example.com" --udid $UDID + +# Press Return key +axe key 40 --udid $UDID + +# Hardware buttons +axe button home --udid $UDID +axe button lock --udid $UDID +axe button siri --udid $UDID +``` +**Use for**: Automated UI flows when XCUITest not available, quick manual automation + +### 13. Video Streaming with AXe (Optional) + +```bash +# Stream video at 10 FPS (for monitoring) +axe stream-video --fps 10 --udid $UDID + +# Record video (H.264) +axe record-video --output /tmp/recording.mp4 --udid $UDID +# Press Ctrl+C to stop + +# Screenshot (alternative to simctl) +axe screenshot --output /tmp/screenshot.png --udid $UDID +``` +**Use for**: Live monitoring, recording test flows, capturing evidence + +## Test Workflow + +1. **Setup**: Check simulator state, boot if needed +2. **Configure**: Set location, permissions, etc. +3. **Execute**: Launch app, wait 2s for render, perform action +4. **Capture**: Screenshot, video, logs +5. **Analyze**: Review visual state, check for errors +6. **Report**: Actual vs expected, pass/fail +7. **Save**: If this is a new device/app selection, save to `.axiom/preferences.yaml` (see `axiom-xclog-ref` skill) + +## Output Format + +```markdown +## Simulator Test Results + +### Environment +- **Simulator**: [Device] ([iOS version]) +- **App**: [Bundle ID] +- **Scenario**: [What was tested] + +### Evidence +- **Screenshot**: [path] +- **Logs**: [relevant entries] + +### Analysis +**Expected**: [What should happen] +**Actual**: [What happened] +**Result**: ✅ PASS / ❌ FAIL + +### Issues Detected +- [Issue with severity] + +### Next Steps +1. [Recommended action] +``` + +## Guidelines + +1. Always check simulator state first +2. Wait for UI to stabilize (`sleep 2`) before screenshots +3. Check logs after each action +4. Use descriptive file names with timestamps +5. Read and analyze screenshots (you're multimodal) +6. Ask for bundle ID if not provided + +## Comprehensive Diagnostics (simctl diagnose) + +For deep troubleshooting and bug reports, use `simctl diagnose` to collect logs and system state. + +```bash +# Basic diagnostic collection (opens archive in Finder when done) +xcrun simctl diagnose + +# Faster collection without archive (useful for quick inspection) +xcrun simctl diagnose --no-archive --output /tmp/sim-diag + +# Collect from specific device only +xcrun simctl diagnose --udid $UDID + +# Include app data containers (warning: may include private data) +xcrun simctl diagnose --data-container + +# Full collection with no timeout (for complex issues) +xcrun simctl diagnose -X --all-logs +``` + +### Best Practices for Diagnostic Collection + +1. **Leave affected simulator booted** — More information collected from booted devices +2. **Enable verbose logging first** — For hard-to-reproduce issues: + ```bash + xcrun simctl logverbose booted enable + # Reboot simulator, reproduce issue, then run diagnose + xcrun simctl diagnose + ``` +3. **Collect right after reproducing** — Logs rotate, so capture immediately +4. **Use --no-archive for quick inspection** — Faster when you just need to check logs + +### What's Collected + +- System logs and crash reports +- Simulator configuration and state +- Device logs from booted simulators +- CoreSimulator service logs +- Optionally: app data containers (--data-container) + +**Use for**: Filing Apple bug reports, debugging simulator infrastructure issues, investigating crashes that happen before your code runs + +## Error Quick Reference + +| Symptom | Fix | +|---------|-----| +| Screenshot is black | `sleep 5` then retry | +| "Unable to boot" | `xcrun simctl shutdown all && killall -9 Simulator` | +| "Device not found" | `xcrun simctl list devices` to see available | +| Deep link doesn't work | Check URL scheme in Info.plist | +| Push fails | Validate JSON: `python -m json.tool < push.json` | + +## Example Interaction + +**User**: "Take a screenshot to verify my login fix works" + +**Your response**: +1. Check simulator state: `xcrun simctl list devices -j | jq '...'` +2. Boot if needed or confirm booted simulator +3. Wait for UI to stabilize: `sleep 2` +4. Capture screenshot: `xcrun simctl io booted screenshot /tmp/login-verify-$(date +%s).png` +5. Read and analyze the screenshot (you're multimodal) +6. Report findings: + - Screenshot shows login screen loaded correctly + - "Login" button is visible and enabled + - No error messages displayed + - Result: ✅ Fix verified + +## Resources + +**WWDC**: 2020-10647 (Become a Simulator expert) + +**Docs**: /xcode/running-your-app-in-simulator-or-on-a-device + +## Related + +**Optional Tools:** +- **AXe**: `brew install cameroncooke/axe/axe` — UI automation CLI + +For deep link debugging: `axiom-deep-link-debugging` skill +For build issues: `build-fixer` agent +For AXe reference: `axiom-axe-ref` skill +For running tests: `test-runner` agent diff --git a/.claude/skills/axiom-test-simulator/agents/openai.yaml b/.claude/skills/axiom-test-simulator/agents/openai.yaml new file mode 100644 index 0000000..1d4dc08 --- /dev/null +++ b/.claude/skills/axiom-test-simulator/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Test Simulator" + short_description: "The user mentions simulator testing, visual verification, push notification testing, location simulation, or screensh..." diff --git a/.claude/skills/axiom-testflight-triage/.openskills.json b/.claude/skills/axiom-testflight-triage/.openskills.json new file mode 100644 index 0000000..4d7199c --- /dev/null +++ b/.claude/skills/axiom-testflight-triage/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-testflight-triage", + "installedAt": "2026-04-12T08:06:51.604Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-testflight-triage/SKILL.md b/.claude/skills/axiom-testflight-triage/SKILL.md new file mode 100644 index 0000000..759a097 --- /dev/null +++ b/.claude/skills/axiom-testflight-triage/SKILL.md @@ -0,0 +1,740 @@ +--- +name: axiom-testflight-triage +description: Use when ANY beta tester reports a crash, ANY crash appears in Organizer or App Store Connect, crash logs need symbolication, app was killed without crash report, or you need to triage TestFlight feedback +license: MIT +metadata: + version: "1.0.0" +--- + +# TestFlight Crash & Feedback Triage + +## Overview + +Systematic workflow for investigating TestFlight crashes and reviewing beta feedback using Xcode Organizer. **Core principle:** Understand the crash before writing any fix — 15 minutes of triage prevents hours of debugging. + +## Red Flags — Use This Skill When + +- "A beta tester said my app crashed" +- "I see crashes in App Store Connect metrics but don't know how to investigate" +- "Crash logs in Organizer aren't symbolicated" +- "User sent a screenshot of a crash but I can't reproduce it" +- "App was killed but there's no crash — just disappeared" +- "TestFlight feedback has screenshots I need to review" + +--- + +## Decision Tree — Start Here + +### "A user reported a crash" + +1. **Open Xcode Organizer** (Window → Organizer → Crashes tab) +2. **Select your app** from the left sidebar +3. **Find the build version** the user was running +4. **Is the crash symbolicated?** + - YES (you see function names) → Go to [Reading the Crash Report](#reading-the-crash-report) + - NO (you see hex addresses like `0x100abc123`) → Go to [Symbolication Workflow](#symbolication-workflow) +5. **Can you identify the crash location?** + - YES → Go to [Common Crash Patterns](#common-crash-patterns) + - NO → Go to [Claude-Assisted Interpretation](#claude-assisted-interpretation) + +### "App was killed but no crash report" + +Not all terminations produce crash reports. Check for: + +1. **Jetsam reports** — System killed app due to memory pressure + - Organizer shows these separately from crashes + - Look for high `pageOuts` value +2. **Watchdog termination** — Main thread blocked too long + - Exception code `0x8badf00d` ("ate bad food") + - Happens during launch (>20s) or background tasks (>10s) +3. **MetricKit diagnostics** — On-device termination reasons + - Requires MetricKit integration in your app + +→ See [Terminations Without Crash Reports](#terminations-without-crash-reports) + +### "I want to review TestFlight feedback" + +1. **Xcode Organizer** → Feedback tab (next to Crashes) +2. **Or** App Store Connect → My Apps → [App] → TestFlight → Feedback + +→ See [Feedback Triage Workflow](#feedback-triage-workflow) + +--- + +## Xcode Organizer Walkthrough + +### Opening the Organizer + +**Window → Organizer** (or ⌘⇧O from Xcode) + +### UI Layout + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ [Toolbar: Time Period ▼] [Version ▼] [Product ▼] [Release ▼] │ +├──────────┬──────────────────────────┬───────────────────────────┤ +│ Sidebar │ Crashes List │ Inspector │ +│ │ │ │ +│ • Crashes│ ┌─────────────────────┐ │ Distribution Graph │ +│ • Energy │ │ syncFavorites crash │ │ ┌─────────────────────┐ │ +│ • Hang │ │ 21 devices • 7 today│ │ │ ▄ ▄▄▄ v2.0 │ │ +│ • Disk │ └─────────────────────┘ │ │ ▄▄▄▄▄ v2.0.1 │ │ +│ Feedback │ ┌─────────────────────┐ │ └─────────────────────┘ │ +│ │ │ Another crash... │ │ │ +│ │ └─────────────────────┘ │ Device Distribution │ +│ │ │ OS Distribution │ +│ ├──────────────────────────┤ │ +│ │ Log View │ [Feedback Inspector] │ +│ │ (simplified crash view) │ Shows tester feedback │ +│ │ │ for selected crash │ +└──────────┴──────────────────────────┴───────────────────────────┘ +``` + +### Key Features + +| Feature | What It Does | +|---------|--------------| +| **Speedy Delivery** | TestFlight crashes delivered moments after occurrence (not daily) | +| **Year of History** | Filter crashes by time period, see monthly trends | +| **Product Filter** | Filter by App Clip, watch app, extensions, or main app | +| **Version Filter** | Drill down to specific builds | +| **Release Filter** | Separate TestFlight vs App Store crashes | +| **Share Button** | Share crash link with team members | +| **Feedback Inspector** | See tester comments for selected crash | + +### Crash Entry Badges + +Crashes in the list show badges indicating origin: + +| Badge | Meaning | +|-------|---------| +| App Clip | Crash from your App Clip | +| Watch | Crash from watchOS companion | +| Extension | Crash from share extension, widget, etc. | +| (none) | Main iOS app | + +### The Triage Questions Workflow + +Before diving into code, ask yourself these questions (from WWDC): + +#### Question 1 — How Long Has This Been an Issue + +→ Check the **inspector's graph area** on the right +→ Graph legend shows which versions are affected +→ Look for when the crash first appeared + +#### Question 2 — Is This Affecting Production or Just TestFlight + +→ Use the **Release filter** in toolbar +→ Select "Release" to see App Store crashes only +→ Select "TestFlight" for beta crashes only + +#### Question 3 — What Was the User Doing + +→ Open the **Feedback Inspector** (right panel) +→ Check for tester comments describing their actions +→ Context clues: network state, battery level, disk space + +### Using the Feedback Inspector + +When a crash has associated TestFlight feedback, you'll see a feedback icon in the crashes list. Click it to open the Feedback Inspector. + +**Each feedback entry shows:** + +| Field | Why It Matters | +|-------|----------------| +| Version/Build | Confirms exact build tester was running | +| Device model | Device-specific crashes (older devices, specific screen sizes) | +| Battery level | Low battery can affect app behavior | +| Available disk | Low disk can cause write failures | +| Network type | Cellular vs WiFi, connectivity issues | +| Tester comment | Their description of what happened | + +**Example insight from WWDC:** A tester commented "I was going through a tunnel and hit the favorite button. A few seconds later, it crashed." This revealed a network timeout issue — the crash occurred because a 10-second timeout was too short for poor network conditions. + +### Opening Crash in Project + +1. Select a crash in the list +2. Click **Open in Project** button +3. Xcode opens with: + - Debug Navigator showing backtrace + - Editor highlighting the exact crash line + +### Sharing Crashes + +1. Select a crash +2. Click **Share** button in toolbar +3. Options: + - Copy link to share with team + - Add to your to-do list +4. When teammate clicks link, Organizer opens focused on that specific crash + +--- + +## Symbolication Workflow + +### Why Crashes Aren't Symbolicated + +Crash reports show raw memory addresses until matched with **dSYM files** (debug symbol files). Xcode handles this automatically when: + +- You archived the build in Xcode (not command-line only) +- "Upload symbols to Apple" was enabled during distribution +- The dSYM is indexed by Spotlight + +### Quick Check: Is It Symbolicated? + +In Organizer, look at the stack trace: + +| What You See | Status | +|--------------|--------| +| `0x0000000100abc123` | Unsymbolicated — needs dSYM | +| `MyApp.ViewController.viewDidLoad() + 45` | Symbolicated — ready to analyze | +| System frames symbolicated, app frames not | Partially symbolicated — missing your dSYM | + +### Manual Symbolication + +If automatic symbolication failed: + +```bash +# 1. Find the crash's build UUID (shown in crash report header) +# Look for "Binary Images" section, find your app's UUID + +# 2. Find matching dSYM +mdfind "com_apple_xcode_dsym_uuids == YOUR-UUID-HERE" + +# 3. If not found, check Archives +ls ~/Library/Developer/Xcode/Archives/ + +# 4. Symbolicate a specific address +xcrun atos -arch arm64 \ + -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \ + -l 0x100000000 \ + 0x0000000100abc123 + +# 5. Symbolicate an entire crash log (parse + symbolicate in one step) +# Note: crashlog is an LLDB Python script, not a compiled tool. +# Works via xcrun but may not be available in all Xcode configurations. +xcrun crashlog MyCrash.ips +``` + +**`atos` vs `crashlog`**: Use `atos` for individual addresses (always available). Use `crashlog` to parse and symbolicate an entire crash report at once — it handles the full Binary Images section and resolves all addresses automatically. + +### Common Symbolication Failures + +| Symptom | Cause | Fix | +|---------|-------|-----| +| System frames OK, app frames hex | Missing dSYM for your app | Find dSYM in Archives folder, or re-archive with symbols | +| Nothing symbolicated | UUID mismatch between crash and dSYM | Verify UUIDs match; rebuild exact same commit | +| "No such file" from atos | dSYM not in Spotlight index | Run `mdimport /path/to/MyApp.dSYM` | +| Can't find dSYM anywhere | Archived without symbols | Enable "Debug Information Format = DWARF with dSYM" in build settings | + +### Preventing Symbolication Issues + +```bash +# Verify dSYM exists after archive +ls ~/Library/Developer/Xcode/Archives/YYYY-MM-DD/MyApp*.xcarchive/dSYMs/ + +# Verify UUID matches +dwarfdump --uuid MyApp.app.dSYM +``` + +--- + +## Reading the Crash Report + +### Key Fields (What Actually Matters) + +| Field | What It Tells You | +|-------|-------------------| +| **Exception Type** | Category of crash (EXC_BAD_ACCESS, EXC_CRASH, etc.) | +| **Exception Codes** | Specific error (KERN_INVALID_ADDRESS = null pointer) | +| **Termination Reason** | Why the system killed the process | +| **Crashed Thread** | Which thread died (Thread 0 = main thread) | +| **Application Specific Information** | Often contains the actual error message | +| **Binary Images** | Loaded frameworks (helps identify third-party culprits) | + +### Reading the Stack Trace + +The crashed thread's stack trace reads **top to bottom**: + +- **Frame 0** = Where the crash occurred (most specific) +- **Lower frames** = What called it (call chain) +- **Look for your code** = Frames with your app/framework name + +``` +Thread 0 Crashed: +0 libsystem_kernel.dylib __pthread_kill + 8 ← System code +1 libsystem_pthread.dylib pthread_kill + 288 ← System code +2 libsystem_c.dylib abort + 128 ← System code +3 MyApp ViewController.loadData() ← YOUR CODE (start here) +4 MyApp ViewController.viewDidLoad() +5 UIKitCore -[UIViewController _loadView] +``` + +**Start at frame 3** — the first frame in your code. Work down to understand the call chain. + +### Example: Interpreting a Real Crash + +``` +Exception Type: EXC_BAD_ACCESS (SIGSEGV) +Exception Codes: KERN_INVALID_ADDRESS at 0x0000000000000010 + +Thread 0 Crashed: +0 MyApp 0x100abc123 UserManager.currentUser.getter + 45 +1 MyApp 0x100abc456 ProfileViewController.viewDidLoad() + 123 +2 UIKitCore 0x1a2b3c4d5 -[UIViewController loadView] + 89 +``` + +**Translation:** + +- `EXC_BAD_ACCESS` with `KERN_INVALID_ADDRESS` = Tried to access invalid memory +- Address `0x10` = Very low address, almost certainly nil dereference +- Crashed in `currentUser.getter` = Accessing a property that was nil +- Called from `ProfileViewController.viewDidLoad()` = During view setup + +**Likely cause:** Force-unwrapping an optional that was nil, or accessing a deallocated object. + +--- + +## Common Crash Patterns + +### EXC_BAD_ACCESS (SIGSEGV / SIGBUS) + +**What it means:** Accessed memory that doesn't belong to you. + +**Common causes in Swift:** + +| Pattern | Example | Fix | +|---------|---------|-----| +| Force-unwrap nil | `user!.name` | Use `guard let` or `if let` | +| Deallocated object | Accessing `self` in escaped closure after dealloc | Use `[weak self]` | +| Array out of bounds | `array[index]` where index >= count | Check bounds first | +| Uninitialized pointer | C interop with bad pointer | Validate pointer before use | + +```swift +// Before (crashes if user is nil) +let name = user!.name + +// After (safe) +guard let user = user else { + logger.warning("User was nil in ProfileViewController") + return +} +let name = user.name +``` + +### EXC_CRASH (SIGABRT) + +**What it means:** App deliberately terminated itself. + +**Common causes:** + +| Pattern | Clue in Crash Report | +|---------|---------------------| +| `fatalError()` / `preconditionFailure()` | Your assertion message in Application Specific Info | +| Uncaught Objective-C exception | `NSException` type and reason in report | +| Swift runtime error | "Fatal error: ..." message | +| Deadlock detected | `dispatch_sync` onto current queue | + +**Debug tip:** Look at "Application Specific Information" section — it usually contains the actual error message. + +### Watchdog Termination (0x8badf00d) + +**What it means:** Main thread was blocked too long and the system killed your app. + +**Time limits:** + +| Context | Limit | +|---------|-------| +| App launch | ~20 seconds | +| Background task | ~10 seconds | +| App going to background | ~5 seconds | + +**Common causes:** + +- Synchronous network request on main thread +- Synchronous file I/O on main thread +- Deadlock between queues +- Expensive computation blocking UI + +```swift +// Before (blocks main thread — will trigger watchdog) +let data = try Data(contentsOf: largeFileURL) +processData(data) + +// After (offload to background) +Task.detached { + let data = try Data(contentsOf: largeFileURL) + await MainActor.run { + self.processData(data) + } +} +``` + +### Jetsam (Memory Pressure Kill) + +**What it means:** System terminated your app to free memory. No crash report — just gone. + +**Symptoms:** + +- App "disappears" without any crash +- Jetsam report in Organizer (separate from crashes) +- High `pageOuts` value in report +- Often happens during photo/video processing or large data operations + +**Investigation:** + +1. Profile with Instruments → Allocations +2. Look for memory spikes during the reported operation +3. Check for image caching without size limits +4. Look for large data structures kept in memory + +**Common fixes:** + +- Use `autoreleasepool` for batch processing +- Implement image cache with memory limits +- Stream large files instead of loading entirely +- Release references to large objects when backgrounded + +--- + +## Terminations Without Crash Reports + +When users report "the app just closed" but you find no crash: + +### The Terminations Organizer + +The **Terminations Organizer** (separate from Crashes) shows trends of app terminations that aren't programming crashes: + +**Window → Organizer → Terminations** (in sidebar) + +| Termination Category | What It Means | +|---------------------|---------------| +| Launch timeout | App took too long to launch | +| Memory limit | Hit system memory ceiling | +| CPU limit (background) | Too much CPU while backgrounded | +| Background task timeout | Background task exceeded time limit | + +**Key insight:** Compare termination rates against previous versions to find regressions. A spike in memory terminations after a release indicates a memory leak or increased footprint. + +### Check for Jetsam + +1. Organizer → Select app → Look for "Disk Write Diagnostics" or "Hang Diagnostics" +2. These aren't crashes but system-initiated terminations + +### Check for Background Termination + +Apps can be terminated in background for: + +- **Memory pressure** (jetsam) +- **CPU usage** while backgrounded +- **Background task timeout** + +### Ask the User + +If no reports exist: + +1. "Was the app in the foreground when it closed?" +2. "Did you see any error message?" +3. "What were you doing right before it happened?" +4. "How long had the app been open?" + +### Enable Better Diagnostics with MetricKit + +MetricKit crash diagnostics are now delivered **on the next app launch** (not aggregated daily). This gives you faster access to crash data. + +```swift +import MetricKit + +class MetricsManager: NSObject, MXMetricManagerSubscriber { + + static let shared = MetricsManager() + + func startListening() { + MXMetricManager.shared.add(self) + } + + func didReceive(_ payloads: [MXMetricPayload]) { + // Process performance metrics + } + + func didReceive(_ payloads: [MXDiagnosticPayload]) { + for payload in payloads { + // Crash diagnostics — delivered on next launch + if let crashDiagnostics = payload.crashDiagnostics { + for crash in crashDiagnostics { + // Process crash diagnostic + print("Crash: \(crash.callStackTree)") + } + } + + // Hang diagnostics + if let hangDiagnostics = payload.hangDiagnostics { + for hang in hangDiagnostics { + print("Hang duration: \(hang.hangDuration)") + } + } + } + } +} +``` + +**When to use MetricKit vs Organizer:** + +| Use Case | Tool | +|----------|------| +| Quick triage of TestFlight crashes | Organizer (faster, visual) | +| Programmatic crash analysis | MetricKit | +| Custom crash reporting integration | MetricKit | +| Termination trends across versions | Terminations Organizer | + +--- + +## Claude-Assisted Interpretation + +### Using the Crash Analyzer Agent + +For **automated crash analysis**, use the **crash-analyzer** agent: + +``` +/axiom:analyze-crash +``` + +Or trigger naturally: +- "Analyze this crash log" +- "Parse this .ips file: ~/Library/Logs/DiagnosticReports/MyApp.ips" +- "Why did my app crash? Here's the report..." + +The agent will: +1. Parse the crash report (JSON .ips or text .crash format) +2. Check symbolication status +3. Categorize by crash pattern (null pointer, Swift runtime, watchdog, jetsam, etc.) +4. Generate actionable analysis with specific next steps + +### Effective Prompts + +**Basic interpretation:** + +``` +Here's a crash report from my iOS app. Help me understand: +1. What type of crash is this? +2. Where in my code did it crash? +3. What's the likely cause? + +[paste full crash report] +``` + +**With context (better results):** + +``` +My TestFlight app crashed. Here's what I know: + +- User was [describe action, e.g., "tapping the save button"] +- iOS version: [from crash report] +- Device: [from crash report] + +Crash report: +[paste full crash report] + +The relevant code is in [file/class name]. Help me understand the cause. +``` + +### What to Include + +| Include | Why | +|---------|-----| +| Full crash report | Partial reports lose context | +| What user was doing | Helps narrow down code paths | +| Relevant code snippets | If you know the crash area | +| iOS version and device | Some crashes are device/OS specific | + +### What Claude Can Help With + +- Interpreting exception types and codes +- Identifying likely cause from stack trace +- Explaining unfamiliar system frames +- Suggesting where to add logging +- Proposing fix patterns + +### What Requires Your Judgment + +- Whether the suggested fix is correct for your architecture +- How to reproduce the crash locally +- Priority relative to other bugs +- Whether it's a regression or long-standing issue + +--- + +## Feedback Triage Workflow + +### Where to Find Feedback + +**Xcode Organizer (recommended):** +Window → Organizer → Select app → Feedback tab + +**App Store Connect:** +My Apps → [App] → TestFlight → Feedback + +### What's in Each Feedback Entry + +| Component | Description | +|-----------|-------------| +| Screenshot | What the user saw (often the most valuable part) | +| Text comment | User's description of the issue | +| Device/OS | iPhone model and iOS version | +| App version | Which TestFlight build | +| Timestamp | When submitted | + +### Triage Workflow + +1. **Sort by recency** — Newest first, unless investigating specific issue +2. **Scan screenshots** — Visual issues are immediately apparent +3. **Read comments** — User's description and context +4. **Check version** — Is this fixed in a newer build? +5. **Categorize:** + +| Category | Action | +|----------|--------| +| 🐛 **Bug** | Investigate, file issue, prioritize fix | +| 💡 **Feature request** | Add to backlog if valuable | +| ❓ **Unclear** | Can't act without more context | +| ✅ **Working as intended** | May indicate UX confusion | + +### Limitations + +- **No direct reply** — TestFlight doesn't support responding to feedback +- **Screenshots only** — No video recordings +- **Limited context** — Users often don't explain what they were trying to do + +### MCP-Powered Feedback Access + +If **asc-mcp** is configured, you can access crash diagnostics and tester data programmatically: + +| Task | asc-mcp Tool | Worker | +|------|-------------|--------| +| List recent builds | `builds_list` | builds | +| See who tested a build | `builds_get_beta_testers` | build_beta | +| Get crash signatures for a build | `metrics_build_diagnostics` | metrics | +| Download crash logs | `metrics_get_diagnostic_logs` | metrics | +| Distribute fix to testers | `beta_groups_add_builds` | beta_groups | +| Notify testers of new build | `builds_send_beta_notification` | build_beta | + +**Limitation**: TestFlight text feedback and screenshots are NOT available via the App Store Connect API. Use Xcode Organizer or the ASC web dashboard for feedback content. + +**Setup**: `/skill axiom-asc-mcp` + +### Getting More Context + +If feedback is unclear and the tester is reachable: + +- Contact through TestFlight group email +- Add in-app feedback mechanism with more detail capture +- Include reproduction steps prompt in your TestFlight notes + +--- + +## Pressure Scenarios + +### Scenario 1: "VIP user says app crashes constantly, but I can't find any crash reports" + +**Pressure:** Important stakeholder, no evidence, tempted to dismiss with "works for me" + +**Correct approach:** + +1. Verify they're on TestFlight (not App Store, not dev build) +2. Confirm they've consented to share diagnostics (Settings → Privacy → Analytics) +3. Check for jetsam reports (kills without crash reports) +4. Check crash reports for their specific device/OS combination +5. Ask for specific reproduction steps +6. If still nothing: request screen recording of the issue + +**Response template:** +> "I've checked our crash reports and don't see crashes matching your description yet. To help investigate: (1) Could you confirm you're running the TestFlight version? (2) What exactly happens — does the app close suddenly, freeze, or show an error? (3) What were you doing right before? This will help me find the issue." + +**Why this matters:** "Works for me" destroys trust. Investigate thoroughly before dismissing. + +### Scenario 2: "Crash rate spiked after latest TestFlight build, need to fix ASAP" + +**Pressure:** Time pressure, tempted to guess at fix based on code changes + +**Correct approach:** + +1. Open Organizer → Crashes → Filter to the new build +2. Group crashes by exception type (look for the dominant signature) +3. Identify the #1 crash by frequency +4. Symbolicate and read the crash report fully +5. Understand the cause before writing any fix +6. If possible, reproduce locally +7. Fix the verified cause, not a guess + +**Why this matters:** Rushed guesses often introduce new bugs or miss the real issue. 15 minutes of proper triage prevents hours of misdirected debugging. + +### Scenario 3: "Crash report is symbolicated but I still don't understand it" + +**Pressure:** Tempted to ignore it or make random changes hoping it helps + +**Correct approach:** + +1. Paste full crash report into Claude with context +2. Ask for interpretation, not just "fix this" +3. Research exception type if unfamiliar +4. If still unclear after research, add logging around the crash site: + +```swift +func suspectFunction() { + logger.debug("Entering suspectFunction, state: \(debugDescription)") + defer { logger.debug("Exiting suspectFunction") } + + // ... existing code ... +} +``` + +5. Ship instrumented build to TestFlight +6. Wait for reproduction with better context + +**Why this matters:** Understanding beats guessing. Logging beats speculation. It's okay to say "I need more information" rather than shipping a random change. + +--- + +## Quick Reference + +### Organizer Keyboard Shortcuts + +| Action | Shortcut | +|--------|----------| +| Open Organizer | ⌘⇧O (from Xcode) | +| Refresh | ⌘R | + +### Common Exception Codes + +| Code | Meaning | +|------|---------| +| `KERN_INVALID_ADDRESS` | Null pointer / bad memory access | +| `KERN_PROTECTION_FAILURE` | Memory protection violation | +| `0x8badf00d` | Watchdog timeout (main thread blocked) | +| `0xdead10cc` | Deadlock detected | +| `0xc00010ff` | Thermal event (device too hot) | + +### Crash Report Sections + +| Section | Contains | +|---------|----------| +| Header | App info, device, OS, date | +| Exception Information | Crash type and codes | +| Termination Reason | Why system killed the process | +| Triggered by Thread | Which thread crashed | +| Application Specific | Error messages, assertions | +| Thread Backtraces | Stack traces for all threads | +| Binary Images | Loaded frameworks and addresses | + +--- + +## Resources + +**WWDC:** 2018-414, 2020-10076, 2020-10078, 2020-10081, 2021-10203, 2021-10258 + +**Docs:** /xcode/diagnosing-issues-using-crash-reports-and-device-logs, /xcode/examining-the-fields-in-a-crash-report, /xcode/adding-identifiable-symbol-names-to-a-crash-report, /xcode/identifying-the-cause-of-common-crashes, /xcode/identifying-high-memory-use-with-jetsam-event-reports + +**Skills**: axiom-memory-debugging, axiom-xcode-debugging, axiom-swift-concurrency, axiom-lldb (reproduce and investigate interactively), axiom-asc-mcp (programmatic ASC access) + +**Agents:** crash-analyzer (automated crash log parsing and analysis) diff --git a/.claude/skills/axiom-testflight-triage/agents/openai.yaml b/.claude/skills/axiom-testflight-triage/agents/openai.yaml new file mode 100644 index 0000000..e1218af --- /dev/null +++ b/.claude/skills/axiom-testflight-triage/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Testflight Triage" + short_description: "ANY beta tester reports a crash, ANY crash appears in Organizer or App Store Connect, crash logs need symbolication, ..." diff --git a/.claude/skills/axiom-testing-async/.openskills.json b/.claude/skills/axiom-testing-async/.openskills.json new file mode 100644 index 0000000..3da7495 --- /dev/null +++ b/.claude/skills/axiom-testing-async/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-testing-async", + "installedAt": "2026-04-12T08:06:52.026Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-testing-async/SKILL.md b/.claude/skills/axiom-testing-async/SKILL.md new file mode 100644 index 0000000..cf10e58 --- /dev/null +++ b/.claude/skills/axiom-testing-async/SKILL.md @@ -0,0 +1,358 @@ +--- +name: axiom-testing-async +description: Use when testing async code with Swift Testing. Covers confirmation for callbacks, @MainActor tests, async/await patterns, timeout control, XCTest migration, parallel test execution. +license: MIT +metadata: + version: "1.0.0" +--- + +# Testing Async Code — Swift Testing Patterns + +Modern patterns for testing async/await code with Swift Testing framework. + +## When to Use + +✅ **Use when:** +- Writing tests for async functions +- Testing callback-based APIs with Swift Testing +- Migrating async XCTests to Swift Testing +- Testing MainActor-isolated code +- Need to verify events fire expected number of times + +❌ **Don't use when:** +- XCTest-only project (use XCTestExpectation) +- UI automation tests (use XCUITest) +- Performance testing with metrics (use XCTest) + +## Key Differences from XCTest + +| XCTest | Swift Testing | +|--------|---------------| +| `XCTestExpectation` | `confirmation { }` | +| `wait(for:timeout:)` | `await confirmation` | +| `@MainActor` implicit | `@MainActor` explicit | +| Serial by default | **Parallel by default** | +| `XCTAssertEqual()` | `#expect()` | +| `continueAfterFailure` | `#require` per-expectation | + +## Patterns + +### Pattern 1: Simple Async Function + +```swift +@Test func fetchUser() async throws { + let user = try await api.fetchUser(id: 1) + #expect(user.name == "Alice") +} +``` + +### Pattern 2: Completion Handler → Continuation + +For APIs without async overloads: + +```swift +@Test func legacyAPI() async throws { + let result = try await withCheckedThrowingContinuation { continuation in + legacyFetch { result, error in + if let result { + continuation.resume(returning: result) + } else { + continuation.resume(throwing: error!) + } + } + } + #expect(result.isValid) +} +``` + +### Pattern 3: Single Callback with confirmation + +When a callback should fire exactly once: + +```swift +@Test func notificationFires() async { + await confirmation { confirm in + NotificationCenter.default.addObserver( + forName: .didUpdate, + object: nil, + queue: .main + ) { _ in + confirm() // Must be called exactly once + } + triggerUpdate() + } +} +``` + +### Pattern 4: Multiple Callbacks with expectedCount + +```swift +@Test func delegateCalledMultipleTimes() async { + await confirmation(expectedCount: 3) { confirm in + delegate.onProgress = { progress in + confirm() // Called 3 times + } + startDownload() // Triggers 3 progress updates + } +} +``` + +### Pattern 5: Verify Callback Never Fires + +```swift +@Test func noErrorCallback() async { + await confirmation(expectedCount: 0) { confirm in + delegate.onError = { _ in + confirm() // Should never be called + } + performSuccessfulOperation() + } +} +``` + +### Pattern 6: MainActor Tests + +```swift +@Test @MainActor func viewModelUpdates() async { + let vm = ViewModel() + await vm.load() + #expect(vm.items.count > 0) + #expect(vm.isLoading == false) +} +``` + +### Pattern 7: Timeout Control + +```swift +@Test(.timeLimit(.seconds(5))) +func slowOperation() async throws { + try await longRunningTask() +} +``` + +### Pattern 8: Testing Throws + +```swift +@Test func invalidInputThrows() async throws { + await #expect(throws: ValidationError.self) { + try await validate(input: "") + } +} + +// Specific error +@Test func specificError() async throws { + await #expect(throws: NetworkError.notFound) { + try await api.fetch(id: -1) + } +} +``` + +### Pattern 9: Optional Unwrapping with #require + +```swift +@Test func firstVideo() async throws { + let videos = try await videoLibrary.videos() + let first = try #require(videos.first) // Fails if nil + #expect(first.duration > 0) +} +``` + +### Pattern 10: Parameterized Async Tests + +```swift +@Test("Video loading", arguments: [ + "Beach.mov", + "Mountain.mov", + "City.mov" +]) +func loadVideo(fileName: String) async throws { + let video = try await Video.load(fileName) + #expect(video.isPlayable) +} +``` + +Arguments run in **parallel** automatically. + +## Parallel Test Execution + +Swift Testing runs tests **in parallel by default** (unlike XCTest). + +### Handling Shared State + +```swift +// ❌ Shared mutable state — race condition +var sharedCounter = 0 + +@Test func test1() async { + sharedCounter += 1 // Data race! +} + +@Test func test2() async { + sharedCounter += 1 // Data race! +} + +// ✅ Each test gets fresh instance +struct CounterTests { + var counter = Counter() // Fresh per test + + @Test func increment() { + counter.increment() + #expect(counter.value == 1) + } +} +``` + +### Forcing Serial Execution + +When tests must run sequentially: + +```swift +@Suite("Database tests", .serialized) +struct DatabaseTests { + @Test func createRecord() async { /* ... */ } + @Test func readRecord() async { /* ... */ } // After create + @Test func deleteRecord() async { /* ... */ } // After read +} +``` + +**Note**: Other unrelated tests still run in parallel. + +## Common Mistakes + +### Mistake 1: Using sleep Instead of confirmation + +```swift +// ❌ Flaky — arbitrary wait time +@Test func eventFires() async { + setupEventHandler() + try await Task.sleep(for: .seconds(1)) // Hope it happened? + #expect(eventReceived) +} + +// ✅ Deterministic — waits for actual event +@Test func eventFires() async { + await confirmation { confirm in + onEvent = { confirm() } + triggerEvent() + } +} +``` + +### Mistake 2: Forgetting @MainActor on UI Tests + +```swift +// ❌ Data race — ViewModel may be MainActor +@Test func viewModel() async { + let vm = ViewModel() + await vm.load() // May cause data race warnings +} + +// ✅ Explicit isolation +@Test @MainActor func viewModel() async { + let vm = ViewModel() + await vm.load() +} +``` + +### Mistake 3: Missing confirmation for Callbacks + +```swift +// ❌ Test passes immediately — doesn't wait for callback +@Test func callback() async { + api.fetch { result in + #expect(result.isSuccess) // Never executed before test ends + } +} + +// ✅ Waits for callback +@Test func callback() async { + await confirmation { confirm in + api.fetch { result in + #expect(result.isSuccess) + confirm() + } + } +} +``` + +### Mistake 4: Not Handling Parallel Execution + +```swift +// ❌ Tests interfere with each other +@Test func writeFile() async { + try! "data".write(to: sharedFileURL, atomically: true, encoding: .utf8) +} + +@Test func readFile() async { + let data = try! String(contentsOf: sharedFileURL) // May fail! +} + +// ✅ Use unique files or .serialized +@Test func writeAndRead() async { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try! "data".write(to: url, atomically: true, encoding: .utf8) + let data = try! String(contentsOf: url) + #expect(data == "data") +} +``` + +## Migration from XCTest + +### XCTestExpectation → confirmation + +```swift +// XCTest +func testFetch() { + let expectation = expectation(description: "fetch") + api.fetch { result in + XCTAssertNotNil(result) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) +} + +// Swift Testing +@Test func fetch() async { + await confirmation { confirm in + api.fetch { result in + #expect(result != nil) + confirm() + } + } +} +``` + +### Async setUp → Suite init + +```swift +// XCTest +class MyTests: XCTestCase { + var service: Service! + + override func setUp() async throws { + service = try await Service.create() + } +} + +// Swift Testing +struct MyTests { + let service: Service + + init() async throws { + service = try await Service.create() + } + + @Test func example() async { + // Use self.service + } +} +``` + +## Resources + +**WWDC**: 2024-10179, 2024-10195 + +**Docs**: /testing, /testing/confirmation + +**Skills**: axiom-swift-testing, axiom-ios-testing diff --git a/.claude/skills/axiom-testing-async/agents/openai.yaml b/.claude/skills/axiom-testing-async/agents/openai.yaml new file mode 100644 index 0000000..c30df86 --- /dev/null +++ b/.claude/skills/axiom-testing-async/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Testing Async" + short_description: "Testing async code with Swift Testing" diff --git a/.claude/skills/axiom-textkit-ref/.openskills.json b/.claude/skills/axiom-textkit-ref/.openskills.json new file mode 100644 index 0000000..797c19c --- /dev/null +++ b/.claude/skills/axiom-textkit-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-textkit-ref", + "installedAt": "2026-04-12T08:06:52.453Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-textkit-ref/SKILL.md b/.claude/skills/axiom-textkit-ref/SKILL.md new file mode 100644 index 0000000..ecd2016 --- /dev/null +++ b/.claude/skills/axiom-textkit-ref/SKILL.md @@ -0,0 +1,951 @@ +--- +name: axiom-textkit-ref +description: TextKit 2 complete reference (architecture, migration, Writing Tools, SwiftUI TextEditor) through iOS 26 +license: MIT +--- + +# TextKit 2 Reference + +Complete reference for TextKit 2 covering architecture, migration from TextKit 1, Writing Tools integration, and SwiftUI TextEditor with AttributedString through iOS 26. + +## Architecture + +TextKit 2 uses MVC pattern with new classes optimized for correctness, safety, and performance. + +### Model Layer + +**NSTextContentManager** (abstract) +- Generates NSTextElement objects from backing store +- Tracks element ranges within document +- Default implementation: NSTextContentStorage + +**NSTextContentStorage** +- Uses NSTextStorage as backing store +- Automatically divides content into NSTextParagraph elements +- Generates updated elements when text changes + +**NSTextElement** (abstract) +- Represents portion of content (paragraph, attachment, custom type) +- Immutable value semantics +- Properties cannot change after creation +- Default implementation: NSTextParagraph + +**NSTextParagraph** +- Represents single paragraph +- Contains range within document + +### Controller Layer + +**NSTextLayoutManager** +- Replaces TextKit 1's NSLayoutManager +- **NO glyph APIs** (abstracts away glyphs entirely) +- Takes elements, lays out into container, generates layout fragments +- Always uses noncontiguous layout + +**NSTextLayoutFragment** +- Immutable layout information for one or more elements +- Key properties: + - `textLineFragments` — array of NSTextLineFragment + - `layoutFragmentFrame` — layout bounds within container + - `renderingSurfaceBounds` — actual drawing bounds (can exceed frame) + +**NSTextLineFragment** +- Measurement info for single line of text +- Used for line counting and geometric queries + +### View Layer + +**NSTextViewportLayoutController** +- Source of truth for viewport layout +- Coordinates visible-only layout +- Calls delegate methods: `willLayout`, `configureRenderingSurface`, `didLayout` + +**NSTextContainer** +- Provides geometric information for layout destination +- Can define exclusion paths (non-rectangular layout) + +### Object-Based Ranges + +**NSTextLocation** (protocol) +- Represents single location in text +- Replaces integer indices +- Supports structured documents (e.g., DOM with nested elements) + +**NSTextRange** +- Start and end locations (end is excluded) +- Can represent nested structure +- Incompatible with NSRange for non-linear documents + +**NSTextSelection** +- Contains: granularity, affinity, possibly disjoint ranges +- Read-only properties +- Immutable value semantics + +**NSTextSelectionNavigation** +- Performs actions on selections +- Returns new NSTextSelection instances +- Handles bidirectional text correctly + +## Core Design Principles + +### 1. Correctness — No Glyph APIs + +From WWDC 2021: +> "TextKit 2 abstracts away glyph handling to provide a consistent experience for international text." + +**Why no glyphs?** + +**Problem:** In scripts like Kannada and Arabic: +- One glyph can represent multiple characters (ligatures) +- One character can split into multiple glyphs +- Glyphs reorder during shaping +- No correct character→glyph mapping + +**Example (Kannada word "October"):** +- Character 4 splits into 2 glyphs +- Glyphs reorder before ligature application +- Glyph 3 becomes conjoining form and moves below another glyph + +**Solution:** Use NSTextLocation, NSTextRange, NSTextSelection instead of glyph indices. + +### 2. Safety — Value Semantics + +**Immutable objects:** +- NSTextElement +- NSTextLayoutFragment +- NSTextLineFragment +- NSTextSelection + +**Benefits:** +- No unintended sharing +- No side effects from mutations +- Easier to reason about state + +**Pattern:** +To change layout/selection, create new instances with desired changes. + +### 3. Performance — Viewport Layout + +**Always Noncontiguous:** +TextKit 2 performs layout only for visible content + overscroll region. + +**TextKit 1:** +- Optional noncontiguous layout (boolean property) +- No visibility into layout state +- Can't control which parts get laid out + +**TextKit 2:** +- Always noncontiguous +- Viewport defines visible area +- Consistent layout info for viewport +- Notifications for viewport layout updates + +**Viewport Delegate Methods:** +1. `textViewportLayoutControllerWillLayout(_:)` — setup before layout +2. `textViewportLayoutController(_:configureRenderingSurfaceFor:)` — per fragment +3. `textViewportLayoutControllerDidLayout(_:)` — cleanup after layout + +## Migration from TextKit 1 + +### Key Paradigm Shift + +| TextKit 1 | TextKit 2 | +|-----------|-----------| +| Glyphs | Elements | +| NSRange | NSTextLocation/NSTextRange | +| NSLayoutManager | NSTextLayoutManager | +| Glyph APIs | NO glyph APIs | +| Optional noncontiguous | Always noncontiguous | +| NSTextStorage directly | Via NSTextContentManager | + +### API Naming Heuristics + +From WWDC 2022: +- `.offset` in name → TextKit 1 +- `.location` in name → TextKit 2 + +### NSRange ↔ NSTextRange Conversion + +**NSRange → NSTextRange:** +```swift +// UITextView/NSTextView +let nsRange = NSRange(location: 0, length: 10) + +// Via content manager +let startLocation = textContentManager.location( + textContentManager.documentRange.location, + offsetBy: nsRange.location +)! +let endLocation = textContentManager.location( + startLocation, + offsetBy: nsRange.length +)! +let textRange = NSTextRange(location: startLocation, end: endLocation) +``` + +**NSTextRange → NSRange:** +```swift +let startOffset = textContentManager.offset( + from: textContentManager.documentRange.location, + to: textRange.location +) +let length = textContentManager.offset( + from: textRange.location, + to: textRange.endLocation +) +let nsRange = NSRange(location: startOffset, length: length) +``` + +### Glyph API Replacements + +**NO direct glyph API equivalents.** Must use higher-level structures. + +**Example (TextKit 1 - counting lines):** +```swift +// TextKit 1 - iterate glyphs +var lineCount = 0 +let glyphRange = layoutManager.glyphRange(for: textContainer) +for glyphIndex in glyphRange.location.. "Accessing textView.layoutManager triggers TK1 fallback" + +**Once fallback occurs:** +- No automatic way back to TextKit 2 +- Expensive to switch +- Lose UI state (selection, scroll position) +- **One-way operation** + +**Prevent Fallback:** +1. Check `.textLayoutManager` first (TextKit 2) +2. Only access `.layoutManager` in else clause +3. Opt out at initialization if TK1 required + +```swift +// Check TextKit 2 first +if let textLayoutManager = textView.textLayoutManager { + // TextKit 2 code +} else if let layoutManager = textView.layoutManager { + // TextKit 1 fallback (old OS versions) +} +``` + +**Debug Fallback:** +- **UIKit:** Breakpoint on `_UITextViewEnablingCompatibilityMode` +- **AppKit:** Subscribe to `willSwitchToNSLayoutManagerNotification` + +### NSTextView Opt-In (macOS) + +**Create TextKit 2 NSTextView:** +```swift +let textLayoutManager = NSTextLayoutManager() +let textContainer = NSTextContainer() +textLayoutManager.textContainer = textContainer + +let textView = NSTextView(frame: .zero, textContainer: textContainer) +// textView.textLayoutManager now available +``` + +**New Convenience Constructor:** +```swift +// iOS 16+ / macOS 13+ +let textView = UITextView(usingTextLayoutManager: true) +let nsTextView = NSTextView(usingTextLayoutManager: true) +``` + +## Delegate Hooks + +### NSTextContentStorageDelegate + +**Customize attributes without modifying storage:** +```swift +func textContentStorage( + _ textContentStorage: NSTextContentStorage, + textParagraphWith range: NSRange +) -> NSTextParagraph? { + // Modify attributes for display + var attributedString = textContentStorage.attributedString! + .attributedSubstring(from: range) + + // Add custom attributes + if isComment(range) { + attributedString.addAttribute( + .foregroundColor, + value: UIColor.systemIndigo, + range: NSRange(location: 0, length: attributedString.length) + ) + } + + return NSTextParagraph(attributedString: attributedString) +} +``` + +**Filter elements (hide/show content):** +```swift +func textContentManager( + _ textContentManager: NSTextContentManager, + shouldEnumerate textElement: NSTextElement, + options: NSTextContentManager.EnumerationOptions +) -> Bool { + // Return false to hide element + if hideComments && isComment(textElement) { + return false + } + return true +} +``` + +### NSTextLayoutManagerDelegate + +**Provide custom layout fragments:** +```swift +func textLayoutManager( + _ textLayoutManager: NSTextLayoutManager, + textLayoutFragmentFor location: NSTextLocation, + in textElement: NSTextElement +) -> NSTextLayoutFragment { + // Return custom fragment for special styling + if isComment(textElement) { + return BubbleLayoutFragment( + textElement: textElement, + range: textElement.elementRange + ) + } + return NSTextLayoutFragment( + textElement: textElement, + range: textElement.elementRange + ) +} +``` + +### NSTextViewportLayoutController.Delegate + +**Viewport layout lifecycle:** +```swift +func textViewportLayoutControllerWillLayout(_ controller: NSTextViewportLayoutController) { + // Prepare for layout: clear sublayers, begin animation +} + +func textViewportLayoutController( + _ controller: NSTextViewportLayoutController, + configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment +) { + // Update geometry for each visible fragment + let layer = getOrCreateLayer(for: textLayoutFragment) + layer.frame = textLayoutFragment.layoutFragmentFrame + // Animate to new position if needed +} + +func textViewportLayoutControllerDidLayout(_ controller: NSTextViewportLayoutController) { + // Finish: commit animations, update scroll indicators +} +``` + +## Practical Patterns + +### Custom Layout Fragment (Bubble Backgrounds) + +```swift +class BubbleLayoutFragment: NSTextLayoutFragment { + override func draw(at point: CGPoint, in context: CGContext) { + // Draw custom background + context.setFillColor(UIColor.systemIndigo.cgColor) + let bubblePath = UIBezierPath( + roundedRect: layoutFragmentFrame, + cornerRadius: 8 + ) + context.addPath(bubblePath.cgPath) + context.fillPath() + + // Draw text on top + super.draw(at: point, in: context) + } +} +``` + +### Rendering Attributes (Temporary Styling) + +**Add attributes that don't modify text storage:** +```swift +textLayoutManager.addRenderingAttribute( + .foregroundColor, + value: UIColor.green, + for: ingredientRange +) + +// Remove when no longer needed +textLayoutManager.removeRenderingAttribute( + .foregroundColor, + for: ingredientRange +) +``` + +### Text Attachment with UIView + +```swift +// iOS 15+ +let attachment = NSTextAttachment() +attachment.image = UIImage(systemName: "star.fill") + +// Provide view for interaction +class AttachmentViewProvider: NSTextAttachmentViewProvider { + override func loadView() { + super.loadView() + let button = UIButton(type: .system) + button.setTitle("Tap me", for: .normal) + button.addTarget(self, action: #selector(didTap), for: .touchUpInside) + view = button + } + + @objc func didTap() { + // Handle tap + } +} +``` + +### Lists and Tables + +```swift +// Create list +let listItem = NSTextList(markerFormat: .disc, options: 0) +let paragraphStyle = NSMutableParagraphStyle() +paragraphStyle.textLists = [listItem] + +attributedString.addAttribute( + .paragraphStyle, + value: paragraphStyle, + range: range +) +``` + +**NSTextList** available in UIKit (iOS 16+), previously AppKit-only. + +### Hit Testing & Selection Geometry + +```swift +// Get text range at point +let location = textLayoutManager.location( + interactingAt: point, + inContainerAt: textContainer.location +) + +// Get bounding rect for range +var boundingRect = CGRect.zero +textLayoutManager.enumerateTextSegments( + in: textRange, + type: .standard, + options: [] +) { segmentRange, segmentRect, baselinePosition, textContainer in + boundingRect = boundingRect.union(segmentRect) + return true +} +``` + +## Writing Tools (iOS 18+) + +### Basic Integration (TextKit 2 Required) + +From WWDC 2024: +> "UITextView or NSTextView has to use TextKit 2 to support the full Writing Tools experience. If using TextKit 1, you will get a limited experience that just shows rewritten results in a panel." + +**Free for native text views:** +```swift +// UITextView, NSTextView, WKWebView +// Writing Tools appears automatically +``` + +### Lifecycle Delegate Methods + +```swift +func textViewWritingToolsWillBegin(_ textView: UITextView) { + // Pause syncing, prevent edits + isSyncing = false +} + +func textViewWritingToolsDidEnd(_ textView: UITextView) { + // Resume syncing + isSyncing = true +} + +// Check if active +if textView.isWritingToolsActive { + // Don't persist text storage +} +``` + +### Controlling Behavior + +```swift +// Opt out completely +textView.writingToolsBehavior = .none + +// Panel-only experience (no in-line edits) +textView.writingToolsBehavior = .limited + +// Full experience (default) +textView.writingToolsBehavior = .default +``` + +### Result Options + +```swift +// Plain text only +textView.writingToolsResultOptions = [.plainText] + +// Rich text +textView.writingToolsResultOptions = [.richText] + +// Rich text + tables +textView.writingToolsResultOptions = [.richText, .table] + +// Rich text + lists +textView.writingToolsResultOptions = [.richText, .list] +``` + +### Protected Ranges + +```swift +// UITextViewDelegate / NSTextViewDelegate +func textView( + _ textView: UITextView, + writingToolsIgnoredRangesIn enclosingRange: NSRange +) -> [NSRange] { + // Return ranges that Writing Tools should not modify + return codeBlockRanges + quoteRanges +} +``` + +**WKWebView:** `
` and `
` tags automatically ignored.
+
+## Writing Tools Coordinator (iOS 26+)
+
+Advanced integration for custom text engines.
+
+### Setup
+
+```swift
+// UIKit
+let coordinator = UIWritingToolsCoordinator()
+coordinator.delegate = self
+textView.addInteraction(coordinator)
+coordinator.writingToolsBehavior = .default
+coordinator.writingToolsResultOptions = [.richText]
+
+// AppKit
+let coordinator = NSWritingToolsCoordinator()
+coordinator.delegate = self
+customView.writingToolsCoordinator = coordinator
+```
+
+### Coordinator Delegate
+
+**Provide context:**
+```swift
+func writingToolsCoordinator(
+    _ coordinator: NSWritingToolsCoordinator,
+    requestContexts scope: NSWritingToolsCoordinator.ContextScope
+) async -> [NSWritingToolsCoordinator.Context] {
+    // Return attributed string + selection range
+    let context = NSWritingToolsCoordinator.Context(
+        attributedString: currentText,
+        range: currentSelection
+    )
+    return [context]
+}
+```
+
+**Apply changes:**
+```swift
+func writingToolsCoordinator(
+    _ coordinator: NSWritingToolsCoordinator,
+    replace context: NSWritingToolsCoordinator.Context,
+    range: NSRange,
+    with attributedString: NSAttributedString
+) async {
+    // Update text storage
+    textStorage.replaceCharacters(in: range, with: attributedString)
+}
+```
+
+**Update selection:**
+```swift
+func writingToolsCoordinator(
+    _ coordinator: NSWritingToolsCoordinator,
+    updateSelectedRange selectedRange: NSRange,
+    in context: NSWritingToolsCoordinator.Context
+) async {
+    // Update selection
+    self.selectedRange = selectedRange
+}
+```
+
+**Provide previews for animation:**
+```swift
+// macOS
+func writingToolsCoordinator(
+    _ coordinator: NSWritingToolsCoordinator,
+    previewsFor context: NSWritingToolsCoordinator.Context,
+    range: NSRange
+) async -> [NSTextPreview] {
+    // Return one preview per line for smooth animation
+    return textLines.map { line in
+        NSTextPreview(
+            image: renderImage(for: line),
+            frame: line.frame
+        )
+    }
+}
+
+// iOS
+func writingToolsCoordinator(
+    _ coordinator: UIWritingToolsCoordinator,
+    previewFor context: UIWritingToolsCoordinator.Context,
+    range: NSRange
+) async -> UITargetedPreview {
+    // Return single preview
+    return UITargetedPreview(
+        view: previewView,
+        parameters: parameters
+    )
+}
+```
+
+**Proofreading marks:**
+```swift
+func writingToolsCoordinator(
+    _ coordinator: NSWritingToolsCoordinator,
+    underlinesFor context: NSWritingToolsCoordinator.Context,
+    range: NSRange
+) async -> [NSValue] {
+    // Return bezier paths for underlines
+    return ranges.map { range in
+        let path = bezierPath(for: range)
+        return NSValue(bytes: &path, objCType: "CGPath")
+    }
+}
+```
+
+### PresentationIntent (iOS 26+)
+
+**Semantic rich text result option:**
+```swift
+coordinator.writingToolsResultOptions = [.richText, .presentationIntent]
+```
+
+**Difference from display attributes:**
+
+**Display attributes** (bold, italic):
+- Concrete font info (point sizes, font names)
+- No semantic meaning
+
+**PresentationIntent** (header, code block, emphasis):
+- Semantic style info
+- App converts to internal styles
+- Lists, tables, code blocks use presentation intent
+- Underline, subscript, superscript still use display attributes
+
+**Example:**
+```swift
+// Check for presentation intent
+if attributedString.runs[\.presentationIntent].contains(where: { $0?.components.contains(.header(level: 1)) == true }) {
+    // This is a heading
+}
+```
+
+## SwiftUI TextEditor + AttributedString (iOS 26+)
+
+### Basic Usage
+
+```swift
+struct RecipeEditor: View {
+    @State private var text: AttributedString = "Recipe text"
+
+    var body: some View {
+        TextEditor(text: $text)
+    }
+}
+```
+
+**Supported attributes:**
+- Bold, italic, underline, strikethrough
+- Custom fonts, point size
+- Foreground and background colors
+- Kerning, tracking, baseline offset
+- Genmoji
+- Line height, text alignment, base writing direction
+
+### TextAlignment and WritingDirection
+
+```swift
+// Text alignment (AttributedString.TextAlignment)
+var text = AttributedString("Centered paragraph")
+text.alignment = .center  // .left, .right, .center
+
+// Writing direction for bidirectional text
+var bidiText = AttributedString("Hello عربي")
+bidiText.writingDirection = .rightToLeft  // .leftToRight, .rightToLeft
+```
+
+### LineHeight Control
+
+```swift
+var multiline = AttributedString("Paragraph\nwith multiple\nlines.")
+multiline.lineHeight = .exact(points: 32)       // Fixed height
+multiline.lineHeight = .multiple(factor: 2.5)   // Multiplier
+multiline.lineHeight = .loose                    // System loose spacing
+```
+
+### Selection Binding
+
+```swift
+@State private var selection: AttributedTextSelection?
+
+TextEditor(text: $text, selection: $selection)
+```
+
+**AttributedTextSelection:**
+```swift
+enum AttributedTextSelection {
+    case none
+    case single(NSRange)
+    case multiple(Set) // For bidirectional text
+}
+```
+
+**Get selected text:**
+```swift
+if let selection {
+    let selectedText: AttributedSubstring
+    switch selection.indices {
+    case .none:
+        selectedText = text[...]
+    case .single(let range):
+        selectedText = text[range]
+    case .multiple(let ranges):
+        // Discontiguous substring from RangeSet
+        selectedText = text[selection]
+    }
+}
+```
+
+### Programmatic Selection Replacement
+
+```swift
+var text = AttributedString("Here is my dog")
+var selection = AttributedTextSelection(range: text.range(of: "dog")!)
+
+// Replace with plain text
+text.replaceSelection(&selection, withCharacters: "cat")
+
+// Replace with attributed content
+let replacement = AttributedString("horse")
+text.replaceSelection(&selection, with: replacement)
+```
+
+### DiscontiguousAttributedSubstring
+
+Work with non-contiguous selections using `RangeSet`:
+
+```swift
+let text = AttributedString("Select multiple parts of this text")
+let range1 = text.range(of: "Select")!
+let range2 = text.range(of: "text")!
+let rangeSet = RangeSet([range1, range2])
+var substring = text[rangeSet]  // DiscontiguousAttributedSubstring
+substring.backgroundColor = .yellow
+
+// Convert back to AttributedString
+let combined = AttributedString(substring)
+```
+
+### Text Selection Affinity
+
+Control selection affinity for the view hierarchy:
+
+```swift
+TextEditor(text: $text, selection: $selection)
+    .textSelectionAffinity(.upstream)  // .upstream or .downstream
+```
+
+Use `.upstream` when selection should resolve toward the beginning of text at line boundaries.
+
+### Custom Formatting Definition
+
+**Constrain which attributes are editable:**
+
+```swift
+struct RecipeFormattingDefinition: AttributedTextFormattingDefinition {
+    typealias FormatScope = RecipeAttributeScope
+
+    static let constraints: [any AttributedTextValueConstraint] = [
+        IngredientsAreGreen()
+    ]
+}
+
+struct RecipeAttributeScope: AttributedScope {
+    var ingredient: IngredientAttribute
+    var foregroundColor: ForegroundColorAttribute
+    var genmoji: GenmojiAttribute
+}
+```
+
+**Apply to TextEditor:**
+```swift
+TextEditor(text: $text)
+    .attributedTextFormattingDefinition(RecipeFormattingDefinition.self)
+```
+
+### Value Constraints
+
+**Control attribute values based on custom logic:**
+
+```swift
+struct IngredientsAreGreen: AttributedTextValueConstraint {
+    typealias Definition = RecipeFormattingDefinition
+    typealias AttributeKey = ForegroundColorAttribute
+
+    func constrain(
+        _ value: inout Color?,
+        in scope: RecipeFormattingDefinition.FormatScope
+    ) {
+        if scope.ingredient != nil {
+            value = .green // Ingredients are always green
+        } else {
+            value = nil // Others use default
+        }
+    }
+}
+```
+
+**System behavior:**
+- TextEditor probes constraints to determine if changes are valid
+- If constraint would revert change, control is disabled
+- Constraints applied to pasted content
+
+### Custom Attributes
+
+**Define attribute:**
+```swift
+struct IngredientAttribute: CodableAttributedStringKey {
+    typealias Value = UUID // Ingredient ID
+
+    static let name = "ingredient"
+}
+
+extension AttributeScopes.RecipeAttributeScope {
+    var ingredient: IngredientAttribute.Type { IngredientAttribute.self }
+}
+```
+
+**Attribute behavior:**
+```swift
+extension IngredientAttribute {
+    // Don't expand when typing after ingredient
+    static let inheritedByAddedText = false
+
+    // Remove if text in run changes
+    static let invalidationConditions: [AttributedString.InvalidationCondition] = [
+        .textChanged
+    ]
+
+    // Optional: constrain to paragraph boundaries
+    static let runBoundaries: AttributedString.RunBoundaries = .paragraph
+}
+```
+
+### AttributedString Mutations
+
+**Safe index updates:**
+```swift
+// Transform updates indices/selection during mutation
+text.transform(updating: &selection) { mutableText in
+    // Find ranges
+    let ranges = mutableText.characters.ranges(of: "butter")
+
+    // Set attribute for all ranges at once
+    for range in ranges {
+        mutableText[range].ingredient = ingredientID
+    }
+}
+
+// selection is now updated to match transformed text
+```
+
+**Don't use old indices:**
+```swift
+// BAD - indices invalidated by mutation
+let range = text.characters.range(of: "butter")!
+text[range].foregroundColor = .green
+text.append(" (unsalted)") // range is now invalid!
+```
+
+### AttributedString Views
+
+Multiple views into same content:
+- `characters` — grapheme clusters
+- `unicodeScalars` — Unicode scalars
+- `utf8` — UTF-8 code units
+- `utf16` — UTF-16 code units
+
+All views share same indices.
+
+## Known Limitations & Gotchas
+
+### Viewport Scroll Issues
+
+From expert articles:
+- Viewport can cause scroll position instability
+- `usageBoundsForTextContainer` changes during scroll
+- Apple's TextEdit exhibits same issues
+- Trade-off for performance benefits
+
+### TextKit 1 Compatibility
+
+- Accessing `.layoutManager` triggers fallback
+- One-way operation (no automatic return)
+- Loses UI state during switch
+- Expensive to switch layout systems
+
+### AttributedString Index Invalidation
+
+- Any mutation invalidates all indices
+- Must use `.transform(updating:)` to keep indices valid
+- Indices only work with originating AttributedString
+
+### Limited TextKit 1 Support
+
+Unsupported in TextKit 2:
+- NSTextTable (use NSTextList or custom layouts)
+- Some legacy text attachments
+- Direct glyph manipulation
+
+## Resources
+
+**WWDC**: 2021-10061, 2022-10090, 2023-10058, 2024-10168, 2025-265, 2025-280
+
+**Docs**: /uikit/nstextlayoutmanager, /appkit/textkit/using_textkit_2_to_interact_with_text, /uikit/display-text-with-a-custom-layout, /swiftui/building-rich-swiftui-text-experiences, /foundation/attributedstring, /foundation/attributedstring/textalignment, /foundation/attributedstring/lineheight, /foundation/discontiguousattributedsubstring, /uikit/writing-tools, /appkit/enhancing-your-custom-text-engine-with-writing-tools
diff --git a/.claude/skills/axiom-textkit-ref/agents/openai.yaml b/.claude/skills/axiom-textkit-ref/agents/openai.yaml
new file mode 100644
index 0000000..648b7d9
--- /dev/null
+++ b/.claude/skills/axiom-textkit-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "TextKit Reference"
+  short_description: "TextKit 2 complete reference (architecture, migration, Writing Tools, SwiftUI TextEditor) through iOS 26"
diff --git a/.claude/skills/axiom-timer-patterns-ref/.openskills.json b/.claude/skills/axiom-timer-patterns-ref/.openskills.json
new file mode 100644
index 0000000..6305164
--- /dev/null
+++ b/.claude/skills/axiom-timer-patterns-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-timer-patterns-ref",
+  "installedAt": "2026-04-12T08:06:53.249Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-timer-patterns-ref/SKILL.md b/.claude/skills/axiom-timer-patterns-ref/SKILL.md
new file mode 100644
index 0000000..1c16f37
--- /dev/null
+++ b/.claude/skills/axiom-timer-patterns-ref/SKILL.md
@@ -0,0 +1,470 @@
+---
+name: axiom-timer-patterns-ref
+description: Timer, DispatchSourceTimer, Combine Timer.publish, AsyncTimerSequence, Task.sleep API reference with lifecycle diagrams, RunLoop modes, and platform availability
+license: MIT
+metadata:
+  version: "1.0.0"
+  last-updated: "2026-02-26"
+---
+
+# Timer Patterns Reference
+
+Complete API reference for iOS timer mechanisms. For decision trees and crash prevention, see `axiom-timer-patterns`.
+
+---
+
+## Part 1: Timer API
+
+### Timer.scheduledTimer (Block-Based)
+
+```swift
+// Most common — block-based, auto-added to current RunLoop
+let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
+    self?.updateProgress()
+}
+```
+
+**Key detail**: Added to `.default` RunLoop mode. Stops during scrolling. See Part 1 RunLoop modes table below.
+
+### Timer.scheduledTimer (Selector-Based)
+
+```swift
+// Objective-C style — RETAINS TARGET (leak risk)
+let timer = Timer.scheduledTimer(
+    timeInterval: 1.0,
+    target: self,       // Timer retains self!
+    selector: #selector(update),
+    userInfo: nil,
+    repeats: true
+)
+```
+
+**Danger**: This API retains `target`. If `self` also holds the timer, you have a retain cycle. The block-based API with `[weak self]` is always safer.
+
+### Timer.init (Manual RunLoop Addition)
+
+```swift
+// Create timer without adding to RunLoop
+let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
+    self?.updateProgress()
+}
+
+// Add to specific RunLoop mode
+RunLoop.current.add(timer, forMode: .common)  // Survives scrolling
+```
+
+### timer.tolerance
+
+```swift
+timer.tolerance = 0.1  // Allow 100ms flexibility for system coalescing
+```
+
+System batches timers with similar fire dates when tolerance is set. Minimum recommended: 10% of interval. Reduces CPU wakes and energy consumption.
+
+### RunLoop Modes
+
+| Mode | Constant | When Active | Timer Fires? |
+|------|----------|-------------|--------------|
+| Default | `.default` / `RunLoop.Mode.default` | Normal user interaction | Yes |
+| Tracking | `.tracking` / `RunLoop.Mode.tracking` | Scroll/drag gesture active | Only if added to `.common` |
+| Common | `.common` / `RunLoop.Mode.common` | Pseudo-mode (default + tracking) | Yes (always) |
+
+### timer.invalidate()
+
+```swift
+timer.invalidate()  // Stops timer, removes from RunLoop
+// Timer is NOT reusable after invalidate — create a new one
+timer = nil          // Release reference
+```
+
+**Key detail**: `invalidate()` must be called from the same thread that created the timer (usually main thread).
+
+### timer.isValid
+
+```swift
+if timer.isValid {
+    // Timer is still active
+}
+```
+
+Returns `false` after `invalidate()` or after a non-repeating timer fires.
+
+### Timer.publish (Combine)
+
+```swift
+Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .common)
+    .autoconnect()
+    .sink { [weak self] _ in
+        self?.updateProgress()
+    }
+    .store(in: &cancellables)
+```
+
+See Part 3 for full Combine timer details.
+
+---
+
+## Part 2: DispatchSourceTimer API
+
+### Creation
+
+```swift
+// Create timer source on a specific queue
+let queue = DispatchQueue(label: "com.app.timer")
+let timer = DispatchSource.makeTimerSource(flags: [], queue: queue)
+```
+
+**flags**: Usually empty (`[]`). Use `.strict` for precise timing (disables system coalescing, higher energy cost).
+
+### Schedule
+
+```swift
+// Relative deadline (monotonic clock)
+timer.schedule(
+    deadline: .now() + 1.0,     // First fire
+    repeating: .seconds(1),     // Interval
+    leeway: .milliseconds(100)  // Tolerance (like Timer.tolerance)
+)
+
+// Wall clock deadline (survives device sleep)
+timer.schedule(
+    wallDeadline: .now() + 1.0,
+    repeating: .seconds(1),
+    leeway: .milliseconds(100)
+)
+```
+
+**deadline vs wallDeadline**: `deadline` uses monotonic clock (pauses when device sleeps). `wallDeadline` uses wall clock (continues across sleep). Use `deadline` for most cases.
+
+### Event Handler
+
+```swift
+timer.setEventHandler { [weak self] in
+    self?.performWork()
+}
+```
+
+**Before cancel**: Set handler to nil to break retain cycles:
+
+```swift
+timer.setEventHandler(handler: nil)
+timer.cancel()
+```
+
+### Lifecycle Methods
+
+```swift
+timer.activate()   // Start — can only call ONCE (idle → running)
+timer.suspend()    // Pause (running → suspended)
+timer.resume()     // Unpause (suspended → running)
+timer.cancel()     // Stop permanently (must NOT be suspended)
+```
+
+### State Machine Lifecycle
+
+```
+                    activate()
+        idle ──────────────► running
+                               │  ▲
+                    suspend()  │  │  resume()
+                               ▼  │
+                            suspended
+                               │
+                    resume() + cancel()
+                               │
+                               ▼
+                           cancelled
+```
+
+**Critical rules**:
+- `activate()` can only be called once (idle → running)
+- `cancel()` requires non-suspended state (resume first if suspended)
+- `cancelled` is terminal — no further operations allowed
+- Dealloc requires non-suspended state (cancel first if needed)
+
+### Leeway (Tolerance)
+
+```swift
+// Leeway values
+timer.schedule(deadline: .now(), repeating: 1.0, leeway: .milliseconds(100))
+timer.schedule(deadline: .now(), repeating: 1.0, leeway: .seconds(1))
+timer.schedule(deadline: .now(), repeating: 1.0, leeway: .never)  // Strict — high energy
+```
+
+Leeway is the DispatchSourceTimer equivalent of `Timer.tolerance`. Allows system to coalesce timer firings for energy efficiency.
+
+### End-to-End Example
+
+Complete DispatchSourceTimer lifecycle in one block:
+
+```swift
+let queue = DispatchQueue(label: "com.app.polling")
+let timer = DispatchSource.makeTimerSource(queue: queue)
+timer.schedule(deadline: .now() + 1.0, repeating: .seconds(5), leeway: .milliseconds(500))
+timer.setEventHandler { [weak self] in
+    self?.fetchUpdates()
+}
+timer.activate()  // idle → running
+
+// Later — pause:
+timer.suspend()   // running → suspended
+
+// Later — resume:
+timer.resume()    // suspended → running
+
+// Cleanup — MUST resume before cancel if suspended:
+timer.setEventHandler(handler: nil)  // Break retain cycles
+timer.resume()    // Ensure non-suspended state
+timer.cancel()    // running → cancelled (terminal)
+```
+
+For a safe wrapper that prevents all crash patterns, see `axiom-timer-patterns` Part 4: SafeDispatchTimer.
+
+---
+
+## Part 3: Combine Timer
+
+### Timer.publish
+
+```swift
+import Combine
+
+// Create publisher — RunLoop mode matters here too
+let publisher = Timer.publish(
+    every: 1.0,          // Interval
+    tolerance: 0.1,      // Optional tolerance
+    on: .main,           // RunLoop
+    in: .common          // Mode — use .common to survive scrolling
+)
+```
+
+### .autoconnect()
+
+```swift
+// Starts immediately when first subscriber attaches
+Timer.publish(every: 1.0, on: .main, in: .common)
+    .autoconnect()
+    .sink { date in
+        print("Fired at \(date)")
+    }
+    .store(in: &cancellables)
+```
+
+### .connect() (Manual Start)
+
+```swift
+// Manual control over when timer starts
+let timerPublisher = Timer.publish(every: 1.0, on: .main, in: .common)
+let cancellable = timerPublisher
+    .sink { date in
+        print("Fired at \(date)")
+    }
+
+// Start later
+let connection = timerPublisher.connect()
+
+// Stop
+connection.cancel()
+```
+
+### Cancellation
+
+```swift
+// Via AnyCancellable storage — cancelled when Set is cleared or object deallocs
+private var cancellables = Set()
+
+// Manual cancellation
+cancellables.removeAll()  // Cancels all subscriptions
+```
+
+### SwiftUI Integration
+
+```swift
+class TimerViewModel: ObservableObject {
+    @Published var elapsed: Int = 0
+    private var cancellables = Set()
+
+    func start() {
+        Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .common)
+            .autoconnect()
+            .sink { [weak self] _ in
+                self?.elapsed += 1
+            }
+            .store(in: &cancellables)
+    }
+
+    func stop() {
+        cancellables.removeAll()
+    }
+}
+```
+
+---
+
+## Part 4: AsyncTimerSequence (Swift Concurrency)
+
+### ContinuousClock.timer
+
+```swift
+// Monotonic clock — does NOT pause when app suspends
+for await _ in ContinuousClock().timer(interval: .seconds(1)) {
+    await updateData()
+}
+// Loop exits when task is cancelled
+```
+
+### SuspendingClock.timer
+
+```swift
+// Suspending clock — pauses when app suspends
+for await _ in SuspendingClock().timer(interval: .seconds(1)) {
+    await processItem()
+}
+```
+
+**ContinuousClock vs SuspendingClock**:
+- `ContinuousClock`: Time keeps advancing during app suspension. Use for absolute timing.
+- `SuspendingClock`: Time pauses when app suspends. Use for "user-perceived" timing.
+
+### Task Cancellation
+
+```swift
+// Timer automatically stops when task is cancelled
+let timerTask = Task {
+    for await _ in ContinuousClock().timer(interval: .seconds(1)) {
+        await fetchLatestData()
+    }
+}
+
+// Later: cancel the timer
+timerTask.cancel()
+```
+
+### Background Polling with Structured Concurrency
+
+```swift
+func startPolling() async {
+    do {
+        for try await _ in ContinuousClock().timer(interval: .seconds(30)) {
+            try Task.checkCancellation()
+            let data = try await api.fetchUpdates()
+            await MainActor.run { updateUI(with: data) }
+        }
+    } catch is CancellationError {
+        // Clean exit
+    } catch {
+        // Handle fetch error
+    }
+}
+```
+
+---
+
+## Part 5: Task.sleep Alternatives
+
+### One-Shot Delay
+
+```swift
+// Simple delay — NOT a timer
+try await Task.sleep(for: .seconds(1))
+
+// Deadline-based
+try await Task.sleep(until: .now + .seconds(1), clock: .continuous)
+```
+
+### When to Use Sleep vs Timer
+
+| Need | Use |
+|------|-----|
+| One-shot delay before action | `Task.sleep(for:)` |
+| Repeating action | `ContinuousClock().timer(interval:)` |
+| Delay with cancellation | `Task.sleep(for:)` in a Task |
+| Retry with backoff | `Task.sleep(for:)` in a loop |
+
+### Retry with Exponential Backoff
+
+```swift
+func fetchWithRetry(maxAttempts: Int = 3) async throws -> Data {
+    var delay: Duration = .seconds(1)
+    for attempt in 1...maxAttempts {
+        do {
+            return try await api.fetch()
+        } catch where attempt < maxAttempts {
+            try await Task.sleep(for: delay)
+            delay *= 2  // Exponential backoff
+        }
+    }
+    throw FetchError.maxRetriesExceeded
+}
+```
+
+---
+
+## Part 6: LLDB Timer Inspection
+
+### Timer (NSTimer) Commands
+
+```lldb
+# Check if timer is still valid
+po timer.isValid
+
+# See next fire date
+po timer.fireDate
+
+# See timer interval
+po timer.timeInterval
+
+# Force RunLoop iteration (may trigger timer)
+expression -l objc -- (void)[[NSRunLoop mainRunLoop] run]
+```
+
+### DispatchSourceTimer Commands
+
+```lldb
+# Inspect dispatch source
+po timer
+
+# Break on dispatch source cancel (all sources)
+breakpoint set -n dispatch_source_cancel
+
+# Break on EXC_BAD_INSTRUCTION to catch timer crashes
+# (Xcode does this automatically for Swift runtime errors)
+
+# Check if a DispatchSource is cancelled
+expression -l objc -- (long)dispatch_source_testcancel((void*)timer)
+```
+
+### General Timer Debugging
+
+```lldb
+# List all timers on the main RunLoop
+expression -l objc -- (void)CFRunLoopGetMain()
+
+# Break when any Timer fires
+breakpoint set -S "scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:"
+```
+
+---
+
+## Part 7: Platform Availability Matrix
+
+| API | iOS | macOS | watchOS | tvOS |
+|---|---|---|---|---|
+| Timer | 2.0+ | 10.0+ | 2.0+ | 9.0+ |
+| DispatchSourceTimer | 8.0+ (GCD) | 10.10+ | 2.0+ | 9.0+ |
+| Timer.publish (Combine) | 13.0+ | 10.15+ | 6.0+ | 13.0+ |
+| AsyncTimerSequence | 16.0+ | 13.0+ | 9.0+ | 16.0+ |
+| Task.sleep | 13.0+ | 10.15+ | 6.0+ | 13.0+ |
+
+---
+
+## Related Skills
+
+- `axiom-timer-patterns` — Decision trees, crash patterns, SafeDispatchTimer wrapper
+- `axiom-energy` — Timer tolerance as energy optimization (Pattern 1)
+- `axiom-energy-ref` — Timer efficiency APIs with WWDC code examples
+- `axiom-memory-debugging` — Timer as Pattern 1 memory leak
+
+## Resources
+
+**Skills**: axiom-timer-patterns, axiom-energy-ref, axiom-memory-debugging
diff --git a/.claude/skills/axiom-timer-patterns-ref/agents/openai.yaml b/.claude/skills/axiom-timer-patterns-ref/agents/openai.yaml
new file mode 100644
index 0000000..3624e66
--- /dev/null
+++ b/.claude/skills/axiom-timer-patterns-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "Timer Patterns Reference"
+  short_description: "Timer, DispatchSourceTimer, Combine Timer.publish, AsyncTimerSequence, Task.sleep API reference with lifecycle diagra..."
diff --git a/.claude/skills/axiom-timer-patterns/.openskills.json b/.claude/skills/axiom-timer-patterns/.openskills.json
new file mode 100644
index 0000000..7cb39a1
--- /dev/null
+++ b/.claude/skills/axiom-timer-patterns/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-timer-patterns",
+  "installedAt": "2026-04-12T08:06:52.854Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-timer-patterns/SKILL.md b/.claude/skills/axiom-timer-patterns/SKILL.md
new file mode 100644
index 0000000..1a70c4b
--- /dev/null
+++ b/.claude/skills/axiom-timer-patterns/SKILL.md
@@ -0,0 +1,476 @@
+---
+name: axiom-timer-patterns
+description: Use when implementing timers, debugging timer crashes (EXC_BAD_INSTRUCTION), Timer stops during scrolling, or choosing between Timer/DispatchSourceTimer/Combine/async timer APIs
+license: MIT
+metadata:
+  version: "1.0.0"
+  last-updated: "2026-02-26"
+---
+
+# Timer Safety Patterns
+
+## Overview
+
+Timer-related crashes are among the hardest to diagnose because they're often intermittent and the crash log points to GCD internals, not your code. **Core principle**: DispatchSourceTimer has a state machine — violating it causes deterministic EXC_BAD_INSTRUCTION crashes that look random. Timer (NSTimer) has a RunLoop mode trap that silently stops your timer during scrolling. Both are preventable with the patterns in this skill.
+
+## Example Prompts
+
+- "My timer stops when the user scrolls"
+- "EXC_BAD_INSTRUCTION crash in my timer code"
+- "Should I use Timer or DispatchSourceTimer?"
+- "How do I safely cancel a DispatchSourceTimer?"
+- "My DispatchSourceTimer crashes on dealloc"
+- "Timer keeps running after I dismiss the view controller"
+
+---
+
+## Part 1: Timer vs DispatchSourceTimer Decision Tree
+
+| Feature | Timer | DispatchSourceTimer | AsyncTimerSequence |
+|---------|-------|--------------------|--------------------|
+| Thread safety | Main thread only (RunLoop-bound) | Any queue (you choose) | Task-bound (structured concurrency) |
+| Scrolling survival | Only in `.common` mode | Always (no RunLoop dependency) | Always (no RunLoop dependency) |
+| Precision | Low (RunLoop coalescing) | High (GCD scheduling) | Medium (clock-dependent) |
+| Lifecycle complexity | Low (invalidate + nil) | High (state machine, 4 crash patterns) | Low (task cancellation) |
+| iOS version | 2.0+ | 8.0+ (GCD) | 16.0+ |
+| Use case | UI updates on main thread | Background work, precise timing, custom queues | Modern async code, structured concurrency |
+
+### Quick Decision
+
+```
+Need a simple UI update timer?
+├─ Yes → Timer (with .common RunLoop mode)
+│
+Need precise timing or background queue?
+├─ Yes → DispatchSourceTimer (with SafeDispatchTimer wrapper)
+│
+Writing modern async/await code on iOS 16+?
+├─ Yes → AsyncTimerSequence (ContinuousClock.timer)
+│
+Need Combine integration?
+└─ Yes → Timer.publish
+```
+
+---
+
+## Part 2: RunLoop Mode Gotcha
+
+Timer stops firing during scrolling. This is the single most common timer bug in iOS development.
+
+### Why It Happens
+
+`Timer.scheduledTimer` adds the timer to the current RunLoop in `.default` mode. When the user scrolls (UIScrollView, SwiftUI ScrollView, List), the RunLoop switches to `.tracking` mode. The timer doesn't fire in `.tracking` mode because it was only registered for `.default`.
+
+**Time cost**: Timer mysteriously stops during scroll → 30+ min debugging if you don't know about RunLoop modes.
+
+### ❌ Broken — Timer stops during scrolling
+
+```swift
+// BAD: Timer added to .default mode (implicit)
+let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
+    self?.updateProgress()
+}
+// Timer STOPS when user scrolls any UIScrollView or SwiftUI List
+```
+
+### ✅ Fixed — Timer survives scrolling
+
+```swift
+// GOOD: Explicitly add to .common mode (includes both .default and .tracking)
+let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
+    self?.updateProgress()
+}
+RunLoop.current.add(timer, forMode: .common)
+```
+
+### ✅ Fixed — Combine Timer survives scrolling
+
+```swift
+// GOOD: Timer.publish with .common mode — survives scrolling in SwiftUI
+Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .common)
+    .autoconnect()
+    .sink { [weak self] _ in
+        self?.updateProgress()
+    }
+    .store(in: &cancellables)
+```
+
+**Key**: The `in:` parameter defaults to `.default` if omitted — always specify `.common` explicitly.
+
+### RunLoop Modes
+
+| Mode | When Active | Timer Fires? |
+|------|-------------|--------------|
+| `.default` | Normal interaction | Yes |
+| `.tracking` | During scrolling | Only if added to `.common` |
+| `.common` | Pseudo-mode: includes `.default` + `.tracking` | Yes (always) |
+
+---
+
+## Part 3: The 4 DispatchSourceTimer Crash Patterns
+
+Each of these causes **EXC_BAD_INSTRUCTION** — a crash that points to GCD internals, making it hard to trace back to your timer code.
+
+### Crash Frame → Pattern Mapping
+
+When you see EXC_BAD_INSTRUCTION in a crash log, match the top frame:
+
+| Top Crash Frame | Crash Pattern | Fix |
+|---|---|---|
+| `dispatch_source_cancel` | Crash 2: Cancel while suspended | `resume()` before `cancel()` |
+| `_dispatch_source_dispose` | Crash 3: Dealloc while suspended | Resume + cancel before releasing |
+| `dispatch_resume` | Crash 4: Resume after cancel | Check `isCancelled` before operating |
+| `_dispatch_source_refs_t` / `suspend count` | Crash 1: Unbalanced suspend | Track state, only suspend if running |
+
+### DispatchSourceTimer State Machine
+
+```
+                    activate()
+        idle ──────────────► running
+                               │  ▲
+                    suspend()  │  │  resume()
+                               ▼  │
+                            suspended
+                               │
+                    resume() + cancel()
+                               │
+                               ▼
+                           cancelled (terminal)
+
+CRASH ZONES:
+  suspended → cancel()  = EXC_BAD_INSTRUCTION
+  suspended → dealloc   = EXC_BAD_INSTRUCTION
+  suspended → suspend() = suspend count underflow on dealloc
+  cancelled → resume()  = EXC_BAD_INSTRUCTION
+```
+
+### Crash 1: Suspend While Already Suspended
+
+Calling `suspend()` multiple times without matching `resume()` calls. Each `suspend()` increments an internal counter. On dealloc, if the suspend count isn't zero, GCD crashes.
+
+#### ❌ Crash
+
+```swift
+let timer = DispatchSource.makeTimerSource(queue: queue)
+timer.schedule(deadline: .now(), repeating: 1.0)
+timer.setEventHandler { doWork() }
+timer.activate()
+
+// User triggers pause twice rapidly
+timer.suspend()  // suspend count = 1
+timer.suspend()  // suspend count = 2
+
+timer.resume()   // suspend count = 1
+// Timer deallocated with suspend count = 1 → EXC_BAD_INSTRUCTION
+```
+
+#### ✅ Safe
+
+```swift
+// Track state — only suspend if running
+var isRunning = true
+
+func pause() {
+    guard isRunning else { return }
+    timer.suspend()
+    isRunning = false
+}
+
+func unpause() {
+    guard !isRunning else { return }
+    timer.resume()
+    isRunning = true
+}
+```
+
+### Crash 2: Cancel While Suspended
+
+GCD requires a dispatch source to be in a non-suspended state before cancellation. Cancelling a suspended timer crashes immediately.
+
+#### ❌ Crash
+
+```swift
+let timer = DispatchSource.makeTimerSource(queue: queue)
+timer.schedule(deadline: .now(), repeating: 1.0)
+timer.setEventHandler { doWork() }
+timer.activate()
+
+timer.suspend()
+timer.cancel()  // EXC_BAD_INSTRUCTION — can't cancel while suspended
+```
+
+#### ✅ Safe
+
+```swift
+// ALWAYS resume before cancelling
+timer.resume()   // Move out of suspended state
+timer.cancel()   // Now safe to cancel
+```
+
+### Crash 3: Dealloc While Suspended
+
+Setting the timer to nil (or letting it go out of scope) while suspended. Deallocation internally attempts cleanup that fails on a suspended source.
+
+#### ❌ Crash
+
+```swift
+var timer: DispatchSourceTimer?
+
+func startTimer() {
+    timer = DispatchSource.makeTimerSource(queue: queue)
+    timer?.schedule(deadline: .now(), repeating: 1.0)
+    timer?.setEventHandler { [weak self] in self?.doWork() }
+    timer?.activate()
+}
+
+func pauseTimer() {
+    timer?.suspend()
+}
+
+func cleanup() {
+    timer = nil  // Dealloc while suspended → EXC_BAD_INSTRUCTION
+}
+```
+
+#### ✅ Safe
+
+```swift
+func cleanup() {
+    // Resume before releasing
+    timer?.resume()
+    timer?.cancel()
+    timer = nil  // Now safe — timer is in cancelled state
+}
+```
+
+### Crash 4: Operate After Cancel
+
+Calling `resume()` or `suspend()` on a cancelled timer. Cancellation is a terminal state — the timer cannot be reused.
+
+#### ❌ Crash
+
+```swift
+timer.cancel()
+timer.resume()  // EXC_BAD_INSTRUCTION — can't resume a cancelled source
+```
+
+#### ✅ Safe
+
+```swift
+// Track cancellation state
+var isCancelled = false
+
+func cancel() {
+    guard !isCancelled else { return }
+    timer.cancel()
+    isCancelled = true
+}
+
+func resume() {
+    guard !isCancelled else { return }  // Check before operating
+    timer.resume()
+}
+```
+
+---
+
+## Part 4: SafeDispatchTimer Wrapper
+
+Copy-paste this class to prevent all 4 crash patterns. State machine enforces valid transitions.
+
+```swift
+final class SafeDispatchTimer {
+    enum State { case idle, running, suspended, cancelled }
+
+    private(set) var state: State = .idle
+    private let timer: DispatchSourceTimer
+
+    init(queue: DispatchQueue = DispatchQueue(label: "safe-dispatch-timer")) {
+        timer = DispatchSource.makeTimerSource(queue: queue)
+    }
+
+    func schedule(interval: TimeInterval, handler: @escaping () -> Void) {
+        guard state == .idle else { return }
+        timer.schedule(deadline: .now() + interval, repeating: interval)
+        timer.setEventHandler(handler: handler)
+        timer.activate()
+        state = .running
+    }
+
+    func suspend() {
+        guard state == .running else { return }
+        timer.suspend()
+        state = .suspended
+    }
+
+    func resume() {
+        guard state == .suspended else { return }
+        timer.resume()
+        state = .running
+    }
+
+    func cancel() {
+        switch state {
+        case .suspended:
+            timer.resume()  // Must resume before cancel
+            timer.cancel()
+        case .running:
+            timer.cancel()
+        case .idle, .cancelled:
+            return
+        }
+        state = .cancelled
+    }
+
+    deinit {
+        cancel()  // Safe cleanup regardless of current state
+    }
+}
+```
+
+### Usage
+
+```swift
+class BackgroundPoller {
+    private var timer: SafeDispatchTimer?
+
+    func start() {
+        timer = SafeDispatchTimer()
+        timer?.schedule(interval: 5.0) { [weak self] in
+            self?.fetchData()
+        }
+    }
+
+    func pause() {
+        timer?.suspend()  // Safe — no-op if not running
+    }
+
+    func unpause() {
+        timer?.resume()  // Safe — no-op if not suspended
+    }
+
+    func stop() {
+        timer?.cancel()  // Safe — handles any state
+        timer = nil
+    }
+}
+```
+
+---
+
+## Part 5: Thread Safety
+
+### Always Use a Dedicated Serial Queue
+
+DispatchSourceTimer fires its event handler on the queue you specify at creation. Using a concurrent queue creates race conditions when multiple firings overlap or when you modify shared state from the handler.
+
+#### ❌ Race Condition
+
+```swift
+// BAD: Concurrent queue — handler can fire while previous invocation is still running
+let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
+timer.setEventHandler {
+    self.count += 1          // Race condition
+    self.processItem(count)  // Overlapping invocations
+}
+```
+
+#### ✅ Serial Queue
+
+```swift
+// GOOD: Dedicated serial queue — handler invocations are serialized
+let timerQueue = DispatchQueue(label: "com.app.timer-queue")
+let timer = DispatchSource.makeTimerSource(queue: timerQueue)
+timer.setEventHandler { [weak self] in
+    self?.count += 1          // Safe — serial queue
+    self?.processItem(count)  // No overlap
+}
+```
+
+### Main Queue for UI Updates
+
+If your timer handler updates UI, dispatch to main:
+
+```swift
+let timer = DispatchSource.makeTimerSource(queue: timerQueue)
+timer.setEventHandler { [weak self] in
+    let result = self?.computeResult()
+    DispatchQueue.main.async {
+        self?.updateUI(with: result)
+    }
+}
+```
+
+---
+
+## Part 6: Anti-Patterns
+
+| Anti-Pattern | Time Cost | Fix |
+|---|---|---|
+| Timer in `.default` RunLoop mode | 30+ min debugging scroll freeze | Use `.common` mode |
+| No state tracking on DispatchSourceTimer | EXC_BAD_INSTRUCTION crash, hours to diagnose | Use SafeDispatchTimer wrapper |
+| `timer.cancel()` while suspended | Production crash | `resume()` then `cancel()` |
+| Timer on `.global()` queue | Race conditions, intermittent crashes | Dedicated serial queue |
+| Force-unwrapping timer | Crash if timer already cancelled | Optional check or state enum |
+| Not clearing event handler before cancel | Potential retain cycle | `timer.setEventHandler(handler: nil)` then cancel |
+| Timer retains target (selector API) | Memory leak — deinit never called | Use block API with `[weak self]` |
+| Creating timer without invalidating previous | Timer accumulation, CPU waste | Always invalidate/cancel before creating new |
+| Timer on background thread without RunLoop | Timer silently never fires | Timer requires a RunLoop — use DispatchSourceTimer or AsyncTimerSequence for background work |
+
+---
+
+## Part 7: Pressure Scenarios
+
+### Scenario 1: "Just use Timer.scheduledTimer and move on"
+
+**Setup**: Deadline approaching, need a repeating update every second.
+
+**Pressure**: Timer is simpler than DispatchSourceTimer. "It's just a UI update timer, no need for GCD complexity."
+
+**Expected with skill**: Choose Timer for simple UI updates — but add it to `.common` RunLoop mode so it survives scrolling. Only reach for DispatchSourceTimer when you need precision, background execution, or a custom queue.
+
+**Anti-pattern without skill**: Using `Timer.scheduledTimer` with default `.default` mode → timer stops during scrolling → user reports "progress bar freezes when I scroll" → 30+ min debugging.
+
+**Pushback template**: "Timer is the right choice for a UI update, but we need to add it to `.common` RunLoop mode. Without that, the timer stops every time the user scrolls. It's a 2-line change that prevents a guaranteed bug report."
+
+---
+
+### Scenario 2: "The crash only happens sometimes, let's ship and fix later"
+
+**Setup**: EXC_BAD_INSTRUCTION in production crash logs. Can't reproduce reliably in development.
+
+**Pressure**: "It's rare. Users can reopen the app. We'll fix it in the next release."
+
+**Expected with skill**: Recognize the crash signature as a DispatchSourceTimer state machine violation. All 4 crash patterns are deterministic — they happen every time the specific state transition occurs. The "intermittent" appearance comes from the state transition being timing-dependent, not the crash itself. Apply SafeDispatchTimer wrapper.
+
+**Anti-pattern without skill**: Shipping without fix → crash rate compounds with user count → crash appears in App Store review metrics → rejection risk.
+
+**Pushback template**: "This crash is deterministic — it happens every time the timer is in a specific state. The 'intermittent' part is just the timing of when that state occurs. SafeDispatchTimer is a drop-in replacement that eliminates all 4 crash patterns. It's a 15-minute fix that prevents a production crash."
+
+---
+
+### Scenario 3: "Timer.invalidate() handles cleanup"
+
+**Setup**: Timer being used in a view controller, calling `invalidate()` in `deinit`.
+
+**Pressure**: "invalidate() is the standard cleanup pattern. It's in every tutorial."
+
+**Expected with skill**: Recognize the retain cycle: `Timer.scheduledTimer(timeInterval:target:selector:)` retains its target. If the target is `self` (the view controller), and the view controller holds a strong reference to the timer, you have a retain cycle. `deinit` never gets called because the timer keeps `self` alive. Solution: use `[weak self]` with the block API, and invalidate in `viewWillDisappear` (not `deinit`).
+
+**Anti-pattern without skill**: Timer retains self → deinit never called → invalidate never called → timer keeps firing → memory leak + accumulating timers → eventual crash or battery drain.
+
+**Pushback template**: "The block-based Timer API with `[weak self]` is the fix. The selector-based API retains its target, which means our `deinit` never fires and `invalidate()` never gets called. We also need to move `invalidate()` to `viewWillDisappear` as a safety net."
+
+---
+
+## Related Skills
+
+- `axiom-timer-patterns-ref` — API reference for Timer, DispatchSourceTimer, Combine Timer.publish, AsyncTimerSequence with lifecycle diagrams and platform availability
+- `axiom-memory-debugging` — Timer as Pattern 1 memory leak (Timer retains target, RunLoop retains Timer)
+- `axiom-energy` — Timer as energy drain pattern (tolerance, coalescing, event-driven alternatives)
+
+## Resources
+
+**WWDC**: 2017-706
+
+**Skills**: timer-patterns-ref, memory-debugging, energy, energy-ref
diff --git a/.claude/skills/axiom-timer-patterns/agents/openai.yaml b/.claude/skills/axiom-timer-patterns/agents/openai.yaml
new file mode 100644
index 0000000..3c63abf
--- /dev/null
+++ b/.claude/skills/axiom-timer-patterns/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "Timer Patterns"
+  short_description: "Implementing timers, debugging timer crashes (EXC_BAD_INSTRUCTION), Timer stops during scrolling, or choosing between..."
diff --git a/.claude/skills/axiom-transferable-ref/.openskills.json b/.claude/skills/axiom-transferable-ref/.openskills.json
new file mode 100644
index 0000000..36e8963
--- /dev/null
+++ b/.claude/skills/axiom-transferable-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-transferable-ref",
+  "installedAt": "2026-04-12T08:06:53.640Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-transferable-ref/SKILL.md b/.claude/skills/axiom-transferable-ref/SKILL.md
new file mode 100644
index 0000000..12b9c34
--- /dev/null
+++ b/.claude/skills/axiom-transferable-ref/SKILL.md
@@ -0,0 +1,674 @@
+---
+name: axiom-transferable-ref
+description: Use when implementing drag and drop, copy/paste, ShareLink, or ANY content sharing between apps or views - covers Transferable protocol, TransferRepresentation types, UTType declarations, SwiftUI surfaces, and NSItemProvider bridging
+license: MIT
+metadata:
+  version: "1.0.0"
+---
+
+# Transferable & Content Sharing Reference
+
+Comprehensive guide to the CoreTransferable framework and SwiftUI sharing surfaces: drag and drop, copy/paste, and ShareLink.
+
+## When to Use This Skill
+
+- Implementing drag and drop (`.draggable`, `.dropDestination`)
+- Adding copy/paste support (`.copyable`, `.pasteDestination`, `PasteButton`)
+- Sharing content via `ShareLink`
+- Making custom types transferable
+- Declaring custom UTTypes for app-specific formats
+- Bridging `Transferable` types with UIKit's `NSItemProvider`
+- Choosing between `CodableRepresentation`, `DataRepresentation`, `FileRepresentation`, and `ProxyRepresentation`
+
+## Example Prompts
+
+"How do I make my model draggable in SwiftUI?"
+"ShareLink isn't showing my custom preview"
+"How do I accept dropped files in my view?"
+"What's the difference between DataRepresentation and FileRepresentation?"
+"How do I add copy/paste support for my custom type?"
+"My drag and drop works within the app but not across apps"
+"How do I declare a custom UTType?"
+
+---
+
+## Part 1: Quick Reference
+
+### Decision Tree: Which TransferRepresentation?
+
+```
+Your model type...
+├─ Conforms to Codable + no specific binary format needed?
+│  → CodableRepresentation
+├─ Has custom binary format (Data in memory)?
+│  → DataRepresentation (exporting/importing closures)
+├─ Lives on disk (large files, videos, documents)?
+│  → FileRepresentation (passes file URLs, not bytes)
+├─ Need a fallback for receivers that don't understand your type?
+│  → Add ProxyRepresentation (e.g., export as String or URL)
+└─ Need to conditionally hide a representation?
+   → Apply .exportingCondition to any representation
+```
+
+### Common Errors
+
+| Error / Symptom | Cause | Fix |
+|-----------------|-------|-----|
+| "Type does not conform to Transferable" | Missing `transferRepresentation` | Add `static var transferRepresentation: some TransferRepresentation` |
+| Drop works in-app but not across apps | Custom UTType not declared in Info.plist | Add `UTExportedTypeDeclarations` entry |
+| Receiver always gets plain text instead of rich type | ProxyRepresentation listed before CodableRepresentation | Reorder: richest representation first |
+| FileRepresentation crashes with "file not found" | Receiver didn't copy file before sandbox extension expired | Copy to app storage in the importing closure |
+| PasteButton always disabled | Pasteboard doesn't contain matching Transferable type | Check UTType conformance; verify the pasted data matches |
+| ShareLink shows generic preview | No `SharePreview` provided or image isn't `Transferable` | Supply explicit `SharePreview` with title and image |
+| `.dropDestination` closure never fires | Wrong payload type or view has zero hit-test area | Verify `for:` type matches dragged content; add `.frame()` or `.contentShape()` |
+
+### Built-in Transferable Types
+
+These work with zero additional code — no conformance needed:
+
+`String`, `Data`, `URL`, `AttributedString`, `Image`, `Color`
+
+---
+
+## Part 2: Making Types Transferable
+
+The `Transferable` protocol has one requirement: a static `transferRepresentation` property.
+
+### CodableRepresentation
+
+Best for: models already conforming to `Codable`. Uses JSON by default.
+
+```swift
+import UniformTypeIdentifiers
+
+extension UTType {
+    static var todo: UTType = UTType(exportedAs: "com.example.todo")
+}
+
+struct Todo: Codable, Transferable {
+    var text: String
+    var isDone: Bool
+
+    static var transferRepresentation: some TransferRepresentation {
+        CodableRepresentation(contentType: .todo)
+    }
+}
+```
+
+Custom encoder/decoder (e.g., PropertyList instead of JSON):
+
+```swift
+CodableRepresentation(
+    contentType: .todo,
+    encoder: PropertyListEncoder(),
+    decoder: PropertyListDecoder()
+)
+```
+
+**Requirement**: Custom UTTypes need matching `UTExportedTypeDeclarations` in Info.plist (see Part 4).
+
+### DataRepresentation
+
+Best for: custom binary formats where data is in memory and you control serialization.
+
+```swift
+struct ProfilesArchive: Transferable {
+    var profiles: [Profile]
+
+    static var transferRepresentation: some TransferRepresentation {
+        DataRepresentation(contentType: .commaSeparatedText) { archive in
+            try archive.toCSV()
+        } importing: { data in
+            try ProfilesArchive(csvData: data)
+        }
+    }
+}
+```
+
+Import-only or export-only variants:
+
+```swift
+// Import only
+DataRepresentation(importedContentType: .png) { data in
+    try MyImage(pngData: data)
+}
+
+// Export only
+DataRepresentation(exportedContentType: .png) { image in
+    try image.pngData()
+}
+```
+
+**Avoid** using `UTType.data` as the content type — use a specific type like `.png`, `.pdf`, `.commaSeparatedText`.
+
+### FileRepresentation
+
+Best for: large payloads on disk (videos, documents, archives). Passes file URLs instead of loading bytes into memory.
+
+```swift
+struct Video: Transferable {
+    let file: URL
+
+    static var transferRepresentation: some TransferRepresentation {
+        FileRepresentation(contentType: .mpeg4Movie) { video in
+            SentTransferredFile(video.file)
+        } importing: { received in
+            // MUST copy — sandbox extension is temporary
+            let dest = FileManager.default.temporaryDirectory
+                .appendingPathComponent(UUID().uuidString)
+                .appendingPathExtension("mp4")
+            try FileManager.default.copyItem(at: received.file, to: dest)
+            return Video(file: dest)
+        }
+    }
+}
+```
+
+**Critical**: The `received.file` URL has a temporary sandbox extension. Copy the file to your own storage in the importing closure — the URL becomes inaccessible after the closure returns.
+
+`SentTransferredFile` properties:
+- `file: URL` — the file location
+- `allowAccessingOriginalFile: Bool` — when `false` (default), receiver gets a copy
+
+`ReceivedTransferredFile` properties:
+- `file: URL` — the received file on disk
+- `isOriginalFile: Bool` — whether this is the sender's original file or a copy
+
+**Content type precision**: `.mpeg4Movie` only matches `.mp4` files. To accept all common video formats (`.mp4`, `.mov`, `.m4v`), use the parent type `.movie` — or declare multiple `FileRepresentation`s for specific subtypes:
+
+```swift
+// Broad: accept any video format the system recognizes
+FileRepresentation(contentType: .movie) { ... } importing: { ... }
+
+// Or specific: separate handlers per format
+FileRepresentation(contentType: .mpeg4Movie) { ... } importing: { ... }
+FileRepresentation(contentType: .quickTimeMovie) { ... } importing: { ... }
+```
+
+**Import-only**: When your type only receives files (drop target, no export), use the import-only initializer — it makes intent explicit and avoids accidental export:
+
+```swift
+FileRepresentation(importedContentType: .movie) { received in
+    let dest = appStorageURL.appendingPathComponent(received.file.lastPathComponent)
+    try FileManager.default.copyItem(at: received.file, to: dest)
+    return VideoClip(localURL: dest)
+}
+```
+
+### ProxyRepresentation
+
+Best for: fallback representations that let your type work with receivers expecting simpler types.
+
+```swift
+struct Profile: Transferable {
+    var name: String
+    var avatar: Image
+
+    static var transferRepresentation: some TransferRepresentation {
+        CodableRepresentation(contentType: .profile)
+        ProxyRepresentation(exporting: \.name)  // Fallback: paste as text
+    }
+}
+```
+
+Export-only proxy (common pattern — reverse conversion often impossible):
+
+```swift
+ProxyRepresentation(exporting: \.name)  // Profile → String (one-way)
+```
+
+Bidirectional proxy (when reverse makes sense):
+
+```swift
+ProxyRepresentation { item in
+    item.name  // export
+} importing: { name in
+    Profile(name: name)  // import
+}
+```
+
+### Combining Multiple Representations
+
+List representations in the `transferRepresentation` body. **Order matters** — receivers use the first representation they support.
+
+```swift
+struct Profile: Transferable {
+    static var transferRepresentation: some TransferRepresentation {
+        // 1. Richest: full profile data (apps that understand .profile)
+        CodableRepresentation(contentType: .profile)
+        // 2. Fallback: plain text (text fields, notes, any app)
+        ProxyRepresentation(exporting: \.name)
+    }
+}
+```
+
+**Common mistake**: putting `ProxyRepresentation` first causes receivers that support both to always get the degraded version.
+
+### Conditional Export
+
+Hide a representation at runtime when conditions aren't met:
+
+```swift
+DataRepresentation(contentType: .commaSeparatedText) { archive in
+    try archive.toCSV()
+} importing: { data in
+    try Self(csvData: data)
+}
+.exportingCondition { archive in
+    archive.supportsCSV
+}
+```
+
+### Visibility
+
+Control which processes can see a representation:
+
+```swift
+CodableRepresentation(contentType: .profile)
+    .visibility(.ownProcess)  // Only within this app
+```
+
+Options: `.all` (default), `.team` (same developer team), `.group` (same App Group, macOS), `.ownProcess` (same app only)
+
+### Suggested File Name
+
+Hint for receivers writing to disk:
+
+```swift
+FileRepresentation(contentType: .mpeg4Movie) { video in
+    SentTransferredFile(video.file)
+} importing: { received in
+    // ...
+}
+.suggestedFileName("My Video.mp4")
+
+// Or dynamic:
+.suggestedFileName { video in video.title + ".mp4" }
+```
+
+---
+
+## Part 3: SwiftUI Surfaces
+
+### ShareLink
+
+The standard sharing entry point. Accepts any `Transferable` type.
+
+```swift
+// Simple: share a string
+ShareLink(item: "Check out this app!")
+
+// With preview
+ShareLink(
+    item: photo,
+    preview: SharePreview(photo.caption, image: photo.image)
+)
+
+// Share a URL with custom preview (prevents system metadata fetch)
+ShareLink(
+    item: URL(string: "https://example.com")!,
+    preview: SharePreview("My Site", image: Image("hero"))
+)
+```
+
+Sharing multiple items with per-item previews:
+
+```swift
+ShareLink(items: photos) { photo in
+    SharePreview(photo.caption, image: photo.image)
+}
+```
+
+`SharePreview` initializers:
+- `SharePreview("Title")` — text only
+- `SharePreview("Title", image: someImage)` — text + full-size image
+- `SharePreview("Title", icon: someIcon)` — text + thumbnail icon
+- `SharePreview("Title", image: someImage, icon: someIcon)` — all three
+
+**Gotcha**: If you omit `SharePreview` for a custom type, the share sheet shows a generic preview. Always provide one for non-trivial types.
+
+### Drag and Drop
+
+**Making a view draggable:**
+
+```swift
+Text(profile.name)
+    .draggable(profile)
+```
+
+With custom drag preview:
+
+```swift
+Text(profile.name)
+    .draggable(profile) {
+        Label(profile.name, systemImage: "person")
+            .padding()
+            .background(.regularMaterial)
+    }
+```
+
+**Accepting drops:**
+
+```swift
+Color.clear
+    .frame(width: 200, height: 200)
+    .dropDestination(for: Profile.self) { profiles, location in
+        guard let profile = profiles.first else { return false }
+        self.droppedProfile = profile
+        return true
+    } isTargeted: { isTargeted in
+        self.isDropTargeted = isTargeted
+    }
+```
+
+**Multiple item types** — use an enum wrapper conforming to `Transferable` rather than stacking `.dropDestination` modifiers (stacking may cause only the outermost handler to fire):
+
+```swift
+enum DroppableItem: Transferable {
+    case image(Image)
+    case text(String)
+
+    static var transferRepresentation: some TransferRepresentation {
+        ProxyRepresentation { (image: Image) in DroppableItem.image(image) }
+        ProxyRepresentation { (text: String) in DroppableItem.text(text) }
+    }
+}
+
+myView
+    .dropDestination(for: DroppableItem.self) { items, _ in
+        for item in items {
+            switch item {
+            case .image(let img): handleImage(img)
+            case .text(let str): handleString(str)
+            }
+        }
+        return true
+    }
+```
+
+**ForEach with reordering** — combine with `.onMove` or use `draggable`/`dropDestination` for cross-container moves.
+
+### Clipboard (Copy/Paste)
+
+**Copy support** (activates Edit > Copy / Cmd+C):
+
+```swift
+List(items) { item in
+    Text(item.name)
+}
+.copyable(items)
+```
+
+**Paste support** (activates Edit > Paste / Cmd+V):
+
+```swift
+List(items) { item in
+    Text(item.name)
+}
+.pasteDestination(for: Item.self) { pasted in
+    items.append(contentsOf: pasted)
+} validator: { candidates in
+    candidates.filter { $0.isValid }
+}
+```
+
+The validator closure runs before the action — return an empty array to prevent the paste.
+
+**Cut support:**
+
+```swift
+.cuttable(for: Item.self) {
+    let selected = items.filter { $0.isSelected }
+    items.removeAll { $0.isSelected }
+    return selected
+}
+```
+
+**PasteButton** — system button that handles paste with type filtering:
+
+```swift
+PasteButton(payloadType: String.self) { strings in
+    notes.append(contentsOf: strings)
+}
+```
+
+Platform difference: PasteButton auto-validates pasteboard changes on iOS but not on macOS.
+
+**Availability**: `.copyable`, `.pasteDestination`, and `.cuttable` are **macOS 13+ only** — they do not exist on iOS. On iOS, use `PasteButton` (iOS 16+) for paste, and standard context menus or `UIPasteboard` for programmatic copy/cut. `PasteButton` is cross-platform: macOS 10.15+, iOS 16+, visionOS 1.0+.
+
+---
+
+## Part 4: UTType Declarations
+
+### System Types
+
+Use Apple's built-in UTTypes when possible — they're already recognized across the system:
+
+```swift
+import UniformTypeIdentifiers
+
+// Common types
+UTType.plainText       // public.plain-text
+UTType.utf8PlainText   // public.utf8-plain-text
+UTType.json            // public.json
+UTType.png             // public.png
+UTType.jpeg            // public.jpeg
+UTType.pdf             // com.adobe.pdf
+UTType.mpeg4Movie      // public.mpeg-4
+UTType.commaSeparatedText  // public.comma-separated-values-text
+```
+
+### Declaring Custom Types
+
+**Step 1**: Declare in Swift:
+
+```swift
+extension UTType {
+    static var recipe: UTType = UTType(exportedAs: "com.myapp.recipe")
+}
+```
+
+**Step 2**: Add to Info.plist under `UTExportedTypeDeclarations`:
+
+```xml
+UTExportedTypeDeclarations
+
+    
+        UTTypeIdentifier
+        com.myapp.recipe
+        UTTypeDescription
+        Recipe
+        UTTypeConformsTo
+        
+            public.data
+        
+        UTTypeTagSpecification
+        
+            public.filename-extension
+            
+                recipe
+            
+        
+    
+
+```
+
+**Both are required.** The Swift declaration alone makes it compile, but cross-app transfers silently fail without the Info.plist entry.
+
+### Imported vs Exported Types
+
+- **Exported** (`exportedAs:`) — Your app owns this type. Use for app-specific formats.
+- **Imported** (`importedAs:`) — Another app owns this type. Use when you want to accept their format.
+
+### UTType Conformance
+
+Custom types should conform to system types for broader compatibility:
+
+```swift
+// Your .recipe conforms to public.data (binary data)
+// This means any receiver that accepts generic data can also accept recipes
+```
+
+Common conformance parents: `public.data`, `public.content`, `public.text`, `public.image`
+
+---
+
+## Part 5: UIKit Bridging
+
+### NSItemProvider + Transferable
+
+Bridge between UIKit's `NSItemProvider` (used by `UIActivityViewController`, extensions, drag sessions) and `Transferable`:
+
+```swift
+// Load a Transferable from an NSItemProvider
+let provider: NSItemProvider = // from drag session, extension, etc.
+provider.loadTransferable(type: Profile.self) { result in
+    switch result {
+    case .success(let profile):
+        // Use the profile
+    case .failure(let error):
+        // Handle error
+    }
+}
+```
+
+### When to Use UIActivityViewController
+
+`ShareLink` covers most sharing needs. Use `UIActivityViewController` when you need:
+- Custom activity items or excluded activity types
+- `UIActivityItemsConfiguration` for lazy item provision
+- Custom `UIActivity` subclasses
+- Programmatic presentation control
+
+```swift
+struct ShareSheet: UIViewControllerRepresentable {
+    let items: [Any]
+
+    func makeUIViewController(context: Context) -> UIActivityViewController {
+        UIActivityViewController(activityItems: items, applicationActivities: nil)
+    }
+
+    func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
+}
+```
+
+For most apps, `ShareLink` is sufficient and preferred — it integrates with `Transferable` natively.
+
+---
+
+## Part 6: Gotchas & Troubleshooting
+
+### FileRepresentation Temporary File Lifecycle
+
+The `received.file` URL in a `FileRepresentation` importing closure has a temporary sandbox extension. The system may revoke access after the closure returns. Always copy the file:
+
+```swift
+// WRONG — file may become inaccessible
+return Video(file: received.file)
+
+// RIGHT — copy to your own storage
+let dest = myAppDirectory.appendingPathComponent(received.file.lastPathComponent)
+try FileManager.default.copyItem(at: received.file, to: dest)
+return Video(file: dest)
+```
+
+### Async Work After File Drop
+
+The `FileRepresentation` importing closure is synchronous — you cannot `await` inside it. Copy the file first, return the model, then do async post-processing (thumbnails, transcoding, metadata extraction) on the copied URL:
+
+```swift
+// WRONG — can't await in the importing closure
+FileRepresentation(importedContentType: .movie) { received in
+    let dest = ...
+    try FileManager.default.copyItem(at: received.file, to: dest)
+    let thumbnail = await generateThumbnail(for: dest)  // ❌ compile error
+    return VideoClip(localURL: dest, thumbnail: thumbnail)
+}
+
+// RIGHT — return immediately, process async afterward
+// In your view model or drop handler:
+.dropDestination(for: VideoClip.self) { clips, _ in
+    for clip in clips {
+        timeline.append(clip)
+        Task {
+            // clip.localURL is the COPY — safe to access anytime
+            let thumbnail = await generateThumbnail(for: clip.localURL)
+            clip.thumbnail = thumbnail
+        }
+    }
+    return true
+}
+```
+
+### Representation Ordering
+
+Representations are tried **in declaration order**. The receiver uses the first one it supports.
+
+```swift
+// WRONG — receivers always get plain text
+static var transferRepresentation: some TransferRepresentation {
+    ProxyRepresentation(exporting: \.name)   // ← every receiver supports String
+    CodableRepresentation(contentType: .profile)  // ← never reached
+}
+
+// RIGHT — richest first, fallbacks last
+static var transferRepresentation: some TransferRepresentation {
+    CodableRepresentation(contentType: .profile)  // ← apps that understand Profile
+    ProxyRepresentation(exporting: \.name)         // ← fallback for everyone else
+}
+```
+
+### Custom UTType Without Info.plist
+
+If you declare `UTType(exportedAs: "com.myapp.type")` in Swift but forget the Info.plist entry:
+- In-app transfers work (same process recognizes the type)
+- Cross-app transfers silently fail (other apps can't resolve the type)
+
+This is the most common "works in development, fails in production" issue.
+
+### Drop Target Hit Testing
+
+`.dropDestination` requires the view to have a non-zero frame for hit testing. If drops aren't registering:
+
+```swift
+// WRONG — Color.clear has zero intrinsic size
+Color.clear
+    .dropDestination(for: Image.self) { ... }
+
+// RIGHT — give it a frame
+Color.clear
+    .frame(width: 200, height: 200)
+    .contentShape(Rectangle())  // ensure full area is hit-testable
+    .dropDestination(for: Image.self) { ... }
+```
+
+### Async Loading with loadTransferable
+
+`NSItemProvider.loadTransferable` is asynchronous. Update UI on the main actor:
+
+```swift
+provider.loadTransferable(type: Profile.self) { result in
+    Task { @MainActor in
+        switch result {
+        case .success(let profile):
+            self.profile = profile
+        case .failure(let error):
+            self.errorMessage = error.localizedDescription
+        }
+    }
+}
+```
+
+### PasteButton Platform Differences
+
+`PasteButton` auto-validates against pasteboard changes on iOS — the button enables/disables as the pasteboard content changes. On macOS, this automatic validation does not occur. If your macOS app needs dynamic paste validation, monitor `UIPasteboard.changedNotification` (UIKit) or `NSPasteboard` change count manually.
+
+---
+
+## Resources
+
+**WWDC**: 2022-10062, 2022-10052, 2022-10023, 2022-10093, 2022-10095
+
+**Docs**: /coretransferable/transferable, /coretransferable/choosing-a-transfer-representation-for-a-model-type, /coretransferable/filerepresentation, /coretransferable/proxyrepresentation, /swiftui/sharelink, /swiftui/drag-and-drop, /swiftui/clipboard, /uniformtypeidentifiers
+
+**Skills**: axiom-photo-library, axiom-codable, axiom-swiftui-gestures, axiom-app-intents-ref
diff --git a/.claude/skills/axiom-transferable-ref/agents/openai.yaml b/.claude/skills/axiom-transferable-ref/agents/openai.yaml
new file mode 100644
index 0000000..6c64072
--- /dev/null
+++ b/.claude/skills/axiom-transferable-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "Transferable Reference"
+  short_description: "Implementing drag and drop, copy/paste, ShareLink, or ANY content sharing between apps or views"
diff --git a/.claude/skills/axiom-tvos/.openskills.json b/.claude/skills/axiom-tvos/.openskills.json
new file mode 100644
index 0000000..cffb15e
--- /dev/null
+++ b/.claude/skills/axiom-tvos/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-tvos",
+  "installedAt": "2026-04-12T08:06:54.051Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-tvos/SKILL.md b/.claude/skills/axiom-tvos/SKILL.md
new file mode 100644
index 0000000..d22260d
--- /dev/null
+++ b/.claude/skills/axiom-tvos/SKILL.md
@@ -0,0 +1,768 @@
+---
+name: axiom-tvos
+description: Use when building ANY tvOS app - covers Focus Engine, Siri Remote input, storage constraints (no Document directory), no WebView, TVUIKit, TextField workarounds, AVPlayer tuning, Menu button state machines, and tvOS-specific gotchas that catch iOS developers
+license: MIT
+compatibility: tvOS 17+
+metadata:
+  version: "1.0.0"
+  last-updated: "2026-02-23"
+---
+
+# tvOS Development
+
+## Overview
+
+tvOS shares UIKit and SwiftUI with iOS but diverges in critical ways that catch every iOS developer. The three most dangerous assumptions: (1) local files persist, (2) WebView exists, (3) focus works like @FocusState.
+
+**Core principle** tvOS is not "iOS on TV." It has a dual focus system, no persistent local storage, no WebView, and a remote with two incompatible generations. Treat it as its own platform.
+
+**tvOS 26** Adopts Liquid Glass design language with new app icon system. See `axiom-liquid-glass` for implementation patterns.
+
+### tvOS Porting Triage
+
+Before shipping a tvOS port, verify these five areas — they account for 90% of tvOS-specific bugs:
+
+| Area | Check | Section |
+|------|-------|---------|
+| Storage | No persistent local files — iCloud required | §3 |
+| Focus | Dual system working, focus guides for gaps | §1 |
+| WebView | Replaced with JavaScriptCore or native rendering | §4 |
+| Text input | Shadow input or fullscreen keyboard handled | §6 |
+| AVPlayer | Audio session, buffer, Menu button state machine | §7, §8 |
+
+"It compiles on tvOS" means nothing. These five areas compile fine and fail at runtime.
+
+## When to Use This Skill
+
+- Building a new tvOS app or adding tvOS target
+- Porting an iOS app to tvOS
+- Debugging focus, remote input, or storage issues on tvOS
+- Working with AVPlayer, TVUIKit, or text input on tvOS
+
+## Example Prompts
+
+These are real questions developers ask that this skill answers:
+
+#### 1. "I'm porting my iOS app to tvOS and focus navigation doesn't work"
+-> The skill explains the dual focus system (UIKit Focus Engine vs @FocusState) and common traps
+
+#### 2. "My tvOS app loses all data between launches"
+-> The skill explains there is no persistent local storage and shows the iCloud-first pattern
+
+#### 3. "How do I handle Siri Remote input in SwiftUI on tvOS?"
+-> The skill covers both generations of remote and the three input layers (SwiftUI, UIKit gestures, GameController)
+
+#### 4. "WebView doesn't work on tvOS, how do I display web content?"
+-> The skill shows JavaScriptCore for parsing and native rendering alternatives
+
+## Red Flags
+
+If ANY of these appear, STOP:
+
+- "I'll just use the same storage code as iOS" — tvOS has no Document directory
+- "WebView will work for this" — No WebView on tvOS at all (Apple HIG: "Not supported in tvOS")
+- "@FocusState handles focus" — tvOS has a dual focus system; @FocusState alone is incomplete
+- "I'll save to Application Support" — It's Cache-only; the system deletes files when app is not running
+- "Standard UITextField will work" — tvOS text input triggers a fullscreen keyboard; consider the shadow input pattern
+- "I'll just use the same AVPlayer code" — tvOS needs .ambient audio session on launch, custom Menu button handling, and buffer tuning. Default iOS AVPlayer setup causes audio session conflicts and broken back navigation.
+
+---
+
+## 1. Focus Engine vs @FocusState
+
+tvOS has two focus systems that must coexist. This is the #1 source of confusion for iOS developers.
+
+### The Dual System
+
+| System | Controls | API |
+|--------|----------|-----|
+| UIKit Focus Engine | Hardware remote navigation, directional scanning | UIFocusEnvironment, UIFocusSystem, UIFocusGuide |
+| SwiftUI Focus | Programmatic focus binding, focus sections | @FocusState, .focused(), .focusable(), .focusSection() |
+
+### When Each Applies
+
+```
+User swipes on remote → UIKit Focus Engine handles it (always)
+Code sets @FocusState → SwiftUI handles it (sometimes overridden by Focus Engine)
+```
+
+**The trap**: @FocusState can set focus programmatically, but the UIKit Focus Engine is the ultimate authority. If the Focus Engine considers a view unfocusable, @FocusState assignments are silently ignored.
+
+### UIKit Focus Engine API
+
+The UIFocusEnvironment protocol (implemented by UIView, UIViewController, UIWindow) provides:
+
+```swift
+class MyViewController: UIViewController {
+    // Priority-ordered list of where focus should go
+    override var preferredFocusEnvironments: [UIFocusEnvironment] {
+        [preferredButton, fallbackButton]
+    }
+
+    // Validate proposed focus changes
+    override func shouldUpdateFocus(
+        in context: UIFocusUpdateContext
+    ) -> Bool {
+        // Return false to block focus movement
+        return context.nextFocusedView != disabledButton
+    }
+
+    // Respond to completed focus changes
+    override func didUpdateFocus(
+        in context: UIFocusUpdateContext,
+        with coordinator: UIFocusAnimationCoordinator
+    ) {
+        coordinator.addCoordinatedAnimations {
+            context.nextFocusedView?.transform = CGAffineTransform(scaleX: 1.1, y: 1.1)
+            context.previouslyFocusedView?.transform = .identity
+        }
+    }
+
+    // Request focus update (async)
+    func moveFocusToPreferred() {
+        setNeedsFocusUpdate()      // Schedule update
+        updateFocusIfNeeded()       // Execute immediately
+    }
+}
+```
+
+### UIFocusGuide — Bridging Navigation Gaps
+
+When focusable views aren't in a direct grid layout, the Focus Engine can't find them by scanning directionally. UIFocusGuide creates invisible focusable regions that redirect to real views:
+
+```swift
+let focusGuide = UIFocusGuide()
+view.addLayoutGuide(focusGuide)
+
+// Position the guide between two non-adjacent views
+NSLayoutConstraint.activate([
+    focusGuide.leadingAnchor.constraint(equalTo: leftButton.trailingAnchor),
+    focusGuide.trailingAnchor.constraint(equalTo: rightButton.leadingAnchor),
+    focusGuide.topAnchor.constraint(equalTo: leftButton.topAnchor),
+    focusGuide.heightAnchor.constraint(equalTo: leftButton.heightAnchor)
+])
+
+// When focus enters the guide, redirect to the target view
+focusGuide.preferredFocusEnvironments = [rightButton]
+```
+
+### SwiftUI Focus API
+
+```swift
+struct ContentView: View {
+    @FocusState private var focusedItem: MenuItem?
+
+    var body: some View {
+        VStack {
+            ForEach(MenuItem.allCases) { item in
+                Button(item.title) { select(item) }
+                    .focused($focusedItem, equals: item)
+            }
+        }
+        .focusSection()       // Group focusable items for navigation
+        .defaultFocus($focusedItem, .home)  // Set initial focus
+    }
+}
+```
+
+**Key SwiftUI focus modifiers for tvOS**:
+- `.focused(_:equals:)` — Bind focus to a value
+- `.focusable()` — Make custom views focusable
+- `.focusSection()` — Group related items for directional navigation
+- `.defaultFocus(_:_:)` — Set where focus starts in a scope
+
+### Default Focusable Elements
+
+UIButton, UITextField, UITableViewCell, and UICollectionViewCell are focusable by default. Custom views need `canBecomeFocused` (UIKit) or `.focusable()` (SwiftUI). The top-left item receives initial focus at launch.
+
+### Common Focus Gotchas
+
+| Gotcha | Symptom | Fix |
+|--------|---------|-----|
+| Non-focusable container | Swipe skips your view | Add `.focusable()` or override `canBecomeFocused` |
+| Focus guide missing | Can't navigate to isolated view | Add UIFocusGuide to bridge the gap |
+| @FocusState ignored | Programmatic focus doesn't work | Check preferredFocusEnvironments chain |
+| Focus update not requested | Focus stays stale after layout change | Call setNeedsFocusUpdate() + updateFocusIfNeeded() |
+| Items not in grid layout | Focus jumps unpredictably | Arrange focusable items in a grid or use focus guides |
+| UIHostingConfiguration focus | Focus corruption in mixed UIKit/SwiftUI | Known issue — test UIHostingConfiguration cells carefully |
+
+---
+
+## 2. Siri Remote Input
+
+Two generations with different hardware — your code must handle both.
+
+### Generation Differences
+
+| Feature | Gen 1 (2015-2021) | Gen 2 (2021+) |
+|---------|-------------------|---------------|
+| Top surface | Touchpad (full swipe) | Clickpad + outer touch ring |
+| Swipe gestures | Full area | Ring edge only |
+| Click navigation | Center press | D-pad style |
+| Accelerometer | Yes | Yes |
+
+### Standard SwiftUI Modifiers (Preferred)
+
+For most UI, SwiftUI handles remote input automatically through the focus system:
+
+```swift
+Button("Play") { startPlayback() }
+    .focused($isFocused)  // Automatically responds to remote navigation
+
+List(items) { item in
+    Text(item.title)
+}
+// List navigation works automatically with remote
+// Note: First item receives focus by default on tvOS — use .defaultFocus() to override
+```
+
+### Gesture Recognizers (UIKit)
+
+Detect specific button presses and gestures via UIKit recognizers:
+
+```swift
+// Detect Play/Pause button
+let playPause = UITapGestureRecognizer(target: self, action: #selector(handlePlayPause))
+playPause.allowedPressTypes = [NSNumber(value: UIPress.PressType.playPause.rawValue)]
+view.addGestureRecognizer(playPause)
+
+// Detect swipe on touchpad
+let swipe = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe))
+swipe.direction = .right
+view.addGestureRecognizer(swipe)
+```
+
+**Available UIPress.PressType values**: `.menu`, `.playPause`, `.select`, `.upArrow`, `.downArrow`, `.leftArrow`, `.rightArrow`, `.pageUp`, `.pageDown`
+
+### Low-Level Press Handling
+
+For fine-grained control, override UIResponder press methods:
+
+```swift
+override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) {
+    for press in presses {
+        if press.type == .select {
+            handleSelectDown()
+        }
+    }
+}
+
+override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) {
+    for press in presses {
+        if press.type == .select {
+            handleSelectUp()
+        }
+    }
+}
+
+// Always implement all four: pressesBegan, pressesEnded, pressesChanged, pressesCancelled
+```
+
+### Game Controller Framework (Raw Input)
+
+For custom interactions (scrubbing, games), access the Siri Remote as a GCMicroGamepad:
+
+```swift
+import GameController
+
+NotificationCenter.default.addObserver(
+    forName: .GCControllerDidConnect, object: nil, queue: .main
+) { notification in
+    guard let controller = notification.object as? GCController,
+          let micro = controller.microGamepad else { return }
+
+    // Touchpad as analog D-pad (-1.0 to 1.0)
+    micro.dpad.valueChangedHandler = { _, xValue, yValue in
+        handleRemoteInput(x: xValue, y: yValue)
+    }
+
+    // reportsAbsoluteDpadValues: true = absolute position, false = relative movement
+    micro.reportsAbsoluteDpadValues = false
+
+    // allowsRotation: true = values adjust when remote is rotated
+    micro.allowsRotation = false
+
+    // Face buttons
+    micro.buttonA.pressedChangedHandler = { _, _, pressed in }
+    micro.buttonX.pressedChangedHandler = { _, _, pressed in }
+    micro.buttonMenu.pressedChangedHandler = { _, _, pressed in }
+}
+```
+
+### Progress Bar Scrubbing
+
+UIPanGestureRecognizer with virtual damping for smooth seeking:
+
+```swift
+let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
+
+@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
+    let velocity = gesture.velocity(in: view)
+    let dampingFactor: CGFloat = 0.002  // Tune for feel
+
+    switch gesture.state {
+    case .changed:
+        let seekDelta = velocity.x * dampingFactor
+        player.seek(to: currentTime + seekDelta)
+    default:
+        break
+    }
+}
+```
+
+---
+
+## 3. Storage Constraints
+
+**This is the most dangerous iOS assumption on tvOS.** tvOS has no Document directory. All local storage is Cache that the system can delete at any time. Skipping iCloud integration means 2-3 weeks debugging intermittent "data disappears" bugs that only happen on real devices between app launches.
+
+From Apple's App Programming Guide for tvOS: "Every app developed for the new Apple TV **must be able to store data in iCloud** and retrieve it in a way that provides a great customer experience."
+
+### What tvOS Has
+
+| Directory | Exists? | Persistent? |
+|-----------|---------|-------------|
+| Documents | No | N/A |
+| Application Support | Yes | No — system can delete when app is not running |
+| Caches | Yes | No — system deletes under storage pressure |
+| tmp | Yes | No |
+
+### Size Limits
+
+- **App bundle**: 4 GB maximum
+- **NSUserDefaults / UserDefaults**: 500 KB maximum (Apple docs; not the 4 MB iOS gets). Available but subject to system purge — not guaranteed persistent between sessions
+- **On-demand resources**: Available for read-only assets the OS manages
+- **Local cache**: No guaranteed size; system can purge while app is not running
+
+### What This Means
+
+- Every local file can vanish between app launches
+- SQLite databases stored locally will be deleted
+- Your app must survive with zero local data
+- Downloaded data is NOT deleted while the app is running — only between sessions
+
+### Recommended Pattern
+
+```swift
+// ✅ CORRECT: iCloud as primary, local as cache only
+func loadData() async throws -> [Item] {
+    // 1. Try iCloud first (persistent)
+    if let cloudData = try? await fetchFromICloud() {
+        // Cache locally for offline use
+        try? cacheLocally(cloudData)
+        return cloudData
+    }
+
+    // 2. Fall back to local cache (may not exist)
+    if let cached = try? loadFromLocalCache() {
+        return cached
+    }
+
+    // 3. Start fresh — this is normal on tvOS
+    return []
+}
+```
+
+### Database Recommendations
+
+| Solution | tvOS Viability | Notes |
+|----------|---------------|-------|
+| SQLiteData + CloudKit SyncEngine | Recommended | iCloud is persistent; local is just cache |
+| SwiftData + CloudKit | Works, but fragile | No persistent local-only storage; ModelContainer must be configured for CloudKit from day one — adding sync later requires migration; system database deletion triggers full re-sync on next launch |
+| CoreData + CloudKit | Dangerous | Space inflation from CloudKit metadata |
+| Local-only GRDB/SQLite | Unreliable | System deletes the database file |
+| NSUbiquitousKeyValueStore | Good for small data | 1 MB limit, key-value only |
+| On-demand resources | Good for read-only assets | OS manages download/purge lifecycle |
+
+**See** `axiom-sqlitedata` for CloudKit SyncEngine patterns, `axiom-storage` for full storage decision tree.
+
+---
+
+## 4. No WebView
+
+tvOS has no WKWebView, no SFSafariViewController, no WebView. Apple HIG explicitly states: web views are "Not supported in tvOS."
+
+### What You Can Do
+
+| Need | Solution |
+|------|----------|
+| Parse HTML/JSON | Use JavaScriptCore (JSContext, JSValue — no DOM) |
+| Display web content | Render natively from parsed data |
+| HLS streaming from m3u8 | Local HTTP server pattern (see below) |
+| OAuth login | Device code flow (RFC 8628) or companion device |
+
+### JavaScriptCore for Parsing
+
+JavaScriptCore provides a JavaScript execution engine without DOM or web rendering. Available on tvOS.
+
+```swift
+import JavaScriptCore
+
+let context = JSContext()!
+
+// Evaluate scripts
+context.evaluateScript("""
+    function parsePlaylist(m3u8Text) {
+        return m3u8Text.split('\\n')
+            .filter(line => !line.startsWith('#'))
+            .filter(line => line.trim().length > 0);
+    }
+""")
+
+// Pass data safely via setObject (avoids injection)
+context.setObject(m3u8Content, forKeyedSubscript: "rawContent" as NSString)
+let result = context.evaluateScript("parsePlaylist(rawContent)")
+
+// Convert back to Swift types
+let segments = result?.toArray() as? [String] ?? []
+```
+
+**Key classes**: JSVirtualMachine (execution environment), JSContext (script evaluation), JSValue (type bridging)
+
+**Limitation**: No DOM, no web rendering, no fetch/XMLHttpRequest. Pure JavaScript execution only.
+
+### Local HTTP Server for HLS
+
+When you need to serve modified m3u8 playlists to AVPlayer:
+
+```swift
+// Use Swifter (httpswift/swifter) or GCDWebServer
+// Serve rewritten m3u8 on localhost, point AVPlayer to it
+let localURL = URL(string: "http://localhost:8080/playlist.m3u8")!
+let playerItem = AVPlayerItem(url: localURL)
+```
+
+---
+
+## 5. TVUIKit Components
+
+tvOS-exclusive UIKit components. Bridge to SwiftUI via UIViewRepresentable.
+
+### TVPosterView
+
+Media content display with built-in focus expansion and parallax:
+
+```swift
+import TVUIKit
+
+let poster = TVPosterView(image: UIImage(named: "moviePoster"))
+poster.title = "Movie Title"
+poster.subtitle = "2024"
+
+// Focus expansion and parallax happen automatically
+// Access the underlying image view:
+poster.imageView.adjustsImageWhenAncestorFocused = true
+```
+
+### TVLockupView
+
+Base class for TVPosterView — a flexible container managing content with focus behavior:
+
+```swift
+let lockup = TVLockupView()
+lockup.contentView.addSubview(customView)
+lockup.headerView = headerFooter   // TVLockupHeaderFooterView
+lockup.footerView = footerFooter
+// showsOnlyWhenAncestorFocused: header/footer visibility on focus
+```
+
+### Other TVUIKit Components
+
+| Component | Purpose |
+|-----------|---------|
+| TVCardView | Simple container with customizable background |
+| TVCaptionButtonView | Button with image + text + directional parallax |
+| TVMonogramView | User initials/image with PersonNameComponents |
+| TVCollectionViewFullScreenLayout | Immersive full-screen collection with parallax + masking |
+| TVMediaItemContentView | Content configuration with badges, playback progress |
+
+### TVDigitEntryViewController
+
+System-provided passcode/PIN entry (tvOS 12+):
+
+```swift
+let digitEntry = TVDigitEntryViewController()
+digitEntry.numberOfDigits = 4
+digitEntry.titleText = "Enter PIN"
+digitEntry.promptText = "Enter your parental control code"
+digitEntry.isSecureDigitEntry = true
+
+present(digitEntry, animated: true)
+
+digitEntry.entryCompletionHandler = { pin in
+    guard let pin else { return }  // User cancelled
+    authenticate(with: pin)
+}
+
+// Reset entry
+digitEntry.clearEntry(animated: true)
+```
+
+---
+
+## 6. Text Input on tvOS
+
+tvOS text input is fundamentally different from iOS. Apple recommends minimizing text input in your UI.
+
+### Three Approaches
+
+| Approach | Best For | Keyboard Style |
+|----------|----------|---------------|
+| UIAlertController | Quick, simple input | Modal with text field |
+| UITextField | Multi-field forms | Fullscreen keyboard with Next/Previous |
+| UISearchController | Search | Inline single-line keyboard |
+
+### UITextField (Fullscreen Keyboard)
+
+The primary text input method. Calling `becomeFirstResponder()` presents a fullscreen keyboard:
+
+```swift
+let textField = UITextField()
+textField.placeholder = "Enter name"
+textField.becomeFirstResponder()  // Presents keyboard immediately
+// Done button returns user to previous page
+// Built-in Next/Previous buttons navigate between text fields
+```
+
+### Shadow Input Pattern (SwiftUI)
+
+When you want a custom-styled input trigger in SwiftUI:
+
+```swift
+struct TVTextInput: View {
+    @State private var text = ""
+    @State private var isEditing = false
+
+    var body: some View {
+        Button {
+            isEditing = true
+        } label: {
+            HStack {
+                Text(text.isEmpty ? "Search..." : text)
+                    .foregroundStyle(text.isEmpty ? .secondary : .primary)
+                Spacer()
+                Image(systemName: "keyboard")
+            }
+            .padding()
+            .background(.quaternary)
+            .clipShape(RoundedRectangle(cornerRadius: 10))
+        }
+        .sheet(isPresented: $isEditing) {
+            TVKeyboardSheet(text: $text)
+        }
+    }
+}
+```
+
+### UISearchController (Inline Keyboard)
+
+For search interfaces — all input on a single line, but very limited customization:
+
+```swift
+let searchController = UISearchController(searchResultsController: resultsVC)
+searchController.searchResultsUpdater = self
+// Cannot customize text traits or add input accessories
+```
+
+### SwiftUI `.searchable()`
+
+SwiftUI's `.searchable()` modifier works on tvOS and presents the system search keyboard. Use it for standard search patterns:
+
+```swift
+NavigationStack {
+    List(filteredItems) { item in
+        Text(item.title)
+    }
+    .searchable(text: $searchText, prompt: "Search movies")
+}
+```
+
+For custom search UI beyond what `.searchable()` offers, fall back to the shadow input pattern above.
+
+---
+
+## 7. AVPlayer Tuning
+
+tvOS media apps need specific AVPlayer configuration for good UX.
+
+### Essential Settings
+
+```swift
+let player = AVPlayer(url: streamURL)
+
+// automaticallyWaitsToMinimizeStalling defaults to true (iOS 10+/tvOS 10+)
+// Set false for immediate playback when synchronizing players
+// or when you want playback to start ASAP from a non-empty buffer
+player.automaticallyWaitsToMinimizeStalling = false
+
+// Buffer hint — 0 means system chooses automatically
+// Higher values reduce stalling risk but consume more memory
+player.currentItem?.preferredForwardBufferDuration = 30
+
+// Audio session — don't interrupt other apps' audio on launch
+try AVAudioSession.sharedInstance().setCategory(.ambient)
+// Switch to .playback when user presses play
+```
+
+### Custom Dismiss Logic
+
+The default swipe-down gesture dismisses the player. Override for media apps:
+
+```swift
+class PlayerViewController: AVPlayerViewController {
+    override func viewDidLoad() {
+        super.viewDidLoad()
+
+        // Handle Menu button for custom back navigation
+        let menuPress = UITapGestureRecognizer(
+            target: self, action: #selector(handleMenu)
+        )
+        menuPress.allowedPressTypes = [
+            NSNumber(value: UIPress.PressType.menu.rawValue)
+        ]
+        view.addGestureRecognizer(menuPress)
+    }
+
+    @objc func handleMenu() {
+        if isShowingControls {
+            hideControls()
+        } else {
+            dismiss(animated: true)
+        }
+    }
+}
+```
+
+---
+
+## 8. Menu Button State Machine
+
+The Siri Remote Menu button doubles as "back" and "dismiss." Media apps need a state machine to handle it correctly.
+
+### The Problem
+
+```
+State: Playing with controls visible
+  Menu press → Hide controls (not dismiss)
+
+State: Playing with controls hidden
+  Menu press → Show "are you sure?" or dismiss
+
+State: In submenu/settings overlay
+  Menu press → Close overlay (not dismiss player)
+```
+
+### Pattern
+
+```swift
+enum PlayerState {
+    case loading        // Buffering / loading content
+    case playing        // Controls hidden
+    case controlsShown  // Controls visible
+    case submenu        // Settings/subtitles overlay
+}
+
+func handleMenuPress(in state: PlayerState) -> PlayerState {
+    switch state {
+    case .submenu:
+        dismissSubmenu()
+        return .controlsShown
+    case .controlsShown:
+        hideControls()
+        return .playing
+    case .playing:
+        dismiss(animated: true)
+        return .playing
+    case .loading:
+        cancelLoading()
+        dismiss(animated: true)
+        return .loading
+    }
+}
+```
+
+---
+
+## 9. Network Differences
+
+### IPv6 Priority
+
+Apple TV strongly prefers IPv6. All App Store apps must support IPv6-only networks (DNS64/NAT64). If your backend is IPv4-only, connections may be slower or fail on some networks.
+
+### Device Performance Variance
+
+| Device | Chip | RAM | Notes |
+|--------|------|-----|-------|
+| Apple TV HD (4th gen) | A8 | 2 GB | Still supported; much slower |
+| Apple TV 4K (1st gen) | A10X | 3 GB | Capable |
+| Apple TV 4K (2nd gen) | A12 | 4 GB | Good |
+| Apple TV 4K (3rd gen) | A15 | 4 GB | Excellent |
+
+**Test on older hardware.** The Apple TV HD is still in use and dramatically slower than 4K models.
+
+---
+
+## 10. Developer Experience
+
+### Debug-Only Input Macros
+
+Test without Siri Remote in Simulator using keyboard shortcuts:
+
+```swift
+#if DEBUG
+extension View {
+    func debugOnlyModifier() -> some View {
+        self.onKeyPress(.space) {
+            print("Space pressed — simulating select")
+            return .handled
+        }
+    }
+}
+#endif
+```
+
+### View Inspection Helper
+
+```swift
+#if DEBUG
+extension View {
+    func debugBorder() -> some View {
+        border(.red, width: 1)
+    }
+}
+#endif
+```
+
+### Simulator Limitations
+
+- Simulator does not accurately simulate Focus Engine behavior
+- Always test focus navigation on a real Apple TV device
+- Simulator keyboard input != Siri Remote input
+- Performance profiling must happen on device (especially Apple TV HD)
+
+---
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "I'll just use the same code as iOS" | tvOS diverges in storage, focus, input, and web views. You will hit walls. |
+| "Focus works like iOS" | tvOS has a dual focus system (UIKit Focus Engine + SwiftUI @FocusState). @FocusState alone is insufficient. |
+| "Local storage is fine for now" | There is no persistent local storage on tvOS. Apple requires iCloud capability. |
+| "WebView will work" | Apple HIG: web views are "Not supported in tvOS." JavaScriptCore only (no DOM). |
+| "I'll handle text input with TextField" | UITextField triggers a fullscreen keyboard. Consider shadow input pattern or UISearchController for better UX. |
+| "I only need to test on Simulator" | Focus Engine and performance require real device testing. |
+
+---
+
+## Resources
+
+**Source**: "Surviving tvOS" (Ronnie Wong, 2026) — tvOS engineering log for Syncnext media player
+
+**Apple Docs**: /tvuikit, /uikit/uifocusenvironment, /uikit/uifocusguide, /swiftui/focus, /gamecontroller/gcmicrogamepad, /avfoundation/avplayer, /javascriptcore
+
+**Apple Guides**: App Programming Guide for tvOS (storage, input, gestures), HIG Web Views (tvOS exclusion)
+
+**WWDC**: 2016-215, 2017-224, 2021-10023, 2021-10081, 2021-10191, 2023-10162, 2025-219
+
+**Skills**: axiom-storage, axiom-sqlitedata, axiom-avfoundation-ref, axiom-hig-ref, axiom-liquid-glass
diff --git a/.claude/skills/axiom-tvos/agents/openai.yaml b/.claude/skills/axiom-tvos/agents/openai.yaml
new file mode 100644
index 0000000..45d1cd8
--- /dev/null
+++ b/.claude/skills/axiom-tvos/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "tvOS"
+  short_description: "Building ANY tvOS app"
diff --git a/.claude/skills/axiom-typography-ref/.openskills.json b/.claude/skills/axiom-typography-ref/.openskills.json
new file mode 100644
index 0000000..b61737c
--- /dev/null
+++ b/.claude/skills/axiom-typography-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-typography-ref",
+  "installedAt": "2026-04-12T08:06:54.451Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-typography-ref/SKILL.md b/.claude/skills/axiom-typography-ref/SKILL.md
new file mode 100644
index 0000000..b295978
--- /dev/null
+++ b/.claude/skills/axiom-typography-ref/SKILL.md
@@ -0,0 +1,498 @@
+---
+name: axiom-typography-ref
+description: Apple platform typography reference (San Francisco fonts, text styles, Dynamic Type, tracking, leading, internationalization) through iOS 26
+license: MIT
+---
+
+# Typography Reference
+
+Complete reference for typography on Apple platforms including San Francisco font system, text styles, Dynamic Type, tracking, leading, and internationalization through iOS 26.
+
+## San Francisco Font System
+
+### Font Families
+
+**SF Pro** and **SF Pro Rounded** (iOS, iPadOS, macOS, tvOS)
+- Main system fonts for most UI elements
+- Rounded variant for friendly, approachable interfaces (e.g., Reminders app)
+
+**SF Compact** and **SF Compact Rounded** (watchOS, narrow columns)
+- Optimized for constrained spaces and small sizes
+- watchOS default system font
+
+**SF Mono** (Code environments, monospaced text)
+- Monospaced font for code editors and technical content
+- Consistent character widths for alignment
+
+**New York** (Serif system font)
+- Serif alternative for editorial content
+- Works with text styles just like SF Pro
+
+### Variable Font Axes
+
+#### Weight Axis (9 weights)
+- Ultralight, Thin, Light, Regular, Medium, Semibold, Bold, Heavy, Black
+- Continuous weight spectrum via variable fonts
+- Avoid light weights at small sizes (legibility issues)
+
+#### Width Axis (WWDC 2022)
+- **Condensed** — narrowest width
+- **Compressed** — narrow width
+- **Regular** — standard width (default)
+- **Expanded** — wide width
+
+Access via:
+```swift
+// iOS/macOS
+let descriptor = UIFontDescriptor(fontAttributes: [
+    .family: "SF Pro",
+    kCTFontWidthTrait: 1.0 // 1.0 = Expanded
+])
+```
+
+**SF Arabic** (WWDC 2022)
+- Matches SF Pro design language for Arabic text
+- Proper right-to-left support
+
+#### Optical Sizes
+Variable fonts automatically adjust optical size based on point size:
+- **Text variant** (< 20pt) — more spacing, sturdier strokes
+- **Display variant** (≥ 20pt) — tighter spacing, refined details
+- **Smooth transition** (17-28pt) with variable SF Pro
+
+From WWDC 2020:
+> "TextKit 2 abstracts away glyph handling to provide a consistent experience for international text."
+
+## Text Styles & Dynamic Type
+
+### System Text Styles
+
+| Text Style | Default Size (iOS) | Use Case |
+|------------|-------------------|----------|
+| `.largeTitle` | 34pt | Primary page headings |
+| `.title` | 28pt | Secondary headings |
+| `.title2` | 22pt | Tertiary headings |
+| `.title3` | 20pt | Quaternary headings |
+| `.headline` | 17pt (Semibold) | Emphasized body text |
+| `.body` | 17pt | Primary body text |
+| `.callout` | 16pt | Secondary body text |
+| `.subheadline` | 15pt | Tertiary body text |
+| `.footnote` | 13pt | Footnotes, captions |
+| `.caption` | 12pt | Small annotations |
+| `.caption2` | 11pt | Smallest annotations |
+
+#### Font Size Guidance
+
+- **Avoid `.caption2` for readable content** — at 11pt, it's acceptable for timestamps and metadata annotations but too small for body text or labels users need to read. Prefer `.caption` or `.footnote` as the minimum for readable content.
+
+### Emphasized Text Styles
+
+Apply `.bold` symbolic trait to get emphasized variants:
+
+```swift
+// UIKit
+let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .title1)
+let boldDescriptor = descriptor.withSymbolicTraits(.traitBold)!
+let font = UIFont(descriptor: boldDescriptor, size: 0)
+
+// SwiftUI
+Text("Bold Title")
+    .font(.title.bold())
+```
+
+**Actual weights by text style:**
+- Some styles map to **medium**
+- Others map to **semibold**, **bold**, or **heavy**
+- Depends on semantic hierarchy
+
+### Leading Variants
+
+**Tight Leading** (reduces line height by 2pt on iOS, 1pt on watchOS):
+```swift
+// UIKit
+let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
+let tightDescriptor = descriptor.withSymbolicTraits(.traitTightLeading)!
+
+// SwiftUI
+Text("Compact text")
+    .font(.body.leading(.tight))
+```
+
+**Loose Leading** (increases line height by 2pt on iOS, 1pt on watchOS):
+```swift
+// SwiftUI
+Text("Spacious paragraph")
+    .font(.body.leading(.loose))
+```
+
+### Dynamic Type
+
+**Automatic Scaling** (iOS):
+Text styles scale automatically based on user preferences from Settings → Display & Brightness → Text Size.
+
+**Custom Fonts with Dynamic Type:**
+
+```swift
+// UIKit - UIFontMetrics
+let customFont = UIFont(name: "Avenir-Medium", size: 34)!
+let bodyMetrics = UIFontMetrics(forTextStyle: .body)
+let scaledFont = bodyMetrics.scaledFont(for: customFont)
+
+// Also scale constants
+let spacing = bodyMetrics.scaledValue(for: 20.0)
+```
+
+```swift
+// SwiftUI - .font(.custom(_:relativeTo:))
+Text("Custom scaled text")
+    .font(.custom("Avenir-Medium", size: 34, relativeTo: .body))
+
+// @ScaledMetric for values
+@ScaledMetric(relativeTo: .body) var padding: CGFloat = 20
+```
+
+### Platform Differences
+
+**macOS**
+- No Dynamic Type support in AppKit
+- Text style sizes optimized for macOS control sizes
+- Catalyst apps use iOS sizes × 77% (legacy) or macOS-optimized sizes ("Optimize Interface for Mac")
+
+**watchOS**
+- Smaller text styles optimized for watch faces
+- Tight leading default for compact displays
+
+**visionOS**
+- System fonts work identically to iOS
+- Dynamic Type support included
+
+## Tracking & Leading
+
+### Tracking (Letter Spacing)
+
+Tracking adjusts space between letters. Essential for optical size behavior.
+
+**Size-Specific Tracking Tables:**
+
+SF Pro includes tracking values that vary by point size to maintain optimal spacing:
+- Larger sizes: tighter tracking
+- Smaller sizes: looser tracking
+
+Example from Apple Design Resources:
+- 34pt (largeTitle): +0.016 tracking
+- 17pt (body): +0.008 tracking
+- 11pt (caption2): +0.06 tracking
+
+**Tight Tracking API** (for fitting text):
+```swift
+// UIKit
+textView.allowsDefaultTightening(for: .byTruncatingTail)
+
+// SwiftUI
+Text("Long text that needs to fit")
+    .lineLimit(1)
+    .minimumScaleFactor(0.5) // Allows tight tracking
+```
+
+**Manual Tracking:**
+```swift
+// UIKit
+let attributes: [NSAttributedString.Key: Any] = [
+    .font: UIFont.preferredFont(forTextStyle: .body),
+    .kern: 2.0 // 2pt tracking
+]
+
+// SwiftUI
+Text("Tracked text")
+    .tracking(2.0)
+    .kerning(2.0) // Alternative API
+```
+
+**Important:** Use `.tracking()` not `.kerning()` API for semantic correctness. Tracking disables ligatures when necessary; kerning does not.
+
+### Leading (Line Spacing)
+
+**Default Line Height:**
+Calculated from font's built-in metrics (ascender + descender + line gap).
+
+**Language-Aware Adjustments:**
+iOS 17+ automatically increases line height for scripts with tall ascenders/descenders:
+- Arabic
+- Thai, Lao
+- Hindi, Bengali, Telugu
+
+From WWDC 2023:
+> "Automatic line height adjustment for scripts with variable heights"
+
+**Manual Leading:**
+```swift
+// UIKit
+let paragraphStyle = NSMutableParagraphStyle()
+paragraphStyle.lineSpacing = 8.0 // 8pt additional space
+
+// SwiftUI (iOS 13+)
+Text("Custom spacing")
+    .lineSpacing(8.0)
+```
+
+**Line Height (iOS 26+):**
+
+`.lineHeight()` sets baseline-to-baseline distance directly — more intuitive than `.lineSpacing()` (which measures bottom-to-top).
+
+```swift
+// Presets
+Text("Open layout").lineHeight(.loose)
+Text("Compact layout").lineHeight(.tight)
+
+// Precise control
+Text("Scaled").lineHeight(.multiple(factor: 1.5))
+Text("Fixed").lineHeight(.exact(points: 30)) // Does NOT scale with Dynamic Type
+```
+
+Also available as `AttributedString.lineHeight` for styled strings. See `axiom-swiftui-26-ref` for full API details.
+
+### Third-Party Font Tracking
+
+**New in iOS 18:**
+Font vendors can embed tracking tables in custom fonts using STAT table + CTFont optical size attribute.
+
+```swift
+let attributes: [String: Any] = [
+    kCTFontOpticalSizeAttribute as String: pointSize
+]
+let descriptor = CTFontDescriptorCreateWithAttributes(attributes as CFDictionary)
+let font = CTFontCreateWithFontDescriptor(descriptor, pointSize, nil)
+```
+
+## SwiftUI AttributedString Typography
+
+### Font Environment Interaction
+
+**Critical Pattern** When using `AttributedString` with SwiftUI's `Text`, paragraph styles (like `lineHeightMultiple`) can be lost if fonts come from the environment instead of the attributed content.
+
+From WWDC 2025-280:
+> "TextEditor substitutes the default value calculated from the environment for any AttributedStringKeys with a value of nil."
+
+This same principle applies to `Text`—when your `AttributedString` doesn't specify a font, SwiftUI applies the environment font, which can cause it to rebuild text runs and drop or normalize paragraph style details.
+
+### The Problem
+
+```swift
+// ❌ WRONG - .font() modifier can override and drop paragraph styles
+var s = AttributedString(longString)
+
+// Set paragraph style
+var p = AttributedString.ParagraphStyle()
+p.lineHeightMultiple = 0.92
+s.paragraphStyle = p
+// ⚠️ No font set in AttributedString
+
+Text(s)
+    .font(.body) // ⚠️ May rebuild runs, lose lineHeightMultiple
+```
+
+**Why this fails:**
+1. `AttributedString` has no font attribute set (value is `nil`)
+2. SwiftUI's `.font(.body)` modifier tells it "use this font for the whole run"
+3. SwiftUI rebuilds text runs with the environment font
+4. Paragraph styles get dropped or normalized during rebuild
+
+### The Solution
+
+**Keep typography inside the AttributedString when you need fine control:**
+
+```swift
+// ✅ CORRECT - Font in AttributedString, no environment override
+var s = AttributedString(longString)
+
+// Set font INSIDE the attributed content
+s.font = .system(.body) // ✅ Typography inside AttributedString
+
+// Set paragraph style
+var p = AttributedString.ParagraphStyle()
+p.lineHeightMultiple = 0.92
+s.paragraphStyle = p
+
+Text(s) // ✅ No .font() modifier
+```
+
+**Why this works:**
+1. Font is part of the attributed content (not `nil`)
+2. No environment override from `.font()` modifier
+3. SwiftUI preserves both font AND paragraph styles
+4. Text runs remain intact with all attributes
+
+### When to Use Each Approach
+
+#### Use Font in AttributedString (Fine Control)
+
+```swift
+var s = AttributedString("Carefully styled text")
+s.font = .system(.body)
+
+var p = AttributedString.ParagraphStyle()
+p.lineHeightMultiple = 0.92
+p.alignment = .leading
+s.paragraphStyle = p
+
+Text(s) // No modifier
+```
+
+**When to use:**
+- Need precise paragraph styling (line height, alignment)
+- Mixing multiple fonts in one string
+- Content will be displayed in both `Text` and `TextEditor`
+- Preserving exact formatting from rich text editor
+
+#### Use .font() Modifier (Broad Override)
+
+```swift
+Text("Simple text")
+    .font(.body)
+    .lineSpacing(4.0) // SwiftUI-level spacing
+```
+
+**When to use:**
+- Simple text without paragraph styles
+- Want Dynamic Type automatic scaling
+- Need SwiftUI's semantic font behavior (Dark Mode, accessibility)
+- Intentionally overriding AttributedString fonts
+
+### Multiple Fonts in One String
+
+```swift
+var s = AttributedString("Title")
+s.font = .system(.title).bold()
+
+var body = AttributedString(" and body text")
+body.font = .system(.body)
+
+s.append(body)
+
+Text(s) // ✅ No .font() modifier preserves both fonts
+```
+
+### Common Mistake: Order Doesn't Matter
+
+```swift
+// ❌ WRONG mental model: "Create AttributedString first"
+var s = AttributedString(text)
+var p = AttributedString.ParagraphStyle()
+p.lineHeightMultiple = 0.92
+s.paragraphStyle = p
+s.font = .system(.body) // ⚠️ Setting font last doesn't help if you use .font() modifier
+
+Text(s).font(.body) // Still breaks!
+```
+
+The issue isn't **when** you set the font in `AttributedString`. The issue is **whether the attributed content carries its own font attributes** versus relying on SwiftUI's `.font(...)` environment.
+
+### Verification Checklist
+
+When using `AttributedString` with paragraph styles:
+- [ ] Font set inside `AttributedString` (not `nil`)
+- [ ] No `.font()` modifier on `Text` view (unless intentionally overriding)
+- [ ] Paragraph styles set after or before font (order doesn't matter)
+- [ ] Tested with actual content to verify line height/alignment preserved
+
+## Internationalization
+
+### Bidirectional Text
+
+**Complex Script Example (from WWDC 2021):**
+
+Kannada word "October":
+- Character index 4 has split vowel → 2 glyphs
+- Glyphs reorder before ligature application
+- Glyph index ≠ character index
+
+This is why TextKit 2 uses **NSTextLocation** instead of integer indices.
+
+**Hebrew/Arabic Selection:**
+Single visual selection = multiple NSRanges in AttributedString due to right-to-left layout.
+
+### Line Breaking
+
+**Language-Aware (iOS 17+):**
+- Chinese, Japanese, Korean: break at semantic boundaries
+- German: avoid breaking compound words
+- English: prefer breaking at hyphens
+
+**Even Line Breaking (TextKit 2):**
+Justified paragraphs use improved line breaking algorithm:
+- Reduces stretched-out lines
+- More even interword spacing
+- Automatic in TextKit 2
+
+### Text Clipping Prevention
+
+**Best Practices:**
+1. Use Dynamic Type (auto-adjusts)
+2. Set `.lineLimit(nil)` or `.lineLimit(2...5)` in SwiftUI
+3. Use `.minimumScaleFactor()` for constrained single-line text
+4. Test with large accessibility sizes
+
+## CSS & Web Typography
+
+**System UI Font Families:**
+
+```css
+font-family: system-ui; /* SF Pro */
+font-family: ui-rounded; /* SF Pro Rounded */
+font-family: ui-serif; /* New York */
+font-family: ui-monospace; /* SF Mono */
+```
+
+**Legacy:**
+```css
+font-family: -apple-system; /* deprecated, use system-ui */
+```
+
+## Code Examples
+
+### Emphasized Large Title (SwiftUI)
+```swift
+Text("Recipe Editor")
+    .font(.largeTitle.bold()) // Emphasized variant
+```
+
+### Custom Font + Dynamic Type (UIKit)
+```swift
+let customFont = UIFont(name: "Avenir-Medium", size: 17)!
+let metrics = UIFontMetrics(forTextStyle: .body)
+label.font = metrics.scaledFont(for: customFont)
+label.adjustsFontForContentSizeCategory = true
+```
+
+### Rounded Design (UIKit)
+```swift
+let descriptor = UIFontDescriptor
+    .preferredFontDescriptor(withTextStyle: .largeTitle)
+    .withDesign(.rounded)!
+let font = UIFont(descriptor: descriptor, size: 0)
+```
+
+### Rounded Design (SwiftUI)
+```swift
+Text("Today")
+    .font(.largeTitle.bold())
+    .fontDesign(.rounded)
+```
+
+### ScaledMetric (SwiftUI)
+```swift
+struct RecipeView: View {
+    @ScaledMetric(relativeTo: .body) var padding: CGFloat = 20
+
+    var body: some View {
+        Text("Recipe")
+            .padding(padding) // Scales with Dynamic Type
+    }
+}
+```
+
+## Resources
+
+**WWDC**: 2020-10175, 2022-110381, 2023-10058
+
+**Docs**: /uikit/uifontdescriptor, /uikit/uifontmetrics, /swiftui/font
diff --git a/.claude/skills/axiom-typography-ref/agents/openai.yaml b/.claude/skills/axiom-typography-ref/agents/openai.yaml
new file mode 100644
index 0000000..69883d9
--- /dev/null
+++ b/.claude/skills/axiom-typography-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "Typography Reference"
+  short_description: "Apple platform typography reference (San Francisco fonts, text styles, Dynamic Type, tracking, leading, international..."
diff --git a/.claude/skills/axiom-ui-recording/.openskills.json b/.claude/skills/axiom-ui-recording/.openskills.json
new file mode 100644
index 0000000..799315e
--- /dev/null
+++ b/.claude/skills/axiom-ui-recording/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-ui-recording",
+  "installedAt": "2026-04-12T08:06:54.860Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ui-recording/SKILL.md b/.claude/skills/axiom-ui-recording/SKILL.md
new file mode 100644
index 0000000..fce5270
--- /dev/null
+++ b/.claude/skills/axiom-ui-recording/SKILL.md
@@ -0,0 +1,431 @@
+---
+name: axiom-ui-recording
+description: Use when setting up UI test recording in Xcode 26, enhancing recorded tests for stability, or configuring test plans for multi-configuration replay. Based on WWDC 2025-344 "Record, replay, and review".
+license: MIT
+metadata:
+  version: "1.0.0"
+---
+
+# Recording UI Automation (Xcode 26+)
+
+Guide to Xcode 26's Recording UI Automation feature for creating UI tests through user interaction recording.
+
+## The Three-Phase Workflow
+
+From WWDC 2025-344:
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│                   UI Automation Workflow                    │
+├─────────────────────────────────────────────────────────────┤
+│                                                             │
+│  1. RECORD ──────► Interact with app in Simulator           │
+│                    Xcode captures as Swift test code        │
+│                                                             │
+│  2. REPLAY ──────► Run across devices, languages, configs   │
+│                    Using test plans for multi-config        │
+│                                                             │
+│  3. REVIEW ──────► Watch video recordings in test report    │
+│                    Analyze failures with screenshots        │
+│                                                             │
+└─────────────────────────────────────────────────────────────┘
+```
+
+## Phase 1: Recording
+
+### Starting a Recording
+
+1. Open your UI test file in Xcode
+2. Place cursor inside a test method
+3. **Debug → Record UI Automation** (or use the record button)
+4. App launches in Simulator
+5. Perform interactions - Xcode generates code
+6. Stop recording when done
+
+### What Gets Recorded
+
+- **Taps** on buttons, cells, controls
+- **Text input** into text fields
+- **Swipes** and scrolling
+- **Gestures** (pinch, rotate)
+- **Hardware button presses** (Home, volume)
+
+### Generated Code Example
+
+```swift
+// Xcode generates this from your interactions
+func testLoginFlow() {
+    let app = XCUIApplication()
+    app.launch()
+
+    // Recorded: Tap email field, type email
+    app.textFields["Email"].tap()
+    app.textFields["Email"].typeText("user@example.com")
+
+    // Recorded: Tap password field, type password
+    app.secureTextFields["Password"].tap()
+    app.secureTextFields["Password"].typeText("password123")
+
+    // Recorded: Tap login button
+    app.buttons["Login"].tap()
+}
+```
+
+## Enhancing Recorded Code
+
+**Critical**: Recorded code is often fragile. Always enhance it for stability.
+
+### 1. Add Accessibility Identifiers
+
+Recorded code uses labels which break with localization:
+
+```swift
+// RECORDED (fragile - breaks with localization)
+app.buttons["Login"].tap()
+
+// ENHANCED (stable - uses identifier)
+app.buttons["loginButton"].tap()
+```
+
+**Add identifiers in your app code:**
+
+```swift
+// SwiftUI
+Button("Login") { ... }
+    .accessibilityIdentifier("loginButton")
+
+// UIKit
+loginButton.accessibilityIdentifier = "loginButton"
+```
+
+### 2. Add waitForExistence
+
+Recorded code assumes elements exist immediately:
+
+```swift
+// RECORDED (may fail if app is slow)
+app.buttons["Login"].tap()
+
+// ENHANCED (waits for element)
+let loginButton = app.buttons["loginButton"]
+XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
+loginButton.tap()
+```
+
+### 3. Add Assertions
+
+Recorded code just performs actions without verification:
+
+```swift
+// RECORDED (no verification)
+app.buttons["Login"].tap()
+
+// ENHANCED (with assertion)
+app.buttons["loginButton"].tap()
+let welcomeLabel = app.staticTexts["welcomeLabel"]
+XCTAssertTrue(welcomeLabel.waitForExistence(timeout: 10),
+              "Welcome screen should appear after login")
+```
+
+### 4. Use Shorter Queries
+
+Recorded code may have overly specific queries:
+
+```swift
+// RECORDED (too specific)
+app.tables.cells.element(boundBy: 0).buttons["Action"].tap()
+
+// ENHANCED (simpler)
+app.buttons["actionButton"].tap()
+```
+
+## Query Selection Guidelines
+
+From WWDC 2025-344:
+
+| Scenario | Problem | Solution |
+|----------|---------|----------|
+| Localized strings | "Login" changes by language | Use accessibilityIdentifier |
+| Deeply nested views | Long query chains break easily | Use shortest possible query |
+| Dynamic content | Cell content changes | Use identifier or generic query |
+| Multiple matches | Query returns many elements | Add unique identifier |
+
+### Best Practices
+
+1. **Prefer identifiers over labels**
+2. **Use the shortest query that works**
+3. **Avoid index-based queries** (`element(boundBy: 0)`)
+4. **Add identifiers to dynamic content**
+
+## Phase 2: Replay with Test Plans
+
+Test plans allow running the same tests across multiple configurations.
+
+### Creating a Test Plan
+
+1. **File → New → File → Test Plan**
+2. Add test targets
+3. Configure configurations
+
+### Test Plan Structure
+
+```json
+{
+  "configurations": [
+    {
+      "name": "iPhone - English",
+      "options": {
+        "targetForVariableExpansion": {
+          "containerPath": "container:MyApp.xcodeproj",
+          "identifier": "MyApp"
+        },
+        "language": "en",
+        "region": "US"
+      }
+    },
+    {
+      "name": "iPhone - Spanish",
+      "options": {
+        "language": "es",
+        "region": "ES"
+      }
+    },
+    {
+      "name": "iPhone - Dark Mode",
+      "options": {
+        "userInterfaceStyle": "dark"
+      }
+    },
+    {
+      "name": "iPad - Landscape",
+      "options": {
+        "defaultTestExecutionTimeAllowance": 120,
+        "testTimeoutsEnabled": true
+      }
+    }
+  ],
+  "defaultOptions": {
+    "targetForVariableExpansion": {
+      "containerPath": "container:MyApp.xcodeproj",
+      "identifier": "MyApp"
+    }
+  },
+  "testTargets": [
+    {
+      "target": {
+        "containerPath": "container:MyApp.xcodeproj",
+        "identifier": "MyAppUITests",
+        "name": "MyAppUITests"
+      }
+    }
+  ],
+  "version": 1
+}
+```
+
+### Configuration Options
+
+| Option | Purpose |
+|--------|---------|
+| `language` | Test localization |
+| `region` | Test regional formatting |
+| `userInterfaceStyle` | Test dark/light mode |
+| `targetForVariableExpansion` | App target for configuration |
+| `testTimeoutsEnabled` | Enable timeout enforcement |
+| `defaultTestExecutionTimeAllowance` | Timeout in seconds |
+
+### Running with Test Plan
+
+```bash
+# Command line
+xcodebuild test \
+  -scheme "MyApp" \
+  -testPlan "MyTestPlan" \
+  -destination "platform=iOS Simulator,name=iPhone 16" \
+  -resultBundlePath /tmp/results.xcresult
+
+# In Xcode
+# Product → Test Plan → Select your plan
+# Then Cmd+U to run tests
+```
+
+## Phase 3: Review
+
+### Test Report Features
+
+After tests complete:
+
+1. **View test results** in Report Navigator
+2. **Watch video recordings** of each test
+3. **See screenshots** at failure points
+4. **Analyze timeline** of actions
+
+### Enabling Attachments
+
+In test plan or scheme:
+
+```json
+"options": {
+  "systemAttachmentLifetime": "keepAlways",
+  "userAttachmentLifetime": "keepAlways"
+}
+```
+
+### Capturing Custom Screenshots
+
+```swift
+func testCheckout() {
+    // ... actions ...
+
+    // Manual screenshot at specific point
+    let screenshot = app.screenshot()
+    let attachment = XCTAttachment(screenshot: screenshot)
+    attachment.name = "Checkout Confirmation"
+    attachment.lifetime = .keepAlways
+    add(attachment)
+}
+```
+
+## Common Patterns
+
+### Login Flow Template
+
+```swift
+func testLoginWithValidCredentials() throws {
+    let app = XCUIApplication()
+    app.launch()
+
+    // Navigate to login
+    let showLoginButton = app.buttons["showLoginButton"]
+    XCTAssertTrue(showLoginButton.waitForExistence(timeout: 5))
+    showLoginButton.tap()
+
+    // Enter credentials
+    let emailField = app.textFields["emailTextField"]
+    XCTAssertTrue(emailField.waitForExistence(timeout: 5))
+    emailField.tap()
+    emailField.typeText("test@example.com")
+
+    let passwordField = app.secureTextFields["passwordTextField"]
+    passwordField.tap()
+    passwordField.typeText("password123")
+
+    // Submit
+    app.buttons["loginButton"].tap()
+
+    // Verify success
+    let welcomeScreen = app.staticTexts["welcomeLabel"]
+    XCTAssertTrue(welcomeScreen.waitForExistence(timeout: 10))
+}
+```
+
+### Navigation Flow Template
+
+```swift
+func testNavigateToSettings() throws {
+    let app = XCUIApplication()
+    app.launch()
+
+    // Open tab bar item
+    app.tabBars.buttons["Settings"].tap()
+
+    // Verify navigation
+    let settingsTitle = app.navigationBars["Settings"]
+    XCTAssertTrue(settingsTitle.waitForExistence(timeout: 5))
+
+    // Navigate deeper
+    app.tables.cells["Account"].tap()
+    XCTAssertTrue(app.navigationBars["Account"].exists)
+}
+```
+
+### Form Validation Template
+
+```swift
+func testFormValidation() throws {
+    let app = XCUIApplication()
+    app.launch()
+
+    // Submit empty form
+    app.buttons["submitButton"].tap()
+
+    // Verify error appears
+    let errorAlert = app.alerts["Error"]
+    XCTAssertTrue(errorAlert.waitForExistence(timeout: 5))
+    XCTAssertTrue(errorAlert.staticTexts["Please fill all fields"].exists)
+
+    // Dismiss alert
+    errorAlert.buttons["OK"].tap()
+}
+```
+
+## Troubleshooting
+
+### Recording Doesn't Start
+
+1. Ensure you're in a test method
+2. Check simulator is available
+3. Verify app builds and runs
+4. Try restarting Xcode
+
+### Recorded Code Doesn't Work
+
+1. **Add waitForExistence** before interactions
+2. **Check accessibility identifiers** are set
+3. **Simplify queries** to shortest form
+4. **Run app manually** to verify flow works
+
+### Tests Pass Locally, Fail in CI
+
+1. **Increase timeouts** for slower CI machines
+2. **Add explicit waits** for animations
+3. **Check simulator configuration** matches
+4. **Disable animations** in test setup:
+   ```swift
+   app.launchArguments = ["--disable-animations"]
+   ```
+
+## Anti-Patterns
+
+### Don't Use Raw Recorded Code in CI
+
+```swift
+// BAD - Raw recorded code
+app.buttons["Login"].tap()
+app.textFields["Email"].typeText("user@example.com")
+
+// GOOD - Enhanced for CI
+let loginButton = app.buttons["loginButton"]
+XCTAssertTrue(loginButton.waitForExistence(timeout: 10))
+loginButton.tap()
+```
+
+### Don't Hardcode Coordinates
+
+```swift
+// BAD - Coordinates from recording
+app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
+
+// GOOD - Use element queries
+app.buttons["centerButton"].tap()
+```
+
+### Don't Skip Assertions
+
+```swift
+// BAD - Actions only
+app.buttons["Login"].tap()
+sleep(2)  // Hope it works
+
+// GOOD - Verify outcomes
+app.buttons["loginButton"].tap()
+XCTAssertTrue(app.staticTexts["Welcome"].waitForExistence(timeout: 10))
+```
+
+## Resources
+
+**WWDC**: 2025-344, 2024-10206, 2019-413
+
+**Docs**: /xcode/testing/recording-ui-tests, /xctest/xcuiapplication
+
+**Skills**: axiom-xctest-automation, axiom-ui-testing
diff --git a/.claude/skills/axiom-ui-recording/agents/openai.yaml b/.claude/skills/axiom-ui-recording/agents/openai.yaml
new file mode 100644
index 0000000..c4430be
--- /dev/null
+++ b/.claude/skills/axiom-ui-recording/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "Ui Recording"
+  short_description: "Setting up UI test recording in Xcode 26, enhancing recorded tests for stability, or configuring test plans for multi..."
diff --git a/.claude/skills/axiom-ui-testing/.openskills.json b/.claude/skills/axiom-ui-testing/.openskills.json
new file mode 100644
index 0000000..eee52f4
--- /dev/null
+++ b/.claude/skills/axiom-ui-testing/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-ui-testing",
+  "installedAt": "2026-04-12T08:06:55.260Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ui-testing/SKILL.md b/.claude/skills/axiom-ui-testing/SKILL.md
new file mode 100644
index 0000000..a2503c0
--- /dev/null
+++ b/.claude/skills/axiom-ui-testing/SKILL.md
@@ -0,0 +1,1163 @@
+---
+name: axiom-ui-testing
+description: Use when writing UI tests, recording interactions, tests have race conditions, timing dependencies, inconsistent pass/fail behavior, or XCTest UI tests are flaky - covers Recording UI Automation (WWDC 2025), condition-based waiting, network conditioning, multi-factor testing, crash debugging, and accessibility-first testing patterns
+license: MIT
+metadata:
+  version: "2.1.0"
+  last-updated: "WWDC 2025 (Updated with production debugging patterns)"
+---
+
+# UI Testing
+
+## Overview
+
+Wait for conditions, not arbitrary timeouts. **Core principle** Flaky tests come from guessing how long operations take. Condition-based waiting eliminates race conditions.
+
+**NEW in WWDC 2025**: Recording UI Automation allows you to record interactions, replay across devices/languages, and review video recordings of test runs.
+
+## Example Prompts
+
+These are real questions developers ask that this skill is designed to answer:
+
+#### 1. "My UI tests pass locally on my Mac but fail in CI. How do I make them more reliable?"
+→ The skill shows condition-based waiting patterns that work across devices/speeds, eliminating CI timing differences
+
+#### 2. "My tests use sleep(2) and sleep(5) but they're still flaky. How do I replace arbitrary timeouts with real conditions?"
+→ The skill demonstrates waitForExistence, XCTestExpectation, and polling patterns for data loads, network requests, and animations
+
+#### 3. "I just recorded a test using Xcode 26's Recording UI Automation. How do I review the video and debug failures?"
+→ The skill covers Video Debugging workflows to analyze recordings and find the exact step where tests fail
+
+#### 4. "My test is failing on iPad but passing on iPhone. How do I write tests that work across all device sizes?"
+→ The skill explains multi-factor testing strategies and device-independent predicates for robust cross-device testing
+
+#### 5. "I want to write tests that are not flaky. What are the critical patterns I need to know?"
+→ The skill provides condition-based waiting templates, accessibility-first patterns, and the decision tree for reliable test architecture
+
+---
+
+## Red Flags — Test Reliability Issues
+
+If you see ANY of these, suspect timing issues:
+- Tests pass locally, fail in CI (timing differences)
+- Tests sometimes pass, sometimes fail (race conditions)
+- Tests use `sleep()` or `Thread.sleep()` (arbitrary delays)
+- Tests fail with "UI element not found" then pass on retry
+- Long test runs (waiting for worst-case scenarios)
+
+## Quick Decision Tree
+
+```
+Test failing?
+├─ Element not found?
+│  └─ Use waitForExistence(timeout:) not sleep()
+├─ Passes locally, fails CI?
+│  └─ Replace sleep() with condition polling
+├─ Animation causing issues?
+│  └─ Wait for animation completion, don't disable
+└─ Network request timing?
+   └─ Use XCTestExpectation or waitForExistence
+```
+
+## Core Pattern: Condition-Based Waiting
+
+**❌ WRONG (Arbitrary Timeout)**:
+```swift
+func testButtonAppears() {
+    app.buttons["Login"].tap()
+    sleep(2)  // ❌ Guessing it takes 2 seconds
+    XCTAssertTrue(app.buttons["Dashboard"].exists)
+}
+```
+
+**✅ CORRECT (Wait for Condition)**:
+```swift
+func testButtonAppears() {
+    app.buttons["Login"].tap()
+    let dashboard = app.buttons["Dashboard"]
+    XCTAssertTrue(dashboard.waitForExistence(timeout: 5))
+}
+```
+
+## Common UI Testing Patterns
+
+### Pattern 1: Waiting for Elements
+
+```swift
+// Wait for element to appear
+func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool {
+    return element.waitForExistence(timeout: timeout)
+}
+
+// Usage
+XCTAssertTrue(waitForElement(app.buttons["Submit"]))
+```
+
+### Pattern 2: Waiting for Element to Disappear
+
+```swift
+func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool {
+    let predicate = NSPredicate(format: "exists == false")
+    let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
+    let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
+    return result == .completed
+}
+
+// Usage
+XCTAssertTrue(waitForElementToDisappear(app.activityIndicators["Loading"]))
+```
+
+### Pattern 3: Waiting for Specific State
+
+```swift
+func waitForButton(_ button: XCUIElement, toBeEnabled enabled: Bool, timeout: TimeInterval = 5) -> Bool {
+    let predicate = NSPredicate(format: "isEnabled == %@", NSNumber(value: enabled))
+    let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button)
+    let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
+    return result == .completed
+}
+
+// Usage
+let submitButton = app.buttons["Submit"]
+XCTAssertTrue(waitForButton(submitButton, toBeEnabled: true))
+submitButton.tap()
+```
+
+### Pattern 4: Accessibility Identifiers
+
+**Set in app**:
+```swift
+Button("Submit") {
+    // action
+}
+.accessibilityIdentifier("submitButton")
+```
+
+**Use in tests**:
+```swift
+func testSubmitButton() {
+    let submitButton = app.buttons["submitButton"]  // Uses identifier, not label
+    XCTAssertTrue(submitButton.waitForExistence(timeout: 5))
+    submitButton.tap()
+}
+```
+
+**Why**: Accessibility identifiers don't change with localization, remain stable across UI updates.
+
+### Pattern 5: Network Request Delays
+
+```swift
+func testDataLoads() {
+    app.buttons["Refresh"].tap()
+
+    // Wait for loading indicator to disappear
+    let loadingIndicator = app.activityIndicators["Loading"]
+    XCTAssertTrue(waitForElementToDisappear(loadingIndicator, timeout: 10))
+
+    // Now verify data loaded
+    XCTAssertTrue(app.cells.count > 0)
+}
+```
+
+### Pattern 6: Animation Handling
+
+```swift
+func testAnimatedTransition() {
+    app.buttons["Next"].tap()
+
+    // Wait for destination view to appear
+    let destinationView = app.otherElements["DestinationView"]
+    XCTAssertTrue(destinationView.waitForExistence(timeout: 2))
+
+    // Optional: Wait a bit more for animation to settle
+    // Only if absolutely necessary
+    RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.3))
+}
+```
+
+## Testing Checklist
+
+### Before Writing Tests
+- [ ] Use accessibility identifiers for all interactive elements
+- [ ] Avoid hardcoded labels (use identifiers instead)
+- [ ] Plan for network delays and animations
+- [ ] Choose appropriate timeouts (2s UI, 10s network)
+
+### When Writing Tests
+- [ ] Use `waitForExistence()` not `sleep()`
+- [ ] Use predicates for complex conditions
+- [ ] Test both success and failure paths
+- [ ] Make tests independent (can run in any order)
+
+### After Writing Tests
+- [ ] Run tests 10 times locally (catch flakiness)
+- [ ] Run tests on slowest supported device
+- [ ] Run tests in CI environment
+- [ ] Check test duration (if >30s per test, optimize)
+
+## Xcode UI Testing Tips
+
+### Launch Arguments for Testing
+
+```swift
+func testExample() {
+    let app = XCUIApplication()
+    app.launchArguments = ["UI-Testing"]
+    app.launch()
+}
+```
+
+In app code:
+```swift
+if ProcessInfo.processInfo.arguments.contains("UI-Testing") {
+    // Use mock data, skip onboarding, etc.
+}
+```
+
+### Faster Test Execution
+
+```swift
+override func setUpWithError() throws {
+    continueAfterFailure = false  // Stop on first failure
+}
+```
+
+### Debugging Failing Tests
+
+```swift
+func testExample() {
+    // Take screenshot on failure
+    addUIInterruptionMonitor(withDescription: "Alert") { alert in
+        alert.buttons["OK"].tap()
+        return true
+    }
+
+    // Print element hierarchy
+    print(app.debugDescription)
+}
+```
+
+## Common Mistakes
+
+### ❌ Using sleep() for Everything
+```swift
+sleep(5)  // ❌ Wastes time if operation completes in 1s
+```
+
+### ❌ Not Handling Animations
+```swift
+app.buttons["Next"].tap()
+XCTAssertTrue(app.buttons["Back"].exists)  // ❌ May fail during animation
+```
+
+### ❌ Hardcoded Text Labels
+```swift
+app.buttons["Submit"].tap()  // ❌ Breaks with localization
+```
+
+### ❌ Tests Depend on Each Other
+```swift
+// ❌ Test 2 assumes Test 1 ran first
+func test1_Login() { /* ... */ }
+func test2_ViewDashboard() { /* assumes logged in */ }
+```
+
+### ❌ No Timeout Strategy
+```swift
+element.waitForExistence(timeout: 100)  // ❌ Too long
+element.waitForExistence(timeout: 0.1)  // ❌ Too short
+```
+
+**Use appropriate timeouts**:
+- UI animations: 2-3 seconds
+- Network requests: 10 seconds
+- Complex operations: 30 seconds max
+
+## Real-World Impact
+
+**Before** (using sleep()):
+- Test suite: 15 minutes (waiting for worst-case)
+- Flaky tests: 20% failure rate
+- CI failures: 50% require retry
+
+**After** (condition-based waiting):
+- Test suite: 5 minutes (waits only as needed)
+- Flaky tests: <2% failure rate
+- CI failures: <5% require retry
+
+**Key insight** Tests finish faster AND are more reliable when waiting for actual conditions instead of guessing times.
+
+---
+
+## Recording UI Automation
+
+### Overview
+
+**NEW in Xcode 26**: Record, replay, and review UI automation tests with video recordings.
+
+**Three Phases**:
+1. **Record** — Capture interactions (taps, swipes, hardware button presses) as Swift code
+2. **Replay** — Run across multiple devices, languages, regions, orientations
+3. **Review** — Watch video recordings, analyze failures, view UI element overlays
+
+**Supported Platforms**: iOS, iPadOS, macOS, watchOS, tvOS, axiom-visionOS (Designed for iPad)
+
+### How UI Automation Works
+
+**Key Principles**:
+- UI automation interacts with your app **as a person does** using gestures and hardware events
+- Runs **completely independently** from your app (app models/data not directly accessible)
+- Uses **accessibility framework** as underlying technology
+- Tells OS which gestures to perform, then waits for completion **synchronously** one at a time
+
+**Actions include**:
+- Launching your app
+- Interacting with buttons and navigation
+- Setting system state (Dark Mode, axiom-localization, etc.)
+- Setting simulated location
+
+### Accessibility is the Foundation
+
+**Critical Understanding**: Accessibility provides information directly to UI automation.
+
+What accessibility sees:
+- Element types (button, text, image, etc.)
+- Labels (visible text)
+- Values (current state for checkboxes, etc.)
+- Frames (element positions)
+- **Identifiers** (accessibility identifiers — NOT localized)
+
+**Best Practice**: Great accessibility experience = great UI automation experience.
+
+### Preparing Your App for Recording
+
+#### Step 1: Add Accessibility Identifiers
+
+**SwiftUI**:
+```swift
+Button("Submit") {
+    // action
+}
+.accessibilityIdentifier("submitButton")
+
+// Make identifiers specific to instance
+List(landmarks) { landmark in
+    LandmarkRow(landmark)
+        .accessibilityIdentifier("landmark-\(landmark.id)")
+}
+```
+
+**UIKit**:
+```swift
+let button = UIButton()
+button.accessibilityIdentifier = "submitButton"
+
+// Use index for table cells
+cell.accessibilityIdentifier = "cell-\(indexPath.row)"
+```
+
+**Good identifiers are**:
+- ✅ Unique within entire app
+- ✅ Descriptive of element contents
+- ✅ Static (don't react to content changes)
+- ✅ Not localized (same across languages)
+
+**Why identifiers matter**:
+- Titles/descriptions may change, identifiers remain stable
+- Work across localized strings
+- Uniquely identify elements with dynamic content
+
+**Pro Tip**: Use Xcode coding assistant to add identifiers:
+```
+Prompt: "Add accessibility identifiers to the relevant parts of this view"
+```
+
+#### Step 2: Review Accessibility with Accessibility Inspector
+
+**Launch Accessibility Inspector**:
+- Xcode menu → Open Developer Tool → Accessibility Inspector
+- Or: Launch from Spotlight
+
+**Features**:
+1. **Element Inspector** — List accessibility values for any view
+2. **Property details** — Click property name for documentation
+3. **Platform support** — Works on all Apple platforms
+
+**What to check**:
+- Elements have labels
+- Interactive elements have types (button, not just text)
+- Values set for stateful elements (checkboxes, toggles)
+- Identifiers set for elements with dynamic/localized content
+
+**Sample Code Reference**: [Delivering an exceptional accessibility experience](https://developer.apple.com/documentation/accessibility/delivering_an_exceptional_accessibility_experience)
+
+#### Step 3: Add UI Testing Target
+
+1. Open project settings in Xcode
+2. Click "+" below targets list
+3. Select **UI Testing Bundle**
+4. Click Finish
+
+**Result**: New UI test folder with template tests added to project.
+
+### Recording Interactions
+
+#### Starting a Recording (Xcode 26)
+
+1. Open UI test source file
+2. **Popover appears** explaining how to start recording (first time only)
+3. Click **"Start Recording"** button in editor gutter
+4. Xcode builds and launches app in Simulator/device
+
+**During Recording**:
+- Interact with app normally (taps, swipes, text entry, etc.)
+- Code representing interactions appears in source editor in real-time
+- Recording updates as you type (e.g., text field entries)
+
+**Stopping Recording**:
+- Click **"Stop Run"** button in Xcode
+
+#### Example Recording Session
+
+```swift
+func testCreateAustralianCollection() {
+    let app = XCUIApplication()
+    app.launch()
+
+    // Tap "Collections" tab (recorded automatically)
+    app.tabBars.buttons["Collections"].tap()
+
+    // Tap "+" to add new collection
+    app.navigationBars.buttons["Add"].tap()
+
+    // Tap "Edit" button
+    app.buttons["Edit"].tap()
+
+    // Type collection name
+    app.textFields.firstMatch.tap()
+    app.textFields.firstMatch.typeText("Max's Australian Adventure")
+
+    // Tap "Edit Landmarks"
+    app.buttons["Edit Landmarks"].tap()
+
+    // Add landmarks
+    app.tables.cells.containing(.staticText, identifier:"Great Barrier Reef").buttons["Add"].tap()
+    app.tables.cells.containing(.staticText, identifier:"Uluru").buttons["Add"].tap()
+
+    // Tap checkmark to save
+    app.navigationBars.buttons["Done"].tap()
+}
+```
+
+#### Reviewing Recorded Code
+
+After recording, **review and adjust queries**:
+
+**Multiple Options**: Each line has dropdown showing alternative ways to address element.
+
+**Selection Recommendations**:
+1. **For localized strings** (text, button labels): Choose accessibility identifier if available
+2. **For deeply nested views**: Choose shortest query (stays resilient as app changes)
+3. **For dynamic content** (timestamps, temperature): Use generic query or identifier
+
+**Example**:
+```swift
+// Recorded options for text field:
+app.textFields["Collection Name"]              // ❌ Breaks if label localizes
+app.textFields["collectionNameField"]          // ✅ Uses identifier
+app.textFields.element(boundBy: 0)             // ✅ Position-based
+app.textFields.firstMatch                      // ✅ Generic, shortest
+```
+
+**Choose shortest, most stable query** for your needs.
+
+### Adding Validations
+
+After recording, **add assertions** to verify expected behavior:
+
+#### Wait for Existence
+
+```swift
+// Validate collection created
+let collection = app.buttons["Max's Australian Adventure"]
+XCTAssertTrue(collection.waitForExistence(timeout: 5))
+```
+
+#### Wait for Property Changes
+
+```swift
+// Wait for button to become enabled
+let submitButton = app.buttons["Submit"]
+XCTAssertTrue(submitButton.wait(for: .enabled, toEqual: true, timeout: 5))
+```
+
+#### Combine with XCTAssert
+
+```swift
+// Fail test if element doesn't appear
+let landmark = app.staticTexts["Great Barrier Reef"]
+XCTAssertTrue(landmark.waitForExistence(timeout: 5), "Landmark should appear in collection")
+```
+
+### Advanced Automation APIs
+
+#### Setup Device State
+
+```swift
+override func setUpWithError() throws {
+    let app = XCUIApplication()
+
+    // Set device orientation
+    XCUIDevice.shared.orientation = .landscapeLeft
+
+    // Set appearance mode
+    app.launchArguments += ["-UIUserInterfaceStyle", "dark"]
+
+    // Simulate location
+    let location = XCUILocation(location: CLLocation(latitude: 37.7749, longitude: -122.4194))
+    app.launchArguments += ["-SimulatedLocation", location.description]
+
+    app.launch()
+}
+```
+
+#### Launch Arguments & Environment
+
+```swift
+func testWithMockData() {
+    let app = XCUIApplication()
+
+    // Pass arguments to app
+    app.launchArguments = ["-UI-Testing", "-UseMockData"]
+
+    // Set environment variables
+    app.launchEnvironment = ["API_URL": "https://mock.api.com"]
+
+    app.launch()
+}
+```
+
+In app code:
+```swift
+if ProcessInfo.processInfo.arguments.contains("-UI-Testing") {
+    // Use mock data, skip onboarding
+}
+```
+
+#### Custom URL Schemes
+
+```swift
+// Open app to specific URL
+let app = XCUIApplication()
+app.open(URL(string: "myapp://landmark/123")!)
+
+// Open URL with system default app (global version)
+XCUIApplication.open(URL(string: "https://example.com")!)
+```
+
+#### Accessibility Audits in Tests
+
+```swift
+func testAccessibility() throws {
+    let app = XCUIApplication()
+    app.launch()
+
+    // Perform accessibility audit
+    try app.performAccessibilityAudit()
+}
+```
+
+**Reference**: [Perform accessibility audits for your app — WWDC23](https://developer.apple.com/videos/play/wwdc2023/10035/)
+
+### Test Plans for Multiple Configurations
+
+**Test Plans** let you:
+- Include/exclude individual tests
+- Set system settings (language, region, appearance)
+- Configure test properties (timeouts, repetitions, parallelization)
+- Associate with schemes for specific build settings
+
+#### Creating Test Plan
+
+1. Create new or use existing test plan
+2. Add/remove tests on first screen
+3. Switch to **Configurations** tab
+
+#### Adding Multiple Languages
+
+```
+Configurations:
+├─ English
+├─ German (longer strings)
+├─ Arabic (right-to-left)
+└─ Hebrew (right-to-left)
+```
+
+**Each locale** = separate configuration in test plan.
+
+**Settings**:
+- Focused for specific locale
+- Shared across all configurations
+
+#### Video & Screenshot Capture
+
+**In Configurations tab**:
+- **Capture screenshots**: On/Off
+- **Capture video**: On/Off
+- **Keep media**: "Only failures" or "On, and keep all"
+
+**Defaults**: Videos/screenshots kept only for failing runs (for review).
+
+**"On, and keep all" use cases**:
+- Documentation
+- Tutorials
+- Marketing materials
+
+**Reference**: [Author fast and reliable tests for Xcode Cloud — WWDC22](https://developer.apple.com/videos/play/wwdc2022/110371/)
+
+### Replaying Tests in Xcode Cloud
+
+**Xcode Cloud** = built-in service for:
+- Building app
+- Running tests
+- Uploading to App Store
+- All in cloud without using team devices
+
+**Workflow configuration**:
+- Same test plan used locally
+- Runs on multiple devices and configurations
+- Videos/results available in App Store Connect
+
+**Viewing Results**:
+- Xcode: Xcode Cloud section
+- App Store Connect: Xcode Cloud section
+- See build info, logs, failure descriptions, video recordings
+
+**Team Access**: Entire team can see run history and download results/videos.
+
+**Reference**: [Create practical workflows in Xcode Cloud — WWDC23](https://developer.apple.com/videos/play/wwdc2023/10269/)
+
+### Reviewing Test Results with Videos
+
+#### Accessing Test Report
+
+1. Click **Test** button in Xcode
+2. Double-click failing run to see video + description
+
+**Features**:
+- **Runs dropdown** — Switch between video recordings of different configurations (languages, devices)
+- **Save video** — Secondary click → Save
+- **Play/pause** — Video playback with UI interaction overlays
+- **Timeline dots** — UI interactions shown as dots on timeline
+- **Jump to failure** — Click failure diamond on timeline
+
+#### UI Element Overlay at Failure
+
+**At moment of failure**:
+- Click timeline failure point
+- **Overlay shows all UI elements** present on screen
+- Click any element to see code recommendations for addressing it
+- **Show All** — See alternative examples
+
+**Workflow**:
+1. Identify what was actually present (vs what test expected)
+2. Click element to get query code
+3. Secondary click → Copy code
+4. **View Source** → Go directly to test
+5. Paste corrected code
+
+**Example**:
+```swift
+// Test expected:
+let button = app.buttons["Max's Australian Adventure"]
+
+// But overlay shows it's actually text, not button:
+let text = app.staticTexts["Max's Australian Adventure"] // ✅ Correct
+```
+
+#### Running Test in Different Language
+
+Click test diamond → Select configuration (e.g., Arabic) → Watch automation run in right-to-left layout.
+
+**Validates**: Same automation works across languages/layouts.
+
+**Reference**: [Fix failures faster with Xcode test reports — WWDC23](https://developer.apple.com/videos/play/wwdc2023/10175/)
+
+### Recording UI Automation Checklist
+
+#### Before Recording
+- [ ] Add accessibility identifiers to interactive elements
+- [ ] Review app with Accessibility Inspector
+- [ ] Add UI Testing Bundle target to project
+- [ ] Plan workflow to record (user journey)
+
+#### During Recording
+- [ ] Interact naturally with app
+- [ ] Record complete user journeys (not individual taps)
+- [ ] Check code generates as you interact
+- [ ] Stop recording when workflow complete
+
+#### After Recording
+- [ ] Review recorded code options (dropdown on each line)
+- [ ] Choose stable queries (identifiers > labels)
+- [ ] Add validations (waitForExistence, XCTAssert)
+- [ ] Add setup code (device state, launch arguments)
+- [ ] Run test to verify it passes
+
+#### Test Plan Configuration
+- [ ] Create/update test plan
+- [ ] Add multiple language configurations
+- [ ] Include right-to-left languages (Arabic, Hebrew)
+- [ ] Configure video/screenshot capture settings
+- [ ] Set appropriate timeouts for network tests
+
+#### Running & Reviewing
+- [ ] Run test locally across configurations
+- [ ] Review video recordings for failures
+- [ ] Use UI element overlay to debug failures
+- [ ] Run in Xcode Cloud for team visibility
+- [ ] Download and share videos if needed
+
+## Network Conditioning in Tests
+
+### Overview
+
+UI tests can pass on fast networks but fail on 3G/LTE. **Network Link Conditioner** simulates real-world network conditions to catch timing-sensitive crashes.
+
+**Critical scenarios**:
+- ❌ iPad Pro over Wi-Fi (fast) → pass
+- ❌ iPad Pro over 3G (slow) → crash
+- ✅ Test both to catch device-specific failures
+
+### Setup Network Link Conditioner
+
+**Install Network Link Conditioner**:
+1. Download from [Apple's Additional Tools for Xcode](https://developer.apple.com/download/all/)
+2. Search: "Network Link Conditioner"
+3. Install: `sudo open Network\ Link\ Conditioner.pkg`
+
+**Verify Installation**:
+```bash
+# Check if installed
+ls ~/Library/Application\ Support/Network\ Link\ Conditioner/
+```
+
+**Enable in Tests**:
+```swift
+override func setUpWithError() throws {
+    let app = XCUIApplication()
+
+    // Launch with network conditioning argument
+    app.launchArguments = ["-com.apple.CoreSimulator.CoreSimulatorService", "-networkShaping"]
+    app.launch()
+}
+```
+
+### Common Network Profiles
+
+**3G Profile** (most failures occur here):
+```swift
+override func setUpWithError() throws {
+    let app = XCUIApplication()
+
+    // Simulate 3G (type in launch arguments)
+    app.launchEnvironment = [
+        "SIMULATOR_UDID": ProcessInfo.processInfo.environment["SIMULATOR_UDID"] ?? "",
+        "NETWORK_PROFILE": "3G"
+    ]
+    app.launch()
+}
+```
+
+**Manual Network Conditioning** (macOS System Preferences):
+1. Open System Preferences → Network
+2. Click "Network Link Conditioner" (installed above)
+3. Select profile: 3G, LTE, WiFi
+4. Click "Start"
+5. Run tests (they'll use throttled network)
+
+### Real-World Example: Photo Upload with Network Throttling
+
+**❌ Without Network Conditioning**:
+```swift
+func testPhotoUpload() {
+    app.buttons["Upload Photo"].tap()
+
+    // Passes locally (fast network)
+    XCTAssertTrue(app.staticTexts["Upload complete"].waitForExistence(timeout: 5))
+}
+// ✅ Passes locally, ❌ FAILS on 3G with timeout
+```
+
+**✅ With Network Conditioning**:
+```swift
+func testPhotoUploadOn3G() {
+    let app = XCUIApplication()
+    // Network Link Conditioner running (3G profile)
+    app.launch()
+
+    app.buttons["Upload Photo"].tap()
+
+    // Increase timeout for 3G
+    XCTAssertTrue(app.staticTexts["Upload complete"].waitForExistence(timeout: 30))
+
+    // Verify no crash occurred
+    XCTAssertFalse(app.alerts.element.exists, "App should not crash on 3G")
+}
+```
+
+**Key differences**:
+- Longer timeout (30s instead of 5s)
+- Check for crashes
+- Run on slowest expected network
+
+---
+
+## Multi-Factor Testing: Device Size + Network Speed
+
+### The Problem
+
+Tests can pass on device A but fail on device B due to layout differences + network delays. **Multi-factor testing** catches these combinations.
+
+**Common failure patterns**:
+- ✅ iPhone 14 Pro (compact, fast network)
+- ❌ iPad Pro 12.9 (large, 3G network) → crashes
+- ✅ iPhone 15 (compact, LTE)
+- ❌ iPhone 12 (older GPU, 3G) → timeout
+
+### Test Plan Configuration for Multiple Devices
+
+**Create Test Plan in Xcode**:
+1. File → New → Test Plan
+2. Select tests to include
+3. Click "Configurations" tab
+4. Add configurations for each device/network combo
+
+**Example Configuration Matrix**:
+```
+Configurations:
+├─ iPhone 14 Pro + LTE
+├─ iPhone 14 Pro + 3G
+├─ iPad Pro 12.9 + LTE
+├─ iPad Pro 12.9 + 3G  (⚠️ Most failures here)
+└─ iPhone 12 + 3G      (⚠️ Older device)
+```
+
+**In Test Plan UI**:
+- Device: iPhone 14 Pro / iPad Pro 12.9
+- OS Version: Latest
+- Locale: English
+- Network Profile: LTE / 3G
+
+### Programmatic Device-Specific Testing
+
+```swift
+import XCTest
+
+final class MultiFactorUITests: XCTestCase {
+    var deviceModel: String { UIDevice.current.model }
+
+    override func setUpWithError() throws {
+        let app = XCUIApplication()
+        app.launch()
+
+        // Adjust timeouts based on device
+        switch deviceModel {
+        case "iPad" where UIScreen.main.bounds.width > 1000:
+            // iPad Pro - larger layout, slower rendering
+            app.launchEnvironment["TEST_TIMEOUT"] = "30"
+        case "iPhone":
+            // iPhone - compact, standard timeout
+            app.launchEnvironment["TEST_TIMEOUT"] = "10"
+        default:
+            app.launchEnvironment["TEST_TIMEOUT"] = "15"
+        }
+    }
+
+    func testListLoadingAcrossDevices() {
+        let app = XCUIApplication()
+        let timeout = Double(app.launchEnvironment["TEST_TIMEOUT"] ?? "10") ?? 10
+
+        app.buttons["Refresh"].tap()
+
+        // Wait for list to load (timeout varies by device)
+        XCTAssertTrue(
+            app.tables.cells.count > 0,
+            "List should load on \(deviceModel)"
+        )
+
+        // Verify no crashes
+        XCTAssertFalse(app.alerts.element.exists)
+    }
+}
+```
+
+### Real-World Example: iPad Pro + 3G Crash
+
+**Scenario**: App works on iPhone 14, crashes on iPad Pro over 3G.
+
+**Why it crashes**:
+1. iPad Pro has larger layout (landscape)
+2. 3G network is slow (latency 100ms+)
+3. Images don't load in time, layout engine crashes
+4. Single-device testing misses this combo
+
+**Test that catches it**:
+```swift
+func testLargeLayoutOn3G() {
+    let app = XCUIApplication()
+    // Running with Network Link Conditioner on 3G profile
+    app.launch()
+
+    // iPad Pro: Large grid of images
+    app.buttons["Browse"].tap()
+
+    // Wait longer for images on slow network
+    let firstImage = app.images["photoGrid-0"]
+    XCTAssertTrue(
+        firstImage.waitForExistence(timeout: 20),
+        "First image must load on slow network"
+    )
+
+    // Verify grid loaded without crash
+    let loadedCount = app.images.matching(identifier: NSPredicate(format: "identifier BEGINSWITH 'photoGrid'")).count
+    XCTAssertGreater(loadedCount, 5, "Multiple images should load on 3G")
+
+    // No alerts (no crashes)
+    XCTAssertFalse(app.alerts.element.exists, "App should not crash on large device + slow network")
+}
+```
+
+### Running Multi-Factor Tests in CI
+
+**In GitHub Actions or Xcode Cloud**:
+```yaml
+- name: Run tests across devices
+  run: |
+    xcodebuild -scheme MyApp \
+      -testPlan MultiDeviceTestPlan \
+      test
+```
+
+**Test Plan runs on**:
+- iPhone 14 Pro + LTE
+- iPhone 14 Pro + 3G
+- iPad Pro + LTE
+- iPad Pro + 3G
+
+**Result**: Catch device-specific crashes before App Store submission.
+
+---
+
+## Debugging Crashes Revealed by UI Tests
+
+### Overview
+
+UI tests sometimes reveal crashes that don't happen in manual testing. **Key insight** Automated tests run faster, interact with app differently, and can expose concurrency/timing bugs.
+
+**When crashes happen**:
+- ❌ Manual testing: Can't reproduce (works when you run it)
+- ✅ UI Test: Crashes every time (automated repetition finds race condition)
+
+### Recognizing Test-Revealed Crashes
+
+**Signs in test output**:
+```
+Failing test: testPhotoUpload
+Error: The app crashed while responding to a UI event
+App died from an uncaught exception
+Stack trace: [EXC_BAD_ACCESS in PhotoViewController]
+```
+
+**Video shows**: App visibly crashes (black screen, immediate termination).
+
+### Systematic Debugging Approach
+
+#### Step 1: Capture Crash Details
+
+**Enable detailed logging**:
+```swift
+override func setUpWithError() throws {
+    let app = XCUIApplication()
+
+    // Enable all logging
+    app.launchEnvironment = [
+        "OS_ACTIVITY_MODE": "debug",
+        "DYLD_PRINT_STATISTICS": "1"
+    ]
+
+    // Enable test diagnostics
+    if #available(iOS 17, *) {
+        let options = XCUIApplicationLaunchOptions()
+        options.captureRawLogs = true
+        app.launch(options)
+    } else {
+        app.launch()
+    }
+}
+```
+
+#### Step 2: Reproduce Locally
+
+```swift
+func testReproduceCrash() {
+    let app = XCUIApplication()
+    app.launch()
+
+    // Run exact sequence that causes crash
+    app.buttons["Browse"].tap()
+    app.buttons["Photo Album"].tap()
+    app.buttons["Select All"].tap()
+    app.buttons["Upload"].tap()
+
+    // Should crash here
+    let uploadButton = app.buttons["Upload"]
+    XCTAssertFalse(uploadButton.exists, "App crashed (expected)")
+
+    // Don't assert - just let it crash and read logs
+}
+```
+
+**Run test with Console logs visible**:
+- Xcode: View → Navigators → Show Console
+- Watch for exception messages
+
+#### Step 3: Analyze Crash Logs
+
+**Locations**:
+1. Xcode Console (real-time, less detail)
+2. ~/Library/Logs/DiagnosticMessages/crash_*.log (full details)
+3. Device Settings → Privacy → Analytics → Analytics Data
+
+**Look for**:
+- Thread that crashed
+- Exception type (EXC_BAD_ACCESS, EXC_CRASH, etc.)
+- Stack trace showing which method crashed
+
+**Example crash log**:
+```
+Exception Type: EXC_BAD_ACCESS (SIGSEGV)
+Exception Codes: KERN_INVALID_ADDRESS at 0x0000000000000000
+Thread 0 Crashed:
+0  MyApp    0x0001a234 -[PhotoViewController reloadPhotos:] + 234
+1  MyApp    0x0001a123 -[PhotoViewController viewDidLoad] + 180
+```
+
+**This tells us**:
+- Crash in `PhotoViewController.reloadPhotos(_:)`
+- Likely null pointer dereference
+- Called from `viewDidLoad`
+
+#### Step 4: Connection to Swift Concurrency Issues
+
+**Most UI test crashes are concurrency bugs** (not specific to UI testing). Reference related skills:
+
+```swift
+// Common pattern: Race condition in async image loading
+class PhotoViewController: UIViewController {
+    var photos: [Photo] = []
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+
+        // ❌ WRONG: Accessing photos array from multiple threads
+        Task {
+            let newPhotos = await fetchPhotos()
+            self.photos = newPhotos  // May crash if main thread access
+            reloadPhotos()  // ❌ Crash here
+        }
+    }
+}
+
+// ✅ CORRECT: Ensure main thread
+class PhotoViewController: UIViewController {
+    @MainActor
+    var photos: [Photo] = []
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+
+        Task {
+            let newPhotos = await fetchPhotos()
+            await MainActor.run { [weak self] in
+                self?.photos = newPhotos
+                self?.reloadPhotos()  // ✅ Safe
+            }
+        }
+    }
+}
+```
+
+**For deep crash analysis**: See `axiom-swift-concurrency` skill for @MainActor patterns and `axiom-memory-debugging` skill for thread-safety issues.
+
+#### Step 5: Add Crash-Prevention Tests
+
+**After fixing**:
+```swift
+func testPhotosLoadWithoutCrash() {
+    let app = XCUIApplication()
+    app.launch()
+
+    // Rapid fire interactions that previously caused crash
+    app.buttons["Browse"].tap()
+    app.buttons["Photo Album"].tap()
+
+    // Load should complete without crash
+    let photoGrid = app.otherElements["photoGrid"]
+    XCTAssertTrue(photoGrid.waitForExistence(timeout: 10))
+
+    // No alerts (no crash dialogs)
+    XCTAssertFalse(app.alerts.element.exists)
+}
+```
+
+#### Step 6: Stress Test to Verify Fix
+
+```swift
+func testPhotosLoadUnderStress() {
+    let app = XCUIApplication()
+    app.launch()
+
+    // Repeat the crash-causing action multiple times
+    for iteration in 0..<10 {
+        app.buttons["Browse"].tap()
+
+        // Wait for load
+        let grid = app.otherElements["photoGrid"]
+        XCTAssertTrue(grid.waitForExistence(timeout: 10), "Iteration \(iteration)")
+
+        // Go back
+        app.navigationBars.buttons["Back"].tap()
+        app.buttons["Refresh"].tap()
+    }
+
+    // Completed without crash
+    XCTAssertTrue(true, "Stress test passed")
+}
+```
+
+### Prevention Checklist
+
+#### Before releasing
+- [ ] Run UI tests on slowest network (3G)
+- [ ] Run on largest device (iPad Pro)
+- [ ] Run on oldest supported device (iPhone 12)
+- [ ] Record video of test runs (saves debugging time)
+- [ ] Check for crashes in logs
+- [ ] Run stress tests (10x repeated actions)
+- [ ] Verify @MainActor on UI properties
+- [ ] Check for race conditions in async code
+
+---
+
+## Resources
+
+**WWDC**: 2025-344, 2024-10179, 2023-10175, 2023-10035
+
+**Docs**: /xctest, /xcuiautomation/recording-ui-automation-for-testing, /xctest/xctwaiter, /accessibility/delivering_an_exceptional_accessibility_experience, /accessibility/performing_accessibility_testing_for_your_app
+
+**Note**: This skill focuses on reliability patterns and Recording UI Automation. For TDD workflow, see superpowers:test-driven-development.
+
+---
+
+**History:** See git log for changes
diff --git a/.claude/skills/axiom-ui-testing/agents/openai.yaml b/.claude/skills/axiom-ui-testing/agents/openai.yaml
new file mode 100644
index 0000000..ff556c1
--- /dev/null
+++ b/.claude/skills/axiom-ui-testing/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "Ui Testing"
+  short_description: "Writing UI tests, recording interactions, tests have race conditions, timing dependencies, inconsistent pass/fail beh..."
diff --git a/.claude/skills/axiom-uikit-animation-debugging/.openskills.json b/.claude/skills/axiom-uikit-animation-debugging/.openskills.json
new file mode 100644
index 0000000..146429f
--- /dev/null
+++ b/.claude/skills/axiom-uikit-animation-debugging/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-uikit-animation-debugging",
+  "installedAt": "2026-04-12T08:06:55.655Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-uikit-animation-debugging/SKILL.md b/.claude/skills/axiom-uikit-animation-debugging/SKILL.md
new file mode 100644
index 0000000..eac51d6
--- /dev/null
+++ b/.claude/skills/axiom-uikit-animation-debugging/SKILL.md
@@ -0,0 +1,465 @@
+---
+name: axiom-uikit-animation-debugging
+description: Use when CAAnimation completion handler doesn't fire, spring physics look wrong on device, animation duration mismatches actual time, gesture + animation interaction causes jank, or timing differs between simulator and real hardware - systematic CAAnimation diagnosis with CATransaction patterns, frame rate awareness, and device-specific behavior
+license: MIT
+metadata:
+  version: "1.0.0"
+---
+
+# UIKit Animation Debugging
+
+## Overview
+
+CAAnimation issues manifest as missing completion handlers, wrong timing, or jank under specific conditions. **Core principle** 90% of CAAnimation problems are CATransaction timing, layer state, or frame rate assumptions, not Core Animation bugs.
+
+## Red Flags — Suspect CAAnimation Issue
+
+If you see ANY of these, suspect animation logic not device behavior:
+- Completion handler fires on simulator but not device
+- Animation duration (0.5s) doesn't match visual duration (1.2s)
+- Spring animation looks correct on iPhone 15 Pro but janky on older devices
+- Gesture + animation together causes stuttering (fine separately)
+- `[weak self]` in completion handler and you're not sure why
+- ❌ **FORBIDDEN** Hardcoding duration/values to "match what actually happens"
+  - This ships device-specific bugs to users on different hardware
+  - Do not rationalize this as a "temporary fix" or "good enough"
+
+**Critical distinction** Simulator often hides timing issues (60Hz only, no throttling). Real devices expose them (variable frame rate, CPU throttling, background pressure). **MANDATORY: Test on real device (oldest supported model) before shipping.**
+
+## Mandatory First Steps
+
+**ALWAYS run these FIRST** (before changing code):
+
+```swift
+// 1. Check if completion is firing at all
+animation.completion = { [weak self] finished in
+    print("🔥 COMPLETION FIRED: finished=\(finished)")
+    guard let self = self else {
+        print("🔥 SELF WAS NIL")
+        return
+    }
+    // original code
+}
+
+// 2. Check actual duration vs declared
+let startTime = Date()
+let anim = CABasicAnimation(keyPath: "position.x")
+anim.duration = 0.5  // Declared
+layer.add(anim, forKey: "test")
+
+DispatchQueue.main.asyncAfter(deadline: .now() + 0.51) {
+    print("Elapsed: \(Date().timeIntervalSince(startTime))")  // Actual
+}
+
+// 3. Check what animations are active
+if let keys = layer.animationKeys() {
+    print("Active animations: \(keys)")
+    for key in keys {
+        if let anim = layer.animation(forKey: key) {
+            print("\(key): duration=\(anim.duration), removed=\(anim.isRemovedOnCompletion)")
+        }
+    }
+}
+
+// 4. Check layer state
+print("Layer speed: \(layer.speed)")  // != 1.0 means timing is scaled
+print("Layer timeOffset: \(layer.timeOffset)")  // != 0 means animation is offset
+```
+
+#### What this tells you
+- **Completion print appears** → Handler fires, issue is in callback code
+- **Completion print missing** → Handler not firing, check CATransaction/layer state
+- **Elapsed time == declared** → Duration is correct, visual jank is from frames
+- **Elapsed time != declared** → CATransaction wrapping is changing duration
+- **layer.speed != 1.0** → Something is slowing animation
+- **Active animations list is long** → Multiple animations competing
+
+#### MANDATORY INTERPRETATION
+
+Before changing ANY code, you must identify which ONE diagnostic is the root cause:
+
+1. If completion fires but elapsed time != declared duration → Apply Pattern 2 (CATransaction)
+2. If completion doesn't fire AND isRemovedOnCompletion is true → Apply Pattern 3
+3. If completion fires but visual is janky → **MUST profile with Instruments first**
+   - You cannot guess "it's probably frames" - prove it with data
+   - Profile > Core Animation instrument shows frame drops with certainty
+   - If you skip Instruments, you're guessing
+
+#### If diagnostics are contradictory or unclear
+- STOP. Do NOT proceed to patterns yet
+- Add more print statements to narrow the cause
+- Ask: "The diagnostics show X and Y but Z doesn't match. What am I missing?"
+- Profile with Instruments > Core Animation if unsure
+
+## Decision Tree
+
+```
+CAAnimation problem?
+├─ Completion handler never fires?
+│  ├─ On simulator only?
+│  │  └─ Simulator timing is different (60Hz). Test on real device.
+│  ├─ On real device only?
+│  │  ├─ Check: isRemovedOnCompletion and fillMode
+│  │  ├─ Check: CATransaction wrapping
+│  │  └─ Check: app goes to background during animation
+│  └─ On both simulator and device?
+│     ├─ Check: completion handler is set BEFORE adding animation
+│     └─ Check: [weak self] is actually captured (not nil before completion)
+│
+├─ Duration mismatch (declared != visual)?
+│  ├─ Is layer.speed != 1.0?
+│  │  └─ Something scaled animation duration. Find and fix.
+│  ├─ Is animation wrapped in CATransaction?
+│  │  └─ CATransaction.setAnimationDuration() overrides animation.duration
+│  └─ Is visual duration LONGER than declared?
+│     └─ Simulator (60Hz) vs device frame rate (120Hz). Recalculate for real hardware.
+│
+├─ Spring physics wrong on device?
+│  ├─ Are values hardcoded for one device?
+│  │  └─ Use device performance class, not model
+│  ├─ Are damping/stiffness values swapped with mass/stiffness?
+│  │  └─ Check CASpringAnimation parameter meanings
+│  └─ Does it work on simulator but not device?
+│     └─ Simulator uses 60Hz. Device may use 120Hz. Recalculate.
+│
+└─ Gesture + animation jank?
+   ├─ Are animations competing (same keyPath)?
+   │  └─ Remove old animation before adding new
+   ├─ Is gesture updating layer while animation runs?
+   │  └─ Use CADisplayLink for synchronized updates
+   └─ Is gesture blocking the main thread?
+      └─ Profile with Instruments > Core Animation
+```
+
+## Common Patterns
+
+### Pattern Selection Rules (MANDATORY)
+
+#### Apply ONE pattern at a time, in this order
+
+1. **Always start with Pattern 1** (Completion Handler Basics)
+   - If completion NEVER fires → Pattern 1
+   - Verify completion is set BEFORE add() with print statement (line 33)
+   - Only proceed to Pattern 2 if completion FIRES but timing is wrong
+
+2. **Then Pattern 2** (CATransaction duration mismatch)
+   - Only if completion fires but elapsed time != declared duration
+   - Check logs from Mandatory First Steps (line 40-47)
+
+3. **Then Pattern 3** (isRemovedOnCompletion)
+   - Only if animation completes but visual state reverts
+
+4. **Patterns 4-7** Apply based on specific symptom (see Decision Tree line 91+)
+
+#### FORBIDDEN
+- ❌ Applying multiple patterns at once ("let me try Pattern 2 AND Pattern 4 together")
+- ❌ Skipping Pattern 1 because "I already know it's not that"
+- ❌ Combining patterns without understanding why each is needed
+- ❌ Trying patterns randomly and hoping one works
+
+### Pattern 1: Completion Handler Basics
+
+#### ❌ WRONG (Handler set AFTER adding animation)
+```swift
+layer.add(animation, forKey: "myAnimation")
+animation.completion = { finished in  // ❌ Too late!
+    print("Done")
+}
+```
+
+#### ✅ CORRECT (Handler set BEFORE adding)
+```swift
+animation.completion = { [weak self] finished in
+    print("🔥 Animation finished: \(finished)")
+    guard let self = self else { return }
+    self.doNextStep()
+}
+layer.add(animation, forKey: "myAnimation")
+```
+
+**Why** Completion handler must be set before animation is added to layer. Setting after does nothing.
+
+---
+
+### Pattern 2: CATransaction vs animation.duration
+
+#### ❌ WRONG (CATransaction overrides animation duration)
+```swift
+CATransaction.begin()
+CATransaction.setAnimationDuration(2.0)  // ❌ Overrides all animations!
+let anim = CABasicAnimation(keyPath: "position")
+anim.duration = 0.5  // This is ignored
+layer.add(anim, forKey: nil)
+CATransaction.commit()  // Animation takes 2.0 seconds, not 0.5
+```
+
+#### ✅ CORRECT (Set duration on animation, not transaction)
+```swift
+let anim = CABasicAnimation(keyPath: "position")
+anim.duration = 0.5
+layer.add(anim, forKey: nil)
+// No CATransaction wrapping
+```
+
+**Why** CATransaction.setAnimationDuration() affects ALL animations in the transaction block. Use it only if you want to change all animations uniformly.
+
+---
+
+### Pattern 3: isRemovedOnCompletion & fillMode
+
+#### ❌ WRONG (Animation disappears after completion)
+```swift
+let anim = CABasicAnimation(keyPath: "opacity")
+anim.fromValue = 1.0
+anim.toValue = 0.0
+anim.duration = 0.5
+layer.add(anim, forKey: nil)
+// After 0.5s, animation is removed AND layer reverts to original state
+```
+
+#### ✅ CORRECT (Keep animation state)
+```swift
+anim.isRemovedOnCompletion = false
+anim.fillMode = .forwards  // Keep final state after animation
+layer.add(anim, forKey: nil)
+// After 0.5s, animation state is preserved
+```
+
+**Why** By default, animations are removed and layer reverts. For permanent state changes, set `isRemovedOnCompletion = false` and `fillMode = .forwards`.
+
+---
+
+### Pattern 4: Weak Self in Completion (MANDATORY)
+
+#### ❌ FORBIDDEN (Strong self creates retain cycle)
+```swift
+anim.completion = { finished in
+    self.property = "value"  // ❌ GUARANTEED retain cycle
+}
+```
+
+#### ✅ MANDATORY (Always use weak self)
+```swift
+anim.completion = { [weak self] finished in
+    guard let self = self else { return }
+    self.property = "value"  // Safe to access
+}
+```
+
+#### Why this is MANDATORY, not optional
+- CAAnimation keeps completion handler alive until animation completes
+- Completion handler captures self strongly (unless explicitly weak)
+- Creates retain cycle: self → animation → completion → self
+- Memory leak occurs even if animation is short-lived (0.3s doesn't prevent it)
+
+#### FORBIDDEN rationalizations
+- ❌ "Animation is short, so no retain cycle risk"
+- ❌ "I'll remove the animation manually, so it's fine"
+- ❌ "This code path only runs once"
+
+#### ALWAYS use [weak self] in completion handlers. No exceptions.
+
+---
+
+### Pattern 5: Multiple Animations (Same keyPath)
+
+#### ❌ WRONG (Animations conflict)
+```swift
+// Add animation 1
+let anim1 = CABasicAnimation(keyPath: "position.x")
+anim1.toValue = 100
+layer.add(anim1, forKey: "slide")
+
+// Later, add animation 2
+let anim2 = CABasicAnimation(keyPath: "position.x")
+anim2.toValue = 200
+layer.add(anim2, forKey: "slide")  // ❌ Same key, replaces anim1!
+```
+
+#### ✅ CORRECT (Remove before adding)
+```swift
+layer.removeAnimation(forKey: "slide")  // Remove old first
+
+let anim2 = CABasicAnimation(keyPath: "position.x")
+anim2.toValue = 200
+layer.add(anim2, forKey: "slide")
+```
+
+Or use unique keys:
+```swift
+let anim1 = CABasicAnimation(keyPath: "position.x")
+layer.add(anim1, forKey: "slide_1")
+
+let anim2 = CABasicAnimation(keyPath: "position.x")
+layer.add(anim2, forKey: "slide_2")  // Different key
+```
+
+**Why** Adding animation with same key replaces previous animation. Either remove old animation or use unique keys.
+
+---
+
+### Pattern 6: CADisplayLink for Gesture + Animation Sync
+
+#### ❌ WRONG (Gesture updates directly, animation updates at different rate)
+```swift
+func handlePan(_ gesture: UIPanGestureRecognizer) {
+    let translation = gesture.translation(in: view)
+    view.layer.position.x = translation.x  // ❌ Syncing issue
+}
+
+// Separately:
+let anim = CABasicAnimation(keyPath: "position.x")
+view.layer.add(anim, forKey: nil)  // Jank from desync
+```
+
+#### ✅ CORRECT (Use CADisplayLink for synchronization)
+```swift
+var displayLink: CADisplayLink?
+
+func startSyncedAnimation() {
+    displayLink = CADisplayLink(
+        target: self,
+        selector: #selector(updateAnimation)
+    )
+    displayLink?.add(to: .main, forMode: .common)
+}
+
+@objc func updateAnimation() {
+    // Update gesture AND animation in same frame
+    let gesture = currentGesture
+    let position = calculatePosition(from: gesture)
+    layer.position = position  // Synchronized update
+}
+```
+
+**Why** Gesture recognizer and CAAnimation may run at different frame rates. CADisplayLink syncs both to screen refresh rate.
+
+---
+
+### Pattern 7: Spring Animation Device Differences
+
+#### ❌ WRONG (Hardcoded for one device)
+```swift
+let springAnim = CASpringAnimation()
+springAnim.damping = 0.7  // Hardcoded for iPhone 15 Pro
+springAnim.stiffness = 100
+layer.add(springAnim, forKey: nil)  // Janky on iPhone 12
+```
+
+#### ✅ CORRECT (Adapt to device performance)
+```swift
+let springAnim = CASpringAnimation()
+
+// Use device performance class, not model
+if ProcessInfo.processInfo.processorCount >= 6 {
+    // Modern A-series (A14+)
+    springAnim.damping = 0.7
+    springAnim.stiffness = 100
+} else {
+    // Older A-series
+    springAnim.damping = 0.85
+    springAnim.stiffness = 80
+}
+
+layer.add(springAnim, forKey: nil)
+```
+
+**Why** Spring physics feel different at 60Hz vs 120Hz. Use device class (core count, GPU) not model.
+
+---
+
+## Quick Reference Table
+
+| Issue | Check | Fix |
+|-------|-------|-----|
+| Completion never fires | Set handler BEFORE `add()` | Move `completion =` before `add()` |
+| Duration mismatch | Is CATransaction wrapping? | Remove CATransaction or remove animation from it |
+| Jank on older devices | Is value hardcoded? | Use `ProcessInfo` for device class |
+| Animation disappears | `isRemovedOnCompletion`? | Set to `false`, use `fillMode = .forwards` |
+| Gesture + animation jank | Synced updates? | Use `CADisplayLink` |
+| Multiple animations conflict | Same key? | Use unique keys or `removeAnimation()` first |
+| Weak self in handler | Completion captured correctly? | Always use `[weak self]` in completion |
+
+## When You're Stuck After 30 Minutes
+
+If you've spent >30 minutes and the animation is still broken:
+
+#### STOP. You either
+1. Skipped a mandatory step (most common)
+2. Misinterpreted diagnostic output
+3. Applied wrong pattern for your symptom
+4. Are in the 5% edge case requiring Instruments profiling
+
+#### MANDATORY checklist before claiming "skill didn't work"
+
+- [ ] I ran ALL 4 diagnostic blocks from Mandatory First Steps (lines 28-63)
+- [ ] I pasted the EXACT output of diagnostics (logs, print statements)
+- [ ] I identified ONE root cause from "What this tells you" (lines 66-72)
+- [ ] I applied the FIRST matching pattern from Decision Tree (lines 91+)
+- [ ] I tested the pattern on a REAL device, not just simulator
+- [ ] I verified the pattern with print statements/logs showing the fix worked
+
+#### If ALL boxes are checked and still broken
+- You MUST profile with Instruments > Core Animation
+- Time cost: 30-60 minutes (unavoidable for edge cases)
+- Hardcoding, asyncAfter, or "shipping and hoping" are FORBIDDEN
+- Ask for guidance before adding any workarounds
+
+#### Time cost transparency
+- Pattern 1: 2-5 minutes
+- Pattern 2: 3-5 minutes
+- Instruments profiling: 30-60 minutes (for edge cases only)
+- Trying random fixes without profiling: 2-4 hours + risk of shipping broken
+
+## Common Mistakes
+
+❌ **Setting completion handler AFTER adding animation**
+- Completion is not set in time
+- Fix: Set completion BEFORE `layer.add()`
+
+❌ **Assuming simulator timing = device timing**
+- Simulator runs 60Hz, devices run 60Hz-120Hz
+- Fix: Test on real device before tuning duration
+
+❌ **Hardcoding device-specific values**
+- "This value works on iPhone 15 Pro" → fails on iPhone 12
+- Fix: Use `ProcessInfo.processInfo.processorCount` or test class
+
+❌ **Wrapping animation in CATransaction.setAnimationDuration()**
+- Overrides all animation durations in that transaction
+- Fix: Set duration on animation, not transaction
+
+❌ **FORBIDDEN: Using strong self in completion handler**
+- GUARANTEED retain cycle: self → animation → completion → self
+- Fix: ALWAYS use `[weak self]` with guard
+
+❌ **Not removing old animation before adding new**
+- Same keyPath replaces previous animation
+- Fix: `layer.removeAnimation(forKey:)` first or use unique keys
+
+❌ **Ignoring layer.speed and layer.timeOffset**
+- These scale animation timing invisibly
+- Fix: Check these values if timing is wrong
+
+## Real-World Impact
+
+**Before** CAAnimation debugging 2-4 hours per issue
+- Print everywhere, test on simulator, hardcode values, ship and hope
+- "Maybe it's a device bug?"
+- DispatchQueue.asyncAfter as fallback timer
+
+**After** 15-30 minutes with systematic diagnosis
+- Check completion handler setup (2 min)
+- Check CATransaction wrapping (3 min)
+- Check layer state and duration mismatch (5 min)
+- Identify root cause, apply pattern (5 min)
+- Test on real device (varies)
+
+**Key insight** CAAnimation issues are almost always CATransaction, layer state, or frame rate assumptions, never Core Animation bugs.
+
+---
+
+**Last Updated**: 2025-11-30
+**Status**: TDD-tested with pressure scenarios
+**Framework**: UIKit CAAnimation
+
diff --git a/.claude/skills/axiom-uikit-animation-debugging/agents/openai.yaml b/.claude/skills/axiom-uikit-animation-debugging/agents/openai.yaml
new file mode 100644
index 0000000..6eeb22f
--- /dev/null
+++ b/.claude/skills/axiom-uikit-animation-debugging/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "UIKit Animation Debugging"
+  short_description: "CAAnimation completion handler doesn't fire, spring physics look wrong on device, animation duration mismatches actua..."
diff --git a/.claude/skills/axiom-uikit-bridging/.openskills.json b/.claude/skills/axiom-uikit-bridging/.openskills.json
new file mode 100644
index 0000000..d96be8f
--- /dev/null
+++ b/.claude/skills/axiom-uikit-bridging/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-uikit-bridging",
+  "installedAt": "2026-04-12T08:06:56.077Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-uikit-bridging/SKILL.md b/.claude/skills/axiom-uikit-bridging/SKILL.md
new file mode 100644
index 0000000..d4a6295
--- /dev/null
+++ b/.claude/skills/axiom-uikit-bridging/SKILL.md
@@ -0,0 +1,772 @@
+---
+name: axiom-uikit-bridging
+description: Use when wrapping UIKit views/controllers in SwiftUI, embedding SwiftUI in UIKit, or debugging UIKit-SwiftUI interop issues. Covers UIViewRepresentable, UIViewControllerRepresentable, UIHostingController, UIHostingConfiguration, coordinators, lifecycle, state binding, memory management.
+license: MIT
+---
+
+# UIKit-SwiftUI Bridging
+
+Systematic guidance for bridging UIKit and SwiftUI. Most production iOS apps need both — this skill teaches the bridging patterns themselves, not the domain-specific views being bridged.
+
+## Decision Framework
+
+```dot
+digraph bridge {
+    start [label="What are you bridging?" shape=diamond];
+
+    start -> "UIViewRepresentable" [label="UIView subclass → SwiftUI"];
+    start -> "UIViewControllerRepresentable" [label="UIViewController → SwiftUI"];
+    start -> "UIGestureRecognizerRepresentable" [label="UIGestureRecognizer → SwiftUI\n(iOS 18+)"];
+    start -> "UIHostingController" [label="SwiftUI view → UIKit"];
+    start -> "UIHostingConfiguration" [label="SwiftUI in UIKit cell\n(iOS 16+)"];
+
+    "UIViewRepresentable" [shape=box];
+    "UIViewControllerRepresentable" [shape=box];
+    "UIGestureRecognizerRepresentable" [shape=box];
+    "UIHostingController" [shape=box];
+    "UIHostingConfiguration" [shape=box];
+}
+```
+
+**Quick rules:**
+- Wrapping a `UIView` → `UIViewRepresentable` (Part 1)
+- Wrapping a `UIViewController` → `UIViewControllerRepresentable` (Part 2)
+- Wrapping a `UIGestureRecognizer` subclass → `UIGestureRecognizerRepresentable` (Part 2b, iOS 18+)
+- Embedding SwiftUI in UIKit navigation → `UIHostingController` (Part 3)
+- SwiftUI in UICollectionView/UITableView cells → `UIHostingConfiguration` (Part 3)
+- Sharing state between UIKit and SwiftUI → `@Observable` shared model (Part 4)
+
+---
+
+# Part 1: UIViewRepresentable — Wrapping UIViews
+
+Use when you have a `UIView` subclass (MKMapView, WKWebView, custom drawing views) and need it in SwiftUI.
+
+> For comprehensive MapKit patterns and the SwiftUI Map vs MKMapView decision, see `axiom-mapkit`.
+
+## Lifecycle
+
+```
+makeUIView(context:)         → Called ONCE. Create and configure the view.
+updateUIView(_:context:)     → Called on EVERY SwiftUI state change. Patch, don't recreate.
+dismantleUIView(_:coordinator:) → Called when removed from hierarchy. Clean up observers/timers.
+```
+
+**Critical**: `updateUIView` is called frequently. Guard against unnecessary work:
+
+```swift
+struct MapView: UIViewRepresentable {
+    let region: MKCoordinateRegion
+
+    func makeUIView(context: Context) -> MKMapView {
+        let map = MKMapView()
+        map.delegate = context.coordinator
+        return map
+    }
+
+    func updateUIView(_ map: MKMapView, context: Context) {
+        // ✅ Guard: only update if region actually changed
+        if map.region.center.latitude != region.center.latitude
+            || map.region.center.longitude != region.center.longitude {
+            map.setRegion(region, animated: true)
+        }
+    }
+
+    static func dismantleUIView(_ map: MKMapView, coordinator: Coordinator) {
+        map.removeAnnotations(map.annotations)
+    }
+}
+```
+
+## State Synchronization
+
+State flows in two directions across the bridge:
+
+**SwiftUI → UIKit**: Via `updateUIView`. SwiftUI state changes trigger this method.
+
+**UIKit → SwiftUI**: Via the Coordinator, using `@Binding` on the parent struct.
+
+```swift
+struct SearchField: UIViewRepresentable {
+    @Binding var text: String
+    @Binding var isEditing: Bool
+
+    func makeUIView(context: Context) -> UISearchBar {
+        let bar = UISearchBar()
+        bar.delegate = context.coordinator
+        return bar
+    }
+
+    func updateUIView(_ bar: UISearchBar, context: Context) {
+        bar.text = text  // SwiftUI → UIKit
+    }
+
+    func makeCoordinator() -> Coordinator { Coordinator(self) }
+
+    class Coordinator: NSObject, UISearchBarDelegate {
+        var parent: SearchField
+
+        init(_ parent: SearchField) { self.parent = parent }
+
+        func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
+            parent.text = searchText  // UIKit → SwiftUI
+        }
+
+        func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
+            parent.isEditing = true  // UIKit → SwiftUI
+        }
+
+        func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
+            parent.isEditing = false
+        }
+    }
+}
+```
+
+## Layout Property Warning
+
+SwiftUI owns the layout of representable views. **Never modify `center`, `bounds`, `frame`, or `transform`** on the wrapped UIView — this is undefined behavior per Apple documentation. SwiftUI sets these properties during its layout pass. If you need custom sizing, override `intrinsicContentSize` on the UIView or use `sizeThatFits(_:)`.
+
+## Coordinator Pattern
+
+The Coordinator is a reference type (`class`) that:
+1. Acts as the delegate/data source for the UIKit view
+2. Holds a reference to the parent `UIViewRepresentable` struct
+3. Bridges UIKit callbacks back to SwiftUI `@Binding` properties
+
+`makeCoordinator()` is **optional** — omit it when the UIKit view needs no delegate callbacks or UIKit→SwiftUI communication (e.g., a static display-only view).
+
+**Why not closures?** Closures capture `self` and create retain cycles. The Coordinator pattern gives you a stable reference type that SwiftUI manages.
+
+```swift
+// ❌ Closure-based: retain cycle risk, no delegate protocol support
+func makeUIView(context: Context) -> UITextField {
+    let field = UITextField()
+    field.addTarget(self, action: #selector(textChanged), for: .editingChanged) // Won't compile — self is a struct
+    return field
+}
+
+// ✅ Coordinator: clean lifecycle, delegate support
+func makeCoordinator() -> Coordinator { Coordinator(self) }
+
+class Coordinator: NSObject, UITextFieldDelegate {
+    var parent: SearchField
+    init(_ parent: SearchField) { self.parent = parent }
+
+    func textFieldDidChangeSelection(_ textField: UITextField) {
+        parent.text = textField.text ?? ""
+    }
+}
+```
+
+## Sizing
+
+UIViewRepresentable views participate in SwiftUI layout. Control sizing with:
+
+```swift
+// If the UIView has intrinsicContentSize, SwiftUI respects it
+// For views without intrinsic size (MKMapView, WKWebView), set a frame:
+MapView(region: region)
+    .frame(height: 300)
+
+// For views that should size to fit their content:
+WrappedLabel(text: "Hello")
+    .fixedSize()  // Uses intrinsicContentSize
+```
+
+Override `sizeThatFits(_:)` for custom size proposals:
+
+```swift
+struct WrappedLabel: UIViewRepresentable {
+    let text: String
+
+    func makeUIView(context: Context) -> UILabel {
+        let label = UILabel()
+        label.numberOfLines = 0
+        return label
+    }
+
+    func updateUIView(_ label: UILabel, context: Context) {
+        label.text = text
+    }
+
+    // Custom size proposal — SwiftUI calls this during layout
+    func sizeThatFits(_ proposal: ProposedViewSize, uiView: UILabel, context: Context) -> CGSize? {
+        let width = proposal.width ?? UIView.layoutFittingCompressedSize.width
+        return uiView.systemLayoutSizeFitting(
+            CGSize(width: width, height: UIView.layoutFittingCompressedSize.height),
+            withHorizontalFittingPriority: .required,
+            verticalFittingPriority: .fittingSizeLevel
+        )
+    }
+}
+```
+
+## Scroll-Tracking Navigation Bars (iOS 15+)
+
+When wrapping a UIScrollView subclass, tell the navigation bar which scroll view to track for large title collapse:
+
+```swift
+func makeUIView(context: Context) -> UITableView {
+    let table = UITableView()
+    return table
+}
+
+func updateUIView(_ table: UITableView, context: Context) {
+    // Tell the nearest navigation controller to track this scroll view
+    // for inline/large title transitions
+    if let navController = sequence(first: table as UIResponder, next: \.next)
+        .compactMap({ $0 as? UINavigationController }).first {
+        navController.navigationBar.setContentScrollView(table, forEdge: .top)
+    }
+}
+```
+
+Without this, navigation bar large titles won't collapse when scrolling a wrapped UIScrollView.
+
+## Animation Bridging
+
+Use `context.transaction.animation` to bridge SwiftUI animations into UIKit:
+
+```swift
+func updateUIView(_ uiView: UIView, context: Context) {
+    if context.transaction.animation != nil {
+        UIView.animate(withDuration: 0.3) {
+            uiView.alpha = isVisible ? 1 : 0
+        }
+    } else {
+        uiView.alpha = isVisible ? 1 : 0
+    }
+}
+```
+
+**iOS 18+ animation unification**: SwiftUI animations can be applied directly to UIKit views via `UIView.animate(_:)`. However, be aware of incompatibilities:
+
+- SwiftUI animations are **NOT backed by CAAnimation** — they use a different rendering path
+- **Incompatible with** `UIViewPropertyAnimator` and `UIView` keyframe animations
+- **Velocity retargeting**: Re-targeted SwiftUI animations carry forward velocity from interrupted animations, creating fluid transitions
+
+For comprehensive animation bridging patterns, see `/skill axiom-swiftui-animation-ref` Part 10.
+
+---
+
+# Part 2: UIViewControllerRepresentable — Wrapping UIViewControllers
+
+Use when wrapping a full `UIViewController` — pickers, mail compose, Safari, camera, or any controller that manages its own view hierarchy.
+
+## Lifecycle
+
+```
+makeUIViewController(context:)         → Called ONCE. Create and configure.
+updateUIViewController(_:context:)     → Called on SwiftUI state changes.
+dismantleUIViewController(_:coordinator:) → Cleanup.
+```
+
+## Canonical Example: PHPickerViewController
+
+```swift
+struct PhotoPicker: UIViewControllerRepresentable {
+    @Binding var selectedImages: [UIImage]
+    @Environment(\.dismiss) private var dismiss
+
+    func makeUIViewController(context: Context) -> PHPickerViewController {
+        var config = PHPickerConfiguration()
+        config.selectionLimit = 5
+        config.filter = .images
+        let picker = PHPickerViewController(configuration: config)
+        picker.delegate = context.coordinator
+        return picker
+    }
+
+    func updateUIViewController(_ picker: PHPickerViewController, context: Context) {
+        // PHPicker doesn't support updates after creation
+    }
+
+    func makeCoordinator() -> Coordinator { Coordinator(self) }
+
+    class Coordinator: NSObject, PHPickerViewControllerDelegate {
+        var parent: PhotoPicker
+
+        init(_ parent: PhotoPicker) { self.parent = parent }
+
+        func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
+            parent.selectedImages = []
+            for result in results {
+                result.itemProvider.loadObject(ofClass: UIImage.self) { image, _ in
+                    if let image = image as? UIImage {
+                        DispatchQueue.main.async {
+                            self.parent.selectedImages.append(image)
+                        }
+                    }
+                }
+            }
+            parent.dismiss()
+        }
+    }
+}
+```
+
+## When the Controller Presents Its Own UI
+
+Some controllers (UIImagePickerController, MFMailComposeViewController, SFSafariViewController) present their own full-screen UI. Handle dismissal through the coordinator:
+
+```swift
+class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
+    var parent: MailComposer
+
+    func mailComposeController(_ controller: MFMailComposeViewController,
+                                didFinishWith result: MFMailComposeResult, error: Error?) {
+        parent.dismiss()  // Let SwiftUI handle the dismissal
+    }
+}
+```
+
+**Don't** call `controller.dismiss(animated:)` directly from the coordinator — let SwiftUI's `@Environment(\.dismiss)` or the binding that controls presentation handle it.
+
+## Presentation Context
+
+The wrapped controller doesn't automatically inherit SwiftUI's navigation context. If you need the controller to push onto a navigation stack, you need UIViewControllerRepresentable inside a NavigationStack, and the controller needs access to the navigation controller:
+
+```swift
+// ❌ This won't push — the controller has no navigationController
+struct WrappedVC: UIViewControllerRepresentable {
+    func makeUIViewController(context: Context) -> MyViewController {
+        let vc = MyViewController()
+        vc.navigationController?.pushViewController(otherVC, animated: true) // nil
+        return vc
+    }
+}
+
+// ✅ Present modally instead, or use UIHostingController in a UIKit navigation flow
+.sheet(isPresented: $showPicker) {
+    PhotoPicker(selectedImages: $images)
+}
+```
+
+---
+
+# Part 2b: UIGestureRecognizerRepresentable (iOS 18+)
+
+Use when you need a UIKit gesture recognizer in SwiftUI — for gestures that SwiftUI's native gesture API doesn't support (custom subclasses, precise UIKit gesture state machine, hit testing control).
+
+**Pre-iOS 18 fallback**: Attach the gesture recognizer to a transparent `UIView` wrapped with `UIViewRepresentable`, using the Coordinator as the target/action receiver (see Part 1 Coordinator Pattern). You lose `CoordinateSpaceConverter` but can use the recognizer's `location(in:)` directly.
+
+## Lifecycle
+
+```
+makeUIGestureRecognizer(context:)              → Called ONCE. Create the recognizer.
+handleUIGestureRecognizerAction(_:context:)    → Called when the gesture is recognized.
+updateUIGestureRecognizer(_:context:)          → Called on SwiftUI state changes.
+makeCoordinator(converter:)                    → Optional. Create coordinator for state.
+```
+
+**No manual target/action** — the system manages action target installation. Implement `handleUIGestureRecognizerAction` instead.
+
+## Canonical Example: Long Press with Location
+
+```swift
+struct LongPressGesture: UIGestureRecognizerRepresentable {
+    @Binding var pressLocation: CGPoint?
+
+    func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer {
+        let recognizer = UILongPressGestureRecognizer()
+        recognizer.minimumPressDuration = 0.5
+        return recognizer
+    }
+
+    func handleUIGestureRecognizerAction(
+        _ recognizer: UILongPressGestureRecognizer, context: Context
+    ) {
+        switch recognizer.state {
+        case .began:
+            // localLocation converts UIKit coordinates to SwiftUI coordinate space
+            pressLocation = context.converter.localLocation
+        case .ended, .cancelled:
+            pressLocation = nil
+        default:
+            break
+        }
+    }
+}
+
+// Usage
+struct ContentView: View {
+    @State private var pressLocation: CGPoint?
+
+    var body: some View {
+        Rectangle()
+            .gesture(LongPressGesture(pressLocation: $pressLocation))
+    }
+}
+```
+
+## CoordinateSpaceConverter
+
+The `context.converter` bridges UIKit gesture coordinates into SwiftUI coordinate spaces:
+
+| Property/Method | Description |
+|-----------------|-------------|
+| `localLocation` | Gesture position in the attached SwiftUI view's space |
+| `localTranslation` | Gesture movement in local space |
+| `localVelocity` | Gesture velocity in local space |
+| `location(in:)` | Transform location to an ancestor coordinate space |
+| `translation(in:)` | Transform translation to an ancestor space |
+| `velocity(in:)` | Transform velocity to an ancestor space |
+
+## When to Use This vs SwiftUI Gestures
+
+| Need | Use |
+|------|-----|
+| Standard tap, drag, long press, rotation, magnification | SwiftUI native gestures |
+| Custom `UIGestureRecognizer` subclass | `UIGestureRecognizerRepresentable` |
+| Precise control over gesture state machine (`.possible`, `.began`, `.changed`, etc.) | `UIGestureRecognizerRepresentable` |
+| Gesture that requires `delegate` methods for failure requirements or simultaneous recognition | `UIGestureRecognizerRepresentable` with a Coordinator |
+| Coordinate space conversion between UIKit and SwiftUI | `UIGestureRecognizerRepresentable` (converter is built-in) |
+
+---
+
+# Part 3: UIHostingController — SwiftUI Inside UIKit
+
+Use when embedding SwiftUI views in an existing UIKit navigation hierarchy.
+
+## Basic Embedding
+
+```swift
+// Push onto UIKit navigation stack
+let profileView = ProfileView(user: user)
+let hostingController = UIHostingController(rootView: profileView)
+navigationController?.pushViewController(hostingController, animated: true)
+
+// Present modally
+let settingsView = SettingsView()
+let hostingController = UIHostingController(rootView: settingsView)
+hostingController.modalPresentationStyle = .pageSheet
+present(hostingController, animated: true)
+```
+
+## Child View Controller Embedding
+
+When embedding as a child VC (e.g., a SwiftUI card inside a UIKit layout):
+
+```swift
+let swiftUIView = StatusCard(status: currentStatus)
+let hostingController = UIHostingController(rootView: swiftUIView)
+hostingController.sizingOptions = .intrinsicContentSize  // iOS 16+
+
+addChild(hostingController)
+view.addSubview(hostingController.view)
+hostingController.view.translatesAutoresizingMaskIntoConstraints = false
+NSLayoutConstraint.activate([
+    hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+    hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+    hostingController.view.topAnchor.constraint(equalTo: headerView.bottomAnchor)
+])
+hostingController.didMove(toParent: self)
+```
+
+**`sizingOptions: .intrinsicContentSize`** (iOS 16+) makes the hosting controller report its SwiftUI content size to Auto Layout. Without this, the hosting controller's view has no intrinsic size and relies entirely on constraints.
+
+**`sizingOptions` cases** (iOS 16+, `OptionSet`):
+- `.intrinsicContentSize` — auto-invalidates intrinsic content size when SwiftUI content changes
+- `.preferredContentSize` — tracks content's ideal size in the controller's `preferredContentSize`
+
+## Explicit Size Queries
+
+Use `sizeThatFits(in:)` to calculate the SwiftUI content's preferred size for Auto Layout integration:
+
+```swift
+let hostingController = UIHostingController(rootView: CompactCard(item: item))
+
+// Query preferred size for a given width constraint
+let fittingSize = hostingController.sizeThatFits(in: CGSize(width: 320, height: .infinity))
+// Returns the optimal CGSize for the SwiftUI content
+```
+
+This is useful when you need the hosting controller's size before adding it to the view hierarchy, or when embedding in contexts where `sizingOptions` alone isn't sufficient (e.g., manually sizing popover content).
+
+## Environment Bridging
+
+Standard system environment values (`colorScheme`, `sizeCategory`, `locale`) bridge automatically through the UIKit trait system. Custom `@Environment` keys from a parent SwiftUI view do NOT — unless you use `UITraitBridgedEnvironmentKey`.
+
+**Option 1: Inject explicitly** (simplest, works on all versions):
+
+```swift
+let view = DetailView(store: appStore, theme: currentTheme)
+let hostingController = UIHostingController(rootView: view)
+```
+
+**Option 2: UITraitBridgedEnvironmentKey** (iOS 17+, bidirectional bridging):
+
+Bridge custom environment values between UIKit traits and SwiftUI environment:
+
+```swift
+// 1. Define a UIKit trait
+struct FeatureOneTrait: UITraitDefinition {
+    static let defaultValue = false
+}
+
+extension UIMutableTraits {
+    var featureOne: Bool {
+        get { self[FeatureOneTrait.self] }
+        set { self[FeatureOneTrait.self] = newValue }
+    }
+}
+
+// 2. Define a SwiftUI EnvironmentKey
+struct FeatureOneKey: EnvironmentKey {
+    static let defaultValue = false
+}
+
+extension EnvironmentValues {
+    var featureOne: Bool {
+        get { self[FeatureOneKey.self] }
+        set { self[FeatureOneKey.self] = newValue }
+    }
+}
+
+// 3. Bridge them
+extension FeatureOneKey: UITraitBridgedEnvironmentKey {
+    static func read(from traitCollection: UITraitCollection) -> Bool {
+        traitCollection[FeatureOneTrait.self]
+    }
+    static func write(to mutableTraits: inout UIMutableTraits, value: Bool) {
+        mutableTraits.featureOne = value
+    }
+}
+```
+
+Now `@Environment(\.featureOne)` automatically syncs in both directions — UIKit `traitOverrides` update SwiftUI views, and SwiftUI `.environment(\.featureOne, true)` updates UIKit views.
+
+To push values from UIKit into hosted SwiftUI content:
+
+```swift
+// In any UIKit view controller — flows down to UIHostingController children
+viewController.traitOverrides.featureOne = true
+```
+
+## UIHostingConfiguration (iOS 16+)
+
+Use SwiftUI views as UICollectionView or UITableView cells:
+
+```swift
+cell.contentConfiguration = UIHostingConfiguration {
+    HStack {
+        Image(systemName: item.icon)
+            .foregroundStyle(.tint)
+        VStack(alignment: .leading) {
+            Text(item.title)
+                .font(.headline)
+            Text(item.subtitle)
+                .font(.subheadline)
+                .foregroundStyle(.secondary)
+        }
+    }
+}
+.margins(.all, EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
+.minSize(width: nil, height: 44)  // Minimum tap target height
+.background(.quaternarySystemFill)  // ShapeStyle background
+```
+
+**Cell clipping?** UIHostingConfiguration cells self-size. If cells are clipped, the collection view layout likely uses fixed `itemSize` — switch to `estimated` dimensions in your compositional layout so cells can grow to fit the SwiftUI content.
+
+#### Advantages over full UIHostingController
+- No child view controller management
+- Automatic cell sizing
+- Self-sizing invalidation on state change
+- Compatible with diffable data sources
+
+#### When to use UIHostingConfiguration vs UIHostingController
+
+| Scenario | Use |
+|----------|-----|
+| Cell content in UICollectionView/UITableView | UIHostingConfiguration |
+| Full screen or navigation destination | UIHostingController |
+| Child VC in a layout | UIHostingController |
+| Overlay or decoration | UIHostingConfiguration in a supplementary view |
+
+## Scroll-Tracking for Navigation Bars
+
+When a UIHostingController contains a scroll view and is pushed onto a UINavigationController, large title collapse may not work. Use `setContentScrollView`:
+
+```swift
+let hostingController = UIHostingController(rootView: ScrollableListView())
+
+// After pushing, tell the nav bar to track the scroll view
+if let scrollView = hostingController.view.subviews.compactMap({ $0 as? UIScrollView }).first {
+    navigationController?.navigationBar.setContentScrollView(scrollView, forEdge: .top)
+}
+```
+
+This is a common issue when embedding SwiftUI `List` or `ScrollView` in UIKit navigation.
+
+## Keyboard Handling in Hybrid Layouts
+
+When mixing UIKit and SwiftUI, keyboard avoidance may not work automatically. Use `UIKeyboardLayoutGuide` (iOS 15+) for constraint-based keyboard tracking in UIKit layouts that contain SwiftUI content:
+
+```swift
+// Constrain the hosting controller's view above the keyboard
+hostingController.view.bottomAnchor.constraint(
+    equalTo: view.keyboardLayoutGuide.topAnchor
+).isActive = true
+```
+
+---
+
+# Part 4: Shared State with @Observable
+
+When UIKit and SwiftUI coexist in the same app, you need a shared model layer. `@Observable` (iOS 17+) works naturally in both frameworks without Combine.
+
+## @Observable as the Shared Model Layer
+
+```swift
+@Observable
+class AppState {
+    var userName: String = ""
+    var isLoggedIn: Bool = false
+    var itemCount: Int = 0
+}
+```
+
+**SwiftUI side** — standard property wrappers:
+
+```swift
+struct ProfileView: View {
+    @State var appState: AppState  // or @Environment, @Bindable
+
+    var body: some View {
+        Text("Welcome, \(appState.userName)")
+        Text("\(appState.itemCount) items")
+    }
+}
+```
+
+**Why UIKit needs explicit observation**: SwiftUI's rendering engine automatically participates in the Observation framework — when a view's `body` accesses an `@Observable` property, SwiftUI registers that access and re-renders when it changes. UIKit is imperative and has no equivalent re-evaluation mechanism, so you must opt in explicitly.
+
+**UIKit side (pre-iOS 26)** — manual observation with `withObservationTracking()`:
+
+```swift
+class DashboardViewController: UIViewController {
+    let appState: AppState
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        observeState()
+    }
+
+    private func observeState() {
+        withObservationTracking {
+            // Properties accessed here are tracked
+            titleLabel.text = appState.userName
+            countLabel.text = "\(appState.itemCount) items"
+        } onChange: {
+            // Fires ONCE on the thread that mutated the property — must re-register
+            // Always dispatch to main: onChange can fire on ANY thread
+            DispatchQueue.main.async { [weak self] in
+                self?.observeState()
+            }
+        }
+    }
+}
+```
+
+**UIKit side (iOS 26+)** — automatic observation tracking:
+
+UIKit automatically tracks `@Observable` property access in designated lifecycle methods. Properties read in these methods trigger automatic UI updates when they change:
+
+| Method | Class | What it updates |
+|--------|-------|----------------|
+| `updateProperties()` | UIView, UIViewController | Content and styling |
+| `layoutSubviews()` | UIView | Geometry and positioning |
+| `viewWillLayoutSubviews()` | UIViewController | Pre-layout |
+| `draw(_:)` | UIView | Custom drawing |
+
+```swift
+class DashboardViewController: UIViewController {
+    let appState: AppState
+
+    // iOS 26+: Properties accessed here are auto-tracked
+    override func updateProperties() {
+        super.updateProperties()
+        titleLabel.text = appState.userName
+        countLabel.text = "\(appState.itemCount) items"
+    }
+}
+```
+
+**Info.plist requirement**: In iOS 18, add `UIObservationTrackingEnabled = true` to your Info.plist to enable automatic observation tracking. iOS 26+ enables it by default.
+
+## iOS 16 Fallback: ObservableObject + Combine
+
+If targeting iOS 16 (before `@Observable`), use `ObservableObject` with `@Published` and observe via Combine on the UIKit side:
+
+```swift
+class AppState: ObservableObject {
+    @Published var userName: String = ""
+    @Published var itemCount: Int = 0
+}
+
+// UIKit side — observe with Combine sink
+class DashboardViewController: UIViewController {
+    let appState: AppState
+    private var cancellables = Set()
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        appState.$userName
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] name in
+                self?.titleLabel.text = name
+            }
+            .store(in: &cancellables)
+    }
+}
+```
+
+## Migration Note
+
+`@Observable` replaces `ObservableObject` + `@Published` without requiring Combine. For hybrid apps:
+- Replace `ObservableObject` classes with `@Observable`
+- Remove `@Published` property wrappers (observation is automatic)
+- SwiftUI views keep working — `@State` and `@Environment` support `@Observable` directly
+- UIKit views gain observation through `withObservationTracking()` (iOS 17+) or automatic tracking (iOS 26+)
+
+---
+
+# Part 5: Common Gotchas
+
+| Gotcha | Symptom | Fix |
+|--------|---------|-----|
+| Coordinator retains parent | Memory leak, views never deallocate | Coordinator stores `var parent: X` (not `let`). SwiftUI updates the parent reference on each `updateUIView` call. Don't add extra strong references. |
+| updateUIView called excessively | UIKit view flickers, resets scroll position, drops user input | Guard with equality checks. Compare old vs new values before applying changes. |
+| Environment doesn't cross bridge | Custom environment values are nil/default | Use `UITraitBridgedEnvironmentKey` (iOS 17+) for bidirectional bridging, or inject dependencies through initializer. System traits (color scheme, size category) bridge automatically. |
+| Large title won't collapse | Navigation bar stays expanded when scrolling wrapped UIScrollView | Call `setContentScrollView(_:forEdge:)` on the navigation bar. |
+| UIHostingController sizing wrong | View is zero-sized or jumps after layout | Use `sizingOptions: .intrinsicContentSize` (iOS 16+). For earlier versions, call `hostingController.view.invalidateIntrinsicContentSize()` after root view changes. |
+| Mixed navigation stacks | Unpredictable back button behavior, lost state | Don't mix UINavigationController and NavigationStack in the same flow. Migrate entire navigation subtrees. |
+| makeUIView called multiple times | View recreated unexpectedly | Ensure the `UIViewRepresentable` struct's identity is stable. Avoid putting it inside a conditional that changes identity. |
+| Coordinator not receiving callbacks | Delegate methods never fire | Set `delegate = context.coordinator` in `makeUIView`, not `updateUIView`. Verify protocol conformance. |
+| Layout properties modified on representable view | View jumps, disappears, or has inconsistent layout | Never modify `center`, `bounds`, `frame`, or `transform` on the wrapped UIView — SwiftUI owns these. |
+| Keyboard hides content in hybrid layout | Text field or content hidden behind keyboard | Use `UIKeyboardLayoutGuide` (iOS 15+) constraints in UIKit, or ensure SwiftUI's keyboard avoidance isn't disabled. |
+| @Observable not updating UIKit views | UIKit views show stale data after model changes | Use `withObservationTracking()` (iOS 17+) or enable `UIObservationTrackingEnabled` in Info.plist (iOS 18). iOS 26+ auto-tracks in `updateProperties()`. |
+
+---
+
+# Part 6: Anti-Patterns
+
+| Pattern | Problem | Fix |
+|---------|---------|-----|
+| "I'll use UIViewRepresentable for the whole screen" | UIViewControllerRepresentable exists for controllers that manage their own view hierarchy, handle rotation, and participate in the responder chain | Use UIViewControllerRepresentable for UIViewControllers. UIViewRepresentable is for bare UIViews. |
+| "I don't need a coordinator, I'll use closures" | Closures capture the struct value (not reference), become stale on updates, and can't conform to delegate protocols | Use the Coordinator. It's a stable reference type that SwiftUI keeps alive and updates. |
+| "I'll rebuild the UIKit view every update" | `makeUIView` runs once. Recreating the view in `updateUIView` causes flickering, lost state, and performance issues. | Create in `makeUIView`. Patch properties in `updateUIView`. |
+| "SwiftUI environment will just work across the bridge" | Custom `@Environment` values don't cross UIKit boundaries | Use `UITraitBridgedEnvironmentKey` (iOS 17+) for bridging, or inject explicitly through initializers. System trait-based values bridge automatically. |
+| "I'll dismiss the UIKit controller directly" | Calling `dismiss(animated:)` from coordinator bypasses SwiftUI's presentation state, leaving bindings out of sync | Use `@Environment(\.dismiss)` or the `@Binding var isPresented` to let SwiftUI handle dismissal. |
+| "I'll skip dismantleUIView, it'll clean up automatically" | Timers, observers, and KVO registrations on the UIView leak | Implement `dismantleUIView` (static method) for any cleanup that `deinit` alone won't handle. |
+
+---
+
+## Resources
+
+**WWDC**: 2019-231, 2022-10072, 2023-10149, 2024-10118, 2024-10145, 2025-243, 2025-256
+
+**Docs**: /swiftui/uiviewrepresentable, /swiftui/uiviewcontrollerrepresentable, /swiftui/uigesturerecognizerrepresentable, /uikit/uihostingcontroller, /uikit/uihostingconfiguration, /swiftui/uitraitbridgedenvironmentkey, /observation, /uikit/updating-views-automatically-with-observation-tracking
+
+**Skills**: app-composition, swiftui-animation-ref, camera-capture, transferable-ref, swift-concurrency
diff --git a/.claude/skills/axiom-uikit-bridging/agents/openai.yaml b/.claude/skills/axiom-uikit-bridging/agents/openai.yaml
new file mode 100644
index 0000000..1011d68
--- /dev/null
+++ b/.claude/skills/axiom-uikit-bridging/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "UIKit Bridging"
+  short_description: "Wrapping UIKit views/controllers in SwiftUI, embedding SwiftUI in UIKit, or debugging UIKit-SwiftUI interop issues"
diff --git a/.claude/skills/axiom-using-axiom/.openskills.json b/.claude/skills/axiom-using-axiom/.openskills.json
new file mode 100644
index 0000000..1225b1e
--- /dev/null
+++ b/.claude/skills/axiom-using-axiom/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": ".claude-plugin/plugins/axiom/skills/axiom-using-axiom",
+  "installedAt": "2026-04-12T08:05:35.674Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-using-axiom/SKILL.md b/.claude/skills/axiom-using-axiom/SKILL.md
new file mode 100644
index 0000000..4338428
--- /dev/null
+++ b/.claude/skills/axiom-using-axiom/SKILL.md
@@ -0,0 +1,96 @@
+---
+name: axiom-using-axiom
+description: Use when starting any iOS/Swift conversation - establishes how to find and use Axiom skills, requiring Skill tool invocation before ANY response including clarifying questions
+license: MIT
+---
+
+
+If you think there is even a 1% chance an Axiom skill might apply to your iOS/Swift task, you ABSOLUTELY MUST check for the skill.
+
+IF AN AXIOM SKILL APPLIES TO YOUR iOS/SWIFT TASK, YOU DO NOT HAVE A CHOICE. YOU MUST USE IT.
+
+This is not negotiable. This is not optional. You cannot rationalize your way out of this.
+
+
+# Using Axiom Skills
+
+## The Rule
+
+**Check for Axiom skills BEFORE ANY RESPONSE when working with iOS/Swift projects.** This includes clarifying questions. Even 1% chance means check first.
+
+## Red Flags — iOS-Specific Rationalizations
+
+These thoughts mean STOP—you're rationalizing:
+
+| Thought | Reality |
+|---------|---------|
+| "This is just a simple build issue" | Build failures have patterns. Check ios-build first. |
+| "I can fix this SwiftUI bug quickly" | SwiftUI issues have hidden gotchas. Check ios-ui first. |
+| "Let me just add this database column" | Schema changes risk data loss. Check ios-data first. |
+| "This async code looks straightforward" | Swift concurrency has subtle rules. Check ios-concurrency first. |
+| "I'll debug the memory leak manually" | Leak patterns are documented. Check ios-performance first. |
+| "Let me explore the Xcode project first" | Axiom skills tell you HOW to explore. Check first. |
+| "I remember how to do this from last time" | iOS changes constantly. Skills are up-to-date. |
+| "This iOS/platform version doesn't exist" | Your training ended January 2025. Invoke Axiom skills for post-cutoff facts. |
+| "The user just wants a quick answer" | Quick answers without patterns create tech debt. Check skills first. |
+| "This doesn't need a formal workflow" | If an Axiom skill exists for it, use it. |
+| "I'll gather info first, then check skills" | Skills tell you WHAT info to gather. Check first. |
+
+## Skill Priority for iOS Development
+
+When multiple Axiom skills could apply, use this priority:
+
+1. **Environment/Build first** (ios-build) — Fix the environment before debugging code
+2. **Architecture patterns** (ios-ui, axiom-ios-data, axiom-ios-concurrency) — These determine HOW to structure the solution
+3. **Implementation details** (ios-integration, axiom-ios-ai, axiom-ios-vision) — These guide specific feature work
+
+Examples:
+- "Xcode build failed" → ios-build first (environment)
+- "Add SwiftUI screen" → ios-ui first (architecture), then maybe ios-integration if using system features
+- "App is slow" → ios-performance first (diagnose), then fix the specific domain
+- "Network request failing" → ios-build first (environment check), then ios-networking (implementation)
+
+## iOS Project Detection
+
+Axiom skills apply when:
+- Working directory contains `.xcodeproj` or `.xcworkspace`
+- User mentions iOS, Swift, Xcode, SwiftUI, UIKit
+- User asks about Apple frameworks (SwiftData, CloudKit, etc.)
+- User reports iOS-specific errors (concurrency, memory, build failures)
+
+## Using Axiom Router Skills
+
+Axiom uses **router skills** for progressive disclosure:
+
+1. Check the appropriate router skill first (ios-build, axiom-ios-ui, axiom-ios-data, etc.)
+2. Router will invoke the specialized skill(s) you actually need
+3. Follow the specialized skill exactly
+
+**Do not skip the router.** Routers have decision logic to select the right specialized skill.
+
+### Multi-Domain Questions
+
+When a question spans multiple domains, **invoke ALL relevant routers — don't stop after the first one.**
+
+Examples:
+- "My SwiftUI view doesn't update when SwiftData changes" → invoke **both** ios-ui AND ios-data
+- "My widget isn't showing updated data from SwiftData" → invoke **both** ios-integration AND ios-data
+- "My Foundation Models session freezes the UI" → invoke **both** ios-ai AND ios-concurrency
+- "My Core Data saves lose data from background tasks" → invoke **both** ios-data AND ios-concurrency
+
+**How to tell**: If the question mentions symptoms from two different domains, or involves two different frameworks, invoke both routers. Each router has cross-domain routing guidance for common overlaps.
+
+## Backward Compatibility
+
+- Direct skill invocation still works: `/skill axiom-swift-concurrency`
+- Commands work unchanged: `/axiom:fix-build`, `/axiom:audit-accessibility`
+- Agents work via routing or direct command invocation
+
+## When Axiom Skills Don't Apply
+
+Skip Axiom skills for:
+- Non-iOS/Swift projects (Android, web, backend)
+- Generic programming questions unrelated to Apple platforms
+- Questions about Claude Code itself (use claude-code-guide skill)
+
+But when in doubt for iOS/Swift work: **check first, decide later.**
diff --git a/.claude/skills/axiom-ux-flow-audit/.openskills.json b/.claude/skills/axiom-ux-flow-audit/.openskills.json
new file mode 100644
index 0000000..2a5092b
--- /dev/null
+++ b/.claude/skills/axiom-ux-flow-audit/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-ux-flow-audit",
+  "installedAt": "2026-04-12T08:06:56.484Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ux-flow-audit/SKILL.md b/.claude/skills/axiom-ux-flow-audit/SKILL.md
new file mode 100644
index 0000000..f596c50
--- /dev/null
+++ b/.claude/skills/axiom-ux-flow-audit/SKILL.md
@@ -0,0 +1,300 @@
+---
+name: axiom-ux-flow-audit
+description: Use when auditing user journeys, checking for UX dead ends, dismiss traps, buried CTAs, missing empty/loading/error states, or broken data paths in iOS apps (SwiftUI and UIKit).
+license: MIT
+---
+
+# UX Flow Audit
+
+**UX issues are not polish — they're defects that cause support tickets, bad reviews, and user churn.**
+
+Axiom's code-level auditors check patterns. This skill checks what users actually experience: Can they complete their task? Can they get back? Do they know what's happening?
+
+## 6 iOS UX Principles (Detection Anchors)
+
+These principles anchor every detection category. When a principle is violated, users get stuck, confused, or frustrated.
+
+### 1. Honor the Promise
+
+What the button/title says must match what the user gets. A "Settings" button that opens a profile page breaks trust.
+
+### 2. Escape Hatch
+
+Every modal (sheet, fullScreenCover, alert) must have a way out. A sheet without a dismiss button or drag-to-dismiss traps users.
+
+### 3. Primary Action Visibility
+
+The main thing users came to do must be immediately visible and tappable. If the CTA requires scrolling or menu-diving, users won't find it.
+
+### 4. Dead End Prevention
+
+Every view must have a forward path (next step) or a completion state (success message, return to start). A view with no actions and no navigation is a dead end.
+
+### 5. Progressive Disclosure
+
+Don't overwhelm on first screen. Show essentials first, details on demand. An onboarding flow that dumps 12 settings on page one loses users.
+
+### 6. Feedback Loop
+
+Users must know what's happening during async operations. No loading state = "is it broken?" No error state = "what went wrong?" No empty state = "is this feature missing?"
+
+## Detection Categories
+
+**8 Core Defects** (always check — these are UX bugs, not opinions):
+
+1. Dead-End Views (CRITICAL)
+2. Dismiss Traps (CRITICAL)
+3. Buried CTAs (HIGH)
+4. Promise-Scope Mismatch (HIGH)
+5. Deep Link Dead Ends (HIGH)
+6. Missing Empty States (HIGH)
+7. Missing Loading/Error States (HIGH)
+8. Accessibility Dead Ends (HIGH)
+
+**3 Contextual Checks** (check when product context warrants — these involve design judgment):
+
+9. Onboarding Gaps (MEDIUM) — requires knowing the product's onboarding strategy
+10. Broken Data Paths (MEDIUM) — overlaps with code correctness; include only when UX-visible
+11. Platform Parity Gaps (MEDIUM) — depends on target device strategy
+
+Core defects are always worth reporting. Contextual checks require product knowledge — flag them if they look wrong, but acknowledge they may be intentional decisions.
+
+### 1. Dead-End Views (CRITICAL)
+
+Views with no navigation forward, no actions, and no completion state.
+
+**Detect**:
+- SwiftUI: Views with no `NavigationLink`, `Button`, `.sheet`, `.fullScreenCover`, `.navigationDestination`, or dismiss action
+- UIKit: View controllers with no `IBAction`, no `addTarget`, no navigation push/present calls, no `UIBarButtonItem`
+- Check for views/VCs that are navigation destinations but offer no way to proceed or return
+
+**Common cause**: Placeholder views during development that ship to production.
+
+### 2. Dismiss Traps (CRITICAL)
+
+Sheets or fullScreenCover without a dismiss path.
+
+**Detect**:
+- SwiftUI: `.fullScreenCover` without `@Environment(\.dismiss)` or explicit dismiss button; `.sheet` with `.interactiveDismissDisabled(true)` without alternative dismiss; alert/confirmation dialogs missing cancel actions
+- UIKit: `present(_:animated:)` with `modalPresentationStyle = .fullScreen` where presented VC has no dismiss/close button; `isModalInPresentation = true` without alternative dismiss path
+
+**Why critical**: Users literally cannot leave the screen. The only escape is force-quitting the app.
+
+### 3. Buried CTAs (HIGH)
+
+Primary actions hidden below fold, in menus, or behind navigation.
+
+**Detect**:
+- Primary action buttons placed after long `ScrollView` content
+- Important actions only in `.toolbar` overflow menu (`.secondaryAction`)
+- CTAs inside expandable `DisclosureGroup` sections
+- No prominent action on the main tab's root view
+
+**Not a buried CTA**: Below-fold placement that is intentional — checkout confirmation ("review order then confirm"), terms acceptance ("read then agree"), or content that the user should see before acting. The test: is the below-fold placement serving the user (they need context first) or hurting them (they can't find the action)?
+
+### 4. Promise-Scope Mismatch (HIGH)
+
+NavigationTitle, button label, or tab name doesn't match the content.
+
+**Detect**:
+- `.navigationTitle("X")` where view content is clearly about Y
+- `NavigationLink("Settings")` that navigates to a profile/account view
+- Tab labels that don't match tab content
+- Button text suggesting one action but performing another
+
+### 5. Deep Link Dead Ends (HIGH)
+
+URL opens but lands on empty or broken state.
+
+**Detect**:
+- `.onOpenURL` handlers that push a view without checking if data exists
+- Deep link destinations that assume pre-loaded state
+- Universal link handling that doesn't validate the entity ID
+- No fallback when deep-linked content is unavailable
+
+**Cross-reference**: `axiom-swiftui-nav` covers deep link architecture. This category checks the UX outcome.
+
+### 6. Missing Empty States (HIGH)
+
+Lists, grids, or content views with no data show blank screen.
+
+**Detect**:
+- `List` or `ForEach` without `if items.isEmpty { ... }` or `.overlay` for empty state
+- `@Query` results displayed without empty check
+- Search results with no "no results" view
+- Filtered views that can reach zero items
+
+### 7. Missing Loading/Error States (HIGH)
+
+Async operations with no feedback.
+
+**Detect**:
+- SwiftUI: `.task { }` or `Task { }` that fetches data without a loading indicator; `try await` without error presentation (no `.alert`, no error state variable); state enum missing `.loading` or `.error` cases
+- UIKit: `URLSession` calls without `UIActivityIndicatorView` or progress UI; completion handlers that don't update UI on error; missing `UIAlertController` for failure cases
+- Both: Network calls without timeout or retry UI
+- Both: `catch` blocks that only `print`/log in `#if DEBUG` with no user-visible feedback — the user sees the operation silently fail
+
+**Focus on network/write operations**: Skip loading indicators for fast local reads (GRDB queries, UserDefaults, cached data) that complete in under 100ms — adding spinners to these creates visual flicker. Focus on network calls, database writes, and any operation that can meaningfully fail.
+
+**Scan systematically**: When you find a silent-error pattern in one file (e.g., `catch { print(...) }` without user feedback), scan ALL similar files for the same pattern. A single catch-block issue usually indicates a codebase-wide habit.
+
+### 8. Accessibility Dead Ends (HIGH)
+
+Actions only reachable via gestures or visual cues, invisible to assistive technology.
+
+**Detect**:
+- `.onLongPressGesture` / `.swipeActions` / `DragGesture` without `.accessibilityAction` equivalent
+- Custom controls without `.accessibilityLabel` or `.accessibilityHint`
+- Navigation that depends on color alone (e.g., "tap the green button")
+- Pull-to-refresh (`refreshable`) without VoiceOver-accessible alternative (note: `refreshable` is automatically accessible — check custom implementations)
+
+**Cross-reference**: `axiom-accessibility-diag` covers full WCAG compliance. This category specifically checks UX flow reachability from assistive technology.
+
+### 9. Onboarding Gaps (MEDIUM)
+
+First-launch flow that's incomplete or overwhelming.
+
+**Detect**:
+- No `@AppStorage`-gated onboarding check
+- Onboarding flow without skip/later option
+- More than 5 onboarding screens
+- Onboarding that requires account creation before showing app value
+
+### 10. Broken Data Paths (MEDIUM)
+
+State/binding wiring issues that manifest as UX problems (view shows stale data, edits don't save, view appears empty when data exists).
+
+**Detect**:
+- Views accepting `@Binding` that are initialized with `.constant()` in non-preview code
+- Views expecting `@Environment` values not provided by ancestors
+- `@Observable` models created locally when they should be injected
+- `@State` used where `@Binding` should propagate changes upward
+
+**Scope note**: This overlaps with general SwiftUI correctness (`axiom-swiftui-debugging`). Include findings here only when the broken data path causes a visible UX problem — blank screen, stale content, edits that don't persist. Skip compiler-level or crash-level issues that belong in code review.
+
+### 11. Platform Parity Gaps (MEDIUM)
+
+iPad sidebar missing, landscape broken, Mac Catalyst issues.
+
+**Detect**:
+- `NavigationStack` without `NavigationSplitView` alternative for iPad
+- No `.horizontalSizeClass` checks for adaptive layout
+- Views that break in landscape (fixed heights, no scroll)
+- Missing keyboard shortcut support on iPad/Mac
+
+## Audit Process
+
+### Step 1: Map Entry Points
+
+Find all ways users enter the app:
+- `@main` App struct / SceneDelegate
+- `.onOpenURL` handlers (deep links)
+- Widget `Link` destinations
+- Notification response handlers (`UNUserNotificationCenterDelegate`)
+- Spotlight/Siri intent handlers
+
+### Step 2: Map Navigation Containers
+
+Find all navigation structure:
+- `NavigationStack` / `NavigationSplitView`
+- `TabView` with tab structure
+- `.sheet` / `.fullScreenCover` presentations
+- Custom modal presentations
+
+### Step 3: Trace Flows
+
+For each entry point → completion path:
+1. Can the user reach their goal?
+2. Can the user get back?
+3. Does the user know what's happening at each step?
+
+### Step 4: Check Data Wiring
+
+- Are `@Binding` vars actually passed from parent?
+- Are `@Observable` objects injected via environment?
+- Are `@Query` results handled for empty case?
+
+### Step 5: Check Platform Adaptivity
+
+- iPad: Does sidebar/split view work?
+- Landscape: Does layout adapt?
+- Mac Catalyst/Designed for iPad: Do keyboard shortcuts exist?
+
+### Step 6: Check Accessibility Flows
+
+- Can VoiceOver users complete every flow?
+- Are gesture-only actions backed by accessibility actions?
+
+## Cross-Auditor Correlation
+
+When findings overlap with other Axiom auditors, note the correlation and elevate severity:
+
+| UX Finding | Overlapping Auditor | Compound Effect | Severity Bump |
+|------------|-------------------|-----------------|---------------|
+| Dead end + missing NavigationPath | swiftui-nav-auditor | Programmatic fix impossible | CRITICAL |
+| Gesture-only action + no `.accessibilityAction` | accessibility-auditor | Dead end for VoiceOver users | CRITICAL |
+| Missing loading state + unhandled async error | concurrency-auditor | Crash + no user feedback | CRITICAL |
+| Missing empty state + @Query with no results | swiftdata-auditor | Blank screen after data migration | HIGH |
+| Deep link dead end + no URL validation | swiftui-nav-auditor | Silent failure from external link | HIGH |
+
+## Output Format
+
+### Enhanced Rating Table (for CRITICAL and HIGH findings)
+
+| Finding | Urgency | Blast Radius | Fix Effort | ROI |
+|---------|---------|-------------|-----------|-----|
+| Dead-end after payment | Ship-blocker | All users | 30 min | Critical |
+| Missing empty state on search | Next release | Users who search | 15 min | High |
+
+**Urgency**: Ship-blocker / Next release / Backlog
+**Blast Radius**: All users / Specific flow / Edge case
+**Fix Effort**: Time estimate for the fix
+**ROI**: Computed from urgency x blast radius / effort
+
+### Navigation Reachability Score
+
+At end of audit, output:
+
+```
+## Navigation Reachability
+
+- Total screens found: [N] (views with navigation presentation)
+- Deep-linkable screens: [N] (.onOpenURL can reach them)
+- Widget-reachable screens: [N] (widget Link destinations)
+- Notification-reachable screens: [N] (notification handlers)
+- Coverage: [N]% of screens are externally reachable
+```
+
+## Fix Effort Reality Check
+
+Most UX flow defects are fast fixes. When someone says "that's a big change," check this table:
+
+| Defect | Typical Fix | Time |
+|--------|------------|------|
+| Dismiss trap (no close button) | Add toolbar Cancel button + `dismiss()` | 10-15 min |
+| Missing empty state | Add `if items.isEmpty { ContentUnavailableView(...) }` | 15-20 min |
+| Buried CTA (placement change) | Move button from `.secondaryAction` to `.primaryAction` | 20-30 min |
+| Dead-end view (no forward path) | Add NavigationLink or action button | 15-30 min |
+| Missing loading state | Add `@State var isLoading` + ProgressView overlay | 15-20 min |
+| Silent error (no user feedback) | Add `.alert` presentation on catch block | 10-15 min |
+| Gesture-only action | Add `.accessibilityAction` + visible button alternative | 15-20 min |
+
+**The cost of NOT fixing**: A dismiss trap or dead end after payment generates 1-star reviews within hours of launch. Each review costs 10-20 positive reviews to offset. The 15-minute fix prevents weeks of damage control.
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "UX issues are just polish, we'll fix later" | UX dead ends cause 1-star reviews. They're defects, not enhancements. A 15-min fix now prevents weeks of damage control. |
+| "Users will figure it out" | Users don't figure it out. They delete the app. Average user tries for 30 seconds. |
+| "We'll add empty states after launch" | Empty states are the FIRST thing new users see. Launching without them means launching broken. |
+| "That fix is a big design change" | Most UX fixes are placement or state changes (10-30 min). Check the Fix Effort table above. |
+| "Accessibility is a separate concern" | If VoiceOver users can't complete a flow, it's a dead end. Same defect, different user. |
+| "This screen is just temporary" | Temporary screens ship. Check them anyway. |
+| "The dismiss gesture handles it" | fullScreenCover has no dismiss gesture. That's the trap. |
+
+## Resources
+
+**Skills**: axiom-swiftui-nav, axiom-accessibility-diag, axiom-hig, axiom-swiftui-debugging
+
+**Agents**: ux-flow-auditor (automated scanning), swiftui-nav-auditor (navigation architecture), accessibility-auditor (WCAG compliance)
diff --git a/.claude/skills/axiom-ux-flow-audit/agents/openai.yaml b/.claude/skills/axiom-ux-flow-audit/agents/openai.yaml
new file mode 100644
index 0000000..a01998f
--- /dev/null
+++ b/.claude/skills/axiom-ux-flow-audit/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "UX Flow Audit"
+  short_description: "Auditing user journeys, checking for UX dead ends, dismiss traps, buried CTAs, missing empty/loading/error states, or..."
diff --git a/.claude/skills/axiom-validate-screenshots/.openskills.json b/.claude/skills/axiom-validate-screenshots/.openskills.json
new file mode 100644
index 0000000..cdf5d9b
--- /dev/null
+++ b/.claude/skills/axiom-validate-screenshots/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-validate-screenshots",
+  "installedAt": "2026-04-12T08:06:56.485Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-validate-screenshots/SKILL.md b/.claude/skills/axiom-validate-screenshots/SKILL.md
new file mode 100644
index 0000000..e40e0b6
--- /dev/null
+++ b/.claude/skills/axiom-validate-screenshots/SKILL.md
@@ -0,0 +1,217 @@
+---
+name: axiom-validate-screenshots
+description: Use when the user mentions App Store screenshot validation, screenshot review, checking screenshots before submission, or verifying screenshot dimensions and content.
+license: MIT
+disable-model-invocation: true
+---
+
+
+> **Note:** This audit may use Bash commands to run builds, tests, or CLI tools.
+# App Store Screenshot Validator Agent
+
+You are an expert at reviewing App Store screenshots for compliance, quality, and content issues before submission. You use Claude's multimodal vision to visually inspect each screenshot.
+
+## Your Mission
+
+Validate App Store screenshots against Apple's submission requirements and catch issues that would cause rejection or hurt conversion — placeholder text, wrong dimensions, debug artifacts, broken UI, and competitor references.
+
+## Step 1: Get Screenshot Folder
+
+If no folder path was provided in the prompt, ask the user:
+
+> "Where are your App Store screenshots? Please provide the folder path (e.g., `~/Desktop/Screenshots` or `./marketing/screenshots`)."
+
+**Do not proceed without a folder path.**
+
+## Step 2: Discover Screenshots
+
+Use the Glob tool to find all image files:
+
+```
+Glob: /**/*.png
+Glob: /**/*.jpg
+Glob: /**/*.jpeg
+```
+
+Count the results. If 0 images found, report and stop.
+
+If more than 20 images found, tell the user:
+
+> "Found [N] screenshots. To keep analysis thorough, I'll check the first 20. If you'd like me to focus on a specific subset (e.g., one device size or one locale), let me know."
+
+Then proceed with the first 20.
+
+## Step 3: Dimension Check
+
+Run batch dimension checking on the files discovered in Step 2:
+
+```bash
+# Check dimensions for all screenshots from Step 2 Glob results
+for f in "" "" ""; do
+  sips -g pixelWidth -g pixelHeight "$f" 2>/dev/null
+done
+```
+
+Match each screenshot against required App Store sizes:
+
+### Required Device Screenshots
+
+| Device | Portrait | Landscape |
+|--------|----------|-----------|
+| iPhone 6.9" (16 Pro Max) | 1320 × 2868 | 2868 × 1320 |
+| iPhone 6.7" (15 Plus/Pro Max) | 1290 × 2796 | 2796 × 1290 |
+| iPhone 6.5" (11 Pro Max/Xs Max) | 1242 × 2688 | 2688 × 1242 |
+| iPhone 5.5" (8 Plus) | 1242 × 2208 | 2208 × 1242 |
+| iPad 13" (Pro M4) | 2064 × 2752 | 2752 × 2064 |
+| iPad 12.9" (Pro 6th gen) | 2048 × 2732 | 2732 × 2048 |
+
+**Note**: App Store Connect accepts exact matches only. Even 1px off will be rejected.
+
+## Step 4: Visual Content Analysis
+
+Analyze each screenshot one at a time using the Read tool. For each image, check:
+
+### CRITICAL Issues (App Store rejection risk)
+
+- **Placeholder/test text**: "Lorem ipsum", "Test", "TODO", "Sample", "Hello World", "John Doe", sample phone numbers, example@email.com
+- **Competitor names or logos**: Other app names, brand logos, trademarked terms (Guidelines 2.3.1)
+- **Debug indicators**: "STAGING", "DEBUG", "DEV", FPS overlay, console output, Xcode debug bars, purple memory warnings
+- **Wrong device in frame**: iPad screenshot in iPhone frame or vice versa
+
+### HIGH Issues (likely rejection or poor conversion)
+
+- **Status bar problems**: Missing status bar, status bar showing carrier "Carrier" (any realistic time is acceptable — 9:41 is Apple's iPhone marketing convention, not a requirement)
+- **Pricing claims**: Specific prices that may vary by region ("Only $0.99!") — violates Guidelines 2.3.7
+- **Broken/truncated UI**: Cut-off text, overlapping elements, missing images (broken image icons), empty states that look like errors
+- **Loading spinners or progress bars**: Screenshots should show completed states
+- **System alerts or permission dialogs**: Location permission popup, notification permission, etc.
+
+### MEDIUM Issues (quality concerns)
+
+- **Content completeness**: Empty lists, blank content areas, missing profile pictures where expected
+- **Text legibility**: Text too small to read, poor contrast against background, text obscured by device frame
+- **Consistency across set**: Mixed themes (some dark mode, some light), different device frames, inconsistent branding
+- **Orientation mismatch**: Landscape screenshots mixed with portrait in same device set
+- **Low resolution or compression artifacts**: Blurry text, JPEG artifacts visible
+
+### False Positives to IGNORE
+
+These are NOT issues:
+- **"9:41" time in status bar** — This is Apple's standard convention, perfectly fine
+- **Marketing text overlays** — Headline text, feature callouts, promotional copy are expected
+- **Intentional blur or redaction** — Privacy demonstrations, background blur effects
+- **Stylized/artistic screenshots** — Device frames, gradient backgrounds, composite images
+- **Demo content that looks realistic** — Professional sample data is good practice
+
+## Step 5: Generate Report
+
+```markdown
+# App Store Screenshot Validation Report
+
+## Summary
+- **Total screenshots**: [N]
+- **CRITICAL issues**: [count] (rejection risk)
+- **HIGH issues**: [count] (likely rejection or poor conversion)
+- **MEDIUM issues**: [count] (quality concerns)
+- **Passed**: [count] (no issues detected)
+
+## Dimension Check
+
+| File | Dimensions | Matches Device | Status |
+|------|-----------|----------------|--------|
+| home-screen.png | 1290 × 2796 | iPhone 6.7" Portrait | ✅ |
+| settings.png | 1280 × 2796 | No match (10px short) | ❌ |
+
+### Device Coverage
+- ✅ iPhone 6.7" — [N] screenshots
+- ❌ iPhone 6.5" — MISSING (required for older devices)
+- ✅ iPad 12.9" — [N] screenshots
+
+## Issues Found
+
+### CRITICAL
+
+#### [filename.png] — Placeholder text detected
+- **What**: "Lorem ipsum dolor sit amet" visible in main content area
+- **Why it matters**: App Store Review Guidelines 2.1 — apps must be complete
+- **Fix**: Replace with realistic app content
+
+### HIGH
+
+#### [filename.png] — Loading spinner visible
+- **What**: Activity indicator visible in center of screen
+- **Why it matters**: Screenshots should show completed, functional states
+- **Fix**: Capture screenshot after content has loaded
+
+### MEDIUM
+
+#### Inconsistent theme across set
+- **What**: 3 screenshots use light mode, 2 use dark mode
+- **Fix**: Use consistent appearance across all screenshots in a device set
+
+## Device Coverage Summary
+
+| Required Device | Screenshots Found | Status |
+|----------------|-------------------|--------|
+| iPhone 6.9" | 0 | ❌ Missing |
+| iPhone 6.7" | 5 | ✅ Complete |
+| iPhone 6.5" | 5 | ✅ Complete |
+| iPhone 5.5" | 0 | ⚠️ Optional |
+| iPad 13" | 0 | ❌ Missing (if iPad app) |
+| iPad 12.9" | 3 | ✅ Complete |
+
+## Next Steps
+
+1. **Fix CRITICAL issues** — These will cause rejection
+2. **Fix HIGH issues** — These are likely to cause rejection or hurt conversion
+3. **Consider MEDIUM issues** — These affect perceived quality
+4. **Add missing device sizes** — Check which devices are required for your app
+5. **Re-run validation** — `/axiom:audit screenshots` after fixes
+```
+
+## Guidelines
+
+### Processing Order
+1. Dimension check ALL screenshots first (fast, batch operation)
+2. Then visually analyze each screenshot sequentially (one at a time via Read tool)
+3. Generate combined report at the end
+
+### When Uncertain
+- If you're unsure whether text is placeholder or intentional, flag it as MEDIUM (not CRITICAL) with a note: "Verify this is intentional content"
+- If image quality makes it hard to read text, note that as a finding
+
+### App Store Guidelines Referenced
+- **2.1** — App Completeness (no placeholder content)
+- **2.3.1** — Accurate Screenshots (must reflect actual app experience)
+- **2.3.3** — Screenshots must not include images that mislead
+- **2.3.7** — Accurate pricing and availability
+
+### Image Reading
+- Use the Read tool to view each screenshot — it supports PNG and JPG
+- Describe what you see before making judgments
+- Be specific about location of issues (top-left, center, navigation bar, etc.)
+
+## When No Issues Found
+
+```markdown
+# App Store Screenshot Validation Report
+
+## Summary
+All [N] screenshots passed validation.
+
+## Verified
+- ✅ All dimensions match required App Store sizes
+- ✅ No placeholder or test content detected
+- ✅ No debug indicators or development artifacts
+- ✅ No competitor references
+- ✅ UI appears complete and functional in all screenshots
+- ✅ Consistent theme and branding across set
+
+## Device Coverage
+[Coverage table]
+
+## Recommendations
+- Consider adding screenshots for [missing device sizes] if applicable
+- Ensure screenshots are localized for each target market
+- Test screenshots at actual App Store listing size (they appear small on device)
+```
diff --git a/.claude/skills/axiom-validate-screenshots/agents/openai.yaml b/.claude/skills/axiom-validate-screenshots/agents/openai.yaml
new file mode 100644
index 0000000..5777528
--- /dev/null
+++ b/.claude/skills/axiom-validate-screenshots/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "Validate Screenshots"
+  short_description: "The user mentions App Store screenshot validation, screenshot review, checking screenshots before submission, or veri..."
diff --git a/.claude/skills/axiom-vision-diag/.openskills.json b/.claude/skills/axiom-vision-diag/.openskills.json
new file mode 100644
index 0000000..f6714ec
--- /dev/null
+++ b/.claude/skills/axiom-vision-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-vision-diag",
+  "installedAt": "2026-04-12T08:06:57.359Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-vision-diag/SKILL.md b/.claude/skills/axiom-vision-diag/SKILL.md
new file mode 100644
index 0000000..8211fee
--- /dev/null
+++ b/.claude/skills/axiom-vision-diag/SKILL.md
@@ -0,0 +1,976 @@
+---
+name: axiom-vision-diag
+description: subject not detected, hand pose missing landmarks, low confidence observations, Vision performance, coordinate conversion, VisionKit errors, observation nil, text not recognized, barcode not detected, DataScannerViewController not working, document scan issues
+license: MIT
+compatibility: iOS 11+, iPadOS 11+, macOS 10.13+, tvOS 11+, axiom-visionOS 1+
+metadata:
+  version: "1.1.0"
+  last-updated: "2026-01-03"
+---
+
+# Vision Framework Diagnostics
+
+Systematic troubleshooting for Vision framework issues: subjects not detected, missing landmarks, low confidence, performance problems, coordinate mismatches, text recognition failures, barcode detection issues, and document scanning problems.
+
+## Overview
+
+**Core Principle**: When Vision doesn't work, the problem is usually:
+1. **Environment** (lighting, occlusion, edge of frame) - 40%
+2. **Confidence threshold** (ignoring low confidence data) - 30%
+3. **Threading** (blocking main thread causes frozen UI) - 15%
+4. **Coordinates** (mixing lower-left and top-left origins) - 10%
+5. **API availability** (using iOS 17+ APIs on older devices) - 5%
+
+**Always check environment and confidence BEFORE debugging code.**
+
+## Red Flags
+
+Symptoms that indicate Vision-specific issues:
+
+| Symptom | Likely Cause |
+|---------|--------------|
+| Subject not detected at all | Edge of frame, poor lighting, very small subject |
+| Hand landmarks intermittently nil | Hand near edge, parallel to camera, glove/occlusion |
+| Body pose skipped frames | Person bent over, upside down, flowing clothing |
+| UI freezes during processing | Running Vision on main thread |
+| Overlays in wrong position | Coordinate conversion (lower-left vs top-left) |
+| Crash on older devices | Using iOS 17+ APIs without `@available` check |
+| Person segmentation misses people | >4 people in scene (instance mask limit) |
+| Low FPS in camera feed | `maximumHandCount` too high, not dropping frames |
+| Text not recognized at all | Blurry image, stylized font, wrong recognition level |
+| Text misread (wrong characters) | Language correction disabled, missing custom words |
+| Barcode not detected | Wrong symbology, code too small, glare/reflection |
+| DataScanner shows blank screen | Camera access denied, device not supported |
+| Document edges not detected | Low contrast, non-rectangular, glare |
+| Real-time scanning too slow | Processing every frame, region too large |
+
+## Mandatory First Steps
+
+Before investigating code, run these diagnostics:
+
+### Step 1: Verify Detection with Diagnostic Code
+
+```swift
+let request = VNGenerateForegroundInstanceMaskRequest()  // Or hand/body pose
+let handler = VNImageRequestHandler(cgImage: testImage)
+
+do {
+    try handler.perform([request])
+
+    if let results = request.results {
+        print("✅ Request succeeded")
+        print("Result count: \(results.count)")
+
+        if let observation = results.first as? VNInstanceMaskObservation {
+            print("All instances: \(observation.allInstances)")
+            print("Instance count: \(observation.allInstances.count)")
+        }
+    } else {
+        print("⚠️ Request succeeded but no results")
+    }
+} catch {
+    print("❌ Request failed: \(error)")
+}
+```
+
+**Expected output**:
+- ✅ Request succeeded, instance count > 0 → Detection working
+- ⚠️ Request succeeded, instance count = 0 → Nothing detected (see Decision Tree)
+- ❌ Request failed → API availability issue
+
+### Step 2: Check Confidence Scores
+
+```swift
+// For hand/body pose
+if let observation = request.results?.first as? VNHumanHandPoseObservation {
+    let allPoints = try observation.recognizedPoints(.all)
+
+    for (key, point) in allPoints {
+        print("\(key): confidence \(point.confidence)")
+
+        if point.confidence < 0.3 {
+            print("  ⚠️ LOW CONFIDENCE - unreliable")
+        }
+    }
+}
+```
+
+**Expected output**:
+- Most landmarks > 0.5 confidence → Good detection
+- Many landmarks < 0.3 → Poor lighting, occlusion, or edge of frame
+
+### Step 3: Verify Threading
+
+```swift
+print("🧵 Thread: \(Thread.current)")
+
+if Thread.isMainThread {
+    print("❌ Running on MAIN THREAD - will block UI!")
+} else {
+    print("✅ Running on background thread")
+}
+```
+
+**Expected output**:
+- ✅ Background thread → Correct
+- ❌ Main thread → Move to `DispatchQueue.global()`
+
+## Decision Tree
+
+```
+Vision not working as expected?
+│
+├─ No results returned?
+│  ├─ Check Step 1 output
+│  │  ├─ "Request failed" → See Pattern 1a (API availability)
+│  │  ├─ "No results" → See Pattern 1b (nothing detected)
+│  │  └─ Results but count = 0 → See Pattern 1c (edge of frame)
+│
+├─ Landmarks have nil/low confidence?
+│  ├─ Hand pose → See Pattern 2 (hand detection issues)
+│  ├─ Body pose → See Pattern 3 (body detection issues)
+│  └─ Face detection → See Pattern 4 (face detection issues)
+│
+├─ UI freezing/slow?
+│  ├─ Check Step 3 (threading)
+│  │  ├─ Main thread → See Pattern 5a (move to background)
+│  │  └─ Background thread → See Pattern 5b (performance tuning)
+│
+├─ Overlays in wrong position?
+│  └─ See Pattern 6 (coordinate conversion)
+│
+├─ Person segmentation missing people?
+│  └─ See Pattern 7 (crowded scenes)
+│
+├─ VisionKit not working?
+│  └─ See Pattern 8 (VisionKit specific)
+│
+├─ Text recognition issues?
+│  ├─ No text detected → See Pattern 9a (image quality)
+│  ├─ Wrong characters → See Pattern 9b (language/correction)
+│  └─ Too slow → See Pattern 9c (recognition level)
+│
+├─ Barcode detection issues?
+│  ├─ Barcode not detected → See Pattern 10a (symbology/size)
+│  └─ Wrong payload → See Pattern 10b (barcode quality)
+│
+├─ DataScannerViewController issues?
+│  ├─ Blank screen → See Pattern 11a (availability check)
+│  └─ Items not detected → See Pattern 11b (data types)
+│
+└─ Document scanning issues?
+   ├─ Edges not detected → See Pattern 12a (contrast/shape)
+   └─ Perspective wrong → See Pattern 12b (corner points)
+```
+
+## Diagnostic Patterns
+
+### Pattern 1a: Request Failed (API Availability)
+
+**Symptom**: `try handler.perform([request])` throws error
+
+**Common errors**:
+```
+"VNGenerateForegroundInstanceMaskRequest is only available on iOS 17.0 or newer"
+"VNDetectHumanBodyPose3DRequest is only available on iOS 17.0 or newer"
+```
+
+**Root cause**: Using iOS 17+ APIs on older deployment target
+
+**Fix**:
+
+```swift
+if #available(iOS 17.0, *) {
+    let request = VNGenerateForegroundInstanceMaskRequest()
+    // ...
+} else {
+    // Fallback for iOS 14-16
+    let request = VNGeneratePersonSegmentationRequest()
+    // ...
+}
+```
+
+**Prevention**: Check API availability in `axiom-vision-ref` before implementing
+
+**Time to fix**: 10 min
+
+### Pattern 1b: No Results (Nothing Detected)
+
+**Symptom**: `request.results == nil` or `results.isEmpty`
+
+**Diagnostic**:
+
+```swift
+// 1. Save debug image to Photos
+UIImageWriteToSavedPhotosAlbum(debugImage, nil, nil, nil)
+
+// 2. Inspect visually
+// - Is subject too small? (< 10% of image)
+// - Is subject blurry?
+// - Poor contrast with background?
+```
+
+**Common causes**:
+- Subject too small (resize or crop closer)
+- Subject too blurry (increase lighting, stabilize camera)
+- Low contrast (subject same color as background)
+
+**Fix**:
+
+```swift
+// Crop image to focus on region of interest
+let croppedImage = cropImage(sourceImage, to: regionOfInterest)
+let handler = VNImageRequestHandler(cgImage: croppedImage)
+```
+
+**Time to fix**: 30 min
+
+### Pattern 1c: Edge of Frame Issues
+
+**Symptom**: Subject detected intermittently as object moves across frame
+
+**Root cause**: Partial occlusion when subject touches image edges
+
+**Diagnostic**:
+
+```swift
+// Check if subject is near edges
+if let observation = results.first as? VNInstanceMaskObservation {
+    let mask = try observation.createScaledMask(
+        for: observation.allInstances,
+        croppedToInstancesContent: true
+    )
+
+    let bounds = calculateMaskBounds(mask)
+
+    if bounds.minX < 0.1 || bounds.maxX > 0.9 ||
+       bounds.minY < 0.1 || bounds.maxY > 0.9 {
+        print("⚠️ Subject too close to edge")
+    }
+}
+```
+
+**Fix**:
+
+```swift
+// Add padding to capture area
+let paddedRect = captureRect.insetBy(dx: -20, dy: -20)
+
+// OR guide user with on-screen overlay
+overlayView.addSubview(guideBox)  // Visual boundary
+```
+
+**Time to fix**: 20 min
+
+### Pattern 2: Hand Pose Issues
+
+**Symptom**: `VNDetectHumanHandPoseRequest` returns nil or low confidence landmarks
+
+**Diagnostic**:
+
+```swift
+if let observation = request.results?.first as? VNHumanHandPoseObservation {
+    let thumbTip = try? observation.recognizedPoint(.thumbTip)
+    let wrist = try? observation.recognizedPoint(.wrist)
+
+    print("Thumb confidence: \(thumbTip?.confidence ?? 0)")
+    print("Wrist confidence: \(wrist?.confidence ?? 0)")
+
+    // Check hand orientation
+    if let thumb = thumbTip, let wristPoint = wrist {
+        let angle = atan2(
+            thumb.location.y - wristPoint.location.y,
+            thumb.location.x - wristPoint.location.x
+        )
+        print("Hand angle: \(angle * 180 / .pi) degrees")
+
+        if abs(angle) > 80 && abs(angle) < 100 {
+            print("⚠️ Hand parallel to camera (hard to detect)")
+        }
+    }
+}
+```
+
+**Common causes**:
+| Cause | Confidence Pattern | Fix |
+|-------|-------------------|-----|
+| Hand near edge | Tips have low confidence | Adjust framing |
+| Hand parallel to camera | All landmarks low | Prompt user to rotate hand |
+| Gloves/occlusion | Fingers low, wrist high | Remove gloves or change lighting |
+| Feet detected as hands | Unexpected hand detected | Add `chirality` check or ignore |
+
+**Fix for parallel hand**:
+
+```swift
+// Detect and warn user
+if avgConfidence < 0.4 {
+    showWarning("Rotate your hand toward the camera")
+}
+```
+
+**Time to fix**: 45 min
+
+### Pattern 3: Body Pose Issues
+
+**Symptom**: `VNDetectHumanBodyPoseRequest` skips frames or returns low confidence
+
+**Diagnostic**:
+
+```swift
+if let observation = request.results?.first as? VNHumanBodyPoseObservation {
+    let nose = try? observation.recognizedPoint(.nose)
+    let root = try? observation.recognizedPoint(.root)
+
+    if let nosePoint = nose, let rootPoint = root {
+        let bodyAngle = atan2(
+            nosePoint.location.y - rootPoint.location.y,
+            nosePoint.location.x - rootPoint.location.x
+        )
+
+        let angleFromVertical = abs(bodyAngle - .pi / 2)
+
+        if angleFromVertical > .pi / 4 {
+            print("⚠️ Person bent over or upside down")
+        }
+    }
+}
+```
+
+**Common causes**:
+| Cause | Solution |
+|-------|----------|
+| Person bent over | Prompt user to stand upright |
+| Upside down (handstand) | Use ARKit instead (better for dynamic poses) |
+| Flowing clothing | Increase contrast or use tighter clothing |
+| Multiple people overlapping | Use person instance segmentation |
+
+**Time to fix**: 1 hour
+
+### Pattern 4: Face Detection Issues
+
+**Symptom**: `VNDetectFaceRectanglesRequest` misses faces or returns wrong count
+
+**Diagnostic**:
+
+```swift
+if let faces = request.results as? [VNFaceObservation] {
+    print("Detected \(faces.count) faces")
+
+    for face in faces {
+        print("Face bounds: \(face.boundingBox)")
+        print("Confidence: \(face.confidence)")
+
+        if face.boundingBox.width < 0.1 {
+            print("⚠️ Face too small")
+        }
+    }
+}
+```
+
+**Common causes**:
+- Face < 10% of image (crop closer)
+- Profile view (use face landmarks request instead)
+- Poor lighting (increase exposure)
+
+**Time to fix**: 30 min
+
+### Pattern 5a: UI Freezing (Main Thread)
+
+**Symptom**: App freezes when performing Vision request
+
+**Diagnostic** (Step 3 above confirms main thread)
+
+**Fix**:
+
+```swift
+// BEFORE (wrong)
+let request = VNGenerateForegroundInstanceMaskRequest()
+try handler.perform([request])  // Blocks UI
+
+// AFTER (correct)
+DispatchQueue.global(qos: .userInitiated).async {
+    let request = VNGenerateForegroundInstanceMaskRequest()
+    try? handler.perform([request])
+
+    DispatchQueue.main.async {
+        // Update UI
+    }
+}
+```
+
+**Time to fix**: 15 min
+
+### Pattern 5b: Performance Issues (Background Thread)
+
+**Symptom**: Already on background thread but still slow / dropping frames
+
+**Diagnostic**:
+
+```swift
+let start = CFAbsoluteTimeGetCurrent()
+
+try handler.perform([request])
+
+let elapsed = CFAbsoluteTimeGetCurrent() - start
+print("Request took \(elapsed * 1000)ms")
+
+if elapsed > 0.2 {  // 200ms = too slow for real-time
+    print("⚠️ Request too slow for real-time processing")
+}
+```
+
+**Common causes & fixes**:
+
+| Cause | Fix | Time Saved |
+|-------|-----|------------|
+| `maximumHandCount` = 10 | Set to actual need (e.g., 2) | 50-70% |
+| Processing every frame | Skip frames (process every 3rd) | 66% |
+| Full-res images | Downscale to 1280x720 | 40-60% |
+| Multiple requests per frame | Batch or alternate requests | 30-50% |
+
+**Fix for real-time camera**:
+
+```swift
+// Skip frames
+frameCount += 1
+guard frameCount % 3 == 0 else { return }
+
+// OR downscale
+let scaledImage = resizeImage(sourceImage, to: CGSize(width: 1280, height: 720))
+
+// OR set lower hand count
+request.maximumHandCount = 2  // Instead of default
+```
+
+**Time to fix**: 1 hour
+
+### Pattern 6: Coordinate Conversion
+
+**Symptom**: UI overlays appear in wrong position
+
+**Diagnostic**:
+
+```swift
+// Vision point (lower-left origin, normalized)
+let visionPoint = recognizedPoint.location
+print("Vision point: \(visionPoint)")  // e.g., (0.5, 0.8)
+
+// Convert to UIKit
+let uiX = visionPoint.x * imageWidth
+let uiY = (1 - visionPoint.y) * imageHeight  // FLIP Y
+print("UIKit point: (\(uiX), \(uiY))")
+
+// Verify overlay
+overlayView.center = CGPoint(x: uiX, y: uiY)
+```
+
+**Common mistakes**:
+
+```swift
+// ❌ WRONG (no Y flip)
+let uiPoint = CGPoint(
+    x: axiom-visionPoint.x * width,
+    y: axiom-visionPoint.y * height
+)
+
+// ❌ WRONG (forgot to scale from normalized)
+let uiPoint = CGPoint(
+    x: axiom-visionPoint.x,
+    y: 1 - visionPoint.y
+)
+
+// ✅ CORRECT
+let uiPoint = CGPoint(
+    x: axiom-visionPoint.x * width,
+    y: (1 - visionPoint.y) * height
+)
+```
+
+**Time to fix**: 20 min
+
+### Pattern 7: Crowded Scenes (>4 People)
+
+**Symptom**: `VNGeneratePersonInstanceMaskRequest` misses people or combines them
+
+**Diagnostic**:
+
+```swift
+// Count faces
+let faceRequest = VNDetectFaceRectanglesRequest()
+try handler.perform([faceRequest])
+
+let faceCount = faceRequest.results?.count ?? 0
+print("Detected \(faceCount) faces")
+
+// Person instance segmentation
+let personRequest = VNGeneratePersonInstanceMaskRequest()
+try handler.perform([personRequest])
+
+let personCount = (personRequest.results?.first as? VNInstanceMaskObservation)?.allInstances.count ?? 0
+print("Detected \(personCount) people")
+
+if faceCount > 4 && personCount <= 4 {
+    print("⚠️ Crowded scene - some people combined or missing")
+}
+```
+
+**Fix**:
+
+```swift
+if faceCount > 4 {
+    // Fallback: Use single mask for all people
+    let singleMaskRequest = VNGeneratePersonSegmentationRequest()
+    try handler.perform([singleMaskRequest])
+
+    // OR guide user
+    showWarning("Please reduce number of people in frame (max 4)")
+}
+```
+
+**Time to fix**: 30 min
+
+### Pattern 8: VisionKit Specific Issues
+
+**Symptom**: `ImageAnalysisInteraction` not showing subject lifting UI
+
+**Diagnostic**:
+
+```swift
+// 1. Check interaction types
+print("Interaction types: \(interaction.preferredInteractionTypes)")
+
+// 2. Check if analysis is set
+print("Analysis: \(interaction.analysis != nil ? "set" : "nil")")
+
+// 3. Check if view supports interaction
+if let view = interaction.view {
+    print("View: \(view)")
+} else {
+    print("❌ View not set")
+}
+```
+
+**Common causes**:
+
+| Symptom | Cause | Fix |
+|---------|-------|-----|
+| No UI appears | `analysis` not set | Call `analyzer.analyze()` and set result |
+| UI appears but no subject lifting | Wrong interaction type | Set `.imageSubject` or `.automatic` |
+| Crash on interaction | View removed before interaction | Keep view in memory |
+
+**Fix**:
+
+```swift
+// Ensure analysis is set
+let analyzer = ImageAnalyzer()
+let analysis = try await analyzer.analyze(image, configuration: config)
+
+interaction.analysis = analysis  // Required!
+interaction.preferredInteractionTypes = .imageSubject
+```
+
+**Time to fix**: 20 min
+
+### Pattern 9a: Text Not Detected (Image Quality)
+
+**Symptom**: `VNRecognizeTextRequest` returns no results or empty strings
+
+**Diagnostic**:
+
+```swift
+let request = VNRecognizeTextRequest()
+request.recognitionLevel = .accurate
+
+try handler.perform([request])
+
+if request.results?.isEmpty ?? true {
+    print("❌ No text detected")
+
+    // Check image quality
+    print("Image size: \(image.size)")
+    print("Minimum text height: \(request.minimumTextHeight)")
+}
+
+for obs in request.results as? [VNRecognizedTextObservation] ?? [] {
+    let top = obs.topCandidates(3)
+    for candidate in top {
+        print("'\(candidate.string)' confidence: \(candidate.confidence)")
+    }
+}
+```
+
+**Common causes**:
+
+| Cause | Symptom | Fix |
+|-------|---------|-----|
+| Blurry image | No results | Improve lighting, stabilize camera |
+| Text too small | No results | Lower `minimumTextHeight` or crop closer |
+| Stylized font | Misread or no results | Try `.accurate` recognition level |
+| Low contrast | Partial results | Improve lighting, increase image contrast |
+| Rotated text | No results with `.fast` | Use `.accurate` (handles rotation) |
+
+**Fix for small text**:
+
+```swift
+// Lower minimum text height (default ignores very small text)
+request.minimumTextHeight = 0.02  // 2% of image height
+```
+
+**Time to fix**: 30 min
+
+### Pattern 9b: Wrong Characters (Language/Correction)
+
+**Symptom**: Text is detected but characters are wrong (e.g., "C001" → "COOL")
+
+**Diagnostic**:
+
+```swift
+// Check all candidates, not just first
+for observation in results {
+    let candidates = observation.topCandidates(5)
+    for (i, candidate) in candidates.enumerated() {
+        print("Candidate \(i): '\(candidate.string)' (\(candidate.confidence))")
+    }
+}
+```
+
+**Common causes**:
+
+| Input Type | Problem | Fix |
+|------------|---------|-----|
+| Serial numbers | Language correction "fixes" them | Disable `usesLanguageCorrection` |
+| Technical codes | Misread as words | Add to `customWords` |
+| Non-English | Wrong ML model | Set correct `recognitionLanguages` |
+| House numbers | Stylized → misread | Check all candidates, not just top |
+
+**Fix for codes/serial numbers**:
+
+```swift
+let request = VNRecognizeTextRequest()
+request.usesLanguageCorrection = false  // Don't "fix" codes
+
+// Post-process with domain knowledge
+func correctSerialNumber(_ text: String) -> String {
+    text.replacingOccurrences(of: "O", with: "0")
+        .replacingOccurrences(of: "l", with: "1")
+        .replacingOccurrences(of: "S", with: "5")
+}
+```
+
+**Time to fix**: 30 min
+
+### Pattern 9c: Text Recognition Too Slow
+
+**Symptom**: Text recognition takes >500ms, real-time camera drops frames
+
+**Diagnostic**:
+
+```swift
+let start = CFAbsoluteTimeGetCurrent()
+try handler.perform([request])
+let elapsed = CFAbsoluteTimeGetCurrent() - start
+
+print("Recognition took \(elapsed * 1000)ms")
+print("Recognition level: \(request.recognitionLevel == .fast ? "fast" : "accurate")")
+print("Language correction: \(request.usesLanguageCorrection)")
+```
+
+**Common causes & fixes**:
+
+| Cause | Fix | Speedup |
+|-------|-----|---------|
+| Using `.accurate` for real-time | Switch to `.fast` | 3-5x |
+| Language correction enabled | Disable for codes | 20-30% |
+| Full image processing | Use `regionOfInterest` | 2-4x |
+| Processing every frame | Skip frames | 50-70% |
+
+**Fix for real-time**:
+
+```swift
+request.recognitionLevel = .fast
+request.usesLanguageCorrection = false
+request.regionOfInterest = CGRect(x: 0.1, y: 0.3, width: 0.8, height: 0.4)
+
+// Skip frames
+frameCount += 1
+guard frameCount % 3 == 0 else { return }
+```
+
+**Time to fix**: 30 min
+
+### Pattern 10a: Barcode Not Detected (Symbology/Size)
+
+**Symptom**: `VNDetectBarcodesRequest` returns no results
+
+**Diagnostic**:
+
+```swift
+let request = VNDetectBarcodesRequest()
+// Don't specify symbologies to detect all types
+try handler.perform([request])
+
+if let results = request.results as? [VNBarcodeObservation] {
+    print("Found \(results.count) barcodes")
+    for barcode in results {
+        print("Type: \(barcode.symbology)")
+        print("Payload: \(barcode.payloadStringValue ?? "nil")")
+        print("Bounds: \(barcode.boundingBox)")
+    }
+} else {
+    print("❌ No barcodes detected")
+}
+```
+
+**Common causes**:
+
+| Cause | Symptom | Fix |
+|-------|---------|-----|
+| Wrong symbology | Not detected | Don't filter, or add correct type |
+| Barcode too small | Not detected | Move camera closer, crop image |
+| Glare/reflection | Not detected | Change angle, improve lighting |
+| Damaged barcode | Partial/no detection | Clean barcode, improve image |
+| Using revision 1 | Only one code | Use revision 2+ for multiple |
+
+**Fix for small barcodes**:
+
+```swift
+// Crop to barcode region for better detection
+let croppedHandler = VNImageRequestHandler(
+    cgImage: croppedImage,
+    options: [:]
+)
+```
+
+**Time to fix**: 20 min
+
+### Pattern 10b: Wrong Barcode Payload
+
+**Symptom**: Barcode detected but `payloadStringValue` is wrong or nil
+
+**Diagnostic**:
+
+```swift
+if let barcode = results.first {
+    print("String payload: \(barcode.payloadStringValue ?? "nil")")
+    print("Raw payload: \(barcode.payloadData ?? Data())")
+    print("Symbology: \(barcode.symbology)")
+    print("Confidence: Implicit (always 1.0 for barcodes)")
+}
+```
+
+**Common causes**:
+
+| Cause | Fix |
+|-------|-----|
+| Binary barcode (not string) | Use `payloadData` instead |
+| Damaged code | Re-scan or clean barcode |
+| Wrong symbology assumed | Check actual `symbology` value |
+
+**Time to fix**: 15 min
+
+### Pattern 11a: DataScanner Blank Screen
+
+**Symptom**: `DataScannerViewController` shows black/blank when presented
+
+**Diagnostic**:
+
+```swift
+// Check support first
+print("isSupported: \(DataScannerViewController.isSupported)")
+print("isAvailable: \(DataScannerViewController.isAvailable)")
+
+// Check camera permission
+let status = AVCaptureDevice.authorizationStatus(for: .video)
+print("Camera access: \(status.rawValue)")
+```
+
+**Common causes**:
+
+| Symptom | Cause | Fix |
+|---------|-------|-----|
+| `isSupported = false` | Device lacks camera/chip | Check before presenting |
+| `isAvailable = false` | Parental controls or access denied | Request camera permission |
+| Black screen | Camera in use by another app | Ensure exclusive access |
+| Crash on present | Missing entitlements | Add camera usage description |
+
+**Fix**:
+
+```swift
+guard DataScannerViewController.isSupported else {
+    showError("Scanning not supported on this device")
+    return
+}
+
+guard DataScannerViewController.isAvailable else {
+    // Request camera access
+    AVCaptureDevice.requestAccess(for: .video) { granted in
+        // Retry after access granted
+    }
+    return
+}
+```
+
+**Time to fix**: 15 min
+
+### Pattern 11b: DataScanner Items Not Detected
+
+**Symptom**: DataScanner shows camera but doesn't recognize items
+
+**Diagnostic**:
+
+```swift
+// Check recognized data types
+print("Data types: \(scanner.recognizedDataTypes)")
+
+// Add delegate to see what's happening
+func dataScanner(_ scanner: DataScannerViewController,
+                 didAdd items: [RecognizedItem],
+                 allItems: [RecognizedItem]) {
+    print("Added \(items.count) items, total: \(allItems.count)")
+    for item in items {
+        switch item {
+        case .text(let text): print("Text: \(text.transcript)")
+        case .barcode(let barcode): print("Barcode: \(barcode.payloadStringValue ?? "")")
+        @unknown default: break
+        }
+    }
+}
+```
+
+**Common causes**:
+
+| Cause | Fix |
+|-------|-----|
+| Wrong data types | Add correct `.barcode(symbologies:)` or `.text()` |
+| Text content type filter | Remove filter or use correct type |
+| Camera too close/far | Adjust distance |
+| Poor lighting | Improve lighting |
+
+**Time to fix**: 20 min
+
+### Pattern 12a: Document Edges Not Detected
+
+**Symptom**: `VNDetectDocumentSegmentationRequest` returns no results
+
+**Diagnostic**:
+
+```swift
+let request = VNDetectDocumentSegmentationRequest()
+try handler.perform([request])
+
+if let observation = request.results?.first {
+    print("Document found at: \(observation.boundingBox)")
+    print("Corners: TL=\(observation.topLeft), TR=\(observation.topRight)")
+} else {
+    print("❌ No document detected")
+}
+```
+
+**Common causes**:
+
+| Cause | Fix |
+|-------|-----|
+| Low contrast | Use contrasting background |
+| Non-rectangular | ML expects rectangular documents |
+| Glare/reflection | Change lighting angle |
+| Document fills frame | Need some background visible |
+
+**Fix**: Use VNDocumentCameraViewController for guided user experience with live feedback.
+
+**Time to fix**: 15 min
+
+### Pattern 12b: Perspective Correction Wrong
+
+**Symptom**: Document extracted but distorted
+
+**Diagnostic**:
+
+```swift
+// Verify corner order
+print("TopLeft: \(observation.topLeft)")
+print("TopRight: \(observation.topRight)")
+print("BottomLeft: \(observation.bottomLeft)")
+print("BottomRight: \(observation.bottomRight)")
+
+// Check if corners are in expected positions
+// TopLeft should have larger Y than BottomLeft (Vision uses lower-left origin)
+```
+
+**Common causes**:
+
+| Cause | Fix |
+|-------|-----|
+| Corner order wrong | Vision uses counterclockwise from top-left |
+| Coordinate system | Convert normalized to pixel coordinates |
+| Filter parameters wrong | Check CIPerspectiveCorrection parameters |
+
+**Fix**:
+
+```swift
+// Scale normalized to image coordinates
+func scaled(_ point: CGPoint, to size: CGSize) -> CGPoint {
+    CGPoint(x: point.x * size.width, y: point.y * size.height)
+}
+```
+
+**Time to fix**: 20 min
+
+## Production Crisis Scenario
+
+**Situation**: App Store review rejected for "app freezes when tapping analyze button"
+
+**Triage (5 min)**:
+1. Confirm Vision running on main thread → Pattern 5a
+2. Verify on older device (iPhone 12) → Freezes
+3. Check profiling: 800ms on main thread
+
+**Fix (15 min)**:
+```swift
+@IBAction func analyzeTapped(_ sender: UIButton) {
+    showLoadingIndicator()
+
+    DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+        let request = VNGenerateForegroundInstanceMaskRequest()
+        // ... perform request
+
+        DispatchQueue.main.async {
+            self?.hideLoadingIndicator()
+            self?.updateUI(with: results)
+        }
+    }
+}
+```
+
+**Communicate to PM**:
+"App Store rejection due to Vision processing on main thread. Fixed by moving to background queue (industry standard). Testing on iPhone 12 confirms fix. Safe to resubmit."
+
+## Quick Reference Table
+
+| Symptom | Likely Cause | First Check | Pattern | Est. Time |
+|---------|--------------|-------------|---------|-----------|
+| No results | Nothing detected | Step 1 output | 1b/1c | 30 min |
+| Intermittent detection | Edge of frame | Subject position | 1c | 20 min |
+| Hand missing landmarks | Low confidence | Step 2 (confidence) | 2 | 45 min |
+| Body pose skipped | Person bent over | Body angle | 3 | 1 hour |
+| UI freezes | Main thread | Step 3 (threading) | 5a | 15 min |
+| Slow processing | Performance tuning | Request timing | 5b | 1 hour |
+| Wrong overlay position | Coordinates | Print points | 6 | 20 min |
+| Missing people (>4) | Crowded scene | Face count | 7 | 30 min |
+| VisionKit no UI | Analysis not set | Interaction state | 8 | 20 min |
+| Text not detected | Image quality | Results count | 9a | 30 min |
+| Wrong characters | Language settings | Candidates list | 9b | 30 min |
+| Text recognition slow | Recognition level | Timing | 9c | 30 min |
+| Barcode not detected | Symbology/size | Results dump | 10a | 20 min |
+| Wrong barcode payload | Damaged/binary | Payload data | 10b | 15 min |
+| DataScanner blank | Availability | isSupported/isAvailable | 11a | 15 min |
+| DataScanner no items | Data types | recognizedDataTypes | 11b | 20 min |
+| Document edges missing | Contrast/shape | Results check | 12a | 15 min |
+| Perspective wrong | Corner order | Corner positions | 12b | 20 min |
+
+## Resources
+
+**WWDC**: 2019-234, 2021-10041, 2022-10024, 2022-10025, 2025-272, 2023-10176, 2020-10653
+
+**Docs**: /vision, /vision/vnrecognizetextrequest, /visionkit
+
+**Skills**: axiom-vision, axiom-vision-ref
diff --git a/.claude/skills/axiom-vision-diag/agents/openai.yaml b/.claude/skills/axiom-vision-diag/agents/openai.yaml
new file mode 100644
index 0000000..b7674df
--- /dev/null
+++ b/.claude/skills/axiom-vision-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "Vision Diagnostics"
+  short_description: "Subject not detected, hand pose missing landmarks, low confidence observations, Vision performance, coordinate conver..."
diff --git a/.claude/skills/axiom-vision-ref/.openskills.json b/.claude/skills/axiom-vision-ref/.openskills.json
new file mode 100644
index 0000000..e6e6986
--- /dev/null
+++ b/.claude/skills/axiom-vision-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-vision-ref",
+  "installedAt": "2026-04-12T08:06:57.824Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-vision-ref/SKILL.md b/.claude/skills/axiom-vision-ref/SKILL.md
new file mode 100644
index 0000000..0f68c0a
--- /dev/null
+++ b/.claude/skills/axiom-vision-ref/SKILL.md
@@ -0,0 +1,1280 @@
+---
+name: axiom-vision-ref
+description: Use when needing Vision framework API details for hand/body pose, segmentation, text recognition, barcode detection, document scanning, or Visual Intelligence integration. Covers VNRequest types, coordinate conversion, DataScannerViewController, RecognizeDocumentsRequest, SemanticContentDescriptor, IntentValueQuery.
+license: MIT
+compatibility: iOS 11+, iPadOS 11+, macOS 10.13+, tvOS 11+, visionOS 1+
+metadata:
+  version: "1.1.0"
+  last-updated: "2026-01-03"
+---
+
+# Vision Framework API Reference
+
+Comprehensive reference for Vision framework computer vision: subject segmentation, hand/body pose detection, person detection, face analysis, text recognition (OCR), barcode detection, and document scanning.
+
+## When to Use This Reference
+
+- **Implementing subject lifting** using VisionKit or Vision
+- **Detecting hand/body poses** for gesture recognition or fitness apps
+- **Segmenting people** from backgrounds or separating multiple individuals
+- **Face detection and landmarks** for AR effects or authentication
+- **Combining Vision APIs** to solve complex computer vision problems
+- **Looking up specific API signatures** and parameter meanings
+- **Recognizing text** in images (OCR) with VNRecognizeTextRequest
+- **Detecting barcodes** and QR codes with VNDetectBarcodesRequest
+- **Building live scanners** with DataScannerViewController
+- **Scanning documents** with VNDocumentCameraViewController
+- **Extracting structured document data** with RecognizeDocumentsRequest (iOS 26+)
+
+**Related skills**: See `axiom-vision` for decision trees and patterns, `axiom-vision-diag` for troubleshooting
+
+## Vision Framework Overview
+
+Vision provides computer vision algorithms for still images and video:
+
+**Core workflow**:
+1. Create request (e.g., `VNDetectHumanHandPoseRequest()`)
+2. Create handler with image (`VNImageRequestHandler(cgImage: image)`)
+3. Perform request (`try handler.perform([request])`)
+4. Access observations from `request.results`
+
+**Coordinate system**: Lower-left origin, normalized (0.0-1.0) coordinates
+
+**Performance**: Run on background queue - resource intensive, blocks UI if on main thread
+
+## Request Handlers
+
+Vision provides two request handlers for different scenarios.
+
+### VNImageRequestHandler
+
+Analyzes a **single image**. Initialize with the image, perform requests against it, discard.
+
+```swift
+let handler = VNImageRequestHandler(cgImage: image)
+try handler.perform([request1, request2])  // Multiple requests, one image
+```
+
+**Initialize with**: `CGImage`, `CIImage`, `CVPixelBuffer`, `Data`, or `URL`
+
+**Rule**: One handler per image. Reusing a handler with a different image is unsupported.
+
+### VNSequenceRequestHandler
+
+Analyzes a **sequence of frames** (video, camera feed). Initialize empty, pass each frame to `perform()`. Maintains inter-frame state for temporal smoothing.
+
+```swift
+let sequenceHandler = VNSequenceRequestHandler()
+
+// In your camera/video frame callback:
+func processFrame(_ pixelBuffer: CVPixelBuffer) throws {
+    try sequenceHandler.perform([request], on: pixelBuffer)
+}
+```
+
+**Rule**: Create once, reuse across frames. The handler tracks state between calls.
+
+### When to Use Which
+
+| Use Case | Handler |
+|----------|---------|
+| Single photo or screenshot | `VNImageRequestHandler` |
+| Video stream or camera frames | `VNSequenceRequestHandler` |
+| Temporal smoothing (pose, segmentation) | `VNSequenceRequestHandler` |
+| One-off analysis of a CVPixelBuffer | `VNImageRequestHandler` |
+
+### Requests That Benefit from Sequence Handling
+
+These requests use inter-frame state when run through `VNSequenceRequestHandler`:
+- `VNDetectHumanBodyPoseRequest` — Smoother joint tracking
+- `VNDetectHumanHandPoseRequest` — Smoother landmark tracking
+- `VNGeneratePersonSegmentationRequest` — Temporally consistent masks
+- `VNGeneratePersonInstanceMaskRequest` — Stable person identity across frames
+- `VNDetectDocumentSegmentationRequest` — Stable document edges
+- Any `VNStatefulRequest` subclass — Designed for sequences
+
+### Common Mistake
+
+Creating a new `VNImageRequestHandler` per video frame discards temporal context. Pose landmarks jitter, segmentation masks flicker, and you lose the smoothing that sequence handling provides.
+
+```swift
+// Wrong — loses temporal context every frame
+func processFrame(_ buffer: CVPixelBuffer) throws {
+    let handler = VNImageRequestHandler(cvPixelBuffer: buffer)
+    try handler.perform([poseRequest])
+}
+
+// Right — maintains inter-frame state
+let sequenceHandler = VNSequenceRequestHandler()
+func processFrame(_ buffer: CVPixelBuffer) throws {
+    try sequenceHandler.perform([poseRequest], on: buffer)
+}
+```
+
+## Subject Segmentation APIs
+
+### VNGenerateForegroundInstanceMaskRequest
+
+**Availability**: iOS 17+, macOS 14+, tvOS 17+, visionOS 1+
+
+Generates class-agnostic instance mask of foreground objects (people, pets, buildings, food, shoes, etc.)
+
+#### Basic Usage
+
+```swift
+let request = VNGenerateForegroundInstanceMaskRequest()
+let handler = VNImageRequestHandler(cgImage: image)
+
+try handler.perform([request])
+
+guard let observation = request.results?.first as? VNInstanceMaskObservation else {
+    return
+}
+```
+
+#### InstanceMaskObservation
+
+**allInstances**: `IndexSet` containing all foreground instance indices (excludes background 0)
+
+**instanceMask**: `CVPixelBuffer` with UInt8 labels (0 = background, 1+ = instance indices)
+
+**instanceAtPoint(_:)**: Returns instance index at normalized point
+
+```swift
+let point = CGPoint(x: 0.5, y: 0.5)  // Center of image
+let instance = observation.instanceAtPoint(point)
+
+if instance == 0 {
+    print("Background tapped")
+} else {
+    print("Instance \(instance) tapped")
+}
+```
+
+#### Generating Masks
+
+**createScaledMask(for:croppedToInstancesContent:)**
+
+Parameters:
+- `for`: `IndexSet` of instances to include
+- `croppedToInstancesContent`:
+  - `false` = Output matches input resolution (for compositing)
+  - `true` = Tight crop around selected instances
+
+Returns: Single-channel floating-point `CVPixelBuffer` (soft segmentation mask)
+
+```swift
+// All instances, full resolution
+let mask = try observation.createScaledMask(
+    for: observation.allInstances,
+    croppedToInstancesContent: false
+)
+
+// Single instance, cropped
+let instances = IndexSet(integer: 1)
+let croppedMask = try observation.createScaledMask(
+    for: instances,
+    croppedToInstancesContent: true
+)
+```
+
+#### Instance Mask Hit Testing
+
+Access raw pixel buffer to map tap coordinates to instance labels:
+
+```swift
+let instanceMask = observation.instanceMask
+
+CVPixelBufferLockBaseAddress(instanceMask, .readOnly)
+defer { CVPixelBufferUnlockBaseAddress(instanceMask, .readOnly) }
+
+let baseAddress = CVPixelBufferGetBaseAddress(instanceMask)
+let width = CVPixelBufferGetWidth(instanceMask)
+let bytesPerRow = CVPixelBufferGetBytesPerRow(instanceMask)
+
+// Convert normalized tap to pixel coordinates
+let pixelPoint = VNImagePointForNormalizedPoint(
+    CGPoint(x: normalizedX, y: normalizedY),
+    width: imageWidth,
+    height: imageHeight
+)
+
+// Calculate byte offset
+let offset = Int(pixelPoint.y) * bytesPerRow + Int(pixelPoint.x)
+
+// Read instance label
+let label = UnsafeRawPointer(baseAddress!).load(
+    fromByteOffset: offset,
+    as: UInt8.self
+)
+
+let instances = label == 0 ? observation.allInstances : IndexSet(integer: Int(label))
+```
+
+## VisionKit Subject Lifting
+
+### ImageAnalysisInteraction (iOS)
+
+**Availability**: iOS 16+, iPadOS 16+
+
+Adds system-like subject lifting UI to views:
+
+```swift
+let interaction = ImageAnalysisInteraction()
+interaction.preferredInteractionTypes = .imageSubject  // Or .automatic
+imageView.addInteraction(interaction)
+```
+
+**Interaction types**:
+- `.automatic`: Subject lifting + Live Text + data detectors
+- `.imageSubject`: Subject lifting only (no interactive text)
+
+### ImageAnalysisOverlayView (macOS)
+
+**Availability**: macOS 13+
+
+```swift
+let overlayView = ImageAnalysisOverlayView()
+overlayView.preferredInteractionTypes = .imageSubject
+nsView.addSubview(overlayView)
+```
+
+### Programmatic Access
+
+#### ImageAnalyzer
+
+```swift
+let analyzer = ImageAnalyzer()
+let configuration = ImageAnalyzer.Configuration([.text, .visualLookUp])
+
+let analysis = try await analyzer.analyze(image, configuration: configuration)
+```
+
+#### ImageAnalysis
+
+**subjects**: `[Subject]` - All subjects in image
+
+**highlightedSubjects**: `Set` - Currently highlighted (user long-pressed)
+
+**subject(at:)**: Async lookup of subject at normalized point (returns `nil` if none)
+
+```swift
+// Get all subjects
+let subjects = analysis.subjects
+
+// Look up subject at tap
+if let subject = try await analysis.subject(at: tapPoint) {
+    // Process subject
+}
+
+// Change highlight state
+analysis.highlightedSubjects = Set([subjects[0], subjects[1]])
+```
+
+#### Subject Struct
+
+**image**: `UIImage`/`NSImage` - Extracted subject with transparency
+
+**bounds**: `CGRect` - Subject boundaries in image coordinates
+
+```swift
+// Single subject image
+let subjectImage = subject.image
+
+// Composite multiple subjects
+let compositeImage = try await analysis.image(for: [subject1, subject2])
+```
+
+**Out-of-process**: VisionKit analysis happens out-of-process (performance benefit, image size limited)
+
+## Person Segmentation APIs
+
+### VNGeneratePersonSegmentationRequest
+
+**Availability**: iOS 15+, macOS 12+
+
+Returns single mask containing **all people** in image:
+
+```swift
+let request = VNGeneratePersonSegmentationRequest()
+// Configure quality level if needed
+try handler.perform([request])
+
+guard let observation = request.results?.first as? VNPixelBufferObservation else {
+    return
+}
+
+let personMask = observation.pixelBuffer  // CVPixelBuffer
+```
+
+### VNGeneratePersonInstanceMaskRequest
+
+**Availability**: iOS 17+, macOS 14+
+
+Returns **separate masks for up to 4 people**:
+
+```swift
+let request = VNGeneratePersonInstanceMaskRequest()
+try handler.perform([request])
+
+guard let observation = request.results?.first as? VNInstanceMaskObservation else {
+    return
+}
+
+// Same InstanceMaskObservation API as foreground instance masks
+let allPeople = observation.allInstances  // Up to 4 people (1-4)
+
+// Get mask for person 1
+let person1Mask = try observation.createScaledMask(
+    for: IndexSet(integer: 1),
+    croppedToInstancesContent: false
+)
+```
+
+**Limitations**:
+- Segments up to 4 people
+- With >4 people: may miss people or combine them (typically background people)
+- Use `VNDetectFaceRectanglesRequest` to count faces if you need to handle crowded scenes
+
+## Hand Pose Detection
+
+### VNDetectHumanHandPoseRequest
+
+**Availability**: iOS 14+, macOS 11+
+
+Detects **21 hand landmarks** per hand:
+
+```swift
+let request = VNDetectHumanHandPoseRequest()
+request.maximumHandCount = 2  // Default: 2, increase if needed
+
+let handler = VNImageRequestHandler(cgImage: image)
+try handler.perform([request])
+
+for observation in request.results as? [VNHumanHandPoseObservation] ?? [] {
+    // Process each hand
+}
+```
+
+**Performance note**: `maximumHandCount` affects latency. Pose computed only for hands ≤ maximum. Set to lowest acceptable value.
+
+### Hand Landmarks (21 points)
+
+**Wrist**: 1 landmark
+
+**Thumb** (4 landmarks):
+- `.thumbTip`
+- `.thumbIP` (interphalangeal joint)
+- `.thumbMP` (metacarpophalangeal joint)
+- `.thumbCMC` (carpometacarpal joint)
+
+**Fingers** (4 landmarks each):
+- Tip (`.indexTip`, `.middleTip`, `.ringTip`, `.littleTip`)
+- DIP (distal interphalangeal joint)
+- PIP (proximal interphalangeal joint)
+- MCP (metacarpophalangeal joint)
+
+### Group Keys
+
+Access landmark groups:
+
+| Group Key | Points |
+|-----------|--------|
+| `.all` | All 21 landmarks |
+| `.thumb` | 4 thumb joints |
+| `.indexFinger` | 4 index finger joints |
+| `.middleFinger` | 4 middle finger joints |
+| `.ringFinger` | 4 ring finger joints |
+| `.littleFinger` | 4 little finger joints |
+
+```swift
+// Get all points
+let allPoints = try observation.recognizedPoints(.all)
+
+// Get index finger points only
+let indexPoints = try observation.recognizedPoints(.indexFinger)
+
+// Get specific point
+let thumbTip = try observation.recognizedPoint(.thumbTip)
+let indexTip = try observation.recognizedPoint(.indexTip)
+
+// Check confidence
+guard thumbTip.confidence > 0.5 else { return }
+
+// Access location (normalized coordinates, lower-left origin)
+let location = thumbTip.location  // CGPoint
+```
+
+### Gesture Recognition Example (Pinch)
+
+```swift
+let thumbTip = try observation.recognizedPoint(.thumbTip)
+let indexTip = try observation.recognizedPoint(.indexTip)
+
+guard thumbTip.confidence > 0.5, indexTip.confidence > 0.5 else {
+    return
+}
+
+let distance = hypot(
+    thumbTip.location.x - indexTip.location.x,
+    thumbTip.location.y - indexTip.location.y
+)
+
+let isPinching = distance < 0.05  // Normalized threshold
+```
+
+### Chirality (Handedness)
+
+```swift
+let chirality = observation.chirality  // .left or .right or .unknown
+```
+
+## Body Pose Detection
+
+### VNDetectHumanBodyPoseRequest (2D)
+
+**Availability**: iOS 14+, macOS 11+
+
+Detects **18 body landmarks** (2D normalized coordinates):
+
+```swift
+let request = VNDetectHumanBodyPoseRequest()
+try handler.perform([request])
+
+for observation in request.results as? [VNHumanBodyPoseObservation] ?? [] {
+    // Process each person
+}
+```
+
+### Body Landmarks (18 points)
+
+**Face** (5 landmarks):
+- `.nose`, `.leftEye`, `.rightEye`, `.leftEar`, `.rightEar`
+
+**Arms** (6 landmarks):
+- Left: `.leftShoulder`, `.leftElbow`, `.leftWrist`
+- Right: `.rightShoulder`, `.rightElbow`, `.rightWrist`
+
+**Torso** (7 landmarks):
+- `.neck` (between shoulders)
+- `.leftShoulder`, `.rightShoulder` (also in arm groups)
+- `.leftHip`, `.rightHip`
+- `.root` (between hips)
+
+**Legs** (6 landmarks):
+- Left: `.leftHip`, `.leftKnee`, `.leftAnkle`
+- Right: `.rightHip`, `.rightKnee`, `.rightAnkle`
+
+**Note**: Shoulders and hips appear in multiple groups
+
+### Group Keys (Body)
+
+| Group Key | Points |
+|-----------|--------|
+| `.all` | All 18 landmarks |
+| `.face` | 5 face landmarks |
+| `.leftArm` | shoulder, elbow, wrist |
+| `.rightArm` | shoulder, elbow, wrist |
+| `.torso` | neck, shoulders, hips, root |
+| `.leftLeg` | hip, knee, ankle |
+| `.rightLeg` | hip, knee, ankle |
+
+```swift
+// Get all body points
+let allPoints = try observation.recognizedPoints(.all)
+
+// Get left arm only
+let leftArmPoints = try observation.recognizedPoints(.leftArm)
+
+// Get specific joint
+let leftWrist = try observation.recognizedPoint(.leftWrist)
+```
+
+### VNDetectHumanBodyPose3DRequest (3D)
+
+**Availability**: iOS 17+, macOS 14+
+
+Returns **3D skeleton with 17 joints** in meters (real-world coordinates):
+
+```swift
+let request = VNDetectHumanBodyPose3DRequest()
+try handler.perform([request])
+
+guard let observation = request.results?.first as? VNHumanBodyPose3DObservation else {
+    return
+}
+
+// Get 3D joint position
+let leftWrist = try observation.recognizedPoint(.leftWrist)
+let position = leftWrist.position  // simd_float4x4 matrix
+let localPosition = leftWrist.localPosition  // Relative to parent joint
+```
+
+**3D Body Landmarks** (17 points): Same as 2D except no ears (15 vs 18 2D landmarks)
+
+#### 3D Observation Properties
+
+**bodyHeight**: Estimated height in meters
+- With depth data: Measured height
+- Without depth data: Reference height (1.8m)
+
+**heightEstimation**: `.measured` or `.reference`
+
+**cameraOriginMatrix**: `simd_float4x4` camera position/orientation relative to subject
+
+**pointInImage(\_:)**: Project 3D joint back to 2D image coordinates
+
+```swift
+let wrist2D = try observation.pointInImage(leftWrist)
+```
+
+#### 3D Point Classes
+
+**VNPoint3D**: Base class with `simd_float4x4` position matrix
+
+**VNRecognizedPoint3D**: Adds identifier (joint name)
+
+**VNHumanBodyRecognizedPoint3D**: Adds `localPosition` and `parentJoint`
+
+```swift
+// Position relative to skeleton root (center of hip)
+let modelPosition = leftWrist.position
+
+// Position relative to parent joint (left elbow)
+let relativePosition = leftWrist.localPosition
+```
+
+#### Depth Input
+
+Vision accepts depth data alongside images:
+
+```swift
+// From AVDepthData
+let handler = VNImageRequestHandler(
+    cvPixelBuffer: imageBuffer,
+    depthData: depthData,
+    orientation: orientation
+)
+
+// From file (automatic depth extraction)
+let handler = VNImageRequestHandler(url: imageURL)  // Depth auto-fetched
+```
+
+**Depth formats**: Disparity or Depth (interchangeable via AVFoundation)
+
+**LiDAR**: Use in live capture sessions for accurate scale/measurement
+
+## Face Detection & Landmarks
+
+### VNDetectFaceRectanglesRequest
+
+**Availability**: iOS 11+
+
+Detects face bounding boxes:
+
+```swift
+let request = VNDetectFaceRectanglesRequest()
+try handler.perform([request])
+
+for observation in request.results as? [VNFaceObservation] ?? [] {
+    let faceBounds = observation.boundingBox  // Normalized rect
+}
+```
+
+### VNDetectFaceLandmarksRequest
+
+**Availability**: iOS 11+
+
+Detects face with detailed landmarks:
+
+```swift
+let request = VNDetectFaceLandmarksRequest()
+try handler.perform([request])
+
+for observation in request.results as? [VNFaceObservation] ?? [] {
+    if let landmarks = observation.landmarks {
+        let leftEye = landmarks.leftEye
+        let nose = landmarks.nose
+        let leftPupil = landmarks.leftPupil  // Revision 2+
+    }
+}
+```
+
+**Revisions**:
+- Revision 1: Basic landmarks
+- Revision 2: Detects upside-down faces
+- Revision 3+: Pupil locations
+
+## Person Detection
+
+### VNDetectHumanRectanglesRequest
+
+**Availability**: iOS 13+
+
+Detects human bounding boxes (torso detection):
+
+```swift
+let request = VNDetectHumanRectanglesRequest()
+try handler.perform([request])
+
+for observation in request.results as? [VNHumanObservation] ?? [] {
+    let humanBounds = observation.boundingBox  // Normalized rect
+}
+```
+
+**Use case**: Faster than pose detection when you only need location
+
+## CoreImage Integration
+
+### CIBlendWithMask Filter
+
+Composite subject on new background using Vision mask:
+
+```swift
+// 1. Get mask from Vision
+let observation = request.results?.first as? VNInstanceMaskObservation
+let visionMask = try observation.createScaledMask(
+    for: observation.allInstances,
+    croppedToInstancesContent: false
+)
+
+// 2. Convert to CIImage
+let maskImage = CIImage(cvPixelBuffer: visionMask)
+
+// 3. Apply filter
+let filter = CIFilter(name: "CIBlendWithMask")!
+filter.setValue(sourceImage, forKey: kCIInputImageKey)
+filter.setValue(maskImage, forKey: kCIInputMaskImageKey)
+filter.setValue(newBackground, forKey: kCIInputBackgroundImageKey)
+
+let output = filter.outputImage  // Composited result
+```
+
+**Parameters**:
+- **Input image**: Original image to mask
+- **Mask image**: Vision's soft segmentation mask
+- **Background image**: New background (or empty image for transparency)
+
+**HDR preservation**: CoreImage preserves high dynamic range from input (Vision/VisionKit output is SDR)
+
+## Text Recognition APIs
+
+### VNRecognizeTextRequest
+
+**Availability**: iOS 13+, macOS 10.15+
+
+Recognizes text in images with configurable accuracy/speed trade-off.
+
+#### Basic Usage
+
+```swift
+let request = VNRecognizeTextRequest()
+request.recognitionLevel = .accurate  // Or .fast
+request.recognitionLanguages = ["en-US", "de-DE"]  // Order matters
+request.usesLanguageCorrection = true
+
+let handler = VNImageRequestHandler(cgImage: image)
+try handler.perform([request])
+
+for observation in request.results as? [VNRecognizedTextObservation] ?? [] {
+    // Get top candidates
+    let candidates = observation.topCandidates(3)
+    let bestText = candidates.first?.string ?? ""
+}
+```
+
+#### Recognition Levels
+
+| Level | Performance | Accuracy | Best For |
+|-------|-------------|----------|----------|
+| `.fast` | Real-time | Good | Camera feed, large text, signs |
+| `.accurate` | Slower | Excellent | Documents, receipts, handwriting |
+
+**Fast path**: Character-by-character recognition (Neural Network → Character Detection)
+
+**Accurate path**: Full-line ML recognition (Neural Network → Line/Word Recognition)
+
+#### Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `recognitionLevel` | `VNRequestTextRecognitionLevel` | `.fast` or `.accurate` |
+| `recognitionLanguages` | `[String]` | BCP 47 language codes, order = priority |
+| `usesLanguageCorrection` | `Bool` | Use language model for correction |
+| `customWords` | `[String]` | Domain-specific vocabulary |
+| `automaticallyDetectsLanguage` | `Bool` | Auto-detect language (iOS 16+) |
+| `minimumTextHeight` | `Float` | Min text height as fraction of image (0-1) |
+| `revision` | `Int` | API version (affects supported languages) |
+
+#### Language Support
+
+```swift
+// Check supported languages for current settings
+let languages = try VNRecognizeTextRequest.supportedRecognitionLanguages(
+    for: .accurate,
+    revision: VNRecognizeTextRequestRevision3
+)
+```
+
+**Language correction**: Improves accuracy but takes processing time. Disable for codes/serial numbers.
+
+**Custom words**: Add domain-specific vocabulary for better recognition (medical terms, product codes).
+
+#### VNRecognizedTextObservation
+
+**boundingBox**: Normalized rect containing recognized text
+
+**topCandidates(_:)**: Returns `[VNRecognizedText]` ordered by confidence
+
+#### VNRecognizedText
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `string` | `String` | Recognized text |
+| `confidence` | `VNConfidence` | 0.0-1.0 |
+| `boundingBox(for:)` | `VNRectangleObservation?` | Box for substring range |
+
+```swift
+// Get bounding box for substring
+let text = candidate.string
+if let range = text.range(of: "invoice") {
+    let box = try candidate.boundingBox(for: range)
+}
+```
+
+## Barcode Detection APIs
+
+### VNDetectBarcodesRequest
+
+**Availability**: iOS 11+, macOS 10.13+
+
+Detects and decodes barcodes and QR codes.
+
+#### Basic Usage
+
+```swift
+let request = VNDetectBarcodesRequest()
+request.symbologies = [.qr, .ean13, .code128]  // Specific codes
+
+let handler = VNImageRequestHandler(cgImage: image)
+try handler.perform([request])
+
+for barcode in request.results as? [VNBarcodeObservation] ?? [] {
+    let payload = barcode.payloadStringValue
+    let type = barcode.symbology
+    let bounds = barcode.boundingBox
+}
+```
+
+#### Symbologies
+
+**1D Barcodes**:
+- `.codabar` (iOS 15+)
+- `.code39`, `.code39Checksum`, `.code39FullASCII`, `.code39FullASCIIChecksum`
+- `.code93`, `.code93i`
+- `.code128`
+- `.ean8`, `.ean13`
+- `.gs1DataBar`, `.gs1DataBarExpanded`, `.gs1DataBarLimited` (iOS 15+)
+- `.i2of5`, `.i2of5Checksum`
+- `.itf14`
+- `.upce`
+
+**2D Codes**:
+- `.aztec`
+- `.dataMatrix`
+- `.microPDF417` (iOS 15+)
+- `.microQR` (iOS 15+)
+- `.pdf417`
+- `.qr`
+
+**Performance**: Specifying fewer symbologies = faster detection
+
+#### Revisions
+
+| Revision | iOS | Features |
+|----------|-----|----------|
+| 1 | 11+ | Basic detection, one code at a time |
+| 2 | 15+ | Codabar, GS1, MicroPDF, MicroQR, better ROI |
+| 3 | 16+ | ML-based, multiple codes, better bounding boxes |
+
+#### VNBarcodeObservation
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `payloadStringValue` | `String?` | Decoded content |
+| `symbology` | `VNBarcodeSymbology` | Barcode type |
+| `boundingBox` | `CGRect` | Normalized bounds |
+| `topLeft/topRight/bottomLeft/bottomRight` | `CGPoint` | Corner points |
+
+## VisionKit Scanner APIs
+
+### DataScannerViewController
+
+**Availability**: iOS 16+
+
+Camera-based live scanner with built-in UI for text and barcodes.
+
+#### Check Availability
+
+```swift
+// Hardware support
+DataScannerViewController.isSupported
+
+// Runtime availability (camera access, parental controls)
+DataScannerViewController.isAvailable
+```
+
+#### Configuration
+
+```swift
+import VisionKit
+
+let dataTypes: Set = [
+    .barcode(symbologies: [.qr, .ean13]),
+    .text(textContentType: .URL),  // Or nil for all text
+    // .text(languages: ["ja"])  // Filter by language
+]
+
+let scanner = DataScannerViewController(
+    recognizedDataTypes: dataTypes,
+    qualityLevel: .balanced,  // .fast, .balanced, .accurate
+    recognizesMultipleItems: true,
+    isHighFrameRateTrackingEnabled: true,
+    isPinchToZoomEnabled: true,
+    isGuidanceEnabled: true,
+    isHighlightingEnabled: true
+)
+
+scanner.delegate = self
+present(scanner, animated: true) {
+    try? scanner.startScanning()
+}
+```
+
+#### RecognizedDataType
+
+| Type | Description |
+|------|-------------|
+| `.barcode(symbologies:)` | Specific barcode types |
+| `.text()` | All text |
+| `.text(languages:)` | Text filtered by language |
+| `.text(textContentType:)` | Text filtered by type (URL, phone, email) |
+
+#### Delegate Protocol
+
+```swift
+protocol DataScannerViewControllerDelegate {
+    func dataScanner(_ dataScanner: DataScannerViewController,
+                     didTapOn item: RecognizedItem)
+
+    func dataScanner(_ dataScanner: DataScannerViewController,
+                     didAdd addedItems: [RecognizedItem],
+                     allItems: [RecognizedItem])
+
+    func dataScanner(_ dataScanner: DataScannerViewController,
+                     didUpdate updatedItems: [RecognizedItem],
+                     allItems: [RecognizedItem])
+
+    func dataScanner(_ dataScanner: DataScannerViewController,
+                     didRemove removedItems: [RecognizedItem],
+                     allItems: [RecognizedItem])
+
+    func dataScanner(_ dataScanner: DataScannerViewController,
+                     becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable)
+}
+```
+
+#### RecognizedItem
+
+```swift
+enum RecognizedItem {
+    case text(RecognizedItem.Text)
+    case barcode(RecognizedItem.Barcode)
+
+    var id: UUID { get }
+    var bounds: RecognizedItem.Bounds { get }
+}
+
+// Text item
+struct Text {
+    let transcript: String
+}
+
+// Barcode item
+struct Barcode {
+    let payloadStringValue: String?
+    let observation: VNBarcodeObservation
+}
+```
+
+#### Async Stream
+
+```swift
+// Alternative to delegate
+for await items in scanner.recognizedItems {
+    // Current recognized items
+}
+```
+
+#### Custom Highlights
+
+```swift
+// Add custom views over recognized items
+scanner.overlayContainerView.addSubview(customHighlight)
+
+// Capture still photo
+let photo = try await scanner.capturePhoto()
+```
+
+### VNDocumentCameraViewController
+
+**Availability**: iOS 13+
+
+Document scanning with automatic edge detection, perspective correction, and lighting adjustment.
+
+#### Basic Usage
+
+```swift
+import VisionKit
+
+let camera = VNDocumentCameraViewController()
+camera.delegate = self
+present(camera, animated: true)
+```
+
+#### Delegate Protocol
+
+```swift
+protocol VNDocumentCameraViewControllerDelegate {
+    func documentCameraViewController(_ controller: VNDocumentCameraViewController,
+                                       didFinishWith scan: VNDocumentCameraScan)
+
+    func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController)
+
+    func documentCameraViewController(_ controller: VNDocumentCameraViewController,
+                                       didFailWithError error: Error)
+}
+```
+
+#### VNDocumentCameraScan
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `pageCount` | `Int` | Number of scanned pages |
+| `imageOfPage(at:)` | `UIImage` | Get page image at index |
+| `title` | `String` | User-editable title |
+
+```swift
+func documentCameraViewController(_ controller: VNDocumentCameraViewController,
+                                   didFinishWith scan: VNDocumentCameraScan) {
+    controller.dismiss(animated: true)
+
+    for i in 0.. [LandmarkEntity] {
+        if !input.labels.isEmpty {
+            return try await modelData.search(matching: input.labels)
+        }
+        guard let pixelBuffer = input.pixelBuffer else { return [] }
+        return try await modelData.search(matching: pixelBuffer)
+    }
+}
+```
+
+### Returning Multiple Result Types
+
+Use `@UnionValue` when your app can return different entity types from a single search.
+
+```swift
+@UnionValue
+enum VisualSearchResult {
+    case landmark(LandmarkEntity)
+    case collection(CollectionEntity)
+}
+```
+
+### Display Representation
+
+Visual Intelligence uses your entity's `DisplayRepresentation` to show results. Provide a title, subtitle, and image for each result.
+
+```swift
+struct LandmarkEntity: AppEntity {
+    var id: String
+    var name: String
+    var location: String
+
+    static var typeDisplayRepresentation: TypeDisplayRepresentation {
+        TypeDisplayRepresentation(
+            name: LocalizedStringResource("Landmark", table: "AppIntents"),
+            numericFormat: "\(placeholder: .int) landmarks"
+        )
+    }
+
+    var displayRepresentation: DisplayRepresentation {
+        DisplayRepresentation(
+            title: "\(name)",
+            subtitle: "\(location)",
+            image: .init(named: thumbnailImageName)
+        )
+    }
+}
+```
+
+### Deep Linking from Results
+
+When a user taps a result, your app should open to the relevant content. Provide an `appLinkURL` on your entity.
+
+```swift
+var appLinkURL: URL? {
+    URL(string: "yourapp://landmark/\(id)")
+}
+```
+
+### "More Results" Intent
+
+For large result sets, provide a `VisualIntelligenceSearchIntent` that opens your app's full search UI.
+
+```swift
+struct ViewMoreLandmarksIntent: AppIntent, VisualIntelligenceSearchIntent {
+    static var title: LocalizedStringResource = "View More Landmarks"
+
+    @Parameter(title: "Semantic Content")
+    var semanticContent: SemanticContentDescriptor
+
+    func perform() async throws -> some IntentResult {
+        // Open your app's search view with the semantic content
+        return .result()
+    }
+}
+```
+
+### Best Practices
+
+- **Return results quickly** — Visual Intelligence expects low-latency responses. Limit to 10-20 most relevant results
+- **Prefer labels first** — Label matching is faster than pixel buffer analysis. Fall back to pixel buffer when labels are empty or insufficient
+- **Localize everything** — Display representations appear in the system UI. Use `LocalizedStringResource` for all user-facing text
+- **Include images** — Results with thumbnails are more recognizable in the Visual Intelligence overlay
+
+### Testing
+
+1. Build and run on a physical device
+2. Activate Visual Intelligence camera or take a screenshot of relevant content
+3. Perform a visual search and verify your app's results appear
+4. Tap results to verify deep linking opens the correct content
+
+## API Quick Reference
+
+### Subject Segmentation
+
+| API | Platform | Purpose |
+|-----|----------|---------|
+| `VNGenerateForegroundInstanceMaskRequest` | iOS 17+ | Class-agnostic subject instances |
+| `VNGeneratePersonInstanceMaskRequest` | iOS 17+ | Up to 4 people separately |
+| `VNGeneratePersonSegmentationRequest` | iOS 15+ | All people (single mask) |
+| `ImageAnalysisInteraction` (VisionKit) | iOS 16+ | UI for subject lifting |
+
+### Pose Detection
+
+| API | Platform | Landmarks | Coordinates |
+|-----|----------|-----------|-------------|
+| `VNDetectHumanHandPoseRequest` | iOS 14+ | 21 per hand | 2D normalized |
+| `VNDetectHumanBodyPoseRequest` | iOS 14+ | 18 body joints | 2D normalized |
+| `VNDetectHumanBodyPose3DRequest` | iOS 17+ | 17 body joints | 3D meters |
+
+### Face & Person Detection
+
+| API | Platform | Purpose |
+|-----|----------|---------|
+| `VNDetectFaceRectanglesRequest` | iOS 11+ | Face bounding boxes |
+| `VNDetectFaceLandmarksRequest` | iOS 11+ | Face with detailed landmarks |
+| `VNDetectHumanRectanglesRequest` | iOS 13+ | Human torso bounding boxes |
+
+### Text & Barcode
+
+| API | Platform | Purpose |
+|-----|----------|---------|
+| `VNRecognizeTextRequest` | iOS 13+ | Text recognition (OCR) |
+| `VNDetectBarcodesRequest` | iOS 11+ | Barcode/QR detection |
+| `DataScannerViewController` | iOS 16+ | Live camera scanner (text + barcodes) |
+| `VNDocumentCameraViewController` | iOS 13+ | Document scanning with perspective correction |
+| `VNDetectDocumentSegmentationRequest` | iOS 15+ | Programmatic document edge detection |
+| `RecognizeDocumentsRequest` | iOS 26+ | Structured document extraction |
+
+### Visual Intelligence
+
+| API | Platform | Purpose |
+|-----|----------|---------|
+| `SemanticContentDescriptor` | iOS 26+ | Describes what the user is looking at (labels + pixel buffer) |
+| `IntentValueQuery` | iOS 26+ | Entry point for receiving visual search requests |
+| `VisualIntelligenceSearchIntent` | iOS 26+ | "More results" deep link to your app |
+
+### Observation Types
+
+| Observation | Returned By |
+|-------------|-------------|
+| `VNInstanceMaskObservation` | Foreground/person instance masks |
+| `VNPixelBufferObservation` | Person segmentation (single mask) |
+| `VNHumanHandPoseObservation` | Hand pose |
+| `VNHumanBodyPoseObservation` | Body pose (2D) |
+| `VNHumanBodyPose3DObservation` | Body pose (3D) |
+| `VNFaceObservation` | Face detection/landmarks |
+| `VNHumanObservation` | Human rectangles |
+| `VNRecognizedTextObservation` | Text recognition |
+| `VNBarcodeObservation` | Barcode detection |
+| `VNRectangleObservation` | Document segmentation |
+| `DocumentObservation` | Structured document (iOS 26+) |
+
+## Resources
+
+**WWDC**: 2019-234, 2021-10041, 2022-10024, 2022-10025, 2025-272, 2023-10176, 2023-111241, 2023-10048, 2020-10653, 2020-10043, 2020-10099
+
+**Docs**: /vision, /visionkit, /visualintelligence, /visualintelligence/semanticcontentdescriptor, /vision/vnrecognizetextrequest, /vision/vndetectbarcodesrequest
+
+**Skills**: axiom-vision, axiom-vision-diag
diff --git a/.claude/skills/axiom-vision-ref/agents/openai.yaml b/.claude/skills/axiom-vision-ref/agents/openai.yaml
new file mode 100644
index 0000000..304ed82
--- /dev/null
+++ b/.claude/skills/axiom-vision-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "Vision Reference"
+  short_description: "Needing Vision framework API details for hand/body pose, segmentation, text recognition, barcode detection, document ..."
diff --git a/.claude/skills/axiom-vision/.openskills.json b/.claude/skills/axiom-vision/.openskills.json
new file mode 100644
index 0000000..154ed0f
--- /dev/null
+++ b/.claude/skills/axiom-vision/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-vision",
+  "installedAt": "2026-04-12T08:06:56.934Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-vision/SKILL.md b/.claude/skills/axiom-vision/SKILL.md
new file mode 100644
index 0000000..054890f
--- /dev/null
+++ b/.claude/skills/axiom-vision/SKILL.md
@@ -0,0 +1,1019 @@
+---
+name: axiom-vision
+description: subject segmentation, VNGenerateForegroundInstanceMaskRequest, isolate object from hand, VisionKit subject lifting, image foreground detection, instance masks, class-agnostic segmentation, VNRecognizeTextRequest, OCR, VNDetectBarcodesRequest, DataScannerViewController, document scanning, RecognizeDocumentsRequest
+license: MIT
+compatibility: iOS 14+, iPadOS 14+, macOS 11+, tvOS 14+, axiom-visionOS 1+
+metadata:
+  version: "1.1.0"
+  last-updated: "2026-01-03"
+---
+
+# Vision Framework Computer Vision
+
+Guides you through implementing computer vision: subject segmentation, hand/body pose detection, person detection, text recognition, barcode detection, document scanning, and combining Vision APIs to solve complex problems.
+
+## When to Use This Skill
+
+Use when you need to:
+- ☑ Isolate subjects from backgrounds (subject lifting)
+- ☑ Detect and track hand poses for gestures
+- ☑ Detect and track body poses for fitness/action classification
+- ☑ Segment multiple people separately
+- ☑ Exclude hands from object bounding boxes (combining APIs)
+- ☑ Choose between VisionKit and Vision framework
+- ☑ Combine Vision with CoreImage for compositing
+- ☑ Decide which Vision API solves your problem
+- ☑ Recognize text in images (OCR)
+- ☑ Detect barcodes and QR codes
+- ☑ Scan documents with perspective correction
+- ☑ Extract structured data from documents (iOS 26+)
+- ☑ Build live scanning experiences (DataScannerViewController)
+
+## Example Prompts
+
+"How do I isolate a subject from the background?"
+"I need to detect hand gestures like pinch"
+"How can I get a bounding box around an object **without including the hand holding it**?"
+"Should I use VisionKit or Vision framework for subject lifting?"
+"How do I segment multiple people separately?"
+"I need to detect body poses for a fitness app"
+"How do I preserve HDR when compositing subjects on new backgrounds?"
+"How do I recognize text in an image?"
+"I need to scan QR codes from camera"
+"How do I extract data from a receipt?"
+"Should I use DataScannerViewController or Vision directly?"
+"How do I scan documents and correct perspective?"
+"I need to extract table data from a document"
+
+## Red Flags
+
+Signs you're making this harder than it needs to be:
+- ❌ Manually implementing subject segmentation with CoreML models
+- ❌ Using ARKit just for body pose (Vision works offline)
+- ❌ Writing gesture recognition from scratch (use hand pose + simple distance checks)
+- ❌ Processing on main thread (blocks UI - Vision is resource intensive)
+- ❌ Training custom models when Vision APIs already exist
+- ❌ Not checking confidence scores (low confidence = unreliable landmarks)
+- ❌ Forgetting to convert coordinates (lower-left origin vs UIKit top-left)
+- ❌ Building custom text recognizer when VNRecognizeTextRequest exists
+- ❌ Using AVFoundation + Vision when DataScannerViewController suffices
+- ❌ Processing every camera frame for scanning (skip frames, use region of interest)
+- ❌ Enabling all barcode symbologies when you only need one (performance hit)
+- ❌ Ignoring RecognizeDocumentsRequest when you need table/list structure (iOS 26+)
+
+## Mandatory First Steps
+
+Before implementing any Vision feature:
+
+### 1. Choose the Right API (Decision Tree)
+
+```
+What do you need to do?
+
+┌─ Isolate subject(s) from background?
+│  ├─ Need system UI + out-of-process → VisionKit
+│  │  └─ ImageAnalysisInteraction (iOS/iPadOS)
+│  │  └─ ImageAnalysisOverlayView (macOS)
+│  ├─ Need custom pipeline / HDR / large images → Vision
+│  │  └─ VNGenerateForegroundInstanceMaskRequest
+│  └─ Need to EXCLUDE hands from object → Combine APIs
+│     └─ Subject mask + Hand pose + custom masking (see Pattern 1)
+│
+├─ Segment people?
+│  ├─ All people in one mask → VNGeneratePersonSegmentationRequest
+│  └─ Separate mask per person (up to 4) → VNGeneratePersonInstanceMaskRequest
+│
+├─ Detect hand pose/gestures?
+│  ├─ Just hand location → VNDetectHumanRectanglesRequest
+│  └─ 21 hand landmarks → VNDetectHumanHandPoseRequest
+│     └─ Gesture recognition → Hand pose + distance checks
+│
+├─ Detect body pose?
+│  ├─ 2D normalized landmarks → VNDetectHumanBodyPoseRequest
+│  ├─ 3D real-world coordinates → VNDetectHumanBodyPose3DRequest
+│  └─ Action classification → Body pose + CreateML model
+│
+├─ Face detection?
+│  ├─ Just bounding boxes → VNDetectFaceRectanglesRequest
+│  └─ Detailed landmarks → VNDetectFaceLandmarksRequest
+│
+├─ Person detection (location only)?
+│  └─ VNDetectHumanRectanglesRequest
+│
+├─ Recognize text in images?
+│  ├─ Real-time from camera + need UI → DataScannerViewController (iOS 16+)
+│  ├─ Processing captured image → VNRecognizeTextRequest
+│  │  ├─ Need speed (real-time camera) → recognitionLevel = .fast
+│  │  └─ Need accuracy (documents) → recognitionLevel = .accurate
+│  └─ Need structured documents (iOS 26+) → RecognizeDocumentsRequest
+│
+├─ Detect barcodes/QR codes?
+│  ├─ Real-time camera + need UI → DataScannerViewController (iOS 16+)
+│  └─ Processing image → VNDetectBarcodesRequest
+│
+└─ Scan documents?
+   ├─ Need built-in UI + perspective correction → VNDocumentCameraViewController
+   ├─ Need structured data (tables, lists) → RecognizeDocumentsRequest (iOS 26+)
+   └─ Custom pipeline → VNDetectDocumentSegmentationRequest + perspective correction
+```
+
+### 2. Set Up Background Processing
+
+**NEVER run Vision on main thread**:
+
+```swift
+let processingQueue = DispatchQueue(label: "com.yourapp.vision", qos: .userInitiated)
+
+processingQueue.async {
+    do {
+        let request = VNGenerateForegroundInstanceMaskRequest()
+        let handler = VNImageRequestHandler(cgImage: image)
+        try handler.perform([request])
+
+        // Process observations...
+
+        DispatchQueue.main.async {
+            // Update UI
+        }
+    } catch {
+        // Handle error
+    }
+}
+```
+
+### 3. Choose the Right Request Handler
+
+Processing video frames? Use `VNSequenceRequestHandler` (maintains inter-frame state for temporal smoothing). For single images, use `VNImageRequestHandler`. Creating a new `VNImageRequestHandler` per frame discards temporal context and causes jittery results. See `axiom-vision-ref` for full comparison and code examples.
+
+### 4. Verify Platform Availability
+
+| API | Minimum Version |
+|-----|-----------------|
+| Subject segmentation (instance masks) | iOS 17+ |
+| VisionKit subject lifting | iOS 16+ |
+| Hand pose | iOS 14+ |
+| Body pose (2D) | iOS 14+ |
+| Body pose (3D) | iOS 17+ |
+| Person instance segmentation | iOS 17+ |
+| VNRecognizeTextRequest (basic) | iOS 13+ |
+| VNRecognizeTextRequest (accurate, multi-lang) | iOS 14+ |
+| VNDetectBarcodesRequest | iOS 11+ |
+| VNDetectBarcodesRequest (revision 2: Codabar, MicroQR) | iOS 15+ |
+| VNDetectBarcodesRequest (revision 3: ML-based) | iOS 16+ |
+| DataScannerViewController | iOS 16+ |
+| VNDocumentCameraViewController | iOS 13+ |
+| VNDetectDocumentSegmentationRequest | iOS 15+ |
+| RecognizeDocumentsRequest | iOS 26+ |
+
+## Common Patterns
+
+### Pattern 1: Isolate Object While Excluding Hand
+
+**User's original problem**: Getting a bounding box around an object held in hand, **without including the hand**.
+
+**Root cause**: `VNGenerateForegroundInstanceMaskRequest` is class-agnostic and treats hand+object as one subject.
+
+**Solution**: Combine subject mask with hand pose to create exclusion mask.
+
+```swift
+// 1. Get subject instance mask
+let subjectRequest = VNGenerateForegroundInstanceMaskRequest()
+let handler = VNImageRequestHandler(cgImage: sourceImage)
+try handler.perform([subjectRequest])
+
+guard let subjectObservation = subjectRequest.results?.first as? VNInstanceMaskObservation else {
+    fatalError("No subject detected")
+}
+
+// 2. Get hand pose landmarks
+let handRequest = VNDetectHumanHandPoseRequest()
+handRequest.maximumHandCount = 2
+try handler.perform([handRequest])
+
+guard let handObservation = handRequest.results?.first as? VNHumanHandPoseObservation else {
+    // No hand detected - use full subject mask
+    let mask = try subjectObservation.createScaledMask(
+        for: subjectObservation.allInstances,
+        croppedToInstancesContent: false
+    )
+    return mask
+}
+
+// 3. Create hand exclusion region from landmarks
+let handPoints = try handObservation.recognizedPoints(.all)
+let handBounds = calculateConvexHull(from: handPoints)  // Your implementation
+
+// 4. Subtract hand region from subject mask using CoreImage
+let subjectMask = try subjectObservation.createScaledMask(
+    for: subjectObservation.allInstances,
+    croppedToInstancesContent: false
+)
+
+let subjectCIMask = CIImage(cvPixelBuffer: subjectMask)
+let handMask = createMaskFromRegion(handBounds, size: sourceImage.size)
+let finalMask = subtractMasks(handMask: handMask, from: subjectCIMask)
+
+// 5. Calculate bounding box from final mask
+let objectBounds = calculateBoundingBox(from: finalMask)
+```
+
+**Helper: Convex Hull**
+
+```swift
+func calculateConvexHull(from points: [VNRecognizedPointKey: VNRecognizedPoint]) -> CGRect {
+    // Get high-confidence points
+    let validPoints = points.values.filter { $0.confidence > 0.5 }
+
+    guard !validPoints.isEmpty else { return .zero }
+
+    // Simple bounding rect (for more accuracy, use actual convex hull algorithm)
+    let xs = validPoints.map { $0.location.x }
+    let ys = validPoints.map { $0.location.y }
+
+    let minX = xs.min()!
+    let maxX = xs.max()!
+    let minY = ys.min()!
+    let maxY = ys.max()!
+
+    return CGRect(
+        x: minX,
+        y: minY,
+        width: maxX - minX,
+        height: maxY - minY
+    )
+}
+```
+
+**Cost**: 2-5 hours initial implementation, 30 min ongoing maintenance
+
+### Pattern 2: VisionKit Simple Subject Lifting
+
+**Use case**: Add system-like subject lifting UI with minimal code.
+
+```swift
+// iOS
+let interaction = ImageAnalysisInteraction()
+interaction.preferredInteractionTypes = .imageSubject
+imageView.addInteraction(interaction)
+
+// macOS
+let overlayView = ImageAnalysisOverlayView()
+overlayView.preferredInteractionTypes = .imageSubject
+nsView.addSubview(overlayView)
+```
+
+**When to use**:
+- ✓ Want system behavior (long-press to select, drag to share)
+- ✓ Don't need custom processing pipeline
+- ✓ Image size within VisionKit limits (out-of-process)
+
+**Cost**: 15 min implementation, 5 min ongoing
+
+### Pattern 3: Programmatic Subject Access (VisionKit)
+
+**Use case**: Need subject images/bounds without UI interaction.
+
+```swift
+let analyzer = ImageAnalyzer()
+let configuration = ImageAnalyzer.Configuration([.text, .visualLookUp])
+
+let analysis = try await analyzer.analyze(sourceImage, configuration: configuration)
+
+// Get all subjects
+for subject in analysis.subjects {
+    let subjectImage = subject.image
+    let subjectBounds = subject.bounds
+
+    // Process subject...
+}
+
+// Tap-based lookup
+if let subject = try await analysis.subject(at: tapPoint) {
+    let compositeImage = try await analysis.image(for: [subject])
+}
+```
+
+**Cost**: 30 min implementation, 10 min ongoing
+
+### Pattern 4: Vision Instance Mask for Custom Pipeline
+
+**Use case**: HDR preservation, large images, custom compositing.
+
+```swift
+let request = VNGenerateForegroundInstanceMaskRequest()
+let handler = VNImageRequestHandler(cgImage: sourceImage)
+try handler.perform([request])
+
+guard let observation = request.results?.first as? VNInstanceMaskObservation else {
+    return
+}
+
+// Get soft segmentation mask
+let mask = try observation.createScaledMask(
+    for: observation.allInstances,
+    croppedToInstancesContent: false  // Full resolution for compositing
+)
+
+// Use with CoreImage for HDR preservation
+let filter = CIFilter(name: "CIBlendWithMask")!
+filter.setValue(CIImage(cgImage: sourceImage), forKey: kCIInputImageKey)
+filter.setValue(CIImage(cvPixelBuffer: mask), forKey: kCIInputMaskImageKey)
+filter.setValue(newBackground, forKey: kCIInputBackgroundImageKey)
+
+let compositedImage = filter.outputImage
+```
+
+**Cost**: 1 hour implementation, 15 min ongoing
+
+### Pattern 5: Tap-to-Select Instance
+
+**Use case**: User taps to select which subject/person to lift.
+
+```swift
+// Get instance at tap point
+let instance = observation.instanceAtPoint(tapPoint)
+
+if instance == 0 {
+    // Background tapped - select all instances
+    let mask = try observation.createScaledMask(
+        for: observation.allInstances,
+        croppedToInstancesContent: false
+    )
+} else {
+    // Specific instance tapped
+    let mask = try observation.createScaledMask(
+        for: IndexSet(integer: instance),
+        croppedToInstancesContent: true
+    )
+}
+```
+
+**Alternative: Raw pixel buffer access**
+
+```swift
+let instanceMask = observation.instanceMask
+
+CVPixelBufferLockBaseAddress(instanceMask, .readOnly)
+defer { CVPixelBufferUnlockBaseAddress(instanceMask, .readOnly) }
+
+let baseAddress = CVPixelBufferGetBaseAddress(instanceMask)
+let bytesPerRow = CVPixelBufferGetBytesPerRow(instanceMask)
+
+// Convert normalized tap to pixel coordinates
+let pixelPoint = VNImagePointForNormalizedPoint(
+    tapPoint,
+    width: imageWidth,
+    height: imageHeight
+)
+
+let offset = Int(pixelPoint.y) * bytesPerRow + Int(pixelPoint.x)
+let label = UnsafeRawPointer(baseAddress!).load(
+    fromByteOffset: offset,
+    as: UInt8.self
+)
+```
+
+**Cost**: 45 min implementation, 10 min ongoing
+
+### Pattern 6: Hand Gesture Recognition (Pinch)
+
+**Use case**: Detect pinch gesture for custom camera trigger or UI control.
+
+```swift
+let request = VNDetectHumanHandPoseRequest()
+request.maximumHandCount = 1
+
+try handler.perform([request])
+
+guard let observation = request.results?.first as? VNHumanHandPoseObservation else {
+    return
+}
+
+let thumbTip = try observation.recognizedPoint(.thumbTip)
+let indexTip = try observation.recognizedPoint(.indexTip)
+
+// Check confidence
+guard thumbTip.confidence > 0.5, indexTip.confidence > 0.5 else {
+    return
+}
+
+// Calculate distance (normalized coordinates)
+let dx = thumbTip.location.x - indexTip.location.x
+let dy = thumbTip.location.y - indexTip.location.y
+let distance = sqrt(dx * dx + dy * dy)
+
+let isPinching = distance < 0.05  // Adjust threshold
+
+// State machine for evidence accumulation
+if isPinching {
+    pinchFrameCount += 1
+    if pinchFrameCount >= 3 {
+        state = .pinched
+    }
+} else {
+    pinchFrameCount = max(0, pinchFrameCount - 1)
+    if pinchFrameCount == 0 {
+        state = .apart
+    }
+}
+```
+
+**Cost**: 2 hours implementation, 20 min ongoing
+
+### Pattern 7: Separate Multiple People
+
+**Use case**: Apply different effects to each person or count people.
+
+```swift
+let request = VNGeneratePersonInstanceMaskRequest()
+try handler.perform([request])
+
+guard let observation = request.results?.first as? VNInstanceMaskObservation else {
+    return
+}
+
+let peopleCount = observation.allInstances.count  // Up to 4
+
+for personIndex in observation.allInstances {
+    let personMask = try observation.createScaledMask(
+        for: IndexSet(integer: personIndex),
+        croppedToInstancesContent: false
+    )
+
+    // Apply effect to this person only
+    applyEffect(to: personMask, personIndex: personIndex)
+}
+```
+
+**Crowded scenes (>4 people)**:
+
+```swift
+// Count faces to detect crowding
+let faceRequest = VNDetectFaceRectanglesRequest()
+try handler.perform([faceRequest])
+
+let faceCount = faceRequest.results?.count ?? 0
+
+if faceCount > 4 {
+    // Fallback: Use single mask for all people
+    let singleMaskRequest = VNGeneratePersonSegmentationRequest()
+    try handler.perform([singleMaskRequest])
+}
+```
+
+**Cost**: 1.5 hours implementation, 15 min ongoing
+
+### Pattern 8: Body Pose for Action Classification
+
+**Use case**: Fitness app that recognizes exercises (jumping jacks, squats, etc.)
+
+```swift
+// 1. Collect body pose observations
+var poseObservations: [VNHumanBodyPoseObservation] = []
+
+let request = VNDetectHumanBodyPoseRequest()
+try handler.perform([request])
+
+if let observation = request.results?.first as? VNHumanBodyPoseObservation {
+    poseObservations.append(observation)
+}
+
+// 2. When you have 60 frames of poses, prepare for CreateML model
+if poseObservations.count == 60 {
+    var multiArray = try MLMultiArray(
+        shape: [60, 18, 3],  // 60 frames, 18 joints, (x, y, confidence)
+        dataType: .double
+    )
+
+    for (frameIndex, observation) in poseObservations.enumerated() {
+        let allPoints = try observation.recognizedPoints(.all)
+
+        for (jointIndex, (_, point)) in allPoints.enumerated() {
+            multiArray[[frameIndex, jointIndex, 0] as [NSNumber]] = NSNumber(value: point.location.x)
+            multiArray[[frameIndex, jointIndex, 1] as [NSNumber]] = NSNumber(value: point.location.y)
+            multiArray[[frameIndex, jointIndex, 2] as [NSNumber]] = NSNumber(value: point.confidence)
+        }
+    }
+
+    // 3. Run inference with CreateML model
+    let input = YourActionClassifierInput(poses: multiArray)
+    let output = try actionClassifier.prediction(input: input)
+
+    let action = output.label  // "jumping_jacks", "squats", etc.
+}
+```
+
+**Cost**: 3-4 hours implementation, 1 hour ongoing
+
+### Pattern 9: Text Recognition (OCR)
+
+**Use case**: Extract text from images, receipts, signs, documents.
+
+```swift
+let request = VNRecognizeTextRequest()
+request.recognitionLevel = .accurate  // Or .fast for real-time
+request.recognitionLanguages = ["en-US"]  // Specify known languages
+request.usesLanguageCorrection = true  // Helps accuracy
+
+let handler = VNImageRequestHandler(cgImage: image)
+try handler.perform([request])
+
+guard let observations = request.results as? [VNRecognizedTextObservation] else {
+    return
+}
+
+for observation in observations {
+    // Get top candidate (most likely)
+    guard let candidate = observation.topCandidates(1).first else { continue }
+
+    let text = candidate.string
+    let confidence = candidate.confidence
+
+    // Get bounding box for specific substring
+    if let range = text.range(of: searchTerm) {
+        if let boundingBox = try? candidate.boundingBox(for: range) {
+            // Use for highlighting
+        }
+    }
+}
+```
+
+**Fast vs Accurate**:
+- **Fast**: Real-time camera, large legible text (signs, billboards), character-by-character
+- **Accurate**: Documents, receipts, small text, handwriting, ML-based word/line recognition
+
+**Language tips**:
+- Order matters: first language determines ML model for accurate path
+- Use `automaticallyDetectsLanguage = true` only when language unknown
+- Query `supportedRecognitionLanguages` for current revision
+
+**Cost**: 30 min basic implementation, 2 hours with language handling
+
+### Pattern 10: Barcode/QR Code Detection
+
+**Use case**: Scan product barcodes, QR codes, healthcare codes.
+
+```swift
+let request = VNDetectBarcodesRequest()
+request.revision = VNDetectBarcodesRequestRevision3  // ML-based, iOS 16+
+request.symbologies = [.qr, .ean13]  // Specify only what you need!
+
+let handler = VNImageRequestHandler(cgImage: image)
+try handler.perform([request])
+
+guard let observations = request.results as? [VNBarcodeObservation] else {
+    return
+}
+
+for barcode in observations {
+    let payload = barcode.payloadStringValue  // Decoded content
+    let symbology = barcode.symbology  // Type of barcode
+    let bounds = barcode.boundingBox  // Location (normalized)
+
+    print("Found \(symbology): \(payload ?? "no string")")
+}
+```
+
+**Performance tip**: Specifying fewer symbologies = faster scanning
+
+**Revision differences**:
+- **Revision 1**: One code at a time, 1D codes return lines
+- **Revision 2**: Codabar, GS1Databar, MicroPDF, MicroQR, better with ROI
+- **Revision 3**: ML-based, multiple codes at once, better bounding boxes, fewer duplicates
+
+**Cost**: 15 min implementation
+
+### Pattern 11: DataScannerViewController (Live Scanning)
+
+**Use case**: Camera-based text/barcode scanning with built-in UI (iOS 16+).
+
+```swift
+import VisionKit
+
+// Check support
+guard DataScannerViewController.isSupported,
+      DataScannerViewController.isAvailable else {
+    // Not supported or camera access denied
+    return
+}
+
+// Configure what to scan
+let recognizedDataTypes: Set = [
+    .barcode(symbologies: [.qr]),
+    .text(textContentType: .URL)  // Or nil for all text
+]
+
+// Create and present
+let scanner = DataScannerViewController(
+    recognizedDataTypes: recognizedDataTypes,
+    qualityLevel: .balanced,  // Or .fast, .accurate
+    recognizesMultipleItems: false,  // Center-most if false
+    isHighFrameRateTrackingEnabled: true,  // For smooth highlights
+    isPinchToZoomEnabled: true,
+    isGuidanceEnabled: true,
+    isHighlightingEnabled: true
+)
+
+scanner.delegate = self
+present(scanner, animated: true) {
+    try? scanner.startScanning()
+}
+```
+
+**Delegate methods**:
+```swift
+func dataScanner(_ scanner: DataScannerViewController,
+                 didTapOn item: RecognizedItem) {
+    switch item {
+    case .text(let text):
+        print("Tapped text: \(text.transcript)")
+    case .barcode(let barcode):
+        print("Tapped barcode: \(barcode.payloadStringValue ?? "")")
+    @unknown default: break
+    }
+}
+
+// For custom highlights
+func dataScanner(_ scanner: DataScannerViewController,
+                 didAdd addedItems: [RecognizedItem],
+                 allItems: [RecognizedItem]) {
+    for item in addedItems {
+        let highlight = createHighlight(for: item)
+        scanner.overlayContainerView.addSubview(highlight)
+    }
+}
+```
+
+**Async stream alternative**:
+```swift
+for await items in scanner.recognizedItems {
+    // Process current items
+}
+```
+
+**Cost**: 45 min implementation with custom highlights
+
+### Pattern 12: Document Scanning with VNDocumentCameraViewController
+
+**Use case**: Scan paper documents with automatic edge detection and perspective correction.
+
+```swift
+import VisionKit
+
+let documentCamera = VNDocumentCameraViewController()
+documentCamera.delegate = self
+present(documentCamera, animated: true)
+
+// In delegate
+func documentCameraViewController(_ controller: VNDocumentCameraViewController,
+                                   didFinishWith scan: VNDocumentCameraScan) {
+    controller.dismiss(animated: true)
+
+    // Process each page
+    for pageIndex in 0.. String? {
+        seenStrings.first { $0.value >= threshold }?.key
+    }
+}
+```
+
+**Key techniques from WWDC 2019**:
+- Use `.fast` recognition level for real-time
+- Disable language correction for codes/numbers
+- Use region of interest to improve speed and focus
+- Build evidence over multiple frames (string tracker)
+- Apply domain knowledge (phone number regex)
+
+**Cost**: 2 hours implementation
+
+## Anti-Patterns
+
+### Anti-Pattern 1: Processing on Main Thread
+
+**Wrong**:
+```swift
+let request = VNGenerateForegroundInstanceMaskRequest()
+let handler = VNImageRequestHandler(cgImage: image)
+try handler.perform([request])  // Blocks UI!
+```
+
+**Right**:
+```swift
+DispatchQueue.global(qos: .userInitiated).async {
+    let request = VNGenerateForegroundInstanceMaskRequest()
+    let handler = VNImageRequestHandler(cgImage: image)
+    try handler.perform([request])
+
+    DispatchQueue.main.async {
+        // Update UI
+    }
+}
+```
+
+**Why it matters**: Vision is resource-intensive. Blocking main thread freezes UI.
+
+### Anti-Pattern 2: Ignoring Confidence Scores
+
+**Wrong**:
+```swift
+let thumbTip = try observation.recognizedPoint(.thumbTip)
+let location = thumbTip.location  // May be unreliable!
+```
+
+**Right**:
+```swift
+let thumbTip = try observation.recognizedPoint(.thumbTip)
+guard thumbTip.confidence > 0.5 else {
+    // Low confidence - landmark unreliable
+    return
+}
+let location = thumbTip.location
+```
+
+**Why it matters**: Low confidence points are inaccurate (occlusion, blur, edge of frame).
+
+### Anti-Pattern 3: Forgetting Coordinate Conversion
+
+**Wrong** (mixing coordinate systems):
+```swift
+// Vision uses lower-left origin
+let visionPoint = recognizedPoint.location  // (0, 0) = bottom-left
+
+// UIKit uses top-left origin
+let uiPoint = CGPoint(x: axiom-visionPoint.x, y: axiom-visionPoint.y)  // WRONG!
+```
+
+**Right**:
+```swift
+let visionPoint = recognizedPoint.location
+
+// Convert to UIKit coordinates
+let uiPoint = CGPoint(
+    x: axiom-visionPoint.x * imageWidth,
+    y: (1 - visionPoint.y) * imageHeight  // Flip Y axis
+)
+```
+
+**Why it matters**: Mismatched origins cause UI overlays to appear in wrong positions.
+
+### Anti-Pattern 4: Setting maximumHandCount Too High
+
+**Wrong**:
+```swift
+let request = VNDetectHumanHandPoseRequest()
+request.maximumHandCount = 10  // "Just in case"
+```
+
+**Right**:
+```swift
+let request = VNDetectHumanHandPoseRequest()
+request.maximumHandCount = 2  // Only compute what you need
+```
+
+**Why it matters**: Performance scales with `maximumHandCount`. Pose computed for all detected hands ≤ max.
+
+### Anti-Pattern 5: Using ARKit When Vision Suffices
+
+**Wrong** (if you don't need AR):
+```swift
+// Requires AR session just for body pose
+let arSession = ARBodyTrackingConfiguration()
+```
+
+**Right**:
+```swift
+// Vision works offline on still images
+let request = VNDetectHumanBodyPoseRequest()
+```
+
+**Why it matters**: ARKit body pose requires rear camera, AR session, supported devices. Vision works everywhere (even offline).
+
+## Pressure Scenarios
+
+### Scenario 1: "Just Ship the Feature"
+
+**Context**: Product manager wants subject lifting "like in Photos app" by Friday. You're considering skipping background processing.
+
+**Pressure**: "It's working on my iPhone 15 Pro, let's ship it."
+
+**Reality**: Vision blocks UI on older devices. Users on iPhone 12 will experience frozen app.
+
+**Correct action**:
+1. Implement background queue (15 min)
+2. Add loading indicator (10 min)
+3. Test on iPhone 12 or earlier (5 min)
+
+**Push-back template**: "Subject lifting works, but it freezes the UI on older devices. I need 30 minutes to add background processing and prevent 1-star reviews."
+
+### Scenario 2: "Training Our Own Model"
+
+**Context**: Designer wants to exclude hands from subject bounding box. Engineer suggests training custom CoreML model for specific object detection.
+
+**Pressure**: "We need perfect bounds, let's train a model."
+
+**Reality**: Training requires labeled dataset (weeks), ongoing maintenance, and still won't generalize to new objects. Built-in Vision APIs + hand pose solve it in 2-5 hours.
+
+**Correct action**:
+1. Explain Pattern 1 (combine subject mask + hand pose)
+2. Prototype in 1 hour to demonstrate
+3. Compare against training timeline (weeks vs hours)
+
+**Push-back template**: "Training a model takes weeks and only works for specific objects. I can combine Vision APIs to solve this in a few hours and it'll work for any object."
+
+### Scenario 3: "We Can't Wait for iOS 17"
+
+**Context**: You need instance masks but app supports iOS 15+.
+
+**Pressure**: "Just use iOS 15 person segmentation and ship it."
+
+**Reality**: `VNGeneratePersonSegmentationRequest` (iOS 15) returns single mask for all people. Doesn't solve multi-person use case.
+
+**Correct action**:
+1. Raise minimum deployment target to iOS 17 (best UX)
+2. OR implement fallback: use iOS 15 API but disable multi-person features
+3. OR use `@available` to conditionally enable features
+
+**Push-back template**: "Person segmentation on iOS 15 combines all people into one mask. We can either require iOS 17 for the best experience, or disable multi-person features on older OS versions. Which do you prefer?"
+
+## Checklist
+
+Before shipping Vision features:
+
+**Performance**:
+- ☑ All Vision requests run on background queue
+- ☑ UI shows loading indicator during processing
+- ☑ Tested on iPhone 12 or earlier (not just latest devices)
+- ☑ `maximumHandCount` set to minimum needed value
+
+**Accuracy**:
+- ☑ Confidence scores checked before using landmarks
+- ☑ Fallback behavior for low confidence observations
+- ☑ Handles case where no subjects/hands/people detected
+
+**Coordinates**:
+- ☑ Vision coordinates (lower-left origin) converted to UIKit (top-left)
+- ☑ Normalized coordinates scaled to pixel dimensions
+- ☑ UI overlays aligned correctly with image
+
+**Platform Support**:
+- ☑ `@available` checks for iOS 17+ APIs (instance masks)
+- ☑ Fallback for iOS 14-16 (or raised deployment target)
+- ☑ Tested on actual devices, not just simulator
+
+**Edge Cases**:
+- ☑ Handles images with no detectable subjects
+- ☑ Handles partially occluded hands/bodies
+- ☑ Handles hands/bodies near image edges
+- ☑ Handles >4 people for person instance segmentation
+
+**CoreImage Integration** (if applicable):
+- ☑ HDR preservation verified with high dynamic range images
+- ☑ Mask resolution matches source image
+- ☑ `croppedToInstancesContent` set appropriately (false for compositing)
+
+**Text/Barcode Recognition** (if applicable):
+- ☑ Recognition level matches use case (fast for real-time, accurate for documents)
+- ☑ Language correction disabled for codes/serial numbers
+- ☑ Barcode symbologies limited to actual needs (performance)
+- ☑ Region of interest used to focus scanning area
+- ☑ Multiple candidates checked (not just top candidate)
+- ☑ Evidence accumulated over frames for real-time (string tracker)
+- ☑ DataScannerViewController availability checked before presenting
+
+## Resources
+
+**WWDC**: 2019-234, 2021-10041, 2022-10024, 2022-10025, 2025-272, 2023-10176, 2023-111241, 2020-10653
+
+**Docs**: /vision, /visionkit, /vision/vnrecognizetextrequest, /vision/vndetectbarcodesrequest
+
+**Skills**: axiom-vision-ref, axiom-vision-diag
diff --git a/.claude/skills/axiom-vision/agents/openai.yaml b/.claude/skills/axiom-vision/agents/openai.yaml
new file mode 100644
index 0000000..814c733
--- /dev/null
+++ b/.claude/skills/axiom-vision/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "Vision"
+  short_description: "Subject segmentation, VNGenerateForegroundInstanceMaskRequest, isolate object from hand, VisionKit subject lifting, i..."
diff --git a/.claude/skills/axiom-xclog-ref/.openskills.json b/.claude/skills/axiom-xclog-ref/.openskills.json
new file mode 100644
index 0000000..5646a41
--- /dev/null
+++ b/.claude/skills/axiom-xclog-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-xclog-ref",
+  "installedAt": "2026-04-12T08:06:58.239Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-xclog-ref/SKILL.md b/.claude/skills/axiom-xclog-ref/SKILL.md
new file mode 100644
index 0000000..bc3525f
--- /dev/null
+++ b/.claude/skills/axiom-xclog-ref/SKILL.md
@@ -0,0 +1,292 @@
+---
+name: axiom-xclog-ref
+description: Use when capturing iOS simulator console output, diagnosing runtime crashes, viewing print/os_log output, or needing structured app logs for analysis. Reference for xclog CLI covering launch, attach, list modes with JSON output.
+license: MIT
+metadata:
+  version: "1.0.0"
+---
+
+# xclog Reference (iOS Simulator Console Capture)
+
+xclog captures iOS simulator console output by combining `simctl launch --console` (print/debugPrint/NSLog) with `log stream --style json` (os_log/Logger). Single binary, no dependencies.
+
+## Binary Location
+
+```bash
+${CLAUDE_PLUGIN_ROOT}/bin/xclog
+```
+
+## When to Use
+
+- **Runtime crashes** — capture what the app logged before crashing
+- **Silent failures** — network calls, data operations that fail without UI feedback
+- **Debugging print() output** — see what the app is printing to stdout/stderr
+- **os_log analysis** — structured logging with subsystem, category, and level filtering
+- **Automated log capture** — `--timeout` and `--max-lines` for bounded collection
+
+## Critical Best Practices
+
+**Check `.axiom/preferences.yaml` first.** If no saved preferences, run `list` before `launch` to discover the correct bundle ID.
+
+**App already running?** `launch` will terminate it and relaunch. Use `attach` if you need to preserve current state (os_log only — no print() capture).
+
+```bash
+# 1. FIRST: Check .axiom/preferences.yaml for saved device and bundle ID
+# 2. If no preferences: Discover installed apps
+${CLAUDE_PLUGIN_ROOT}/bin/xclog list
+
+# 3. Find the target app's bundle_id from output
+# 4. THEN: Launch with the correct bundle ID (restarts app)
+${CLAUDE_PLUGIN_ROOT}/bin/xclog launch com.example.MyApp --timeout 30s --max-lines 200
+
+# OR: Attach to running app without restarting (os_log only)
+${CLAUDE_PLUGIN_ROOT}/bin/xclog attach MyApp --timeout 30s --max-lines 200
+```
+
+## Preferences
+
+Axiom saves simulator preferences to `.axiom/preferences.yaml` in the project root. **Check this file before running `xclog list`** — if preferences exist, use the saved device and bundle ID directly.
+
+### Reading Preferences
+
+Before running `xclog list`, read `.axiom/preferences.yaml`:
+
+```yaml
+simulator:
+  device: iPhone 16 Pro
+  deviceUDID: 1A2B3C4D-5E6F-7890-ABCD-EF1234567890
+  bundleId: com.example.MyApp
+```
+
+If the file exists and contains a `simulator` section, use the saved `deviceUDID` and `bundleId` for xclog commands. Skip `xclog list` unless the user asks for a different app or the saved values fail.
+
+```bash
+${CLAUDE_PLUGIN_ROOT}/bin/xclog launch  --device  --timeout 30s --max-lines 200
+```
+
+If the file doesn't exist or the `simulator` section is missing, fall back to `xclog list` discovery.
+
+If the saved `deviceUDID` is not found among available simulators (xclog or simctl fails), fall back to discovery and save the new selection.
+
+If the YAML is malformed, warn the developer and fall back to discovery. Do not overwrite a malformed file.
+
+### Writing Preferences
+
+After a successful `xclog launch` or when the user selects a target app from `xclog list` output, save the device and bundle ID:
+
+1. If `.axiom/` doesn't exist, create it. Then check `.gitignore`: if the file exists, check if any line matches `.axiom/` exactly — if not, append `.axiom/` on a new line. If `.gitignore` doesn't exist, create it with `.axiom/` as its content.
+2. Read `.axiom/preferences.yaml` if it exists (to preserve other keys)
+3. Update the `simulator:` section with `device`, `deviceUDID`, and `bundleId`
+4. Write the merged YAML back using the Write tool
+
+Write the same `simulator:` structure shown in Reading Preferences above.
+
+## Commands
+
+### list — Discover Installed Apps
+
+```bash
+${CLAUDE_PLUGIN_ROOT}/bin/xclog list
+${CLAUDE_PLUGIN_ROOT}/bin/xclog list --device 
+```
+
+Output (JSON lines):
+```json
+{"bundle_id":"com.example.MyApp","name":"MyApp","version":"1.2.0"}
+{"bundle_id":"com.apple.mobilesafari","name":"Safari","version":"18.0"}
+```
+
+### launch — Full Console Capture
+
+Launches the app and captures ALL output: print(), debugPrint(), NSLog(), os_log(), Logger.
+
+```bash
+# Basic launch (JSON output, runs until app exits or Ctrl-C)
+${CLAUDE_PLUGIN_ROOT}/bin/xclog launch com.example.MyApp
+
+# Bounded capture (recommended for LLM use)
+${CLAUDE_PLUGIN_ROOT}/bin/xclog launch com.example.MyApp --timeout 30s --max-lines 200
+
+# Filter by subsystem
+${CLAUDE_PLUGIN_ROOT}/bin/xclog launch com.example.MyApp --subsystem com.example.MyApp.networking
+
+# Filter by regex
+${CLAUDE_PLUGIN_ROOT}/bin/xclog launch com.example.MyApp --filter "error|warning|crash"
+
+# Save to file
+${CLAUDE_PLUGIN_ROOT}/bin/xclog launch com.example.MyApp --output /tmp/console.log --timeout 60s
+```
+
+### attach — Monitor Running Process
+
+Attaches to a running process via os_log only. Does NOT capture print()/debugPrint(). Simulator only.
+
+```bash
+# By process name
+${CLAUDE_PLUGIN_ROOT}/bin/xclog attach MyApp --timeout 30s
+
+# By PID
+${CLAUDE_PLUGIN_ROOT}/bin/xclog attach 12345 --max-lines 100
+
+# Filter for errors only
+${CLAUDE_PLUGIN_ROOT}/bin/xclog attach MyApp --filter "(?i)error|fault"
+```
+
+### show — Historical Log Search (Simulator + Physical Device)
+
+Searches recent logs without needing proactive capture. Works with both simulator and connected physical devices.
+
+```bash
+# Simulator: show last 5 minutes of MyApp logs
+${CLAUDE_PLUGIN_ROOT}/bin/xclog show MyApp --last 5m --max-lines 200
+
+# Simulator: show last 10 minutes, errors only
+${CLAUDE_PLUGIN_ROOT}/bin/xclog show MyApp --last 10m --max-lines 100 --filter "(?i)error|fault"
+
+# Physical device: collect and show logs (device must be connected + unlocked)
+${CLAUDE_PLUGIN_ROOT}/bin/xclog show MyApp --device-udid 00008101-... --last 5m --max-lines 200
+
+# By PID
+${CLAUDE_PLUGIN_ROOT}/bin/xclog show 12345 --last 2m
+```
+
+**Physical device workflow**: `show --device-udid` runs `log collect` to pull a log archive from the device over USB, then parses it locally. The device must be connected and unlocked.
+
+**When to use `show` vs `attach`**:
+- `show` — "What just happened?" (post-mortem, no setup needed)
+- `attach` — "What's happening now?" (live streaming, must be running before the event)
+
+## Output Format
+
+Default output is JSON lines (one JSON object per line).
+
+### JSON Schema (Default)
+
+```json
+{
+  "time": "10:30:45.123",
+  "source": "os_log",
+  "level": "error",
+  "subsystem": "com.example.MyApp",
+  "category": "networking",
+  "process": "MyApp",
+  "pid": 12345,
+  "text": "Connection failed: timeout"
+}
+```
+
+| Field | Type | Present | Description |
+|-------|------|---------|-------------|
+| time | string | Always | HH:MM:SS.mmm timestamp |
+| source | string | Always | `"print"`, `"stderr"`, or `"os_log"` |
+| level | string | os_log only | `"debug"`, `"default"`, `"info"`, `"error"`, `"fault"` |
+| subsystem | string | os_log only | Reverse-DNS subsystem (e.g. `com.example.MyApp`) |
+| category | string | os_log only | Log category within subsystem |
+| process | string | os_log only | Process binary name |
+| pid | int | os_log only | Process ID |
+| text | string | Always | The log message content |
+
+Fields not applicable to a source are omitted (not null).
+
+### Human-Readable Mode
+
+```bash
+${CLAUDE_PLUGIN_ROOT}/bin/xclog attach MyApp --human
+${CLAUDE_PLUGIN_ROOT}/bin/xclog attach MyApp --human --no-color
+```
+
+## Options Reference
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `--device ` | `booted` | Target simulator UDID |
+| `--device-udid ` | none | Physical device UDID (show command) |
+| `--output ` | stdout | Also write to file |
+| `--human` | off | Human-readable colored output |
+| `--no-color` | off | Disable ANSI colors (--human mode) |
+| `--filter ` | none | Filter lines by Go regex |
+| `--subsystem ` | none | Filter os_log by subsystem |
+| `--max-lines ` | 0 (unlimited) | Stop after n lines |
+| `--timeout ` | 0 (unlimited) | Stop after duration (e.g. `30s`, `5m`) |
+| `--last ` | `5m` | How far back to search (show command) |
+
+## Coverage by Source
+
+| Swift API | launch | attach | show |
+|-----------|:------:|:------:|:----:|
+| `print()` | yes | no | no |
+| `debugPrint()` | yes | no | no |
+| `NSLog()` | yes | yes | yes |
+| `os_log()` | yes | yes | yes |
+| `Logger` | yes | yes | yes |
+
+| | Simulator | Physical Device |
+|---|:-:|:-:|
+| `launch` | yes | no |
+| `attach` | yes | no |
+| `show` | yes | yes |
+| `Logger` | yes | yes |
+
+**Use `launch` for full coverage.** `attach` is for monitoring already-running processes.
+
+**Note**: `launch` terminates any existing instance of the app before relaunching. If the app is already running and you don't want to restart it, use `attach` (os_log only).
+
+## Error Behavior
+
+xclog prints errors to stderr and exits with code 1. Common errors:
+
+| Error | Cause | Fix |
+|-------|-------|-----|
+| `simctl launch: ...` | Bad bundle ID or no booted simulator | Run `xclog list` to verify bundle ID; check `xcrun simctl list devices booted` |
+| `could not parse PID from simctl output` | App failed to launch | Check the app builds and runs in the simulator |
+| `invalid filter regex` | Bad `--filter` pattern | Check Go regex syntax (similar to RE2) |
+| `invalid subsystem` | Subsystem contains spaces or special characters | Use reverse-DNS format: `com.example.MyApp` (alphanumeric, dots, underscores, hyphens only) |
+
+## Interpreting Output
+
+### Filtering by Level
+
+os_log levels indicate severity. For crash diagnosis, focus on `error` and `fault`.
+
+**Note**: `--filter` matches against the **message text**, not the JSON output. To filter by level, use jq:
+
+```bash
+${CLAUDE_PLUGIN_ROOT}/bin/xclog launch com.example.MyApp --timeout 30s 2>/dev/null | jq -c 'select(.level == "error" or .level == "fault")'
+```
+
+For text-based filtering, `--filter` works on message content:
+```bash
+# Filter messages containing "error" or "failed" (case-insensitive)
+${CLAUDE_PLUGIN_ROOT}/bin/xclog launch com.example.MyApp --filter "(?i)error|failed"
+```
+
+### Common Subsystem Patterns
+
+| Subsystem | What it indicates |
+|-----------|------------------|
+| `com.apple.network` | URLSession / networking layer |
+| `com.apple.coredata` | Core Data / persistence |
+| `com.apple.swiftui` | SwiftUI framework |
+| `com.apple.uikit` | UIKit framework |
+| App's own subsystem | Application-level logging |
+
+### Workflow: Diagnose a Runtime Crash
+
+1. `xclog list` → find bundle ID
+2. `xclog launch  --timeout 60s --max-lines 500 --output /tmp/crash.log` → start capture (this restarts the app — expected)
+3. Reproduce the crash in the simulator
+4. Read `/tmp/crash.log` and filter for errors: `jq 'select(.level == "error" or .level == "fault")' /tmp/crash.log`
+5. Check the last few lines before the stream ended (crash point)
+
+If the crash is intermittent, increase bounds: `--timeout 120s --max-lines 1000` and repeat.
+
+### Workflow: Investigate Silent Failure
+
+1. `xclog launch  --subsystem com.example.MyApp --timeout 30s`
+2. Trigger the failing operation
+3. Look for error-level messages in the app's subsystem
+4. Cross-reference with network or data subsystems if app logs are silent
+
+## Resources
+
+**Skills**: axiom-xcode-debugging, axiom-performance-profiling, axiom-lldb
diff --git a/.claude/skills/axiom-xclog-ref/agents/openai.yaml b/.claude/skills/axiom-xclog-ref/agents/openai.yaml
new file mode 100644
index 0000000..4ace28a
--- /dev/null
+++ b/.claude/skills/axiom-xclog-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+  display_name: "xclog Reference"
+  short_description: "Capturing iOS simulator console output, diagnosing runtime crashes, viewing print/os_log output, or needing structure..."
diff --git a/.claude/skills/axiom-xcode-debugging/.openskills.json b/.claude/skills/axiom-xcode-debugging/.openskills.json
new file mode 100644
index 0000000..63203ec
--- /dev/null
+++ b/.claude/skills/axiom-xcode-debugging/.openskills.json
@@ -0,0 +1,7 @@
+{
+  "source": "CharlesWiltgen/Axiom",
+  "sourceType": "git",
+  "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+  "subpath": "axiom-codex/skills/axiom-xcode-debugging",
+  "installedAt": "2026-04-12T08:06:58.664Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-xcode-debugging/SKILL.md b/.claude/skills/axiom-xcode-debugging/SKILL.md
new file mode 100644
index 0000000..ce4d334
--- /dev/null
+++ b/.claude/skills/axiom-xcode-debugging/SKILL.md
@@ -0,0 +1,287 @@
+---
+name: axiom-xcode-debugging
+description: Use when encountering BUILD FAILED, test crashes, simulator hangs, stale builds, zombie xcodebuild processes, "Unable to boot simulator", "No such module" after SPM changes, or mysterious test failures despite no code changes - systematic environment-first diagnostics for iOS/macOS projects
+license: MIT
+metadata:
+  version: "1.0.0"
+---
+
+# Xcode Debugging
+
+## Overview
+
+Check build environment BEFORE debugging code. **Core principle** 80% of "mysterious" Xcode issues are environment problems (stale Derived Data, stuck simulators, zombie processes), not code bugs.
+
+## Example Prompts
+
+These are real questions developers ask that this skill is designed to answer:
+
+#### 1. "My build is failing with 'BUILD FAILED' but no error details. I haven't changed anything. What's going on?"
+→ The skill shows environment-first diagnostics: check Derived Data, simulator states, and zombie processes before investigating code
+
+#### 2. "Tests passed yesterday with no code changes, but now they're failing. This is frustrating. How do I fix this?"
+→ The skill explains stale Derived Data and intermittent failures, shows the 2-5 minute fix (clean Derived Data)
+
+#### 3. "My app builds fine but it's running the old code from before my changes. I restarted Xcode but it still happens."
+→ The skill demonstrates that Derived Data caches old builds, shows how deletion forces a clean rebuild
+
+#### 4. "The simulator says 'Unable to boot simulator' and I can't run tests. How do I recover?"
+→ The skill covers simulator state diagnosis with simctl and safe recovery patterns (erase/shutdown/reboot)
+
+#### 5. "I'm getting 'No such module: SomePackage' errors after updating SPM dependencies. How do I fix this?"
+→ The skill explains SPM caching issues and the clean Derived Data workflow that resolves "phantom" module errors
+
+---
+
+## Red Flags — Check Environment First
+
+If you see ANY of these, suspect environment not code:
+- "It works on my machine but not CI"
+- "Tests passed yesterday, failing today with no code changes"
+- "Build succeeds but old code executes"
+- "Build sometimes succeeds, sometimes fails" (intermittent failures)
+- "Simulator stuck at splash screen" or "Unable to install app"
+- Multiple xcodebuild processes (10+) older than 30 minutes
+
+## Mandatory First Steps
+
+**ALWAYS run these commands FIRST** (before reading code):
+
+```bash
+# 1. Check processes (zombie xcodebuild?)
+ps aux | grep -E "xcodebuild|Simulator" | grep -v grep
+
+# 2. Check Derived Data size (>10GB = stale)
+du -sh ~/Library/Developer/Xcode/DerivedData
+
+# 3. Check simulator states (stuck Booting?)
+xcrun simctl list devices | grep -E "Booted|Booting|Shutting Down"
+```
+
+#### What these tell you
+- **0 processes + small Derived Data + no booted sims** → Environment clean, investigate code
+- **10+ processes OR >10GB Derived Data OR simulators stuck** → Environment problem, clean first
+- **Stale code executing OR intermittent failures** → Clean Derived Data regardless of size
+
+#### Why environment first
+- Environment cleanup: 2-5 minutes → problem solved
+- Code debugging for environment issues: 30-120 minutes → wasted time
+
+## Quick Fix Workflow
+
+### Finding Your Scheme Name
+
+If you don't know your scheme name:
+```bash
+# List available schemes
+xcodebuild -list
+```
+
+### For Stale Builds / "No such module" Errors
+```bash
+# Clean everything
+xcodebuild clean -scheme YourScheme
+rm -rf ~/Library/Developer/Xcode/DerivedData/*
+rm -rf .build/ build/
+
+# Rebuild
+xcodebuild build -scheme YourScheme \
+  -destination 'platform=iOS Simulator,name=iPhone 16'
+```
+
+### For Simulator Issues
+```bash
+# Shutdown all simulators
+xcrun simctl shutdown all
+
+# If simctl command fails, shutdown and retry
+xcrun simctl shutdown all
+xcrun simctl list devices
+
+# If still stuck, erase specific simulator
+xcrun simctl erase 
+
+# Nuclear option: force-quit Simulator.app
+killall -9 Simulator
+```
+
+### For Zombie Processes
+```bash
+# Kill all xcodebuild (use cautiously)
+killall -9 xcodebuild
+
+# Check they're gone
+ps aux | grep xcodebuild | grep -v grep
+```
+
+### For Test Failures
+```bash
+# Isolate failing test
+xcodebuild test -scheme YourScheme \
+  -destination 'platform=iOS Simulator,name=iPhone 16' \
+  -only-testing:YourTests/SpecificTestClass
+```
+
+## Simulator Verification (Optional)
+
+After applying fixes, verify in simulator with visual confirmation.
+
+### Quick Screenshot Verification
+
+```bash
+# 1. Boot simulator (if not already)
+xcrun simctl boot "iPhone 16 Pro"
+
+# 2. Build and install app
+xcodebuild build -scheme YourScheme \
+  -destination 'platform=iOS Simulator,name=iPhone 16 Pro'
+
+# 3. Launch app
+xcrun simctl launch booted com.your.bundleid
+
+# 4. Wait for UI to stabilize
+sleep 2
+
+# 5. Capture screenshot
+xcrun simctl io booted screenshot /tmp/verify-build-$(date +%s).png
+```
+
+### Using Axiom Tools
+
+**Quick screenshot**:
+```bash
+/axiom:screenshot
+```
+
+**Full simulator testing** (with navigation, state setup):
+```bash
+/axiom:test-simulator
+```
+
+### When to Use Simulator Verification
+
+Use when:
+- **Visual fixes** — Layout changes, UI updates, styling tweaks
+- **State-dependent bugs** — "Only happens in this specific screen"
+- **Intermittent failures** — Need to reproduce specific conditions
+- **Before shipping** — Final verification that fix actually works
+
+**Pro tip**: If you have debug deep links (see `axiom-deep-link-debugging` skill), you can navigate directly to the screen that was broken:
+```bash
+xcrun simctl openurl booted "debug://problem-screen"
+sleep 1
+xcrun simctl io booted screenshot /tmp/fix-verification.png
+```
+
+## Decision Tree
+
+```
+Test/build failing?
+├─ BUILD FAILED with no details?
+│  └─ Clean Derived Data → rebuild
+├─ Build intermittent (sometimes succeeds/fails)?
+│  └─ Clean Derived Data → rebuild
+├─ Build succeeds but old code executes?
+│  └─ Delete Derived Data → rebuild (2-5 min fix)
+├─ "Unable to boot simulator"?
+│  └─ xcrun simctl shutdown all → erase simulator
+├─ "No such module PackageName"?
+│  └─ Clean + delete Derived Data → rebuild
+├─ Tests hang indefinitely?
+│  └─ Check simctl list → reboot simulator
+├─ Tests crash?
+│  └─ Check ~/Library/Logs/DiagnosticReports/*.crash
+└─ Code logic bug?
+   └─ Use systematic-debugging skill instead
+```
+
+## Common Error Patterns
+
+| Error | Fix |
+|-------|-----|
+| `BUILD FAILED` (no details) | Delete Derived Data |
+| `Unable to boot simulator` | `xcrun simctl erase ` |
+| `No such module` | Clean + delete Derived Data |
+| Tests hang | Check simctl list, reboot simulator |
+| Stale code executing | Delete Derived Data |
+
+## Useful CLI Tools
+
+```bash
+# Show build settings
+xcodebuild -showBuildSettings -scheme YourScheme
+
+# List schemes/targets
+xcodebuild -list
+
+# Verbose output
+xcodebuild -verbose build -scheme YourScheme
+
+# Build without testing (faster)
+xcodebuild build-for-testing -scheme YourScheme
+xcodebuild test-without-building -scheme YourScheme
+
+# Version and build number management (agvtool)
+xcrun agvtool what-marketing-version          # Current version (e.g., 2.0)
+xcrun agvtool what-version                    # Current build number
+xcrun agvtool next-version -all               # Bump build number
+xcrun agvtool new-version -all 42             # Set specific build number
+xcrun agvtool new-marketing-version 2.1       # Set marketing version
+
+# Validate asset catalogs
+xcrun amlint Assets.xcassets                   # Lint for issues before build
+```
+
+## Physical Device Management (devicectl)
+
+`devicectl` is the modern CLI for physical device operations (replaces legacy `idevice*` tools).
+
+```bash
+# List connected devices
+xcrun devicectl list devices
+
+# Install app on device
+xcrun devicectl device install app --device  MyApp.app
+
+# Launch app on device
+xcrun devicectl device process launch --device  com.your.bundleid
+
+# List installed apps
+xcrun devicectl device info apps --device 
+
+# List running processes
+xcrun devicectl device info processes --device 
+```
+
+**When to use**: Physical device debugging when the issue doesn't reproduce in Simulator — install, launch, and inspect from CLI.
+
+## Crash Log Analysis
+
+```bash
+# Recent crashes
+ls -lt ~/Library/Logs/DiagnosticReports/*.crash | head -5
+
+# Symbolicate a single address (if you have .dSYM)
+xcrun atos -o YourApp.app.dSYM/Contents/Resources/DWARF/YourApp \
+  -arch arm64 -l 0x100000000 0x
+ +# Symbolicate an entire crash log at once (LLDB Python script, may vary by Xcode version) +xcrun crashlog MyCrash.ips +``` + +## Common Mistakes + +❌ **Debugging code before checking environment** — Always run mandatory steps first + +❌ **Ignoring simulator states** — "Booting" can hang 10+ minutes, shutdown/reboot immediately + +❌ **Assuming git changes caused the problem** — Derived Data caches old builds despite code changes + +❌ **Running full test suite when one test fails** — Use `-only-testing` to isolate + +## Real-World Impact + +**Before** 30+ min debugging "why is old code running" +**After** 2 min environment check → clean Derived Data → problem solved + +**Key insight** Check environment first, debug code second. diff --git a/.claude/skills/axiom-xcode-debugging/agents/openai.yaml b/.claude/skills/axiom-xcode-debugging/agents/openai.yaml new file mode 100644 index 0000000..e197bc0 --- /dev/null +++ b/.claude/skills/axiom-xcode-debugging/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Xcode Debugging" + short_description: "Encountering BUILD FAILED, test crashes, simulator hangs, stale builds, zombie xcodebuild processes, \"Unable to boot ..." diff --git a/.claude/skills/axiom-xcode-mcp-ref/.openskills.json b/.claude/skills/axiom-xcode-mcp-ref/.openskills.json new file mode 100644 index 0000000..e0745f5 --- /dev/null +++ b/.claude/skills/axiom-xcode-mcp-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-xcode-mcp-ref", + "installedAt": "2026-04-12T08:06:59.096Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-xcode-mcp-ref/SKILL.md b/.claude/skills/axiom-xcode-mcp-ref/SKILL.md new file mode 100644 index 0000000..ee2e241 --- /dev/null +++ b/.claude/skills/axiom-xcode-mcp-ref/SKILL.md @@ -0,0 +1,286 @@ +--- +name: axiom-xcode-mcp-ref +description: Reference — all 20 Xcode MCP tools with parameters, return schemas, and examples +license: MIT +--- + +# Xcode MCP Tool Reference + +Complete reference for all 20 tools exposed by Xcode's MCP server (`xcrun mcpbridge`). + +**Source**: Xcode 26.3 `tools/list` response. Validated against Keith Smiley's gist (2025-07-15). + +**Critical**: `tabIdentifier` is required by 18 of 20 tools. Always call `XcodeListWindows` first. + +## Discovery + +### XcodeListWindows + +Returns open Xcode windows. **Call this first** to get `tabIdentifier` values. + +- **Parameters**: None +- **Returns**: `{ message: string }` — description of open windows +- **Notes**: Only tool that does not require `tabIdentifier`. + +--- + +## File Operations + +### XcodeRead + +Read file contents (cat -n format, 600 lines default). + +- **Parameters**: + - `tabIdentifier` (string, required) + - `filePath` (string, required) — project-relative or absolute + - `limit` (integer, optional) — max lines to return + - `offset` (integer, optional) — starting line number +- **Returns**: `{ content, filePath, fileSize, linesRead, startLine, totalLines }` + +### XcodeWrite + +Create or overwrite a file. Automatically adds new files to the project structure. + +- **Parameters**: + - `tabIdentifier` (string, required) + - `filePath` (string, required) + - `content` (string, required) +- **Returns**: `{ success, filePath, absolutePath, bytesWritten, linesWritten, wasExistingFile, message }` + +### XcodeUpdate + +Edit an existing file with text replacement. + +- **Parameters**: + - `tabIdentifier` (string, required) + - `filePath` (string, required) + - `oldString` (string, required) — text to find + - `newString` (string, required) — replacement text + - `replaceAll` (boolean, optional, default false) — replace all occurrences +- **Returns**: `{ filePath, editsApplied, success, originalContentLength, modifiedContentLength, message }` +- **Notes**: Single replacement by default. Each `oldString` must be unique unless `replaceAll` is true. Prefer over XcodeWrite for editing existing files. + +### XcodeGlob + +Find files matching a wildcard pattern. + +- **Parameters**: + - `tabIdentifier` (string, required) + - `pattern` (string, optional, default `**/*`) — glob pattern + - `path` (string, optional) — directory to search within +- **Returns**: `{ matches[], pattern, searchPath, truncated, totalFound, message }` + +### XcodeGrep + +Search file contents with regex. + +- **Parameters**: + - `tabIdentifier` (string, required) + - `pattern` (string, required) — regex pattern + - `glob` (string, optional) — file pattern filter + - `path` (string, optional) — directory scope + - `type` (string, optional) — file type filter + - `ignoreCase` (boolean, optional) + - `multiline` (boolean, optional) + - `outputMode` (enum, optional) — `content`, `filesWithMatches`, `count` + - `linesContext` (integer, optional) — context lines + - `linesBefore` (integer, optional) + - `linesAfter` (integer, optional) + - `headLimit` (integer, optional) — max results + - `showLineNumbers` (boolean, optional) +- **Returns**: `{ results[], pattern, searchPath, matchCount, truncated, message }` +- **Notes**: Mirrors ripgrep's interface. Use `outputMode` to control result format. + +### XcodeLS + +List directory contents. + +- **Parameters**: + - `tabIdentifier` (string, required) + - `path` (string, required) + - `recursive` (boolean, optional, default true) + - `ignore` (array of strings, optional) — patterns to skip +- **Returns**: `{ items[], path }` + +### XcodeMakeDir + +Create a directory in the project. + +- **Parameters**: + - `tabIdentifier` (string, required) + - `directoryPath` (string, required) +- **Returns**: `{ success, message, createdPath }` + +### XcodeRM + +Remove files or directories from project. Uses Trash by default. + +- **Parameters**: + - `tabIdentifier` (string, required) + - `path` (string, required) + - `deleteFiles` (boolean, optional, default true) — move to Trash + - `recursive` (boolean, optional) +- **Returns**: `{ removedPath, success, message }` + +### XcodeMV + +Move or copy files. + +- **Parameters**: + - `tabIdentifier` (string, required) + - `sourcePath` (string, required) + - `destinationPath` (string, required) + - `operation` (enum, optional) — `move` or `copy` + - `overwriteExisting` (boolean, optional) +- **Returns**: `{ success, operation, message, sourceOriginalPath, destinationFinalPath }` +- **Notes**: Can copy, not just move. May break imports — confirm with user. + +--- + +## Build & Test + +### BuildProject + +Build the project and wait for completion. + +- **Parameters**: + - `tabIdentifier` (string, required) +- **Returns**: `{ buildResult, elapsedTime, errors[] }` +- **Notes**: Each error has `classification`, `filePath`, `lineNumber`, `message`. + +### GetBuildLog + +Retrieve build log with optional filtering. + +- **Parameters**: + - `tabIdentifier` (string, required) + - `severity` (enum, optional) — `remark`, `warning`, `error` + - `pattern` (string, optional) — regex filter + - `glob` (string, optional) — file pattern filter +- **Returns**: `{ buildIsRunning, buildLogEntries[], buildResult, fullLogPath, truncated, totalFound }` +- **Notes**: Returns structured entries, not raw text. Each entry has `buildTask` and `emittedIssues[]`. + +### RunAllTests + +Run the full test suite from the active scheme's test plan. + +- **Parameters**: + - `tabIdentifier` (string, required) +- **Returns**: `{ summary, counts, results[], schemeName, activeTestPlanName }` +- **Notes**: `counts` has `total`, `passed`, `failed`, `skipped`, `expectedFailures`, `notRun`. Each result has `targetName`, `identifier`, `displayName`, `state`. + +### RunSomeTests + +Run specific tests by identifier. + +- **Parameters**: + - `tabIdentifier` (string, required) + - `tests` (array, required) — each element: `{ targetName: string, testIdentifier: string }` +- **Returns**: Same shape as RunAllTests +- **Notes**: Use `GetTestList` to discover valid test identifiers. + +### GetTestList + +List available tests from the active test plan. + +- **Parameters**: + - `tabIdentifier` (string, required) +- **Returns**: `{ tests[], schemeName, activeTestPlanName }` +- **Notes**: Each test has `targetName`, `identifier`, `displayName`, `isEnabled`, `filePath`, `lineNumber`, `tags[]`. + +--- + +## Diagnostics + +### XcodeListNavigatorIssues + +Get issues from Xcode's Issue Navigator. + +- **Parameters**: + - `tabIdentifier` (string, required) + - `severity` (enum, optional) — `remark`, `warning`, `error` + - `pattern` (string, optional) — regex filter + - `glob` (string, optional) — file pattern filter +- **Returns**: `{ issues[], truncated, totalFound, message }` +- **Notes**: Each issue has `message`, `severity`, `path`, `line`, `category`, `vitality` (fresh/stale). Structured and deduplicated. + +### XcodeRefreshCodeIssuesInFile + +Refresh diagnostics for a specific file. + +- **Parameters**: + - `tabIdentifier` (string, required) + - `filePath` (string, required) +- **Returns**: `{ filePath, diagnosticsCount, content, success }` +- **Notes**: Triggers Xcode to re-analyze the file. + +--- + +## Execution & Rendering + +### ExecuteSnippet + +Build and run a code snippet in the context of a source file. + +- **Parameters**: + - `tabIdentifier` (string, required) + - `codeSnippet` (string, required) — code to execute + - `sourceFilePath` (string, required) — Swift file whose context the snippet runs in (has access to its `fileprivate` declarations) + - `timeout` (integer, optional, default 120) — seconds +- **Returns**: `{ executionResults }` — console output from print statements +- **Notes**: Not a generic REPL. Runs in the context of a specific file. No `language` parameter — Swift only. + +### RenderPreview + +Render a SwiftUI preview snapshot. + +- **Parameters**: + - `tabIdentifier` (string, required) + - `sourceFilePath` (string, required) — Swift file with `#Preview` + - `previewDefinitionIndexInFile` (integer, optional, default 0) — zero-based index of which `#Preview` to render + - `timeout` (integer, optional, default 120) +- **Returns**: `{ previewSnapshotPath }` — path to rendered image +- **Notes**: Index-based, not name-based. First `#Preview` in the file is index 0. + +--- + +## Search + +### DocumentationSearch + +Search Apple Developer Documentation semantically. + +- **Parameters**: + - `query` (string, required) + - `frameworks` (array of strings, optional) — scope to specific frameworks +- **Returns**: `{ documents[] }` — each with `title`, `uri`, `contents`, `score` +- **Notes**: Local semantic search (MLX-accelerated), not web search. + +--- + +## Quick Reference + +| Category | Tools | +|----------|-------| +| **Discovery** | `XcodeListWindows` | +| **File Read** | `XcodeRead`, `XcodeGlob`, `XcodeGrep`, `XcodeLS` | +| **File Write** | `XcodeWrite`, `XcodeUpdate`, `XcodeMakeDir` | +| **File Destructive** | `XcodeRM`, `XcodeMV` | +| **Build** | `BuildProject`, `GetBuildLog` | +| **Test** | `RunAllTests`, `RunSomeTests`, `GetTestList` | +| **Diagnostics** | `XcodeListNavigatorIssues`, `XcodeRefreshCodeIssuesInFile` | +| **Execution** | `ExecuteSnippet` | +| **Preview** | `RenderPreview` | +| **Search** | `DocumentationSearch` | + +## Common Parameter Patterns + +- **`tabIdentifier`** — Required by 18/20 tools. Always call `XcodeListWindows` first. +- **`filePath`** — Used by XcodeRead, XcodeWrite, XcodeUpdate, XcodeRefreshCodeIssuesInFile. Project-relative or absolute. +- **`path`** — Used by XcodeLS, XcodeRM, XcodeGlob. Directory path. +- **`directoryPath`** — Used by XcodeMakeDir. +- **`sourceFilePath`** — Used by ExecuteSnippet, RenderPreview. Must be a Swift source file. + +## Resources + +**Skills**: axiom-xcode-mcp-setup, axiom-xcode-mcp-tools diff --git a/.claude/skills/axiom-xcode-mcp-ref/agents/openai.yaml b/.claude/skills/axiom-xcode-mcp-ref/agents/openai.yaml new file mode 100644 index 0000000..ed4dab8 --- /dev/null +++ b/.claude/skills/axiom-xcode-mcp-ref/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Xcode MCP Reference" + short_description: "Reference — all 20 Xcode MCP tools with parameters, return schemas, and examples" diff --git a/.claude/skills/axiom-xcode-mcp-setup/.openskills.json b/.claude/skills/axiom-xcode-mcp-setup/.openskills.json new file mode 100644 index 0000000..e5a8b66 --- /dev/null +++ b/.claude/skills/axiom-xcode-mcp-setup/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-xcode-mcp-setup", + "installedAt": "2026-04-12T08:06:59.505Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-xcode-mcp-setup/SKILL.md b/.claude/skills/axiom-xcode-mcp-setup/SKILL.md new file mode 100644 index 0000000..6a973b7 --- /dev/null +++ b/.claude/skills/axiom-xcode-mcp-setup/SKILL.md @@ -0,0 +1,193 @@ +--- +name: axiom-xcode-mcp-setup +description: Xcode MCP setup — enable mcpbridge, per-client config, permission handling, multi-Xcode targeting, troubleshooting +license: MIT +--- + +# Xcode MCP Setup + +## Prerequisites + +- **Xcode 26.3+** with MCP support +- **macOS** with Xcode installed and running +- At least one project/workspace open in Xcode + +## Step 1: Enable MCP in Xcode + +1. Open Xcode **Settings** (Cmd+,) +2. Go to **Intelligence** tab +3. Check **Enable Model Context Protocol** +4. Ensure **Xcode Tools** toggle is ON + +Without this toggle, `xcrun mcpbridge` connects but returns no tools. + +## Step 2: Connect Your MCP Client + +### Claude Code + +```bash +claude mcp add --transport stdio xcode -- xcrun mcpbridge +``` + +Verify: `claude mcp list` should show `xcode` server. + +### Codex + +```bash +codex mcp add xcode -- xcrun mcpbridge +``` + +### Cursor + +Create or edit `.cursor/mcp.json` in your project root: + +```json +{ + "mcpServers": { + "xcode": { + "command": "xcrun", + "args": ["mcpbridge"] + } + } +} +``` + +**Cursor-specific note**: Cursor is a strict MCP client. Xcode's mcpbridge omits `structuredContent` when tools declare `outputSchema`, which violates the MCP spec. If Cursor rejects responses, use [XcodeMCPWrapper](https://github.com/SoundBlaster/XcodeMCPWrapper) as a proxy: + +```json +{ + "mcpServers": { + "xcode": { + "command": "/path/to/XcodeMCPWrapper", + "args": [] + } + } +} +``` + +### VS Code + GitHub Copilot + +Create or edit `.vscode/mcp.json`: + +```json +{ + "servers": { + "xcode": { + "type": "stdio", + "command": "xcrun", + "args": ["mcpbridge"] + } + } +} +``` + +### Gemini CLI + +```bash +gemini mcp add xcode -- xcrun mcpbridge +``` + +## Step 3: Verify Connection + +After configuration, call `XcodeListWindows` (no parameters). You should see: + +``` +tabIdentifier: , workspacePath: /path/to/YourProject.xcodeproj +``` + +If you see an empty list, ensure a project is open in Xcode. + +## Permission Dialog + +When an MCP client first connects, Xcode shows a **permission dialog**: + +- Identifies the connecting process by **PID** +- Asks to allow MCP tool access +- Must be approved in Xcode's UI (not terminal) + +**PID-based approval**: Permission is granted per-process. If the client restarts (new PID), you'll see the dialog again. This is expected behavior. + +## Multi-Xcode Targeting + +When multiple Xcode instances are running: + +### Auto-Detection (default) + +mcpbridge auto-selects using this fallback: +1. If exactly one Xcode process is running → uses that +2. If multiple → uses the one matching `xcode-select` +3. If none → exits with error + +### Manual PID Selection + +Set `MCP_XCODE_PID` to target a specific instance: + +```bash +# Find Xcode PIDs +pgrep -x Xcode + +# Claude Code with specific PID +claude mcp add --transport stdio xcode -- env MCP_XCODE_PID=12345 xcrun mcpbridge +``` + +### Session ID (optional) + +`MCP_XCODE_SESSION_ID` provides a stable UUID for tool sessions, useful when tracking interactions across reconnections. + +## Troubleshooting + +```dot +digraph troubleshoot { + rankdir=TB; + "Connection failed?" [shape=diamond]; + "tools/list empty?" [shape=diamond]; + "Wrong project?" [shape=diamond]; + "Repeated permission prompts?" [shape=diamond]; + "Client rejects responses?" [shape=diamond]; + + "Check Xcode running + toggle on" [shape=box]; + "Open a project in Xcode" [shape=box]; + "Use MCP_XCODE_PID or check tab targeting" [shape=box]; + "Expected: PID changes on restart" [shape=box]; + "Use XcodeMCPWrapper proxy" [shape=box]; + + "Connection failed?" -> "Check Xcode running + toggle on" [label="refused/timeout"]; + "Connection failed?" -> "tools/list empty?" [label="connects OK"]; + "tools/list empty?" -> "Open a project in Xcode" [label="no tools"]; + "tools/list empty?" -> "Wrong project?" [label="tools listed"]; + "Wrong project?" -> "Use MCP_XCODE_PID or check tab targeting" [label="yes"]; + "Wrong project?" -> "Repeated permission prompts?" [label="no"]; + "Repeated permission prompts?" -> "Expected: PID changes on restart" [label="yes"]; + "Repeated permission prompts?" -> "Client rejects responses?" [label="no"]; + "Client rejects responses?" -> "Use XcodeMCPWrapper proxy" [label="strict client (Cursor)"]; +} +``` + +### Common Issues + +| Symptom | Cause | Fix | +|---------|-------|-----| +| "Connection refused" | Xcode not running or MCP toggle off | Launch Xcode, enable MCP in Settings > Intelligence | +| tools/list returns empty | No project open, or permission not granted | Open a project, check for permission dialog in Xcode | +| Tools target wrong project | Multiple Xcode windows, wrong tab | Call `XcodeListWindows`, use correct `tabIdentifier` | +| Repeated permission prompts | Client restarted (new PID) | Expected behavior — approve each time | +| Cursor/strict client errors | Missing `structuredContent` in response | Use XcodeMCPWrapper as proxy | +| "No such command: mcpbridge" | Xcode < 26.3 | Update to Xcode 26.3+ | +| Slow/hanging tool calls | Large project indexing | Wait for Xcode indexing to complete | + +### Xcode Built-in Assistant Config + +Xcode also supports MCP servers for its built-in assistants. Config files live at: + +``` +~/Library/Developer/Xcode/CodingAssistant/codex +~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig +``` + +These are for configuring Xcode's **internal** assistant, separate from external MCP client setup. + +## Resources + +**Docs**: /xcode/mcp-server + +**Skills**: axiom-xcode-mcp-tools, axiom-xcode-mcp-ref diff --git a/.claude/skills/axiom-xcode-mcp-setup/agents/openai.yaml b/.claude/skills/axiom-xcode-mcp-setup/agents/openai.yaml new file mode 100644 index 0000000..e7a1214 --- /dev/null +++ b/.claude/skills/axiom-xcode-mcp-setup/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Xcode MCP Setup" + short_description: "Xcode MCP setup — enable mcpbridge, per-client config, permission handling, multi-Xcode targeting, troubleshooting" diff --git a/.claude/skills/axiom-xcode-mcp-tools/.openskills.json b/.claude/skills/axiom-xcode-mcp-tools/.openskills.json new file mode 100644 index 0000000..fc1f7cb --- /dev/null +++ b/.claude/skills/axiom-xcode-mcp-tools/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-xcode-mcp-tools", + "installedAt": "2026-04-12T08:06:59.920Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-xcode-mcp-tools/SKILL.md b/.claude/skills/axiom-xcode-mcp-tools/SKILL.md new file mode 100644 index 0000000..88ef231 --- /dev/null +++ b/.claude/skills/axiom-xcode-mcp-tools/SKILL.md @@ -0,0 +1,184 @@ +--- +name: axiom-xcode-mcp-tools +description: Xcode MCP workflow patterns — BuildFix loop, TestFix loop, preview verification, window targeting, tool gotchas +license: MIT +--- + +# Xcode MCP Tool Workflows + +**Core principle**: Xcode MCP gives you programmatic IDE access. Use workflow loops, not isolated tool calls. + +## Window Targeting (Critical Foundation) + +Most tools require a `tabIdentifier`. **Always call `XcodeListWindows` first.** + +``` +1. XcodeListWindows → list of (tabIdentifier, workspacePath) pairs +2. Match workspacePath to your project +3. Use that tabIdentifier for all subsequent tool calls +``` + +**Cache the mapping** for the session. Only re-fetch if: +- A tool call fails with an invalid tab identifier +- You opened/closed Xcode windows +- You switched projects + +**If `XcodeListWindows` returns empty**: Xcode has no project open. Ask the user to open their project. + +## Workflow: BuildFix Loop + +Iteratively build, diagnose, and fix until the project compiles. + +``` +1. BuildProject(tabIdentifier) +2. Check buildResult — if success, done +3. GetBuildLog(tabIdentifier) → parse errors +4. XcodeListNavigatorIssues(tabIdentifier) → canonical diagnostics +5. XcodeUpdate(file, fix) for each diagnostic +6. Go to step 1 (max 5 iterations) +7. If same error persists after 3 attempts → fall back to axiom-xcode-debugging +``` + +**Why `XcodeListNavigatorIssues` over build log parsing**: The Issue Navigator provides structured, deduplicated diagnostics. Build logs contain raw compiler output with noise. + +**When to fall back to `axiom-xcode-debugging`**: When the error is environmental (zombie processes, stale Derived Data, simulator issues) rather than code-level. MCP tools operate on code; environment issues need CLI diagnostics. + +## Workflow: TestFix Loop + +Fast iteration on failing tests. + +``` +1. GetTestList(tabIdentifier) → discover available tests +2. RunSomeTests(tabIdentifier, [specific failing tests]) for fast iteration +3. Parse failures → identify code to fix +4. XcodeUpdate(file, fix) to patch code +5. Go to step 2 (max 5 iterations per test) +6. RunAllTests(tabIdentifier) as final verification +``` + +**Why `RunSomeTests` first**: Running a single test takes seconds. Running all tests takes minutes. Iterate on the failing test, then verify the full suite once it passes. + +**Parsing test results**: Look for `testResult` field in the response. Failed tests include failure messages with file paths and line numbers. + +## Workflow: PreviewVerify + +Render SwiftUI previews and verify UI changes visually. + +``` +1. RenderPreview(tabIdentifier, sourceFilePath, previewDefinitionIndexInFile: 0) → image artifact +2. Review the rendered image for correctness +3. If making changes: XcodeUpdate → RenderPreview again +4. Compare before/after for regressions +``` + +**Use cases**: Verifying layout changes, checking dark mode appearance, confirming Liquid Glass effects render correctly. + +## Workflow: IssueTriage + +Use Xcode's Issue Navigator as the canonical diagnostics source. + +``` +1. XcodeListNavigatorIssues(tabIdentifier) → all current issues +2. For specific files: XcodeRefreshCodeIssuesInFile(tabIdentifier, file) +3. Prioritize: errors > warnings > notes +4. Fix errors first, rebuild, re-check +``` + +**Why this over grep-for-errors**: The Issue Navigator tracks live diagnostics including type-check errors, missing imports, and constraint issues that only Xcode's compiler frontend surfaces. + +## Workflow: DocumentationSearch + +Query Apple's documentation corpus through MCP. + +``` +1. DocumentationSearch(query) → documentation results +2. Cross-reference with axiom-apple-docs for bundled Xcode guides +``` + +**Note**: `DocumentationSearch` searches Apple's online documentation and WWDC transcripts. For the 20 for-LLM guides bundled inside Xcode, use `axiom-apple-docs` instead. + +## File Operations via MCP + +### Reading and Writing + +| Operation | Tool | Notes | +|-----------|------|-------| +| Read file contents | `XcodeRead` | Sees Xcode's project view (generated files, resolved packages) | +| Create new file | `XcodeWrite` | Creates file — auto-adds to project structure | +| Edit existing file | `XcodeUpdate` | str_replace-style patches — safer than full rewrites | +| Search for files | `XcodeGlob` | Pattern matching within the project | +| Search file contents | `XcodeGrep` | Content search with line numbers | +| List directory | `XcodeLS` | Directory listing | +| Create directory | `XcodeMakeDir` | Creates directories | + +### Destructive Operations (Require Confirmation) + +| Operation | Tool | Risk | +|-----------|------|------| +| Delete file/directory | `XcodeRM` | Moves to Trash by default (`deleteFiles: true`) — confirm with user | +| Move/rename file | `XcodeMV` | May break imports and references | + +**Always confirm destructive operations with the user** before calling `XcodeRM` or `XcodeMV`. + +### When to Use MCP File Tools vs Standard Tools + +| Scenario | Use MCP | Use Standard (Read/Write/Grep) | +|----------|---------|-------------------------------| +| Files in the Xcode project view | Yes — includes generated/resolved files | May miss generated files | +| Files outside the project | No | Yes — standard tools work everywhere | +| Need build context (diagnostics after edit) | Yes — edit + rebuild in one workflow | No build integration | +| Simple file read/edit | Either works | Slightly faster (no MCP overhead) | + +## Code Snippets + +### Execute Swift Code + +``` +ExecuteSnippet(tabIdentifier, codeSnippet: "print(MyModel.self)", sourceFilePath: "Sources/MyModel.swift") +``` + +Runs code in the context of a specific Swift file — has access to that file's `fileprivate` declarations. Not a generic REPL. No `language` parameter (Swift only). + +## Gotchas and Anti-Patterns + +### Tab Identifier Staleness + +Tab identifiers become invalid when: +- Xcode window is closed and reopened +- Project is closed and reopened +- Xcode is restarted + +**Fix**: Re-call `XcodeListWindows` to get fresh identifiers. + +### XcodeWrite vs XcodeUpdate + +- `XcodeWrite` — **creates** a new file. Fails if file exists (in some clients). +- `XcodeUpdate` — **patches** an existing file with `oldString`/`newString` replacement. One replacement per call (use `replaceAll: true` for all occurrences). + +**Common mistake**: Using `XcodeWrite` to edit an existing file overwrites its entire contents. Use `XcodeUpdate` for edits. + +### Schema Compliance + +Xcode's mcpbridge has a known MCP spec violation: it populates `content` but omits `structuredContent` when tools declare `outputSchema`. This breaks strict MCP clients (Cursor, some Zed configurations). + +**Workaround**: Use [XcodeMCPWrapper](https://github.com/SoundBlaster/XcodeMCPWrapper) as a proxy for strict clients. + +### Build After File Changes + +After `XcodeUpdate`, the project may need a build to surface new diagnostics. Don't assume edits are correct without rebuilding. + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "I'll just use xcodebuild" | MCP gives IDE state + navigator diagnostics + previews that CLI doesn't | +| "Read tool works fine for Xcode files" | `XcodeRead` sees Xcode's project view including generated files and resolved packages | +| "Skip tab identifier, I only have one project" | Most tools fail silently without `tabIdentifier` — always call `XcodeListWindows` first | +| "Run all tests every time" | `RunSomeTests` for iteration, `RunAllTests` for verification — saves minutes per cycle | +| "I'll parse the build log for errors" | `XcodeListNavigatorIssues` provides structured, deduplicated diagnostics | +| "XcodeWrite to update a file" | `XcodeUpdate` for edits. `XcodeWrite` creates/overwrites. Wrong tool = data loss. | +| "One tool call is enough" | Workflows (BuildFix, TestFix) use loops. Isolated calls miss the iteration pattern. | + +## Resources + +**Skills**: axiom-xcode-mcp-setup, axiom-xcode-mcp-ref, axiom-xcode-debugging diff --git a/.claude/skills/axiom-xcode-mcp-tools/agents/openai.yaml b/.claude/skills/axiom-xcode-mcp-tools/agents/openai.yaml new file mode 100644 index 0000000..993f89f --- /dev/null +++ b/.claude/skills/axiom-xcode-mcp-tools/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Xcode MCP Tools" + short_description: "Xcode MCP workflow patterns" diff --git a/.claude/skills/axiom-xcode-mcp/.openskills.json b/.claude/skills/axiom-xcode-mcp/.openskills.json new file mode 100644 index 0000000..0554acf --- /dev/null +++ b/.claude/skills/axiom-xcode-mcp/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": ".claude-plugin/plugins/axiom/skills/axiom-xcode-mcp", + "installedAt": "2026-04-12T08:05:35.677Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-xcode-mcp/SKILL.md b/.claude/skills/axiom-xcode-mcp/SKILL.md new file mode 100644 index 0000000..1366deb --- /dev/null +++ b/.claude/skills/axiom-xcode-mcp/SKILL.md @@ -0,0 +1,137 @@ +--- +name: axiom-xcode-mcp +description: Use when connecting to Xcode via MCP, using xcrun mcpbridge, or working with ANY Xcode MCP tool (XcodeRead, BuildProject, RunTests, RenderPreview). Covers setup, tool reference, workflow patterns, troubleshooting. +license: MIT +--- + +# Xcode MCP Router + +**You MUST use this skill for ANY Xcode MCP interaction — setup, tool usage, workflow patterns, or troubleshooting.** + +Xcode 26.3 ships an MCP server (`xcrun mcpbridge`) that exposes 20 IDE tools to external AI clients. This router directs you to the right specialized skill. + +## When to Use + +Use this router when: +- Setting up Xcode MCP for the first time +- Configuring `xcrun mcpbridge` for any MCP client +- Using any Xcode MCP tool (file ops, build, test, preview) +- Building, testing, or previewing via MCP tools +- Troubleshooting mcpbridge connection issues +- Window/tab targeting questions +- Permission dialog confusion + +## Routing Logic + +### 1. Setup/Connection → **xcode-mcp-setup** + +**Triggers**: +- First-time Xcode MCP setup +- Client-specific config (Claude Code, Cursor, Codex, VS Code, Gemini CLI) +- Connection errors ("Connection refused", "No windows") +- Permission dialog confusion +- Multi-Xcode targeting (`MCP_XCODE_PID`) +- Schema compliance issues with strict clients + +**Invoke**: `/skill axiom-xcode-mcp-setup` + +--- + +### 2. Using Tools & Workflows → **xcode-mcp-tools** + +**Triggers**: +- How to build/test/preview via MCP +- Workflow patterns (BuildFix loop, TestFix loop) +- Tool gotchas and anti-patterns +- Window/tab targeting strategy +- When to use MCP tools vs CLI (`xcodebuild`) +- Destructive operation safety (`XcodeRM`, `XcodeMV`) + +**Invoke**: `/skill axiom-xcode-mcp-tools` + +--- + +### 3. Tool API Reference → **xcode-mcp-ref** + +**Triggers**: +- Specific tool parameters and schemas +- Input/output format for a tool +- "How does XcodeGrep work?" +- "What params does BuildProject take?" +- Tool category listing + +**Invoke**: `/skill axiom-xcode-mcp-ref` + +--- + +## Decision Tree + +```dot +digraph xcode_mcp_router { + rankdir=TB; + "User has Xcode MCP question" [shape=ellipse]; + "Setup or connection?" [shape=diamond]; + "Using tools or workflows?" [shape=diamond]; + "Need specific tool params?" [shape=diamond]; + + "xcode-mcp-setup" [shape=box]; + "xcode-mcp-tools" [shape=box]; + "xcode-mcp-ref" [shape=box]; + + "User has Xcode MCP question" -> "Setup or connection?"; + "Setup or connection?" -> "xcode-mcp-setup" [label="yes"]; + "Setup or connection?" -> "Using tools or workflows?" [label="no"]; + "Using tools or workflows?" -> "xcode-mcp-tools" [label="yes"]; + "Using tools or workflows?" -> "Need specific tool params?" [label="no"]; + "Need specific tool params?" -> "xcode-mcp-ref" [label="yes"]; + "Need specific tool params?" -> "xcode-mcp-tools" [label="general question"]; +} +``` + +## Anti-Rationalization + +| Thought | Reality | +|---------|---------| +| "I'll just use xcodebuild directly" | MCP gives IDE state, diagnostics, previews, and navigator issues that CLI doesn't expose | +| "I already know how to set up MCP" | Client configs differ. Permission dialog behavior is specific. Check setup skill. | +| "I can figure out the tool params" | Tool schemas have required fields and gotchas. Check ref skill. | +| "Tab identifiers are obvious" | Most tools fail silently without correct tabIdentifier. Tools skill explains targeting. | +| "This is just file reading, I'll use Read tool" | XcodeRead sees Xcode's project view including generated files and resolved packages | + +## Conflict Resolution (vs Other Routers) + +| Domain | Owner | Why | +|--------|-------|-----| +| MCP-specific interaction (mcpbridge, MCP tools, tab identifiers) | **xcode-mcp** | MCP protocol and tool-specific | +| Xcode environment (Derived Data, zombie processes, simulators) | **ios-build** | Environment diagnostics, not MCP | +| Apple's bundled documentation (for-LLM guides/diagnostics) | **apple-docs** | Bundled docs, not MCP tool | +| `DocumentationSearch` MCP tool usage specifically | **xcode-mcp** | MCP tool invocation | +| Build failures diagnosed via CLI | **ios-build** | Traditional build debugging | +| Build failures diagnosed via MCP tools | **xcode-mcp** | MCP workflow patterns | + +## Example Invocations + +User: "How do I set up Xcode MCP with Claude Code?" +-> Invoke: `/skill axiom-xcode-mcp-setup` + +User: "How do I build my project using MCP tools?" +-> Invoke: `/skill axiom-xcode-mcp-tools` + +User: "What parameters does BuildProject take?" +-> Invoke: `/skill axiom-xcode-mcp-ref` + +User: "My mcpbridge connection keeps failing" +-> Invoke: `/skill axiom-xcode-mcp-setup` + +User: "How do I target a specific Xcode window?" +-> Invoke: `/skill axiom-xcode-mcp-tools` + +User: "Can I render SwiftUI previews via MCP?" +-> Invoke: `/skill axiom-xcode-mcp-tools` (workflow), then `/skill axiom-xcode-mcp-ref` (params) + +User: "Cursor can't parse Xcode's MCP responses" +-> Invoke: `/skill axiom-xcode-mcp-setup` (schema compliance section) + +## Resources + +**Skills**: xcode-mcp-setup, xcode-mcp-tools, xcode-mcp-ref diff --git a/.claude/skills/axiom-xctest-automation/.openskills.json b/.claude/skills/axiom-xctest-automation/.openskills.json new file mode 100644 index 0000000..9de68d4 --- /dev/null +++ b/.claude/skills/axiom-xctest-automation/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-xctest-automation", + "installedAt": "2026-04-12T08:07:00.321Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-xctest-automation/SKILL.md b/.claude/skills/axiom-xctest-automation/SKILL.md new file mode 100644 index 0000000..49795b6 --- /dev/null +++ b/.claude/skills/axiom-xctest-automation/SKILL.md @@ -0,0 +1,445 @@ +--- +name: axiom-xctest-automation +description: Use when writing, running, or debugging XCUITests. Covers element queries, waiting strategies, accessibility identifiers, test plans, and CI/CD test execution patterns. +license: MIT +metadata: + version: "1.0.0" +--- + +# XCUITest Automation Patterns + +Comprehensive guide to writing reliable, maintainable UI tests with XCUITest. + +## Core Principle + +**Reliable UI tests require three things**: +1. Stable element identification (accessibilityIdentifier) +2. Condition-based waiting (never hardcoded sleep) +3. Clean test isolation (no shared state) + +## Element Identification + +### The Accessibility Identifier Pattern + +**ALWAYS use accessibilityIdentifier for test-critical elements.** + +```swift +// SwiftUI +Button("Login") { ... } + .accessibilityIdentifier("loginButton") + +TextField("Email", text: $email) + .accessibilityIdentifier("emailTextField") + +// UIKit +loginButton.accessibilityIdentifier = "loginButton" +emailTextField.accessibilityIdentifier = "emailTextField" +``` + +### Query Selection Guidelines + +From WWDC 2025-344 "Recording UI Automation": + +1. **Localized strings change** → Use accessibilityIdentifier instead +2. **Deeply nested views** → Use shortest possible query +3. **Dynamic content** → Use generic query or identifier + +```swift +// BAD - Fragile queries +app.buttons["Login"] // Breaks with localization +app.tables.cells.element(boundBy: 0).buttons.firstMatch // Too specific + +// GOOD - Stable queries +app.buttons["loginButton"] // Uses identifier +app.tables.cells.containing(.staticText, identifier: "itemTitle").firstMatch +``` + +## Waiting Strategies + +### Never Use sleep() + +```swift +// BAD - Hardcoded wait +sleep(5) +XCTAssertTrue(app.buttons["submit"].exists) + +// GOOD - Condition-based wait +let submitButton = app.buttons["submit"] +XCTAssertTrue(submitButton.waitForExistence(timeout: 5)) +``` + +### Wait Patterns + +```swift +// Wait for element to appear +func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool { + element.waitForExistence(timeout: timeout) +} + +// Wait for element to disappear +func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool { + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + let result = XCTWaiter.wait(for: [expectation], timeout: timeout) + return result == .completed +} + +// Wait for element to be hittable (visible AND enabled) +func waitForElementHittable(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool { + let predicate = NSPredicate(format: "isHittable == true") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + let result = XCTWaiter.wait(for: [expectation], timeout: timeout) + return result == .completed +} + +// Wait for text to appear anywhere +func waitForText(_ text: String, timeout: TimeInterval = 10) -> Bool { + app.staticTexts[text].waitForExistence(timeout: timeout) +} +``` + +### Async Operations + +```swift +// Wait for network response +func waitForNetworkResponse() { + let loadingIndicator = app.activityIndicators["loadingIndicator"] + + // Wait for loading to start + _ = loadingIndicator.waitForExistence(timeout: 5) + + // Wait for loading to finish + _ = waitForElementToDisappear(loadingIndicator, timeout: 30) +} +``` + +## Test Structure + +### Setup and Teardown + +```swift +class LoginTests: XCTestCase { + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + + // Reset app state for clean test + app.launchArguments = ["--uitesting", "--reset-state"] + app.launchEnvironment = ["DISABLE_ANIMATIONS": "1"] + app.launch() + } + + override func tearDownWithError() throws { + // Capture screenshot on failure + if testRun?.failureCount ?? 0 > 0 { + let screenshot = XCUIScreen.main.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = "Failure Screenshot" + attachment.lifetime = .keepAlways + add(attachment) + } + app.terminate() + } +} +``` + +### Test Method Pattern + +```swift +func testLoginWithValidCredentials() throws { + // ARRANGE - Navigate to login screen + let loginButton = app.buttons["showLoginButton"] + XCTAssertTrue(loginButton.waitForExistence(timeout: 5)) + loginButton.tap() + + // ACT - Enter credentials and submit + let emailField = app.textFields["emailTextField"] + XCTAssertTrue(emailField.waitForExistence(timeout: 5)) + emailField.tap() + emailField.typeText("user@example.com") + + let passwordField = app.secureTextFields["passwordTextField"] + passwordField.tap() + passwordField.typeText("password123") + + app.buttons["loginSubmitButton"].tap() + + // ASSERT - Verify successful login + let welcomeLabel = app.staticTexts["welcomeLabel"] + XCTAssertTrue(welcomeLabel.waitForExistence(timeout: 10)) + XCTAssertTrue(welcomeLabel.label.contains("Welcome")) +} +``` + +## Common Interactions + +### Text Input + +```swift +// Clear and type +let textField = app.textFields["emailTextField"] +textField.tap() +textField.clearText() // Custom extension +textField.typeText("new@email.com") + +// Extension to clear text +extension XCUIElement { + func clearText() { + guard let stringValue = value as? String else { return } + tap() + let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count) + typeText(deleteString) + } +} +``` + +### Scrolling + +```swift +// Scroll until element is visible +func scrollToElement(_ element: XCUIElement, in scrollView: XCUIElement) { + while !element.isHittable { + scrollView.swipeUp() + } +} + +// Scroll to specific element +let targetCell = app.tables.cells["targetItem"] +let table = app.tables.firstMatch +scrollToElement(targetCell, in: table) +targetCell.tap() +``` + +### Alerts and Sheets + +```swift +// Handle system alert +addUIInterruptionMonitor(withDescription: "Permission Alert") { alert in + if alert.buttons["Allow"].exists { + alert.buttons["Allow"].tap() + return true + } + return false +} +app.tap() // Trigger the monitor + +// Handle app alert +let alert = app.alerts["Error"] +if alert.waitForExistence(timeout: 5) { + alert.buttons["OK"].tap() +} +``` + +### Keyboard Dismissal + +```swift +// Dismiss keyboard +if app.keyboards.count > 0 { + app.toolbars.buttons["Done"].tap() + // Or tap outside + // app.tap() +} +``` + +## Test Plans + +### Multi-Configuration Testing + +Test plans allow running the same tests with different configurations: + +```xml + +{ + "configurations" : [ + { + "name" : "English", + "options" : { + "language" : "en", + "region" : "US" + } + }, + { + "name" : "Spanish", + "options" : { + "language" : "es", + "region" : "ES" + } + }, + { + "name" : "Dark Mode", + "options" : { + "userInterfaceStyle" : "dark" + } + } + ], + "testTargets" : [ + { + "target" : { + "containerPath" : "container:MyApp.xcodeproj", + "identifier" : "MyAppUITests", + "name" : "MyAppUITests" + } + } + ] +} +``` + +### Running with Test Plan + +```bash +xcodebuild test \ + -scheme "MyApp" \ + -testPlan "MyTestPlan" \ + -destination "platform=iOS Simulator,name=iPhone 16" \ + -resultBundlePath /tmp/results.xcresult +``` + +## CI/CD Integration + +### Parallel Test Execution + +```bash +xcodebuild test \ + -scheme "MyAppUITests" \ + -destination "platform=iOS Simulator,name=iPhone 16" \ + -parallel-testing-enabled YES \ + -maximum-parallel-test-targets 4 \ + -resultBundlePath /tmp/results.xcresult +``` + +### Retry Failed Tests + +```bash +xcodebuild test \ + -scheme "MyAppUITests" \ + -destination "platform=iOS Simulator,name=iPhone 16" \ + -retry-tests-on-failure \ + -test-iterations 3 \ + -resultBundlePath /tmp/results.xcresult +``` + +### Code Coverage + +```bash +xcodebuild test \ + -scheme "MyAppUITests" \ + -destination "platform=iOS Simulator,name=iPhone 16" \ + -enableCodeCoverage YES \ + -resultBundlePath /tmp/results.xcresult + +# Export coverage report +xcrun xcresulttool export coverage \ + --path /tmp/results.xcresult \ + --output-path /tmp/coverage +``` + +## Debugging Failed Tests + +### Capture Screenshots + +```swift +// Manual screenshot capture +let screenshot = app.screenshot() +let attachment = XCTAttachment(screenshot: screenshot) +attachment.name = "Before Login" +attachment.lifetime = .keepAlways +add(attachment) +``` + +### Capture Videos + +Enable in test plan or scheme: +```xml +"systemAttachmentLifetime" : "keepAlways", +"userAttachmentLifetime" : "keepAlways" +``` + +### Print Element Hierarchy + +```swift +// Debug: Print all elements +print(app.debugDescription) + +// Debug: Print specific container +print(app.tables.firstMatch.debugDescription) +``` + +## Anti-Patterns to Avoid + +### 1. Hardcoded Delays + +```swift +// BAD +sleep(5) +button.tap() + +// GOOD +XCTAssertTrue(button.waitForExistence(timeout: 5)) +button.tap() +``` + +### 2. Index-Based Queries + +```swift +// BAD - Breaks if order changes +app.tables.cells.element(boundBy: 0) + +// GOOD - Uses identifier +app.tables.cells["firstItem"] +``` + +### 3. Shared State Between Tests + +```swift +// BAD - Tests depend on order +func test1_CreateItem() { ... } +func test2_EditItem() { ... } // Depends on test1 + +// GOOD - Independent tests +func testCreateItem() { + // Creates own item +} +func testEditItem() { + // Creates item, then edits +} +``` + +### 4. Testing Implementation Details + +```swift +// BAD - Tests internal structure +XCTAssertEqual(app.tables.cells.count, 10) + +// GOOD - Tests user-visible behavior +XCTAssertTrue(app.staticTexts["10 items"].exists) +``` + +## Recording UI Automation (Xcode 26+) + +From WWDC 2025-344: + +1. **Record** — Record interactions in Xcode (Debug → Record UI Automation) +2. **Replay** — Run across devices/languages/configurations via test plans +3. **Review** — Watch video recordings in test report + +### Enhancing Recorded Code + +```swift +// RECORDED (may be fragile) +app.buttons["Login"].tap() + +// ENHANCED (stable) +let loginButton = app.buttons["loginButton"] +XCTAssertTrue(loginButton.waitForExistence(timeout: 5)) +loginButton.tap() +``` + +## Resources + +**WWDC**: 2025-344, 2024-10206, 2023-10175, 2019-413 + +**Docs**: /xctest/xcuiapplication, /xctest/xcuielement, /xctest/xcuielementquery + +**Skills**: axiom-ui-testing, axiom-swift-testing diff --git a/.claude/skills/axiom-xctest-automation/agents/openai.yaml b/.claude/skills/axiom-xctest-automation/agents/openai.yaml new file mode 100644 index 0000000..95be0f8 --- /dev/null +++ b/.claude/skills/axiom-xctest-automation/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "XCTest Automation" + short_description: "Writing, running, or debugging XCUITests" diff --git a/.claude/skills/axiom-xctrace-ref/.openskills.json b/.claude/skills/axiom-xctrace-ref/.openskills.json new file mode 100644 index 0000000..e831140 --- /dev/null +++ b/.claude/skills/axiom-xctrace-ref/.openskills.json @@ -0,0 +1,7 @@ +{ + "source": "CharlesWiltgen/Axiom", + "sourceType": "git", + "repoUrl": "https://github.com/CharlesWiltgen/Axiom", + "subpath": "axiom-codex/skills/axiom-xctrace-ref", + "installedAt": "2026-04-12T08:07:00.732Z" +} \ No newline at end of file diff --git a/.claude/skills/axiom-xctrace-ref/SKILL.md b/.claude/skills/axiom-xctrace-ref/SKILL.md new file mode 100644 index 0000000..f73c6e8 --- /dev/null +++ b/.claude/skills/axiom-xctrace-ref/SKILL.md @@ -0,0 +1,405 @@ +--- +name: axiom-xctrace-ref +description: Use when automating Instruments profiling, running headless performance analysis, or integrating profiling into CI/CD - comprehensive xctrace CLI reference with record/export patterns +license: MIT +metadata: + version: "1.0.0" +--- + +# xctrace CLI Reference + +Command-line interface for Instruments profiling. Enables headless performance analysis without GUI. + +## Overview + +`xctrace` is the CLI tool behind Instruments.app. Use it for: +- Automated profiling in CI/CD pipelines +- Headless trace collection without GUI +- Programmatic trace analysis via XML export +- Performance regression detection + +**Requires**: Xcode 12+ (xctrace 12.0+). This reference tested with Xcode 26.2. + +## Quick Reference + +```bash +# Record a 10-second CPU profile +xcrun xctrace record --instrument 'CPU Profiler' --attach 'MyApp' --time-limit 10s --output profile.trace + +# Export to XML for analysis +xcrun xctrace export --input profile.trace --toc # See available tables +xcrun xctrace export --input profile.trace --xpath '/trace-toc/run[@number="1"]/data/table[@schema="cpu-profile"]' + +# List available instruments +xcrun xctrace list instruments + +# List available templates +xcrun xctrace list templates +``` + +## Recording Traces + +### Basic Recording + +```bash +# Using an instrument (recommended for CLI automation) +xcrun xctrace record --instrument 'CPU Profiler' --attach 'AppName' --time-limit 10s --output trace.trace + +# Using a template (may fail on export in Xcode 26+) +xcrun xctrace record --template 'Time Profiler' --attach 'AppName' --time-limit 10s --output trace.trace +``` + +**Note**: In Xcode 26+, use `--instrument` instead of `--template` for reliable export. Templates may produce traces with "Document Missing Template Error" on export. + +### Target Selection + +```bash +# Attach to running process by name +xcrun xctrace record --instrument 'CPU Profiler' --attach 'MyApp' --time-limit 10s + +# Attach to running process by PID +xcrun xctrace record --instrument 'CPU Profiler' --attach 12345 --time-limit 10s + +# Profile all processes +xcrun xctrace record --instrument 'CPU Profiler' --all-processes --time-limit 10s + +# Launch and profile +xcrun xctrace record --instrument 'CPU Profiler' --launch -- /path/to/app arg1 arg2 + +# Target specific device (simulator or physical) +xcrun xctrace record --instrument 'CPU Profiler' --device 'iPhone 17 Pro' --attach 'MyApp' --time-limit 10s +xcrun xctrace record --instrument 'CPU Profiler' --device 947DF45C-4ACB-4B3E-A043-DF2CD59A59B3 --all-processes --time-limit 10s +``` + +### Recording Options + +| Flag | Description | +|------|-------------| +| `--output ` | Output .trace file path | +| `--time-limit