SKILL.md
---
name: axiom-swift
description: Use when reviewing Swift code for modern idioms, working with noncopyable types, implementing drag and drop, adding debug deep links, or building for tvOS.
license: MIT
---
# Swift Language & Platform
**You MUST use this skill for ANY Swift idiom review, ownership/noncopyable types, Transferable/drag-and-drop, debug deep links, or tvOS development.**
<!-- AXIOM_AUDITOR_INLINE_BEGIN — auto-maintained by scripts/build-inlined-auditors.ts; do not hand-edit -->
> **Not on Claude Code?** Where this router says "Launch `some-auditor` agent", read that auditor's file in this suite and follow it inline — the same procedure, needing only file search and read.
>
> Available here: `skills/swift-simplifier.md`.
>
> Agents that need Bash — builds, tests, simulators, crash symbolication — stay Claude Code-only; there is no inline equivalent for those.
<!-- AXIOM_AUDITOR_INLINE_END -->
## Quick Reference
| Symptom / Task | Reference |
|----------------|-----------|
| Outdated Swift patterns (Date(), CGFloat, DateFormatter) | See `skills/swift-modern.md` |
| Foundation modernization (FormatStyle, URL.documentsDirectory) | See `skills/swift-modern.md` |
| Common Claude hallucinations in Swift code | See `skills/swift-modern.md` |
| Swift 6.4 idioms — `anyAppleOS`, `weak let`, `~Sendable` (`OS27`) | See `skills/swift-modern.md` |
| Noncopyable types (~Copyable) | See `skills/ownership-conventions.md` |
| borrowing/consuming parameter ownership | See `skills/ownership-conventions.md` |
| InlineArray, Span, value generics; Swift 6.4 `borrow`/`mutate` accessors (`OS27`) | See `skills/ownership-conventions.md` |
| Reducing ARC overhead | See `skills/ownership-conventions.md` |
| Drag and drop (.draggable, .dropDestination) | See `skills/transferable-ref.md` |
| Copy/paste (.copyable, PasteButton) | See `skills/transferable-ref.md` |
| ShareLink, content sharing | See `skills/transferable-ref.md` |
| Custom UTType declarations | See `skills/transferable-ref.md` |
| TransferRepresentation choices | See `skills/transferable-ref.md` |
| Debug-only deep links for simulator testing | See `skills/deep-link-debugging.md` |
| Navigate to specific screens for screenshots | See `skills/deep-link-debugging.md` |
| tvOS Focus Engine, Siri Remote input | See `skills/tvos.md` |
| tvOS storage constraints (no Documents dir) | See `skills/tvos.md` |
| tvOS text input, AVPlayer tuning | See `skills/tvos.md` |
| TVUIKit components | See `skills/tvos.md` |
| Simplify Swift for clarity (behavior-preserving cleanups) | `swift-simplifier` agent — `/axiom:audit swift-simplify` |
## Decision Tree
```dot
digraph swift {
start [label="Swift task" shape=ellipse];
what [label="What do you need?" shape=diamond];
start -> what;
what -> "skills/swift-modern.md" [label="modern idioms,\noutdated patterns,\nFoundation APIs"];
what -> "skills/ownership-conventions.md" [label="~Copyable, borrowing,\nconsuming, InlineArray,\nSpan, ARC reduction"];
what -> "skills/transferable-ref.md" [label="drag & drop, copy/paste,\nShareLink, UTTypes,\nTransferable conformance"];
what -> "skills/deep-link-debugging.md" [label="debug deep links,\nsimulator navigation,\nscreenshot automation"];
what -> "skills/tvos.md" [label="tvOS app,\nFocus Engine,\nSiri Remote, storage"];
}
```
1. Outdated Swift patterns / modern API replacements / Claude hallucinations? -> `skills/swift-modern.md`
2. ~Copyable / borrowing / consuming / InlineArray / Span? -> `skills/ownership-conventions.md`
3. Drag and drop / copy/paste / ShareLink / Transferable / UTTypes? -> `skills/transferable-ref.md`
4. Debug deep links / simulator navigation / screenshot automation? -> `skills/deep-link-debugging.md`
5. tvOS development / Focus Engine / Siri Remote / storage / AVPlayer? -> `skills/tvos.md`
6. Swift concurrency (async/await, actors, Sendable) -> `/skill axiom-concurrency`
7. Swift performance (COW, ARC, generics optimization) -> See axiom-performance (skills/swift-performance.md)
8. Codable patterns (JSON, CodingKeys, enum serialization) -> See axiom-data (skills/codable.md)
9. Simplify Swift for clarity (guard/optional cleanups, if/switch expressions, boilerplate)? -> `swift-simplifier` agent (`/axiom:audit swift-simplify`)
## Conflict Resolution
**swift vs concurrency**: When Swift 6 concurrency errors appear:
- **Use concurrency, NOT swift** -- Concurrency errors are actor isolation / Sendable issues. `skills/swift-modern.md` covers concurrency *posture* (defaults), but detailed patterns live in axiom-concurrency.
**swift vs performance**: When optimizing Swift code:
- **Use swift for ownership** if the question is borrowing/consuming/~Copyable/InlineArray/Span -> `skills/ownership-conventions.md`
- **Use performance** if the question is COW, ARC profiling, generic specialization, or Instruments workflows -> axiom-performance
**swift vs swiftui**: When implementing drag and drop or copy/paste:
- **Use swift** for Transferable conformance, representation choices, UTType declarations -> `skills/transferable-ref.md`
- **Use swiftui** for view-level modifiers (.draggable, .dropDestination styling, animations)
**swift vs integration**: When sharing content:
- ShareLink + Transferable -> **use swift** (`skills/transferable-ref.md`)
- UIActivityViewController customization, share extensions -> **use integration**
**swift vs axiom-build**: When tvOS build fails:
- Environment/Xcode issues -> **use axiom-build first**
- tvOS platform-specific code issues (Focus Engine, storage, no WebView) -> **use swift** (`skills/tvos.md`)
## Critical Patterns
**Modern Swift Idioms** (`skills/swift-modern.md`):
- 12+ outdated patterns Claude defaults to (Date(), CGFloat, DateFormatter, DispatchQueue.main.async)
- Foundation modernization (FormatStyle, URL.documentsDirectory, .replacing())
- SwiftUI convenience APIs Claude misses (ContentUnavailableView.search, LabeledContent)
- Swift 6.4 concurrency posture defaults
- 12 common Claude hallucinations with corrections
**Ownership & Noncopyable Types** (`skills/ownership-conventions.md`):
- borrowing/consuming parameter modifiers with 7 patterns
- ~Copyable types: FileHandle pattern, limitations table, common compiler errors
- InlineArray: fixed-size stack-allocated arrays with value generics
- Span family: safe contiguous memory access replacing UnsafeBufferPointer
- Decision tree for when ownership modifiers help vs when to skip
**Transferable & Sharing** (`skills/transferable-ref.md`):
- Decision tree: CodableRepresentation vs DataRepresentation vs FileRepresentation vs ProxyRepresentation
- Drag and drop, copy/paste, ShareLink with complete SwiftUI API
- Custom UTType declarations (Swift + Info.plist, both required)
- 7 common errors with fixes (representation ordering, missing Info.plist, hit testing)
- UIKit bridging via NSItemProvider
**Debug Deep Links** (`skills/deep-link-debugging.md`):
- Debug-only URL scheme for simulator navigation
- NavigationPath integration for robust routing
- State configuration links (error states, empty states)
- Integration with /axiom:screenshot and simulator-tester agent
- 60-75% faster iteration with visual verification
**tvOS Development** (`skills/tvos.md`):
- Dual focus system (UIKit Focus Engine + SwiftUI @FocusState)
- Siri Remote input (two generations, three input layers)
- Storage constraints (no Documents directory, iCloud required)
- No WebView (JavaScriptCore only, no DOM)
- AVPlayer tuning, Menu button state machine
- TVUIKit components
## Anti-Rationalization
| Thought | Reality |
|---------|---------|
| "Date() is fine, everyone uses it" | `Date.now` has been the modern pattern since Swift 5.6. `skills/swift-modern.md` lists 12+ patterns Claude gets wrong. |
| "I don't need ownership modifiers" | For most code, correct. But ~Copyable types *require* them, and large value types in hot paths benefit measurably. |
| "Transferable is just Codable for drag and drop" | Transferable has 4 representation types, ordering rules, and Info.plist requirements. Getting it wrong causes silent cross-app failures. |
| "I'll just use the same code as iOS for tvOS" | tvOS has no Documents directory, no WebView, a dual focus system, and two generations of remote hardware. It compiles fine and fails at runtime. |
| "Debug deep links are overkill" | Manual navigation costs 2-3 minutes per iteration. Deep links cut it to 45 seconds. Over a debugging session, that's hours saved. |
| "CGFloat is what SwiftUI uses" | Swift 5.5+ has implicit Double-CGFloat bridging. Use Double everywhere except optionals, inout, and ObjC-bridged APIs. |
| "I'll add the Info.plist entry later" | Custom UTTypes work in-app without Info.plist but silently fail cross-app. This is the #1 "works in dev, fails in prod" Transferable issue. |
| "FormatStyle is too verbose" | `val.formatted(.number.precision(.fractionLength(2)))` is type-safe and localized. `String(format:)` is neither. |
## Example Invocations
User: "Is this Swift code using modern patterns?"
-> Read: `skills/swift-modern.md`
User: "How do I use borrowing and consuming?"
-> Read: `skills/ownership-conventions.md`
User: "How do I make my model draggable?"
-> Read: `skills/transferable-ref.md`
User: "How do I implement ShareLink with a custom preview?"
-> Read: `skills/transferable-ref.md`
User: "I need debug deep links for simulator testing"
-> Read: `skills/deep-link-debugging.md`
User: "I'm building a tvOS app and focus navigation doesn't work"
-> Read: `skills/tvos.md`
User: "What is InlineArray and when should I use it?"
-> Read: `skills/ownership-conventions.md`
User: "My drag and drop works in-app but not across apps"
-> Read: `skills/transferable-ref.md`
User: "tvOS keeps losing my saved data"
-> Read: `skills/tvos.md`
User: "How do I optimize large struct passing?"
-> Read: `skills/ownership-conventions.md`
User: "I need to fix my async/await code"
-> See `/skill axiom-concurrency`
User: "Check my code for Swift 6 concurrency issues"
-> See `/skill axiom-concurrency`
skills/deep-link-debugging.md
# 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` navigation reference 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
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>debug</string>
</array>
<key>CFBundleURLName</key>
<string>com.example.debug</string>
</dict>
</array>
```
**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
Task { @MainActor in
try? await Task.sleep(for: .seconds(1))
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` (navigation reference) — Production deep linking and NavigationStack patterns
- `simulator-tester` — Automated simulator testing using debug deep links
- `axiom-build (skills/xcode-debugging.md)` — 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
skills/ownership-conventions.md
# 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<T>` wrapper or manage manually |
| Can't use with most generics | `<T>` implicitly means `<T: Copyable>` | Use `<T: ~Copyable>` (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<T: ~Copyable>(_ 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<let count: Int, Element> 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.
On Swift 6.4 (Xcode 27) you can also write the type with the `[count of Element]` shorthand (`OS27`):
```swift
let rgb: [3 of Double] = [0.2, 0.4, 0.8] // == InlineArray<3, Double>
```
### 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<InlineArray<3, UInt16>>.size // 6 (2 bytes × 3)
MemoryLayout<InlineArray<3, UInt16>>.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<Element>` | Read-only elements | Safe iteration, passing to algorithms |
| `MutableSpan<Element>` | 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<Int>
var mutable = [10, 20, 30]
var ms = mutable.mutableSpan // MutableSpan<Int>
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<UInt8> {
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.bytes // RawSpan over the array's bytes (Span<Element: BitwiseCopyable>.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<let count: Int, Element> { ... }
// 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.
## Swift 6.4 Additions (OS27)
The 6.4 toolchain (Xcode 27) extends the ownership toolkit. These are verified against the Xcode 27.0 beta compiler:
### `borrow` / `mutate` accessors
Replace `get`/`set` to expose shared storage **without copying** — and to vend `~Copyable` values from a computed property:
```swift
var value: Value {
borrow { storage.pointee } // read-only, no copy
mutate { &storage.pointee } // exclusive in-place access
}
```
### Noncopyable & nonescapable conformances
`Equatable`, `Comparable`, and `Hashable` now work on `~Copyable` types (`Equatable`/`Comparable` also on `~Escapable`), and associated types may be `~Copyable` / `~Escapable`. You no longer have to make a unique-resource type copyable just to compare or hash it:
```swift
struct FileHandle: ~Copyable, Equatable {
let fd: Int32
static func == (a: borrowing FileHandle, b: borrowing FileHandle) -> Bool { a.fd == b.fd }
}
```
### Single-value & unique containers
The 6.4 stdlib adds lightweight ownership containers — verified usable in the Xcode 27 beta (no experimental flag). Each gates on `@available(anyAppleOS 27, *)`:
| Type | Copyability | Init | Role |
|------|-------------|------|------|
| `UniqueBox<Value>` | `~Copyable` | `UniqueBox(consuming value)` | Heap box that uniquely owns a `~Copyable` value |
| `UniqueArray<Element>` | `~Copyable` | `UniqueArray()` / `UniqueArray(capacity:)` | Growable heap array that uniquely owns `~Copyable` elements |
| `Ref<Value>` | `Copyable`, `~Escapable` | `Ref(borrowing value)` | Shareable read-only borrow of a single value |
| `MutableRef<Value>` | `~Copyable`, `~Escapable` | `MutableRef(&value)` | Exclusive in-place borrow of a single value |
```swift
@available(anyAppleOS 27, *)
func demo() {
var counter = 0
let handle = MutableRef(&counter) // exclusive borrow, cannot escape
_ = handle
let box = UniqueBox(LargeValue()) // sole heap owner
_ = consume box
}
```
`UniqueArray` is the growable collection form — a `~Copyable` heap array of `~Copyable` elements, the array analog of `UniqueBox`. Element access is via `borrow`/`mutate` subscript accessors (no implicit copy); assigning it to another binding **consumes** it, so pass `borrowing`/`consuming` explicitly (or `clone()` when `Element` is `Copyable`) when you need a second owner:
```swift
@available(anyAppleOS 27, *)
func buildIDs() {
var ids = UniqueArray<Int>() // or UniqueArray(capacity: 4)
ids.append(1)
ids.append(2)
ids[0] = 10 // mutate accessor, in place
let last = ids.popLast() // -> Element?
_ = (ids.count, ids.isEmpty, last)
consumeIDs(ids) // moves ownership; `ids` unusable after
}
@available(anyAppleOS 27, *)
func consumeIDs(_ x: consuming UniqueArray<Int>) { _ = x.count }
```
`Ref`/`MutableRef` are the single-value analog of `Span`/`MutableSpan`: non-escapable, so the borrow can't outlive its source. On the concurrency side, `withTaskCancellationShield` is usable now and the single-resume `Continuation` is present but limited in this beta — see `swift-concurrency-ref`.
Paren-free optional existentials and opaque types now compile under Swift 6.4 — `var overlay: any Drawable?` and `some P?` no longer have to be written `(any Drawable)?`.
### Borrowing iteration `OS27`
`for`-in over a `~Copyable` / `~Escapable` container without copying it. The protocol is `Iterable`, with `BorrowingIteratorProtocol` supplying `nextSpan(maxCount:)`. Usable with no experimental flag.
```swift
@available(anyAppleOS 27, *)
func sum(_ span: Span<Int>) -> Int {
var total = 0
for x in span { total += x } // borrows; no copy of the container
return total
}
@available(anyAppleOS 27, *)
func sum(_ a: borrowing UniqueArray<Int>) -> Int {
var total = 0
for x in a { total += x }
return total
}
```
Conforming stdlib types: `Span`, `RawSpan`, `MutableSpan`, `MutableRawSpan`, `OutputSpan`, `OutputRawSpan`, `InlineArray`, `UniqueArray`.
`Array`, `Set`, and `Dictionary` do **not** conform — `Iterable` is for the ownership containers, not a retrofit of the copyable collections. Keep using `Sequence` for those.
A generic helper needs `Failure == Never` to iterate without `try`, because `Iterable` carries a typed `Failure`:
```swift
@available(anyAppleOS 27, *)
func total<S: Iterable>(_ s: borrowing S) -> Int
where S: ~Copyable & ~Escapable, S.Element == Int, S.Failure == Never {
var t = 0
for x in s { t += x }
return t
}
```
Drop the `S.Failure == Never` constraint and the loop must be written `for try x in s` in a `throws` function.
### Still forthcoming (re-check each beta)
Other 6.4 stdlib features are **not yet usable** as of Xcode 27 beta 6 (confirmed by compile-probe, build swiftlang-6.4.0.33.1):
| Feature | State in beta |
|---------|---------------|
| `Dictionary.mapKeyedValues` | Absent |
| `FilePath` as a stdlib type | Still requires `import System` |
Treat these as forthcoming; re-probe on each new beta and fold what flips.
## Decision Tree
```
Need explicit ownership?
├─ Working with ~Copyable type?
│ └─ Yes → Required (borrowing/consuming)
├─ Fixed-size collection, no heap allocation?
│ └─ Yes → InlineArray<let count, Element>
├─ Need safe pointer-like access to contiguous memory?
│ ├─ Read-only? → Span<Element>
│ ├─ Mutable? → MutableSpan<Element>
│ └─ 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, 2026-262
**Docs**: /swift/inlinearray, /swift/span
**Skills**: axiom-performance (skills/swift-performance.md), axiom-concurrency
skills/swift-modern.md
# 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:)` | 6.0 | Single pass, no intermediate allocation (SE-0220; reverted before 5.0 shipped, re-introduced in 6.0) |
| `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 |
| Dropping `import UIKit`/`import AppKit` when using SwiftUI | Keep them — SwiftUI re-exports only CoreGraphics, CoreTransferable, DeveloperToolsSupport, and SwiftUICore, NOT UIKit or AppKit. `import UIKit`/`import AppKit` is still required for `UIViewController`, `UIView`, `UIApplication`, gesture recognizers, etc. A few cross-platform types are surfaced through SwiftUI's own bridges (`Image(uiImage:)`, `Color`/`Font`) | — |
## 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.4 Language Features (OS27)
Swift 6.4 ships with Xcode 27 (the toolchain also folds in the 6.3 work). Prefer these in new code:
| Feature | Use | Replaces |
|---------|-----|----------|
| `@available(anyAppleOS 27, *)` / `#if os(anyAppleOS)` | One token for **all** Apple OSes | Verbose `@available(iOS 27, macOS 27, watchOS 27, tvOS 27, visionOS 27, *)` |
| `weak let` | Immutable weak ref → the class can be `Sendable`, not `@unchecked Sendable` | `weak var` forcing `@unchecked Sendable` |
| `class T: ~Sendable` | Explicitly suppress `Sendable` (subclasses can still add it back) | No prior syntax |
| Second memberwise init | A struct mixing `internal` + `private` stored properties also gets an `internal` memberwise init usable from other files | Hand-written init |
| `@diagnose(Group, as: …)` | Set one diagnostic group's severity (`ignored` / `warning` / `error`) for a single declaration | Project-wide `-Wwarning`/`-Werror`, blanket suppression |
| `isTriviallyIdentical(to:)` (SE-0494) | O(1) storage-identity fast path before an O(n) `==` in hot comparison paths | Nothing — layers on `==`, never replaces it (see below) |
```swift
// anyAppleOS — one availability token for the whole 27 cycle
@available(anyAppleOS 27, *)
func showStatus() { ... }
@available(anyAppleOS 27, *)
@available(tvOS, unavailable) // still exclude specific platforms
func launch() { ... }
// weak let → Sendable without the escape hatch
final class Spacecraft: Sendable {
weak let dockedAt: SpaceStation?
}
// @diagnose — scope a diagnostic group's severity to one declaration.
// First argument is a diagnostic-group identifier (e.g. DeprecatedDeclaration),
// NOT a bare keyword. It governs diagnostics emitted inside that declaration.
@diagnose(DeprecatedDeclaration, as: ignored)
func usesLegacyAPI() { oldCall() } // deprecation warning silenced here only
@diagnose(DeprecatedDeclaration, as: error)
func mustNotRegress() { oldCall() } // hard-fails the build instead
```
`@diagnose` is compiler-gated (needs the Swift 6.4 toolchain), not OS-gated — no `@available` applies. It is the per-declaration counterpart to the whole-module `-Wwarning`/`-Werror <group>` flags.
**Caveat**: `anyAppleOS` requires the Swift 6.4 toolchain (Xcode 27+). For code that must build on older Xcode, keep the explicit per-platform `@available`. Either way, `@available(iOS 27, *)`-style gating remains the authoritative runtime check.
### isTriviallyIdentical(to:) — O(1) equality fast path (SE-0494)
Swift 6.4 adds `isTriviallyIdentical(to:)` to the copy-on-write stdlib types — `String`/`Substring` (and their views), `Array`, `ArraySlice`, `ContiguousArray`, `Dictionary`, `Set` — plus the non-CoW `Unsafe*BufferPointer` family and `UTF8Span`, where identity is pointer+count (`Span`/`RawSpan` spell it `isIdentical(to:)`). It answers "do these two values share the same backing storage?" in O(1).
The contract is asymmetric — this is the entire feature:
| Result | Guarantee |
|--------|-----------|
| `true` | Values ARE equal (`==`) — identical storage cannot differ by value |
| `false` | NO information — values may still be equal (e.g. after a CoW copy) |
Never branch "values differ" on `false`; it only means the fast path didn't apply.
**Availability**: `@_alwaysEmitIntoClient` with no `@available` gate — needs the Swift 6.4 toolchain (Xcode 27) to build, but runs on ANY deployment target. Toolchain-gated, not OS-gated.
**When it pays** — all three must hold; otherwise keep plain `==`:
1. The comparison sits on a hot, repeated path — memoization/cache-invalidation checks, `DynamicProperty.update`, diffing large collections. One-off comparisons don't qualify.
2. The upstream value is storage-stable — a stored property that mutates occasionally. A computed property or freshly built value has new storage on every access, so the check is always `false`.
3. The `false` path is deliberate — either recompute is cheap, or you layer the checks:
```swift
// Memoization invalidation — layered fast path
func shouldRecompute(_ new: [Order]) -> Bool {
if cached.isTriviallyIdentical(to: new) { return false } // O(1): same storage ⇒ equal
return cached != new // false told us nothing — fall back to O(n)
}
```
**The pessimization trap**: replacing `==` wholesale when the upstream produces fresh values makes the check always-`false` and triggers the expensive recompute (often O(n log n)) that a `true` from plain `==` would have skipped — a net loss. Even in SE-0494's favorable benchmark (24,000-element array, mutated element at the tail of the comparison order), the measured win was ~13%. Measure the comparison with signposts before and after switching.
## Swift 6.4 Concurrency Posture
Write Swift 6.4-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 | Default for new Xcode 27 app projects (approachable concurrency, Swift 6.2+) |
| Handle errors thrown inside `Task { }` — don't silently ignore them | Swift 6.4 **warns** on an unhandled thrown error in a `Task`; handle in-task or save the task and check later |
| `await` is allowed in `defer` blocks | Swift 6.4 removed the old restriction — clean up with async work directly in `defer` |
| 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-concurrency` (swift-concurrency reference).
## 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.
10. **Writes the verbose 5-platform `@available(iOS 27, macOS 27, …)`** — On Swift 6.4 (Xcode 27), use `@available(anyAppleOS 27, *)`; add per-platform `unavailable` lines only for exclusions.
11. **Uses `weak var` + `@unchecked Sendable`** — On Swift 6.4, `weak let` lets the class be plain `Sendable` with no escape hatch.
12. **Ignores an error thrown in `Task { try … }`** — Swift 6.4 warns; handle it in the task (`do/catch`) or save the task and check the result later.
13. **Puts `[weak self]` on an inner closure nested in an escaping outer closure that already captures `self` strongly** — false safety; Swift 6.4 warns (`[#ImplicitStrongCapture]`). Weaken the OUTER closure (`[weak self]` + `guard let self`), not just the inner. See `axiom-performance (skills/memory-debugging.md)`.
14. **Treats `isTriviallyIdentical(to:)` returning `false` as "values differ"** — `false` carries no information; the values may still be `==` (e.g. after a CoW copy). Only `true` has meaning. See the SE-0494 section above.
## Resources
**WWDC**: 2026-262
**Skills**: axiom-performance (skills/swift-performance.md), axiom-concurrency, axiom-swiftui (skills/swiftui-performance.md)
skills/swift-simplifier.md
<!-- GENERATED from agents/swift-simplifier.md by scripts/build-inlined-auditors.ts — do not edit. -->
# Swift Simplifier
**Claude Code** — launch the `swift-simplifier` agent, or run `/axiom:audit swift-simplify`. It runs this procedure in an isolated context with its own model tier.
**Every other harness** — follow this file inline. It is the same procedure, and it needs only file search and read.
You are an expert at making Swift code clearer and more idiomatic **without changing what it does**. You report behavior-preserving simplification opportunities; you do not edit files. Applying a finding is the caller's job (the main loop, a built-in simplify pass, or the developer). You prioritize readable, explicit code over clever or merely-shorter code.
**Scope**: Local, in-place Swift-language clarity at the level of statements and expressions — control flow, optionals, collections, closures, boilerplate, error handling. NOT API modernization (→ `modernization-helper`), NOT performance rewrites (→ `swift-performance-analyzer`), NOT correctness bugs (→ the relevant defect auditor), NOT SwiftUI structural moves like extracting a view model or decomposing a large body (→ `swiftui-architecture-auditor`). Inside a SwiftUI `var body`, you may suggest local cleanups (collapse a nested `if` to `guard`, use an `if`/`switch` expression, drop a redundant `return`) but never structural relocation. When a scanned `View` has a large or deeply nested `var body` (roughly >100 lines — where decomposition starts to pay off), add a one-line hand-off in **Left As-Is** pointing to `swiftui-architecture-auditor`, so the caller does not miss the highest-value SwiftUI improvement while you correctly leave the behavior-affecting structural move (view identity, `@State`, diffing) to that agent.
## Tool Use Is Mandatory
Run every Glob, Grep, and Read this prompt lists. Do not reason from training data instead of scanning.
- Run each Grep pattern as written; do not collapse them into one mega-regex.
- Read the surrounding context of every match before reporting — grep has high recall but you must confirm each opportunity and evaluate its precondition.
## Files to Exclude
Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
## Phase 1: Scan
Gather the Swift-file universe for the requested scope:
- Whole project: `Glob: **/*.swift` (minus the exclusions above).
- A subsystem/directory or a single file: restrict the Glob to that path.
Then run the detection greps from the catalog below over that file set.
## Phase 2: Verify in Context
For every grep match, Read the surrounding lines. Confirm it is a real opportunity (not a false positive) and determine which safety tag applies — this requires reading the actual code, not the grep line alone.
## Phase 3: Safety / Over-Simplification Gate
This gate is what separates this auditor from a line-golf bot. Reject any candidate that:
- hurts clarity, merges unrelated concerns, or removes a helpful abstraction;
- trades readability for fewer lines;
- cannot meet its precondition (then either attach the precondition as a caveat or drop it).
Assign each surviving finding a safety tag:
- **SAFE** — behavior-preserving as written.
- **PRECONDITION: ⟨condition⟩** — safe only when the stated condition holds; the report MUST state the condition the applier has to verify.
- **ADVISORY** — readability suggestion that can change behavior; report at LOW with an explicit warning, never as behavior-preserving.
## Phase 4: Report
Emit the structured report (format below).
## Detection Catalog
Severity = readability impact (HIGH/MEDIUM/LOW). Each pattern carries its safety tag.
### SAFE
| Pattern | Grep | Rewrite |
|---------|------|---------|
| Long-form optional binding | `if let \w+ = \w+ \{`, `guard let \w+ = \w+ else` | `if let x` / `guard let x` shorthand (Swift 5.7) when RHS is the same identifier |
| Redundant `else` after exit | `\} else \{` near `return`/`throw` | drop `else` when the `if` body always exits |
| Redundant `return` | `return ` in single-expression members/closures | drop `return` (Swift 5.1, SE-0255; closures earlier) |
| `switch` over Optional | `case .some\(`, `case .none` | `if let` / `??` |
| Explicit type → member dot | `Array<\w+>\(\)`, `: Color = Color\(` | leading-dot member syntax where contextual type is known |
| Verbose computed getter | `\{ get \{` | `var x: T { e }` |
| `.description` in interpolation | `\\\(\w+\.description\)` | `\(x)` for CustomStringConvertible |
### PRECONDITION-gated
| Pattern | Grep | Rewrite | PRECONDITION |
|---------|------|---------|--------------|
| Nested `if let` pyramid | `if let .+\{[\s\S]*if let` | comma-form `if let a, let b` (SAFE) or `guard let` | for `guard`: no `else`-branch side effects, an early-exit context exists, no name collision from hoisting |
| Temp-var-then-assign ladder | `var \w+:.*\n.*if `, `switch ` assigning a var | `if`/`switch` **expression** (5.9) | every branch is a single expression, target assigned on every path, no inter-branch statements |
| Nested ternary | `\? .+ \? .+ :` | `switch` expression / if-else | branch mapping is 1:1, no `default` introduced to swallow cases |
| `x != nil ? x! : y` | `!= nil \?`, `\? \w+! :` | `x ?? y` | `x` is a side-effect-free stored/local (no call/computed/subscript) — ternary evals `x` twice, `??` once |
| `.count` zero-checks | `\.count == 0`, `\.count > 0` | `.isEmpty` / `!isEmpty` | receiver is a `Collection`, not a single-pass/side-effecting sequence |
| `.filter{}.count` | `\.filter\s*\{[\s\S]*?\}\.count` | `count(where:)` (Swift 6.0) | predicate pure & non-throwing (unprovable purity → ADVISORY). NOTE overlap with `modernization-helper` Pattern 8 — see Related |
| `.filter{}.first` | `\.filter\s*\{[\s\S]*?\}\.first` | `.first(where:)` | predicate pure & non-throwing (eager full pass vs short-circuit changes invocation count / throw timing) |
| Verbose closure | `\{ \(\w+\) in` | trailing closure / `$0` | single closure arg, no overload ambiguity, not nested-shorthand |
| Redundant `self.` | `self\.\w+` | drop `self.` | not inside `@escaping` closure (may be required / documents capture), no local shadowing a member |
| Redundant type annotation | `let \w+: \w+ =` | drop annotation | does NOT pin a literal type (`Int64`/`Double`/`CGFloat`) or existential/opaque (`any P`/`some P`) |
| `do/catch` that only rethrows | `do \{[\s\S]*?\} catch \{[\s\S]*?throw` | `try` | exactly one `catch`, body is bare `throw`/`throw error`, no transformation, no side effects, AND the function's declared throw type already accepts the rethrown error type — no implicit widening/narrowing across the removed `catch` (typed throws, Swift 6.0) |
### Deployment-floor-aware (Axiom-unique)
| Pattern | Grep | Rewrite | PRECONDITION |
|---------|------|---------|--------------|
| Always-true availability guard | `if #available\(` | unwrap the guard | the guarded floor (e.g. `iOS 26`) is ≤ the project's deployment target. Read `IPHONEOS_DEPLOYMENT_TARGET` (or the package floor); align with Axiom's "latest two OS lines" floor |
### ADVISORY (report at LOW, warn explicitly)
| Pattern | Grep | Note |
|---------|------|------|
| Manual accumulation loop | `for \w+ in .+\{[\s\S]*?append` | suggest `map`/`compactMap`/`reduce` ONLY when the loop is a pure 1-in-1-out transform with no `break`/`continue`/early-`return` and no external mutation; otherwise warn it is NOT behavior-preserving |
| `.filter{}.first` / `.filter{}.count` w/ unprovable purity | (as above) | report as ADVISORY (not PRECONDITION) when predicate purity/non-throwing can't be established |
## Output Format
```markdown
# Swift Simplification Report
## Scope
[file / subsystem / full project — N Swift files scanned]
## Simplification Summary
- SAFE: [count]
- PRECONDITION: [count]
- ADVISORY: [count]
By readability impact — HIGH: [n], MEDIUM: [n], LOW: [n]
## Findings
### [HIGH|MEDIUM|LOW] [SAFE|PRECONDITION: ⟨x⟩|ADVISORY] [Category]
**File**: path/to/file.swift:line
**Before**:
\`\`\`swift
[current code]
\`\`\`
**After**:
\`\`\`swift
[simplified code]
\`\`\`
**Why clearer**: [one line]
[**Verify before applying**: ⟨the precondition⟩ — for PRECONDITION findings]
[**Warning**: this can change behavior because ⟨reason⟩ — for ADVISORY findings]
## Left As-Is
[Tempting changes the gate considered and rejected, with one-line reasons — so the reader can trust the gate ran. Include the large-`body` → `swiftui-architecture-auditor` hand-off here when a scanned `View` body is large or deeply nested.]
```
## Output Limits
If >50 findings in one category: show top 10 by readability impact, give the total count, list the top 3 files. If >100 total: summarize by category, show only HIGH details. Scoping to a file/subsystem is the primary noise control — recommend it when the whole-project report is large.
## False Positives (Not Issues)
- `self.` inside an `@escaping` closure or where it disambiguates a shadowed member — required, leave it.
- Type annotations that pin a numeric literal type or an existential/opaque type — load-bearing, leave them.
- `.filter{}.first` / `.filter{}.count` where the predicate has side effects or can throw — NOT behavior-preserving, report at most as ADVISORY.
- `for` loops with `break`/`continue`/early `return` — no faithful `reduce`/`map` translation.
- `do/catch` that transforms the error, runs side effects, or has multiple clauses — not a bare rethrow.
- `\(x.description)` → `\(x)` only when `x` conforms to `CustomStringConvertible` — Phase 2 must confirm the conformance, not just a `.description` member; a custom non-protocol `description` may differ from `String(describing:)` output.
- Already-idiomatic code; shorthand that would reduce clarity.
## Related
- `axiom-swift` skill — the modern-idiom source this agent draws from; use it to understand or apply a finding.
- `modernization-helper` agent — owns old→new **API** migration (incl. `.filter{}.count` detection, its Pattern 8). Coordinate: simplification of `.filter{}.count` is reported here only as a clarity finding; API-currency migrations belong to modernization-helper.
- `swift-performance-analyzer` agent — owns **speed** rewrites. When a clarity change and a perf change conflict on the same line, defer to it.
- `swiftui-architecture-auditor` agent — owns SwiftUI **structural** moves (extract/decompose). This agent only does local cleanups inside a body.
skills/transferable-ref.md
# 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
UTType.markdown // net.daringfireball.markdown (OS27)
```
**`UTType.markdown` `OS27`** — system-declared identifier `net.daringfireball.markdown`, conforming to `public.utf8-plain-text` (UTF-8 text, **not** `public.plain-text` — the distinction matters for `Transferable` conformance matching). Before 27, apps hand-rolled their own Markdown `UTExportedTypeDeclarations`, so two apps' Markdown types did not interoperate; it is now system-declared and shared across `Transferable`, `fileImporter`/`fileExporter`, `.draggable`, and `DocumentGroup`. Gate with `if #available` when the deployment target is below 27. All platforms.
### 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
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.myapp.recipe</string>
<key>UTTypeDescription</key>
<string>Recipe</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>recipe</string>
</array>
</dict>
</dict>
</array>
```
**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`
#### `UTType(identifier:allowUndeclared:)` `OS27`
A failable init that, with `allowUndeclared: true`, returns a `UTType` **keeping the identity** of an identifier the system does not know — for round-tripping an identifier from another subsystem without losing it. **Gotcha**: that undeclared type has no conformances or tags, so it silently **fails every `conforms(to:)` check**. With `allowUndeclared: false` it behaves identically to `UTType(_:)`. All platforms.
---
## 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.
### Promised Files (Drag to Finder)
On the Mac — Catalyst, iOS-on-Mac, and AppKit apps — a drag to Finder can deliver a **file promise**: the receiver asks for the file only after the drop lands, so the source doesn't write anything for drags that never complete. The APIs below are what make content promise-backed; a plain file URL on the pasteboard is not a promise.
- **`Transferable` sources**: `FileRepresentation` already registers promise-shaped file content through `NSItemProvider` — its exporting closure runs when a receiver actually requests the file. No extra adoption for the common case.
- **AppKit drag sources**: `NSFilePromiseProvider` is the explicit API — you supply the file type up front and write the file in the `NSFilePromiseProviderDelegate` callback when the destination asks.
- **AppKit drop targets**: check the pasteboard for `NSFilePromiseReceiver` and call `receivePromisedFiles(atDestination:options:operationQueue:reader:)` — reading the URL types directly gets you nothing for promised content.
**Gotcha**: the promise callback fires *after* the drag session ends, on the receiver's schedule. The source must still be able to produce the file then — don't tear down the export state (or delete a temp source) when the drag ends, and don't capture view state that may be gone by the time Finder asks.
---
## 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) { ... }
```
### Drop Targets Move When Layout Reflows
A drag session outlives any single layout: mid-drag, the window can resize, a column can collapse, and auto-scroll or a hover-triggered expansion can push targets around. SwiftUI's `.dropDestination` follows the view's current geometry through a reflow — there is no cached frame to invalidate. Custom UIKit drop handling breaks when the delegate caches geometry:
- Compute highlight and insertion position from `session.location(in:)` inside `dropInteraction(_:sessionDidUpdate:)` (or `tableView(_:dropSessionDidUpdate:...)`) **every time it fires** — never from frames captured in `sessionDidEnter`.
- If a resize can remove a target entirely (a sidebar that collapses at narrow widths), return `.cancel`/`.forbidden` from the update callback when the target is gone rather than dropping into a stale rect.
### 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 iOS app needs dynamic paste validation, monitor `UIPasteboard.changedNotification`. On macOS, monitor `NSPasteboard` change count manually (there is no equivalent notification).
---
## 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, /appkit/nsfilepromiseprovider, /appkit/nsfilepromisereceiver
**Skills**: axiom-integration, axiom-data (skills/codable.md), axiom-swiftui
skills/tvos.md
# 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-design (skills/liquid-glass.md)` 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<UIPress>, with event: UIPressesEvent?) {
for press in presses {
if press.type == .select {
handleSelectDown()
}
}
}
override func pressesEnded(_ presses: Set<UIPress>, 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**: Limited storage (significantly less than iOS). 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-data (skills/sqlitedata.md)` for CloudKit SyncEngine patterns, `axiom-data (skills/storage.md)` 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.
**Text display** — tvOS 27 brings system-wide Dynamic Type (Large Text). For adoption, layout adaptation, and Nutrition Labels, see axiom-accessibility (skills/accessibility-diag.md, "Dynamic Type Comes to tvOS").
### 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
**Docs**: /tvuikit, /uikit/uifocusenvironment, /uikit/uifocusguide, /swiftui/focus, /gamecontroller/gcmicrogamepad, /avfoundation/avplayer, /javascriptcore
**WWDC**: 2016-215, 2017-224, 2021-10023, 2021-10081, 2021-10191, 2023-10162, 2025-219
**Skills**: axiom-data (skills/storage.md), axiom-data (skills/sqlitedata.md), axiom-integration, axiom-design (skills/hig-ref.md), axiom-design (skills/liquid-glass.md)