Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:28:37 +08:00
commit ccc65b3f07
180 changed files with 53970 additions and 0 deletions

View File

@@ -0,0 +1,159 @@
---
name: build-iphone-apps
description: Build professional native iPhone apps in Swift with SwiftUI and UIKit. Full lifecycle - build, debug, test, optimize, ship. CLI-only, no Xcode. Targets iOS 26 with iOS 18 compatibility.
---
<essential_principles>
## How We Work
**The user is the product owner. Claude is the developer.**
The user does not write code. The user does not read code. The user describes what they want and judges whether the result is acceptable. Claude implements, verifies, and reports outcomes.
### 1. Prove, Don't Promise
Never say "this should work." Prove it:
```bash
xcodebuild -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | xcsift
xcodebuild test -destination 'platform=iOS Simulator,name=iPhone 16'
xcrun simctl boot "iPhone 16" && xcrun simctl launch booted com.app.bundle
```
If you didn't run it, you don't know it works.
### 2. Tests for Correctness, Eyes for Quality
| Question | How to Answer |
|----------|---------------|
| Does the logic work? | Write test, see it pass |
| Does it look right? | Launch in simulator, user looks at it |
| Does it feel right? | User uses it |
| Does it crash? | Test + launch |
| Is it fast enough? | Profiler |
Tests verify *correctness*. The user verifies *desirability*.
### 3. Report Outcomes, Not Code
**Bad:** "I refactored DataService to use async/await with weak self capture"
**Good:** "Fixed the memory leak. `leaks` now shows 0 leaks. App tested stable for 5 minutes."
The user doesn't care what you changed. The user cares what's different.
### 4. Small Steps, Always Verified
```
Change → Verify → Report → Next change
```
Never batch up work. Never say "I made several changes." Each change is verified before the next. If something breaks, you know exactly what caused it.
### 5. Ask Before, Not After
Unclear requirement? Ask now.
Multiple valid approaches? Ask which.
Scope creep? Ask if wanted.
Big refactor needed? Ask permission.
Wrong: Build for 30 minutes, then "is this what you wanted?"
Right: "Before I start, does X mean Y or Z?"
### 6. Always Leave It Working
Every stopping point = working state. Tests pass, app launches, changes committed. The user can walk away anytime and come back to something that works.
</essential_principles>
<intake>
**Ask the user:**
What would you like to do?
1. Build a new app
2. Debug an existing app
3. Add a feature
4. Write/run tests
5. Optimize performance
6. Ship/release
7. Something else
**Then read the matching workflow from `workflows/` and follow it.**
</intake>
<routing>
| Response | Workflow |
|----------|----------|
| 1, "new", "create", "build", "start" | `workflows/build-new-app.md` |
| 2, "broken", "fix", "debug", "crash", "bug" | `workflows/debug-app.md` |
| 3, "add", "feature", "implement", "change" | `workflows/add-feature.md` |
| 4, "test", "tests", "TDD", "coverage" | `workflows/write-tests.md` |
| 5, "slow", "optimize", "performance", "fast" | `workflows/optimize-performance.md` |
| 6, "ship", "release", "TestFlight", "App Store" | `workflows/ship-app.md` |
| 7, other | Clarify, then select workflow or references |
</routing>
<verification_loop>
## After Every Change
```bash
# 1. Does it build?
xcodebuild -scheme AppName -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | xcsift
# 2. Do tests pass?
xcodebuild -scheme AppName -destination 'platform=iOS Simulator,name=iPhone 16' test
# 3. Does it launch? (if UI changed)
xcrun simctl boot "iPhone 16" 2>/dev/null || true
xcrun simctl install booted ./build/Build/Products/Debug-iphonesimulator/AppName.app
xcrun simctl launch booted com.company.AppName
```
Report to the user:
- "Build: ✓"
- "Tests: 12 pass, 0 fail"
- "App launches in simulator, ready for you to check [specific thing]"
</verification_loop>
<when_to_test>
## Testing Decision
**Write a test when:**
- Logic that must be correct (calculations, transformations, rules)
- State changes (add, delete, update operations)
- Edge cases that could break (nil, empty, boundaries)
- Bug fix (test reproduces bug, then proves it's fixed)
- Refactoring (tests prove behavior unchanged)
**Skip tests when:**
- Pure UI exploration ("make it blue and see if I like it")
- Rapid prototyping ("just get something on screen")
- Subjective quality ("does this feel right?")
- One-off verification (launch and check manually)
**The principle:** Tests let the user verify correctness without reading code. If the user needs to verify it works, and it's not purely visual, write a test.
</when_to_test>
<reference_index>
## Domain Knowledge
All in `references/`:
**Architecture:** app-architecture, swiftui-patterns, navigation-patterns
**Data:** data-persistence, networking
**Platform Features:** push-notifications, storekit, background-tasks
**Quality:** polish-and-ux, accessibility, performance
**Assets & Security:** app-icons, security, app-store
**Development:** project-scaffolding, cli-workflow, cli-observability, testing, ci-cd
</reference_index>
<workflows_index>
## Workflows
All in `workflows/`:
| File | Purpose |
|------|---------|
| build-new-app.md | Create new iOS app from scratch |
| debug-app.md | Find and fix bugs |
| add-feature.md | Add to existing app |
| write-tests.md | Write and run tests |
| optimize-performance.md | Profile and speed up |
| ship-app.md | TestFlight, App Store submission |
</workflows_index>

View File

@@ -0,0 +1,449 @@
# Accessibility
VoiceOver, Dynamic Type, and inclusive design for iOS apps.
## VoiceOver Support
### Basic Labels
```swift
struct ItemRow: View {
let item: Item
var body: some View {
HStack {
Image(systemName: item.icon)
.accessibilityHidden(true) // Icon is decorative
VStack(alignment: .leading) {
Text(item.name)
Text(item.date, style: .date)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if item.isCompleted {
Image(systemName: "checkmark")
.accessibilityHidden(true)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(item.name), \(item.isCompleted ? "completed" : "incomplete")")
.accessibilityHint("Double tap to view details")
}
}
```
### Custom Actions
```swift
struct ItemRow: View {
let item: Item
let onDelete: () -> Void
let onToggle: () -> Void
var body: some View {
HStack {
Text(item.name)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(item.name)
.accessibilityAction(named: "Toggle completion") {
onToggle()
}
.accessibilityAction(named: "Delete") {
onDelete()
}
}
}
```
### Traits
```swift
Text("Important Notice")
.accessibilityAddTraits(.isHeader)
Button("Submit") { }
.accessibilityAddTraits(.startsMediaSession)
Image("photo")
.accessibilityAddTraits(.isImage)
Link("Learn more", destination: url)
.accessibilityAddTraits(.isLink)
Toggle("Enable", isOn: $isEnabled)
.accessibilityAddTraits(isEnabled ? .isSelected : [])
```
### Announcements
```swift
// Announce changes
func saveCompleted() {
AccessibilityNotification.Announcement("Item saved successfully").post()
}
// Screen change
func showNewScreen() {
AccessibilityNotification.ScreenChanged(nil).post()
}
// Layout change
func expandSection() {
isExpanded = true
AccessibilityNotification.LayoutChanged(nil).post()
}
```
### Rotor Actions
```swift
struct ArticleView: View {
@State private var fontSize: CGFloat = 16
var body: some View {
Text(article.content)
.font(.system(size: fontSize))
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
fontSize = min(fontSize + 2, 32)
case .decrement:
fontSize = max(fontSize - 2, 12)
@unknown default:
break
}
}
}
}
```
## Dynamic Type
### Scaled Fonts
```swift
// System fonts scale automatically
Text("Title")
.font(.title)
Text("Body")
.font(.body)
// Custom fonts with scaling
Text("Custom")
.font(.custom("Helvetica", size: 17, relativeTo: .body))
// Fixed size (use sparingly)
Text("Fixed")
.font(.system(size: 12).fixed())
```
### Scaled Metrics
```swift
struct IconButton: View {
@ScaledMetric var iconSize: CGFloat = 24
@ScaledMetric(relativeTo: .body) var spacing: CGFloat = 8
var body: some View {
HStack(spacing: spacing) {
Image(systemName: "star")
.font(.system(size: iconSize))
Text("Favorite")
}
}
}
```
### Line Limits with Accessibility
```swift
Text(item.description)
.lineLimit(3)
.truncationMode(.tail)
// But allow more for accessibility sizes
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
```
### Testing Dynamic Type
```swift
#Preview("Default") {
ContentView()
}
#Preview("Large") {
ContentView()
.environment(\.sizeCategory, .accessibilityLarge)
}
#Preview("Extra Extra Large") {
ContentView()
.environment(\.sizeCategory, .accessibilityExtraExtraLarge)
}
```
## Reduce Motion
```swift
struct AnimatedView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isExpanded = false
var body: some View {
VStack {
// Content
}
.animation(reduceMotion ? .none : .spring(), value: isExpanded)
}
}
// Alternative animations
struct TransitionView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var showDetail = false
var body: some View {
VStack {
if showDetail {
DetailView()
.transition(reduceMotion ? .opacity : .slide)
}
}
.animation(.default, value: showDetail)
}
}
```
## Color and Contrast
### Semantic Colors
```swift
// Use semantic colors that adapt
Text("Primary")
.foregroundStyle(.primary)
Text("Secondary")
.foregroundStyle(.secondary)
Text("Tertiary")
.foregroundStyle(.tertiary)
// Error state
Text("Error")
.foregroundStyle(.red) // Use semantic red, not custom
```
### Increase Contrast
```swift
struct ContrastAwareView: View {
@Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
@Environment(\.accessibilityIncreaseContrast) private var increaseContrast
var body: some View {
HStack {
Circle()
.fill(increaseContrast ? .primary : .secondary)
if differentiateWithoutColor {
// Add non-color indicator
Image(systemName: "checkmark")
}
}
}
}
```
### Color Blind Support
```swift
struct StatusIndicator: View {
let status: Status
@Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
var body: some View {
HStack {
Circle()
.fill(status.color)
.frame(width: 10, height: 10)
if differentiateWithoutColor {
Image(systemName: status.icon)
}
Text(status.label)
}
}
}
enum Status {
case success, warning, error
var color: Color {
switch self {
case .success: return .green
case .warning: return .orange
case .error: return .red
}
}
var icon: String {
switch self {
case .success: return "checkmark.circle"
case .warning: return "exclamationmark.triangle"
case .error: return "xmark.circle"
}
}
var label: String {
switch self {
case .success: return "Success"
case .warning: return "Warning"
case .error: return "Error"
}
}
}
```
## Focus Management
### Focus State
```swift
struct LoginView: View {
@State private var username = ""
@State private var password = ""
@FocusState private var focusedField: Field?
enum Field {
case username, password
}
var body: some View {
Form {
TextField("Username", text: $username)
.focused($focusedField, equals: .username)
.submitLabel(.next)
.onSubmit {
focusedField = .password
}
SecureField("Password", text: $password)
.focused($focusedField, equals: .password)
.submitLabel(.done)
.onSubmit {
login()
}
}
.onAppear {
focusedField = .username
}
}
}
```
### Accessibility Focus
```swift
struct AlertView: View {
@AccessibilityFocusState private var isAlertFocused: Bool
var body: some View {
VStack {
Text("Important Alert")
.accessibilityFocused($isAlertFocused)
}
.onAppear {
isAlertFocused = true
}
}
}
```
## Button Shapes
```swift
struct AccessibleButton: View {
@Environment(\.accessibilityShowButtonShapes) private var showButtonShapes
var body: some View {
Button("Action") { }
.padding()
.background(showButtonShapes ? Color.accentColor.opacity(0.1) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
```
## Smart Invert Colors
```swift
Image("photo")
.accessibilityIgnoresInvertColors() // Photos shouldn't invert
```
## Audit Checklist
### VoiceOver
- [ ] All interactive elements have labels
- [ ] Decorative elements are hidden
- [ ] Custom actions for swipe gestures
- [ ] Headings marked correctly
- [ ] Announcements for dynamic changes
### Dynamic Type
- [ ] All text uses dynamic fonts
- [ ] Layout adapts to large sizes
- [ ] No text truncation at accessibility sizes
- [ ] Touch targets remain accessible (44pt minimum)
### Color and Contrast
- [ ] 4.5:1 contrast ratio for text
- [ ] Information not conveyed by color alone
- [ ] Works with Increase Contrast
- [ ] Works with Smart Invert
### Motion
- [ ] Animations respect Reduce Motion
- [ ] No auto-playing animations
- [ ] Alternative interactions for gesture-only features
### General
- [ ] All functionality available via VoiceOver
- [ ] Logical focus order
- [ ] Error messages are accessible
- [ ] Time limits are adjustable
## Testing Tools
### Accessibility Inspector
1. Open Xcode > Open Developer Tool > Accessibility Inspector
2. Point at elements to inspect labels, traits, hints
3. Run audit for common issues
### VoiceOver Practice
1. Settings > Accessibility > VoiceOver
2. Use with your app
3. Navigate by swiping, double-tap to activate
### Voice Control
1. Settings > Accessibility > Voice Control
2. Test all interactions with voice commands
### Xcode Previews
```swift
#Preview {
ContentView()
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
.environment(\.accessibilityReduceMotion, true)
.environment(\.accessibilityDifferentiateWithoutColor, true)
}
```

View File

@@ -0,0 +1,497 @@
# App Architecture
State management, dependency injection, and architectural patterns for iOS apps.
## State Management
### @Observable (iOS 17+)
The modern approach for shared state:
```swift
@Observable
class AppState {
var items: [Item] = []
var selectedItemID: UUID?
var isLoading = false
var error: AppError?
// Computed properties work naturally
var selectedItem: Item? {
items.first { $0.id == selectedItemID }
}
var hasItems: Bool { !items.isEmpty }
}
// In views - only re-renders when used properties change
struct ContentView: View {
@Environment(AppState.self) private var appState
var body: some View {
if appState.isLoading {
ProgressView()
} else {
ItemList(items: appState.items)
}
}
}
```
### Two-Way Bindings
For binding to @Observable properties:
```swift
struct SettingsView: View {
@Environment(AppState.self) private var appState
var body: some View {
@Bindable var appState = appState
Form {
TextField("Username", text: $appState.username)
Toggle("Notifications", isOn: $appState.notificationsEnabled)
}
}
}
```
### State Decision Tree
**@State** - View-local UI state
- Toggle expanded/collapsed
- Text field content
- Sheet presentation
```swift
struct ItemRow: View {
@State private var isExpanded = false
var body: some View {
VStack {
// ...
}
}
}
```
**@Observable in Environment** - Shared app state
- User session
- Navigation state
- Feature flags
```swift
@main
struct MyApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView()
.environment(appState)
}
}
}
```
**@Query** - SwiftData persistence
- Database entities
- Filtered/sorted queries
```swift
struct ItemList: View {
@Query(sort: \Item.createdAt, order: .reverse)
private var items: [Item]
var body: some View {
List(items) { item in
ItemRow(item: item)
}
}
}
```
## Dependency Injection
### Environment Keys
Define environment keys for testable dependencies:
```swift
// Protocol for testability
protocol NetworkServiceProtocol {
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T
}
// Live implementation
class LiveNetworkService: NetworkServiceProtocol {
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
// Real implementation
}
}
// Mock for testing
class MockNetworkService: NetworkServiceProtocol {
var mockResult: Any?
var mockError: Error?
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
if let error = mockError { throw error }
return mockResult as! T
}
}
// Environment key
struct NetworkServiceKey: EnvironmentKey {
static let defaultValue: NetworkServiceProtocol = LiveNetworkService()
}
extension EnvironmentValues {
var networkService: NetworkServiceProtocol {
get { self[NetworkServiceKey.self] }
set { self[NetworkServiceKey.self] = newValue }
}
}
// Inject at app level
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.networkService, LiveNetworkService())
}
}
}
// Use in views
struct ItemList: View {
@Environment(\.networkService) private var networkService
var body: some View {
// ...
}
func loadItems() async {
let items: [Item] = try await networkService.fetch(.items)
}
}
```
### Dependency Container
For complex apps with many dependencies:
```swift
@Observable
class AppDependencies {
let network: NetworkServiceProtocol
let storage: StorageServiceProtocol
let purchases: PurchaseServiceProtocol
let analytics: AnalyticsServiceProtocol
init(
network: NetworkServiceProtocol = LiveNetworkService(),
storage: StorageServiceProtocol = LiveStorageService(),
purchases: PurchaseServiceProtocol = LivePurchaseService(),
analytics: AnalyticsServiceProtocol = LiveAnalyticsService()
) {
self.network = network
self.storage = storage
self.purchases = purchases
self.analytics = analytics
}
// Convenience for testing
static func mock() -> AppDependencies {
AppDependencies(
network: MockNetworkService(),
storage: MockStorageService(),
purchases: MockPurchaseService(),
analytics: MockAnalyticsService()
)
}
}
// Inject as single environment object
@main
struct MyApp: App {
@State private var dependencies = AppDependencies()
var body: some Scene {
WindowGroup {
ContentView()
.environment(dependencies)
}
}
}
```
## View Models (When Needed)
For views with significant logic, use a view-local model:
```swift
struct ItemDetailScreen: View {
let itemID: UUID
@State private var viewModel: ItemDetailViewModel
init(itemID: UUID) {
self.itemID = itemID
self._viewModel = State(initialValue: ItemDetailViewModel(itemID: itemID))
}
var body: some View {
Form {
if viewModel.isLoading {
ProgressView()
} else if let item = viewModel.item {
ItemContent(item: item)
}
}
.task {
await viewModel.load()
}
}
}
@Observable
class ItemDetailViewModel {
let itemID: UUID
var item: Item?
var isLoading = false
var error: Error?
init(itemID: UUID) {
self.itemID = itemID
}
func load() async {
isLoading = true
defer { isLoading = false }
do {
item = try await fetchItem(id: itemID)
} catch {
self.error = error
}
}
func save() async {
// Save logic
}
}
```
## Coordinator Pattern
For complex navigation flows:
```swift
@Observable
class OnboardingCoordinator {
var currentStep: OnboardingStep = .welcome
var isComplete = false
enum OnboardingStep {
case welcome
case permissions
case personalInfo
case complete
}
func next() {
switch currentStep {
case .welcome:
currentStep = .permissions
case .permissions:
currentStep = .personalInfo
case .personalInfo:
currentStep = .complete
isComplete = true
case .complete:
break
}
}
func back() {
switch currentStep {
case .welcome:
break
case .permissions:
currentStep = .welcome
case .personalInfo:
currentStep = .permissions
case .complete:
currentStep = .personalInfo
}
}
}
struct OnboardingFlow: View {
@State private var coordinator = OnboardingCoordinator()
var body: some View {
Group {
switch coordinator.currentStep {
case .welcome:
WelcomeView(onContinue: coordinator.next)
case .permissions:
PermissionsView(onContinue: coordinator.next, onBack: coordinator.back)
case .personalInfo:
PersonalInfoView(onContinue: coordinator.next, onBack: coordinator.back)
case .complete:
CompletionView()
}
}
.animation(.default, value: coordinator.currentStep)
}
}
```
## Error Handling
### Structured Error Types
```swift
enum AppError: LocalizedError {
case networkError(NetworkError)
case storageError(StorageError)
case validationError(String)
case unauthorized
case unknown(Error)
var errorDescription: String? {
switch self {
case .networkError(let error):
return error.localizedDescription
case .storageError(let error):
return error.localizedDescription
case .validationError(let message):
return message
case .unauthorized:
return "Please sign in to continue"
case .unknown(let error):
return error.localizedDescription
}
}
var recoverySuggestion: String? {
switch self {
case .networkError:
return "Check your internet connection and try again"
case .unauthorized:
return "Tap to sign in"
default:
return nil
}
}
}
enum NetworkError: LocalizedError {
case noConnection
case timeout
case serverError(Int)
case decodingError
var errorDescription: String? {
switch self {
case .noConnection:
return "No internet connection"
case .timeout:
return "Request timed out"
case .serverError(let code):
return "Server error (\(code))"
case .decodingError:
return "Invalid response from server"
}
}
}
```
### Error Presentation
```swift
struct ContentView: View {
@Environment(AppState.self) private var appState
var body: some View {
NavigationStack {
// Content
}
.alert(
"Error",
isPresented: Binding(
get: { appState.error != nil },
set: { if !$0 { appState.error = nil } }
),
presenting: appState.error
) { error in
Button("OK") { }
if error.recoverySuggestion != nil {
Button("Retry") {
Task { await retry() }
}
}
} message: { error in
VStack {
Text(error.localizedDescription)
if let suggestion = error.recoverySuggestion {
Text(suggestion)
.font(.caption)
}
}
}
}
}
```
## Testing Architecture
### Unit Testing with Mocks
```swift
@Test
func testLoadItems() async throws {
// Arrange
let mockNetwork = MockNetworkService()
mockNetwork.mockResult = [Item(name: "Test")]
let viewModel = ItemListViewModel(networkService: mockNetwork)
// Act
await viewModel.load()
// Assert
#expect(viewModel.items.count == 1)
#expect(viewModel.items[0].name == "Test")
#expect(viewModel.isLoading == false)
}
@Test
func testLoadItemsError() async throws {
// Arrange
let mockNetwork = MockNetworkService()
mockNetwork.mockError = NetworkError.noConnection
let viewModel = ItemListViewModel(networkService: mockNetwork)
// Act
await viewModel.load()
// Assert
#expect(viewModel.items.isEmpty)
#expect(viewModel.error != nil)
}
```
### Preview with Dependencies
```swift
#Preview {
ContentView()
.environment(AppDependencies.mock())
.environment(AppState())
}
```

View File

@@ -0,0 +1,542 @@
# App Icons
Complete guide for generating, configuring, and managing iOS app icons from the CLI.
## Quick Start (Xcode 14+)
The simplest approach—provide a single 1024×1024 PNG and let Xcode auto-generate all sizes:
1. Create `Assets.xcassets/AppIcon.appiconset/`
2. Add your 1024×1024 PNG
3. Create `Contents.json` with single-size configuration
```json
{
"images": [
{
"filename": "icon-1024.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
```
The system auto-generates all required device sizes from this single image.
## CLI Icon Generation
### Using sips (Built into macOS)
Generate all required sizes from a 1024×1024 source:
```bash
#!/bin/bash
# generate-app-icons.sh
# Usage: ./generate-app-icons.sh source.png output-dir
SOURCE="$1"
OUTPUT="${2:-AppIcon.appiconset}"
mkdir -p "$OUTPUT"
# Generate all required sizes
sips -z 1024 1024 "$SOURCE" --out "$OUTPUT/icon-1024.png"
sips -z 180 180 "$SOURCE" --out "$OUTPUT/icon-180.png"
sips -z 167 167 "$SOURCE" --out "$OUTPUT/icon-167.png"
sips -z 152 152 "$SOURCE" --out "$OUTPUT/icon-152.png"
sips -z 120 120 "$SOURCE" --out "$OUTPUT/icon-120.png"
sips -z 87 87 "$SOURCE" --out "$OUTPUT/icon-87.png"
sips -z 80 80 "$SOURCE" --out "$OUTPUT/icon-80.png"
sips -z 76 76 "$SOURCE" --out "$OUTPUT/icon-76.png"
sips -z 60 60 "$SOURCE" --out "$OUTPUT/icon-60.png"
sips -z 58 58 "$SOURCE" --out "$OUTPUT/icon-58.png"
sips -z 40 40 "$SOURCE" --out "$OUTPUT/icon-40.png"
sips -z 29 29 "$SOURCE" --out "$OUTPUT/icon-29.png"
sips -z 20 20 "$SOURCE" --out "$OUTPUT/icon-20.png"
echo "Generated icons in $OUTPUT"
```
### Using ImageMagick
```bash
#!/bin/bash
# Requires: brew install imagemagick
SOURCE="$1"
OUTPUT="${2:-AppIcon.appiconset}"
mkdir -p "$OUTPUT"
for size in 1024 180 167 152 120 87 80 76 60 58 40 29 20; do
convert "$SOURCE" -resize "${size}x${size}!" "$OUTPUT/icon-$size.png"
done
```
## Complete Contents.json (All Sizes)
For manual size control or when not using single-size mode:
```json
{
"images": [
{
"filename": "icon-1024.png",
"idiom": "ios-marketing",
"scale": "1x",
"size": "1024x1024"
},
{
"filename": "icon-180.png",
"idiom": "iphone",
"scale": "3x",
"size": "60x60"
},
{
"filename": "icon-120.png",
"idiom": "iphone",
"scale": "2x",
"size": "60x60"
},
{
"filename": "icon-87.png",
"idiom": "iphone",
"scale": "3x",
"size": "29x29"
},
{
"filename": "icon-58.png",
"idiom": "iphone",
"scale": "2x",
"size": "29x29"
},
{
"filename": "icon-120.png",
"idiom": "iphone",
"scale": "3x",
"size": "40x40"
},
{
"filename": "icon-80.png",
"idiom": "iphone",
"scale": "2x",
"size": "40x40"
},
{
"filename": "icon-60.png",
"idiom": "iphone",
"scale": "3x",
"size": "20x20"
},
{
"filename": "icon-40.png",
"idiom": "iphone",
"scale": "2x",
"size": "20x20"
},
{
"filename": "icon-167.png",
"idiom": "ipad",
"scale": "2x",
"size": "83.5x83.5"
},
{
"filename": "icon-152.png",
"idiom": "ipad",
"scale": "2x",
"size": "76x76"
},
{
"filename": "icon-76.png",
"idiom": "ipad",
"scale": "1x",
"size": "76x76"
},
{
"filename": "icon-80.png",
"idiom": "ipad",
"scale": "2x",
"size": "40x40"
},
{
"filename": "icon-40.png",
"idiom": "ipad",
"scale": "1x",
"size": "40x40"
},
{
"filename": "icon-58.png",
"idiom": "ipad",
"scale": "2x",
"size": "29x29"
},
{
"filename": "icon-29.png",
"idiom": "ipad",
"scale": "1x",
"size": "29x29"
},
{
"filename": "icon-40.png",
"idiom": "ipad",
"scale": "2x",
"size": "20x20"
},
{
"filename": "icon-20.png",
"idiom": "ipad",
"scale": "1x",
"size": "20x20"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
```
## Required Sizes Reference
| Purpose | Size (pt) | Scale | Pixels | Device |
|---------|-----------|-------|--------|--------|
| App Store | 1024×1024 | 1x | 1024 | Marketing |
| Home Screen | 60×60 | 3x | 180 | iPhone |
| Home Screen | 60×60 | 2x | 120 | iPhone |
| Home Screen | 83.5×83.5 | 2x | 167 | iPad Pro |
| Home Screen | 76×76 | 2x | 152 | iPad |
| Spotlight | 40×40 | 3x | 120 | iPhone |
| Spotlight | 40×40 | 2x | 80 | iPhone/iPad |
| Settings | 29×29 | 3x | 87 | iPhone |
| Settings | 29×29 | 2x | 58 | iPhone/iPad |
| Notification | 20×20 | 3x | 60 | iPhone |
| Notification | 20×20 | 2x | 40 | iPhone/iPad |
## iOS 18 Dark Mode & Tinted Icons
iOS 18 adds appearance variants: Any (default), Dark, and Tinted.
### Asset Structure
Create three versions of each icon:
- `icon-1024.png` - Standard (Any appearance)
- `icon-1024-dark.png` - Dark mode variant
- `icon-1024-tinted.png` - Tinted variant
### Dark Mode Design
- Use transparent background (system provides dark fill)
- Keep foreground elements recognizable
- Lighten foreground colors for contrast against dark background
- Or provide full icon with dark-tinted background
### Tinted Design
- Must be grayscale, fully opaque
- System applies user's tint color over the grayscale
- Use gradient background: #313131 (top) to #141414 (bottom)
### Contents.json with Appearances
```json
{
"images": [
{
"filename": "icon-1024.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "icon-1024-dark.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "tinted"
}
],
"filename": "icon-1024-tinted.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
```
## Alternate App Icons
Allow users to choose between different app icons.
### Setup
1. Add alternate icon sets to asset catalog
2. Configure build setting in project.pbxproj:
```
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "DarkIcon ColorfulIcon";
```
Or add icons loose in project with @2x/@3x naming and configure Info.plist:
```xml
<key>CFBundleIcons</key>
<dict>
<key>CFBundleAlternateIcons</key>
<dict>
<key>DarkIcon</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>DarkIcon</string>
</array>
</dict>
<key>ColorfulIcon</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>ColorfulIcon</string>
</array>
</dict>
</dict>
<key>CFBundlePrimaryIcon</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon</string>
</array>
</dict>
</dict>
```
### SwiftUI Implementation
```swift
import SwiftUI
enum AppIcon: String, CaseIterable, Identifiable {
case primary = "AppIcon"
case dark = "DarkIcon"
case colorful = "ColorfulIcon"
var id: String { rawValue }
var displayName: String {
switch self {
case .primary: return "Default"
case .dark: return "Dark"
case .colorful: return "Colorful"
}
}
var iconName: String? {
self == .primary ? nil : rawValue
}
}
@Observable
class IconManager {
var currentIcon: AppIcon = .primary
init() {
if let iconName = UIApplication.shared.alternateIconName,
let icon = AppIcon(rawValue: iconName) {
currentIcon = icon
}
}
func setIcon(_ icon: AppIcon) async throws {
guard UIApplication.shared.supportsAlternateIcons else {
throw IconError.notSupported
}
try await UIApplication.shared.setAlternateIconName(icon.iconName)
currentIcon = icon
}
enum IconError: LocalizedError {
case notSupported
var errorDescription: String? {
"This device doesn't support alternate icons"
}
}
}
struct IconPickerView: View {
@Environment(IconManager.self) private var iconManager
@State private var error: Error?
var body: some View {
List(AppIcon.allCases) { icon in
Button {
Task {
do {
try await iconManager.setIcon(icon)
} catch {
self.error = error
}
}
} label: {
HStack {
// Preview image (add to asset catalog)
Image("\(icon.rawValue)-preview")
.resizable()
.frame(width: 60, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 12))
Text(icon.displayName)
Spacer()
if iconManager.currentIcon == icon {
Image(systemName: "checkmark")
.foregroundStyle(.blue)
}
}
}
.buttonStyle(.plain)
}
.navigationTitle("App Icon")
.alert("Error", isPresented: .constant(error != nil)) {
Button("OK") { error = nil }
} message: {
if let error {
Text(error.localizedDescription)
}
}
}
}
```
## Design Guidelines
### Technical Requirements
- **Format**: PNG, non-interlaced
- **Transparency**: Not allowed (fully opaque)
- **Shape**: Square with 90° corners
- **Color Space**: sRGB or Display P3
- **Minimum**: 1024×1024 for App Store
### Design Constraints
1. **No rounded corners** - System applies mask automatically
2. **No text** unless essential to brand identity
3. **No photos or screenshots** - Too detailed at small sizes
4. **No drop shadows or gloss** - System may add effects
5. **No Apple hardware** - Copyright protected
6. **No SF Symbols** - Prohibited in icons/logos
### Safe Zone
The system mask cuts corners using a superellipse shape. Keep critical elements away from edges.
Corner radius formula: `10/57 × icon_size`
- 57px icon = 10px radius
- 1024px icon ≈ 180px radius
### Test at Small Sizes
Your icon must be recognizable at 29×29 pixels (Settings icon size). If details are lost, simplify the design.
## Troubleshooting
### "Missing Marketing Icon" Error
Ensure you have a 1024×1024 icon with idiom `ios-marketing` in Contents.json.
### Icon Has Transparency
App Store rejects icons with alpha channels. Check with:
```bash
sips -g hasAlpha icon-1024.png
```
Remove alpha channel:
```bash
sips -s format png -s formatOptions 0 icon-1024.png --out icon-1024-opaque.png
```
Or with ImageMagick:
```bash
convert icon-1024.png -background white -alpha remove -alpha off icon-1024-opaque.png
```
### Interlaced PNG Error
Convert to non-interlaced:
```bash
convert icon-1024.png -interlace none icon-1024.png
```
### Rounded Corners Look Wrong
Never pre-round your icon. Provide square corners and let iOS apply the mask. Pre-rounding causes visual artifacts where the mask doesn't align.
## Complete Generation Script
One-command generation for a new project:
```bash
#!/bin/bash
# setup-app-icon.sh
# Usage: ./setup-app-icon.sh source.png project-path
SOURCE="$1"
PROJECT="${2:-.}"
ICONSET="$PROJECT/Assets.xcassets/AppIcon.appiconset"
mkdir -p "$ICONSET"
# Generate 1024x1024 (single-size mode)
sips -z 1024 1024 "$SOURCE" --out "$ICONSET/icon-1024.png"
# Remove alpha channel if present
sips -s format png -s formatOptions 0 "$ICONSET/icon-1024.png" --out "$ICONSET/icon-1024.png"
# Generate Contents.json for single-size mode
cat > "$ICONSET/Contents.json" << 'EOF'
{
"images": [
{
"filename": "icon-1024.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
EOF
echo "App icon configured at $ICONSET"
```

View File

@@ -0,0 +1,408 @@
# App Store Submission
App Review guidelines, privacy requirements, and submission checklist.
## Pre-Submission Checklist
### App Completion
- [ ] All features working
- [ ] No crashes or major bugs
- [ ] Performance optimized
- [ ] Memory leaks resolved
### Content Requirements
- [ ] App icon (1024x1024)
- [ ] Screenshots for all device sizes
- [ ] App preview videos (optional)
- [ ] Description and keywords
- [ ] Privacy policy URL
- [ ] Support URL
### Technical Requirements
- [ ] Minimum iOS version set correctly
- [ ] Privacy manifest (`PrivacyInfo.xcprivacy`)
- [ ] All permissions have usage descriptions
- [ ] Export compliance answered
- [ ] Content rights declared
## Screenshots
### Required Sizes
```
iPhone 6.9" (iPhone 16 Pro Max): 1320 x 2868
iPhone 6.7" (iPhone 15 Plus): 1290 x 2796
iPhone 6.5" (iPhone 11 Pro Max): 1284 x 2778
iPhone 5.5" (iPhone 8 Plus): 1242 x 2208
iPad Pro 13" (6th gen): 2064 x 2752
iPad Pro 12.9" (2nd gen): 2048 x 2732
```
### Automating Screenshots
With fastlane:
```ruby
# Fastfile
lane :screenshots do
capture_screenshots(
scheme: "MyAppUITests",
devices: [
"iPhone 16 Pro Max",
"iPhone 8 Plus",
"iPad Pro (12.9-inch) (6th generation)"
],
languages: ["en-US", "es-ES"],
output_directory: "./screenshots"
)
end
```
Snapfile:
```ruby
devices([
"iPhone 16 Pro Max",
"iPhone 8 Plus",
"iPad Pro (12.9-inch) (6th generation)"
])
languages(["en-US"])
scheme("MyAppUITests")
output_directory("./screenshots")
clear_previous_screenshots(true)
```
UI Test for screenshots:
```swift
import XCTest
class ScreenshotTests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
let app = XCUIApplication()
setupSnapshot(app)
app.launch()
}
func testScreenshots() {
snapshot("01-HomeScreen")
// Navigate to feature
app.buttons["Feature"].tap()
snapshot("02-FeatureScreen")
// Show detail
app.cells.firstMatch.tap()
snapshot("03-DetailScreen")
}
}
```
## Privacy Policy
### Required Elements
1. What data is collected
2. How it's used
3. Who it's shared with
4. How long it's retained
5. User rights (access, deletion)
6. Contact information
### Template Structure
```markdown
# Privacy Policy for [App Name]
Last updated: [Date]
## Information We Collect
- Account information (email, name)
- Usage data (features used, session duration)
## How We Use Information
- Provide app functionality
- Improve user experience
- Send notifications (with permission)
## Data Sharing
We do not sell your data. We share with:
- Analytics providers (anonymized)
- Cloud storage providers
## Data Retention
We retain data while your account is active.
Request deletion at [email].
## Your Rights
- Access your data
- Request deletion
- Export your data
## Contact
[email]
```
## App Review Guidelines
### Common Rejections
**1. Incomplete Information**
- Missing demo account credentials
- Unclear functionality
**2. Bugs and Crashes**
- App crashes on launch
- Features don't work
**3. Placeholder Content**
- Lorem ipsum text
- Incomplete UI
**4. Privacy Issues**
- Missing usage descriptions
- Accessing data without permission
**5. Misleading Metadata**
- Screenshots don't match app
- Description claims unavailable features
### Demo Account
In App Store Connect notes:
```
Demo Account:
Username: demo@example.com
Password: Demo123!
Notes:
- Subscription features are enabled
- Push notifications require real device
```
### Review Notes
```
Notes for Review:
1. This app requires camera access for QR scanning (Settings tab > Scan QR).
2. Push notifications are used for:
- Order status updates
- New message alerts
3. Background location is used for:
- Delivery tracking only when order is active
4. Demo account has pre-populated data for testing.
5. In-app purchases can be tested with sandbox account.
```
## Export Compliance
### Quick Check
Answer YES to export compliance if your app:
- Only uses HTTPS for network requests
- Only uses Apple's standard encryption APIs
- Only uses encryption for authentication/DRM
Most apps using HTTPS only can answer YES and select that encryption is exempt.
### Full Compliance
If using custom encryption, you need:
- Encryption Registration Number (ERN) from BIS
- Or exemption documentation
## App Privacy Labels
In App Store Connect, declare:
### Data Types
- Contact Info (name, email, phone)
- Health & Fitness
- Financial Info
- Location
- Browsing History
- Search History
- Identifiers (user ID, device ID)
- Usage Data
- Diagnostics
### Data Use
For each data type:
- **Linked to User**: Can identify the user
- **Used for Tracking**: Cross-app/web advertising
### Example Declaration
```
Contact Info - Email Address:
- Used for: App Functionality (account creation)
- Linked to User: Yes
- Used for Tracking: No
Usage Data:
- Used for: Analytics
- Linked to User: No
- Used for Tracking: No
```
## In-App Purchases
### Configuration
1. App Store Connect > Features > In-App Purchases
2. Create products with:
- Reference name
- Product ID (com.app.product)
- Price
- Localized display name/description
### Review Screenshots
Provide screenshots showing:
- Purchase screen
- Content being purchased
- Restore purchases option
### Subscription Guidelines
- Clear pricing shown before purchase
- Easy cancellation instructions
- Terms of service link
- Restore purchases available
## TestFlight
### Internal Testing
- Up to 100 internal testers
- No review required
- Immediate availability
### External Testing
- Up to 10,000 testers
- Beta App Review required
- Public link option
### Test Notes
```
What to Test:
- New feature: Cloud sync
- Bug fix: Login issues on iOS 18
- Performance improvements
Known Issues:
- Widget may not update immediately
- Dark mode icon pending
```
## Submission Process
### 1. Archive
```bash
xcodebuild archive \
-project MyApp.xcodeproj \
-scheme MyApp \
-archivePath build/MyApp.xcarchive
```
### 2. Export
```bash
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportOptionsPlist ExportOptions.plist \
-exportPath build/
```
### 3. Upload
```bash
xcrun altool --upload-app \
--type ios \
--file build/MyApp.ipa \
--apiKey YOUR_KEY_ID \
--apiIssuer YOUR_ISSUER_ID
```
### 4. Submit
1. App Store Connect > Select build
2. Complete all metadata
3. Submit for Review
## Post-Submission
### Review Timeline
- Average: 24-48 hours
- First submission: May take longer
- Complex apps: May need more review
### Responding to Rejection
1. Read rejection carefully
2. Address ALL issues
3. Reply in Resolution Center
4. Resubmit
### Expedited Review
Request for:
- Critical bug fixes
- Time-sensitive events
- Security issues
Submit request at: https://developer.apple.com/contact/app-store/?topic=expedite
## Phased Release
After approval, choose:
- **Immediate**: Available to everyone
- **Phased**: 7 days gradual rollout
- Day 1: 1%
- Day 2: 2%
- Day 3: 5%
- Day 4: 10%
- Day 5: 20%
- Day 6: 50%
- Day 7: 100%
Can pause or accelerate at any time.
## Version Updates
### What's New
```
Version 2.1
New:
• Cloud sync across devices
• Dark mode support
• Widget for home screen
Improved:
• Faster app launch
• Better search results
Fixed:
• Login issues on iOS 18
• Notification sound not playing
```
### Maintaining Multiple Versions
- Keep previous version available during review
- Test backward compatibility
- Consider forced updates for critical fixes

View File

@@ -0,0 +1,484 @@
# Background Tasks
BGTaskScheduler, background fetch, and silent push for background processing.
## BGTaskScheduler
### Setup
1. Add capability: Background Modes
2. Enable: Background fetch, Background processing
3. Register identifiers in Info.plist:
```xml
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.app.refresh</string>
<string>com.app.processing</string>
</array>
```
### Registration
```swift
import BackgroundTasks
@main
struct MyApp: App {
init() {
registerBackgroundTasks()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
private func registerBackgroundTasks() {
// App Refresh - for frequent, short updates
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.refresh",
using: nil
) { task in
guard let task = task as? BGAppRefreshTask else { return }
handleAppRefresh(task: task)
}
// Processing - for longer, deferrable work
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.processing",
using: nil
) { task in
guard let task = task as? BGProcessingTask else { return }
handleProcessing(task: task)
}
}
}
```
### App Refresh Task
Short tasks that need to run frequently:
```swift
func handleAppRefresh(task: BGAppRefreshTask) {
// Schedule next refresh
scheduleAppRefresh()
// Create task
let refreshTask = Task {
do {
try await syncLatestData()
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
// Handle expiration
task.expirationHandler = {
refreshTask.cancel()
}
}
func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 minutes
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Could not schedule app refresh: \(error)")
}
}
private func syncLatestData() async throws {
// Fetch new data from server
// Update local database
// Badge update if needed
}
```
### Processing Task
Longer tasks that can be deferred:
```swift
func handleProcessing(task: BGProcessingTask) {
// Schedule next
scheduleProcessing()
let processingTask = Task {
do {
try await performHeavyWork()
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
task.expirationHandler = {
processingTask.cancel()
}
}
func scheduleProcessing() {
let request = BGProcessingTaskRequest(identifier: "com.app.processing")
request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) // 1 hour
request.requiresNetworkConnectivity = true
request.requiresExternalPower = false
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Could not schedule processing: \(error)")
}
}
private func performHeavyWork() async throws {
// Database maintenance
// Large file uploads
// ML model training
// Cache cleanup
}
```
## Background URLSession
For large uploads/downloads that continue when app is suspended:
```swift
class BackgroundDownloadService: NSObject {
static let shared = BackgroundDownloadService()
private lazy var session: URLSession = {
let config = URLSessionConfiguration.background(
withIdentifier: "com.app.background.download"
)
config.isDiscretionary = true // System chooses best time
config.sessionSendsLaunchEvents = true // Wake app on completion
return URLSession(
configuration: config,
delegate: self,
delegateQueue: nil
)
}()
private var completionHandler: (() -> Void)?
func download(from url: URL) {
let task = session.downloadTask(with: url)
task.resume()
}
func handleEventsForBackgroundURLSession(
identifier: String,
completionHandler: @escaping () -> Void
) {
self.completionHandler = completionHandler
}
}
extension BackgroundDownloadService: URLSessionDownloadDelegate {
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
// Move file to permanent location
let documentsURL = FileManager.default.urls(
for: .documentDirectory,
in: .userDomainMask
).first!
let destinationURL = documentsURL.appendingPathComponent("downloaded.file")
try? FileManager.default.moveItem(at: location, to: destinationURL)
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DispatchQueue.main.async {
self.completionHandler?()
self.completionHandler = nil
}
}
}
// In AppDelegate
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
BackgroundDownloadService.shared.handleEventsForBackgroundURLSession(
identifier: identifier,
completionHandler: completionHandler
)
}
```
## Silent Push Notifications
Trigger background work from server:
### Configuration
Entitlements:
```xml
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
```
### Handling
```swift
// In AppDelegate
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {
guard let action = userInfo["action"] as? String else {
return .noData
}
do {
switch action {
case "sync":
try await syncData()
return .newData
case "refresh":
try await refreshContent()
return .newData
default:
return .noData
}
} catch {
return .failed
}
}
```
### Payload
```json
{
"aps": {
"content-available": 1
},
"action": "sync",
"data": {
"lastUpdate": "2025-01-01T00:00:00Z"
}
}
```
## Location Updates
Background location monitoring:
```swift
import CoreLocation
class LocationService: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
override init() {
super.init()
manager.delegate = self
manager.allowsBackgroundLocationUpdates = true
manager.pausesLocationUpdatesAutomatically = true
}
// Significant location changes (battery efficient)
func startMonitoringSignificantChanges() {
manager.startMonitoringSignificantLocationChanges()
}
// Region monitoring
func monitorRegion(_ region: CLCircularRegion) {
manager.startMonitoring(for: region)
}
// Continuous updates (high battery usage)
func startContinuousUpdates() {
manager.desiredAccuracy = kCLLocationAccuracyBest
manager.startUpdatingLocation()
}
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
guard let location = locations.last else { return }
// Process location update
Task {
try? await uploadLocation(location)
}
}
func locationManager(
_ manager: CLLocationManager,
didEnterRegion region: CLRegion
) {
// Handle region entry
}
}
```
## Background Audio
For audio playback while app is in background:
```swift
import AVFoundation
class AudioService {
private var player: AVAudioPlayer?
func configureAudioSession() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default)
try session.setActive(true)
}
func play(url: URL) throws {
player = try AVAudioPlayer(contentsOf: url)
player?.play()
}
}
```
## Testing Background Tasks
### Simulate in Debugger
```swift
// Pause in debugger, then:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.app.refresh"]
```
### Force Early Execution
```swift
#if DEBUG
func debugScheduleRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 1) // 1 second for testing
try? BGTaskScheduler.shared.submit(request)
}
#endif
```
## Best Practices
### Battery Efficiency
```swift
// Use discretionary for non-urgent work
let config = URLSessionConfiguration.background(withIdentifier: "com.app.upload")
config.isDiscretionary = true // Wait for good network/power conditions
// Require power for heavy work
let request = BGProcessingTaskRequest(identifier: "com.app.process")
request.requiresExternalPower = true
```
### Respect User Settings
```swift
func scheduleRefreshIfAllowed() {
// Check if user has Low Power Mode
if ProcessInfo.processInfo.isLowPowerModeEnabled {
// Reduce frequency or skip
return
}
// Check background refresh status
switch UIApplication.shared.backgroundRefreshStatus {
case .available:
scheduleAppRefresh()
case .denied, .restricted:
// Inform user if needed
break
@unknown default:
break
}
}
```
### Handle Expiration
Always handle task expiration:
```swift
func handleTask(_ task: BGTask) {
let operation = Task {
// Long running work
}
// CRITICAL: Always set expiration handler
task.expirationHandler = {
operation.cancel()
// Clean up
// Save progress
}
}
```
### Progress Persistence
Save progress so you can resume:
```swift
func performIncrementalSync(task: BGTask) async {
// Load progress
let lastSyncDate = UserDefaults.standard.object(forKey: "lastSyncDate") as? Date ?? .distantPast
do {
// Sync from last position
let newDate = try await syncSince(lastSyncDate)
// Save progress
UserDefaults.standard.set(newDate, forKey: "lastSyncDate")
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
```
## Debugging
### Check Scheduled Tasks
```swift
BGTaskScheduler.shared.getPendingTaskRequests { requests in
for request in requests {
print("Pending: \(request.identifier)")
print("Earliest: \(request.earliestBeginDate ?? Date())")
}
}
```
### Cancel Tasks
```swift
// Cancel specific
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: "com.app.refresh")
// Cancel all
BGTaskScheduler.shared.cancelAllTaskRequests()
```
### Console Logs
```bash
# View background task logs
log stream --predicate 'subsystem == "com.apple.BackgroundTasks"' --level debug
```

View File

@@ -0,0 +1,488 @@
# CI/CD
Xcode Cloud, fastlane, and automated testing and deployment.
## Xcode Cloud
### Setup
1. Enable in Xcode: Product > Xcode Cloud > Create Workflow
2. Configure in App Store Connect
### Basic Workflow
```yaml
# Configured in Xcode Cloud UI
Workflow: Build and Test
Start Conditions:
- Push to main
- Pull Request to main
Actions:
- Build
- Test (iOS Simulator)
Post-Actions:
- Notify (Slack)
```
### Custom Build Scripts
`.ci_scripts/ci_post_clone.sh`:
```bash
#!/bin/bash
set -e
# Install dependencies
brew install swiftlint
# Generate files
cd $CI_PRIMARY_REPOSITORY_PATH
./scripts/generate-assets.sh
```
`.ci_scripts/ci_pre_xcodebuild.sh`:
```bash
#!/bin/bash
set -e
# Run SwiftLint
swiftlint lint --strict --reporter json > swiftlint-report.json || true
# Check for errors
if grep -q '"severity": "error"' swiftlint-report.json; then
echo "SwiftLint errors found"
exit 1
fi
```
### Environment Variables
Set in Xcode Cloud:
- `API_BASE_URL`
- `SENTRY_DSN`
- Secrets (automatically masked)
Access in build:
```swift
let apiURL = Bundle.main.infoDictionary?["API_BASE_URL"] as? String
```
## Fastlane
### Installation
```bash
# Install
brew install fastlane
# Or via bundler
bundle init
echo 'gem "fastlane"' >> Gemfile
bundle install
```
### Fastfile
`fastlane/Fastfile`:
```ruby
default_platform(:ios)
platform :ios do
desc "Run tests"
lane :test do
run_tests(
scheme: "MyApp",
device: "iPhone 16",
code_coverage: true
)
end
desc "Build and upload to TestFlight"
lane :beta do
# Increment build number
increment_build_number(
build_number: latest_testflight_build_number + 1
)
# Build
build_app(
scheme: "MyApp",
export_method: "app-store"
)
# Upload
upload_to_testflight(
skip_waiting_for_build_processing: true
)
# Notify
slack(
message: "New build uploaded to TestFlight!",
slack_url: ENV["SLACK_URL"]
)
end
desc "Deploy to App Store"
lane :release do
# Ensure clean git
ensure_git_status_clean
# Build
build_app(
scheme: "MyApp",
export_method: "app-store"
)
# Upload
upload_to_app_store(
submit_for_review: true,
automatic_release: true,
force: true,
precheck_include_in_app_purchases: false
)
# Tag
add_git_tag(
tag: "v#{get_version_number}"
)
push_git_tags
end
desc "Sync certificates and profiles"
lane :sync_signing do
match(
type: "appstore",
readonly: true
)
match(
type: "development",
readonly: true
)
end
desc "Take screenshots"
lane :screenshots do
capture_screenshots(
scheme: "MyAppUITests"
)
frame_screenshots(
white: true
)
end
end
```
### Match (Code Signing)
`fastlane/Matchfile`:
```ruby
git_url("https://github.com/yourcompany/certificates")
storage_mode("git")
type("appstore")
app_identifier(["com.yourcompany.app"])
username("developer@yourcompany.com")
```
Setup:
```bash
# Initialize
fastlane match init
# Generate certificates
fastlane match appstore
fastlane match development
```
### Appfile
`fastlane/Appfile`:
```ruby
app_identifier("com.yourcompany.app")
apple_id("developer@yourcompany.com")
itc_team_id("123456")
team_id("ABCDEF1234")
```
## GitHub Actions
### Basic Workflow
`.github/workflows/ci.yml`:
```yaml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_15.4.app
- name: Cache SPM
uses: actions/cache@v3
with:
path: |
~/Library/Caches/org.swift.swiftpm
.build
key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}
- name: Build
run: |
xcodebuild build \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
CODE_SIGNING_REQUIRED=NO
- name: Test
run: |
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-resultBundlePath TestResults.xcresult \
CODE_SIGNING_REQUIRED=NO
- name: Upload Results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: TestResults.xcresult
deploy:
needs: test
runs-on: macos-14
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Install Fastlane
run: brew install fastlane
- name: Deploy to TestFlight
env:
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.ASC_KEY }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
run: fastlane beta
```
### Code Signing in CI
```yaml
- name: Import Certificate
env:
CERTIFICATE_BASE64: ${{ secrets.CERTIFICATE_BASE64 }}
CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Import certificate
echo "$CERTIFICATE_BASE64" | base64 --decode > certificate.p12
security import certificate.p12 \
-k build.keychain \
-P "$CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign
# Allow codesign access
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" build.keychain
- name: Install Provisioning Profile
env:
PROVISIONING_PROFILE_BASE64: ${{ secrets.PROVISIONING_PROFILE_BASE64 }}
run: |
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
echo "$PROVISIONING_PROFILE_BASE64" | base64 --decode > profile.mobileprovision
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
```
## Version Management
### Automatic Versioning
```ruby
# In Fastfile
lane :bump_version do |options|
# Get version from tag or parameter
version = options[:version] || git_tag_last_match(pattern: "v*").gsub("v", "")
increment_version_number(
version_number: version
)
increment_build_number(
build_number: number_of_commits
)
end
```
### Semantic Versioning Script
```bash
#!/bin/bash
# scripts/bump-version.sh
TYPE=$1 # major, minor, patch
CURRENT=$(agvtool what-marketing-version -terse1)
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
case $TYPE in
major)
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
;;
minor)
MINOR=$((MINOR + 1))
PATCH=0
;;
patch)
PATCH=$((PATCH + 1))
;;
esac
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
agvtool new-marketing-version $NEW_VERSION
echo "Version bumped to $NEW_VERSION"
```
## Test Reporting
### JUnit Format
```bash
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-resultBundlePath TestResults.xcresult
# Convert to JUnit
xcrun xcresulttool get --format json --path TestResults.xcresult > results.json
# Use xcresult-to-junit or similar tool
```
### Code Coverage
```bash
# Generate coverage
xcodebuild test \
-enableCodeCoverage YES \
-resultBundlePath TestResults.xcresult
# Export coverage report
xcrun xccov view --report --json TestResults.xcresult > coverage.json
```
### Slack Notifications
```ruby
# In Fastfile
after_all do |lane|
slack(
message: "Successfully deployed to TestFlight",
success: true,
default_payloads: [:git_branch, :git_author]
)
end
error do |lane, exception|
slack(
message: "Build failed: #{exception.message}",
success: false
)
end
```
## App Store Connect API
### Key Setup
1. App Store Connect > Users and Access > Keys
2. Generate Key with App Manager role
3. Download `.p8` file
### Fastlane Configuration
`fastlane/Appfile`:
```ruby
# Use API Key instead of password
app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_filepath: "./AuthKey.p8",
in_house: false
)
```
### Upload with altool
```bash
xcrun altool --upload-app \
--type ios \
--file build/MyApp.ipa \
--apiKey $KEY_ID \
--apiIssuer $ISSUER_ID
```
## Best Practices
### Secrets Management
- Never commit secrets to git
- Use environment variables or secret managers
- Rotate keys regularly
- Use match for certificate management
### Build Caching
```yaml
# Cache derived data
- uses: actions/cache@v3
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: ${{ runner.os }}-build-${{ hashFiles('**/*.swift') }}
```
### Parallel Testing
```ruby
run_tests(
devices: ["iPhone 16", "iPad Pro (12.9-inch)"],
parallel_testing: true,
concurrent_workers: 4
)
```
### Conditional Deploys
```yaml
# Only deploy on version tags
on:
push:
tags:
- 'v*'
```

View File

@@ -0,0 +1,459 @@
# CLI Observability
Complete debugging and monitoring without opening Xcode. Claude has full visibility into build errors, runtime logs, crashes, memory issues, and network traffic.
<prerequisites>
```bash
# Install observability tools (one-time)
brew tap ldomaradzki/xcsift && brew install xcsift
brew install mitmproxy xcbeautify
```
</prerequisites>
<build_output>
## Build Error Parsing
**xcsift** converts verbose xcodebuild output to token-efficient JSON for AI agents:
```bash
xcodebuild -project MyApp.xcodeproj -scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
build 2>&1 | xcsift
```
Output includes structured errors with file paths and line numbers:
```json
{
"status": "failed",
"errors": [
{"file": "/path/File.swift", "line": 42, "message": "Type mismatch..."}
]
}
```
**Alternative** (human-readable):
```bash
xcodebuild build 2>&1 | xcbeautify
```
</build_output>
<runtime_logging>
## Runtime Logs
### In-App Logging Pattern
Add to all apps:
```swift
import os
extension Logger {
static let app = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "App")
static let network = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Network")
static let data = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Data")
}
// Usage
Logger.network.debug("Request: \(url)")
Logger.data.error("Save failed: \(error)")
```
### Stream Logs from Simulator
```bash
# All logs from your app
xcrun simctl spawn booted log stream --level debug \
--predicate 'subsystem == "com.yourcompany.MyApp"'
# Filter by category
xcrun simctl spawn booted log stream --level debug \
--predicate 'subsystem == "com.yourcompany.MyApp" AND category == "Network"'
# Errors only
xcrun simctl spawn booted log stream \
--predicate 'subsystem == "com.yourcompany.MyApp" AND messageType == error'
# JSON output for parsing
xcrun simctl spawn booted log stream --level debug --style json \
--predicate 'subsystem == "com.yourcompany.MyApp"'
```
### Search Historical Logs
```bash
# Collect logs from simulator
xcrun simctl spawn booted log collect --output sim_logs.logarchive
# Search collected logs
log show sim_logs.logarchive --predicate 'subsystem == "com.yourcompany.MyApp"'
```
</runtime_logging>
<crash_analysis>
## Crash Logs
### Find Crashes (Simulator)
```bash
# Simulator crash logs
ls ~/Library/Logs/DiagnosticReports/ | grep MyApp
# View latest crash
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -200
```
### Symbolicate with atos
```bash
# Get load address from "Binary Images:" section of crash report
xcrun atos -arch arm64 \
-o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \
-l 0x104600000 \
0x104605ca4
# Verify dSYM matches
xcrun dwarfdump --uuid MyApp.app.dSYM
```
### Symbolicate with LLDB
```bash
xcrun lldb
(lldb) command script import lldb.macosx.crashlog
(lldb) crashlog /path/to/crash.ips
```
</crash_analysis>
<debugger>
## LLDB Debugging
### Launch with Console Output
```bash
# Launch and see stdout/stderr
xcrun simctl launch --console booted com.yourcompany.MyApp
```
### Attach to Running App
```bash
# By name
lldb -n MyApp
# By PID
lldb -p $(pgrep MyApp)
# Wait for app to launch
lldb -n MyApp --wait-for
```
### Essential Commands
```bash
# Breakpoints
(lldb) breakpoint set --file ContentView.swift --line 42
(lldb) breakpoint set --name "AppState.addItem"
(lldb) breakpoint set --name saveItem --condition 'item.name == "Test"'
# Watchpoints (break when value changes)
(lldb) watchpoint set variable self.items.count
# Execution
(lldb) continue # or 'c'
(lldb) next # step over
(lldb) step # step into
(lldb) finish # step out
# Inspection
(lldb) p variable
(lldb) po object
(lldb) frame variable # all local vars
(lldb) bt # backtrace
(lldb) bt all # all threads
# Evaluate expressions
(lldb) expr self.items.count
(lldb) expr self.items.append(newItem)
```
</debugger>
<memory_debugging>
## Memory Debugging
### Leak Detection (Simulator)
```bash
# Check running process for leaks
leaks MyApp
```
### Profiling with xctrace
```bash
# List templates
xcrun xctrace list templates
# Time Profiler
xcrun xctrace record \
--template 'Time Profiler' \
--time-limit 30s \
--output profile.trace \
--device booted \
--launch -- com.yourcompany.MyApp
# Leaks
xcrun xctrace record \
--template 'Leaks' \
--time-limit 5m \
--device booted \
--attach MyApp \
--output leaks.trace
# Export data
xcrun xctrace export --input profile.trace --toc
```
</memory_debugging>
<sanitizers>
## Sanitizers
Enable via xcodebuild flags:
```bash
# Address Sanitizer (memory errors, buffer overflows)
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-enableAddressSanitizer YES
# Thread Sanitizer (race conditions)
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-enableThreadSanitizer YES
# Undefined Behavior Sanitizer
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-enableUndefinedBehaviorSanitizer YES
```
**Note:** ASAN and TSAN cannot run simultaneously.
</sanitizers>
<network_inspection>
## Network Traffic Inspection
### mitmproxy Setup
```bash
# Run proxy (defaults to localhost:8080)
mitmproxy # TUI
mitmdump # CLI output only
```
### Configure macOS Proxy (Simulator uses host network)
```bash
# Enable
networksetup -setwebproxy "Wi-Fi" 127.0.0.1 8080
networksetup -setsecurewebproxy "Wi-Fi" 127.0.0.1 8080
# Disable when done
networksetup -setwebproxystate "Wi-Fi" off
networksetup -setsecurewebproxystate "Wi-Fi" off
```
### Install Certificate on Simulator
```bash
xcrun simctl keychain booted add-root-cert ~/.mitmproxy/mitmproxy-ca-cert.pem
```
**Important:** Restart simulator after proxy/cert changes.
### Log Traffic
```bash
# Log all requests
mitmdump -w traffic.log
# Filter by domain
mitmdump --filter "~d api.example.com"
# Verbose (show bodies)
mitmdump -v
```
</network_inspection>
<test_results>
## Test Result Parsing
```bash
# Run tests with result bundle
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-resultBundlePath TestResults.xcresult
# Get summary
xcrun xcresulttool get test-results summary --path TestResults.xcresult
# Export as JSON
xcrun xcresulttool get --path TestResults.xcresult --format json > results.json
# Coverage report
xcrun xccov view --report TestResults.xcresult
# Coverage as JSON
xcrun xccov view --report --json TestResults.xcresult > coverage.json
```
### Accessibility Audits (Xcode 15+)
Add to UI tests:
```swift
func testAccessibility() throws {
let app = XCUIApplication()
app.launch()
try app.performAccessibilityAudit()
}
```
Run via CLI:
```bash
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyAppUITests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-only-testing:MyAppUITests/AccessibilityTests
```
</test_results>
<swiftui_debugging>
## SwiftUI Debugging
### Track View Re-evaluation
```swift
var body: some View {
let _ = Self._printChanges() // Logs what caused re-render
VStack {
// ...
}
}
```
### Dump Objects
```swift
let _ = dump(someObject) // Full object hierarchy to console
```
**Note:** No CLI equivalent for Xcode's visual view hierarchy inspector. Use logging extensively.
</swiftui_debugging>
<simulator_management>
## Simulator Management
```bash
# List simulators
xcrun simctl list devices
# Boot simulator
xcrun simctl boot "iPhone 16"
open -a Simulator
# Install app
xcrun simctl install booted ./build/Build/Products/Debug-iphonesimulator/MyApp.app
# Launch app
xcrun simctl launch booted com.yourcompany.MyApp
# Launch with console output
xcrun simctl launch --console booted com.yourcompany.MyApp
# Screenshot
xcrun simctl io booted screenshot ~/Desktop/screenshot.png
# Video recording
xcrun simctl io booted recordVideo ~/Desktop/recording.mov
# Set location
xcrun simctl location booted set 37.7749,-122.4194
# Send push notification
xcrun simctl push booted com.yourcompany.MyApp notification.apns
# Reset simulator
xcrun simctl erase booted
```
</simulator_management>
<device_debugging>
## Device Debugging (iOS 17+)
```bash
# List devices
xcrun devicectl list devices
# Install app
xcrun devicectl device install app --device <udid> MyApp.app
# Launch app
xcrun devicectl device process launch --device <udid> com.yourcompany.MyApp
```
</device_debugging>
<standard_debug_workflow>
## Standard Debug Workflow
```bash
# 1. Build with error parsing
xcodebuild -project MyApp.xcodeproj -scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
build 2>&1 | xcsift
# 2. Boot simulator and start log streaming (background terminal)
xcrun simctl boot "iPhone 16"
open -a Simulator
xcrun simctl spawn booted log stream --level debug \
--predicate 'subsystem == "com.yourcompany.MyApp"' &
# 3. Install and launch
xcrun simctl install booted ./build/Build/Products/Debug-iphonesimulator/MyApp.app
xcrun simctl launch booted com.yourcompany.MyApp
# 4. If crash occurs
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -100
# 5. Memory check
leaks MyApp
# 6. Deep debugging
lldb -n MyApp
```
</standard_debug_workflow>
<cli_vs_xcode>
## What CLI Can and Cannot Do
| Task | CLI | Tool |
|------|-----|------|
| Build errors | ✓ | xcsift |
| Runtime logs | ✓ | simctl log stream |
| Crash symbolication | ✓ | atos, lldb |
| Breakpoints/debugging | ✓ | lldb |
| Memory leaks | ✓ | leaks, xctrace |
| CPU profiling | ✓ | xctrace |
| Network inspection | ✓ | mitmproxy |
| Test results | ✓ | xcresulttool |
| Accessibility audit | ✓ | UI tests |
| Sanitizers | ✓ | xcodebuild flags |
| View hierarchy | ⚠️ | _printChanges() only |
| GPU debugging | ✗ | Requires Xcode |
</cli_vs_xcode>

View File

@@ -0,0 +1,407 @@
# CLI Workflow
Build, run, test, and deploy iOS apps entirely from the terminal.
## Prerequisites
```bash
# Ensure Xcode is installed and selected
xcode-select -p
# Should show: /Applications/Xcode.app/Contents/Developer
# If not, run:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
# Install XcodeGen for project creation
brew install xcodegen
# Optional: prettier build output
brew install xcbeautify
# Optional: device deployment
brew install ios-deploy
```
## Create Project (XcodeGen)
Create a new iOS project entirely from CLI:
```bash
# Create directory structure
mkdir MyApp && cd MyApp
mkdir -p MyApp/{App,Models,Views,Services,Resources} MyAppTests MyAppUITests
# Create project.yml (Claude generates this - see project-scaffolding.md for full template)
cat > project.yml << 'EOF'
name: MyApp
options:
bundleIdPrefix: com.yourcompany
deploymentTarget:
iOS: "18.0"
targets:
MyApp:
type: application
platform: iOS
sources: [MyApp]
settings:
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp
DEVELOPMENT_TEAM: YOURTEAMID
EOF
# Create app entry point
cat > MyApp/App/MyApp.swift << 'EOF'
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
Text("Hello, World!")
}
}
}
EOF
# Generate .xcodeproj
xcodegen generate
# Verify
xcodebuild -list -project MyApp.xcodeproj
# Build
xcodebuild -project MyApp.xcodeproj -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16' build
```
See [project-scaffolding.md](project-scaffolding.md) for complete project.yml templates.
## Building
### Basic Build
```bash
# Build for simulator
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
build
# Build for device
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-destination 'generic/platform=iOS' \
build
```
### Clean Build
```bash
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
clean build
```
### Build with Specific SDK
```bash
# List available SDKs
xcodebuild -showsdks
# Build with specific SDK
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-sdk iphonesimulator17.0 \
build
```
## Running on Simulator
### Boot and Launch
```bash
# List available simulators
xcrun simctl list devices
# Boot simulator
xcrun simctl boot "iPhone 16"
# Open Simulator app
open -a Simulator
# Install app
xcrun simctl install booted ~/Library/Developer/Xcode/DerivedData/MyApp-xxx/Build/Products/Debug-iphonesimulator/MyApp.app
# Launch app
xcrun simctl launch booted com.yourcompany.MyApp
# Or install and launch in one step
xcrun simctl install booted MyApp.app && xcrun simctl launch booted com.yourcompany.MyApp
```
### Simulator Management
```bash
# Create simulator
xcrun simctl create "My iPhone 16" "iPhone 16" iOS17.0
# Delete simulator
xcrun simctl delete "My iPhone 16"
# Reset simulator
xcrun simctl erase booted
# Screenshot
xcrun simctl io booted screenshot ~/Desktop/screenshot.png
# Record video
xcrun simctl io booted recordVideo ~/Desktop/recording.mov
```
### Simulate Conditions
```bash
# Set location
xcrun simctl location booted set 37.7749,-122.4194
# Send push notification
xcrun simctl push booted com.yourcompany.MyApp notification.apns
# Set status bar (time, battery, etc.)
xcrun simctl status_bar booted override --time "9:41" --batteryLevel 100
```
## Running on Device
### List Connected Devices
```bash
# List devices
xcrun xctrace list devices
# Or using ios-deploy
ios-deploy --detect
```
### Deploy to Device
```bash
# Install ios-deploy
brew install ios-deploy
# Deploy and run
ios-deploy --bundle MyApp.app --debug
# Just install without launching
ios-deploy --bundle MyApp.app --no-wifi
# Deploy with app data
ios-deploy --bundle MyApp.app --bundle_id com.yourcompany.MyApp
```
### Wireless Debugging
1. Connect device via USB once
2. In Xcode: Window > Devices and Simulators > Connect via network
3. Deploy wirelessly:
```bash
ios-deploy --bundle MyApp.app --wifi
```
## Testing
### Run Unit Tests
```bash
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-resultBundlePath TestResults.xcresult
```
### Run UI Tests
```bash
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyAppUITests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-resultBundlePath UITestResults.xcresult
```
### Run Specific Tests
```bash
# Single test
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-only-testing:MyAppTests/NetworkServiceTests/testFetchItems
# Test class
xcodebuild test \
... \
-only-testing:MyAppTests/NetworkServiceTests
```
### View Test Results
```bash
# Open results in Xcode
open TestResults.xcresult
# Export to JSON (for CI)
xcrun xcresulttool get --path TestResults.xcresult --format json
```
## Debugging
### Console Logs
```bash
# Stream logs from simulator
xcrun simctl spawn booted log stream --predicate 'subsystem == "com.yourcompany.MyApp"'
# Stream logs from device
idevicesyslog | grep MyApp
```
### LLDB
```bash
# Attach to running process
lldb -n MyApp
# Debug app on launch
ios-deploy --bundle MyApp.app --debug
```
### Crash Logs
```bash
# Simulator crash logs
ls ~/Library/Logs/DiagnosticReports/
# Device crash logs (via Xcode)
# Window > Devices and Simulators > View Device Logs
```
## Archiving and Export
### Create Archive
```bash
xcodebuild archive \
-project MyApp.xcodeproj \
-scheme MyApp \
-archivePath build/MyApp.xcarchive \
-destination 'generic/platform=iOS'
```
### Export IPA
Create `ExportOptions.plist`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>YOUR_TEAM_ID</string>
<key>uploadSymbols</key>
<true/>
<key>uploadBitcode</key>
<false/>
</dict>
</plist>
```
Export:
```bash
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportOptionsPlist ExportOptions.plist \
-exportPath build/
```
## App Store Connect
### Upload to TestFlight
```bash
xcrun altool --upload-app \
--type ios \
--file build/MyApp.ipa \
--apiKey YOUR_KEY_ID \
--apiIssuer YOUR_ISSUER_ID
```
Or use `xcrun notarytool` for newer workflows:
```bash
xcrun notarytool submit build/MyApp.ipa \
--key ~/.appstoreconnect/AuthKey_XXXXX.p8 \
--key-id YOUR_KEY_ID \
--issuer YOUR_ISSUER_ID \
--wait
```
### App Store Connect API Key
1. App Store Connect > Users and Access > Keys
2. Generate API Key
3. Download and store securely
## Useful Aliases
Add to `.zshrc`:
```bash
# iOS development
alias ios-build="xcodebuild -project *.xcodeproj -scheme \$(basename *.xcodeproj .xcodeproj) -destination 'platform=iOS Simulator,name=iPhone 16' build"
alias ios-test="xcodebuild test -project *.xcodeproj -scheme \$(basename *.xcodeproj .xcodeproj) -destination 'platform=iOS Simulator,name=iPhone 16'"
alias ios-run="xcrun simctl launch booted"
alias ios-log="xcrun simctl spawn booted log stream --level debug"
alias sim-boot="xcrun simctl boot 'iPhone 16' && open -a Simulator"
alias sim-screenshot="xcrun simctl io booted screenshot ~/Desktop/sim-\$(date +%Y%m%d-%H%M%S).png"
```
## Troubleshooting
### Build Failures
```bash
# Clear derived data
rm -rf ~/Library/Developer/Xcode/DerivedData
# Reset package caches
rm -rf ~/Library/Caches/org.swift.swiftpm
# Resolve packages
xcodebuild -resolvePackageDependencies
```
### Simulator Issues
```bash
# Kill all simulators
killall Simulator
# Reset all simulators
xcrun simctl shutdown all && xcrun simctl erase all
```
### Code Signing
```bash
# List identities
security find-identity -v -p codesigning
# Check provisioning profiles
ls ~/Library/MobileDevice/Provisioning\ Profiles/
```

View File

@@ -0,0 +1,527 @@
# Data Persistence
SwiftData, Core Data, and file-based storage for iOS apps.
## SwiftData (iOS 17+)
### Model Definition
```swift
import SwiftData
@Model
class Item {
var name: String
var createdAt: Date
var isCompleted: Bool
var priority: Int
@Relationship(deleteRule: .cascade)
var tasks: [Task]
@Relationship(inverse: \Category.items)
var category: Category?
init(name: String, priority: Int = 0) {
self.name = name
self.createdAt = Date()
self.isCompleted = false
self.priority = priority
self.tasks = []
}
}
@Model
class Task {
var title: String
var isCompleted: Bool
init(title: String) {
self.title = title
self.isCompleted = false
}
}
@Model
class Category {
var name: String
var items: [Item]
init(name: String) {
self.name = name
self.items = []
}
}
```
### Container Setup
```swift
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Item.self, Category.self])
}
}
```
### Querying Data
```swift
struct ItemList: View {
// Basic query
@Query private var items: [Item]
// Sorted query
@Query(sort: \Item.createdAt, order: .reverse)
private var sortedItems: [Item]
// Filtered query
@Query(filter: #Predicate<Item> { $0.isCompleted == false })
private var incompleteItems: [Item]
// Complex query
@Query(
filter: #Predicate<Item> { !$0.isCompleted && $0.priority > 5 },
sort: [
SortDescriptor(\Item.priority, order: .reverse),
SortDescriptor(\Item.createdAt)
]
)
private var highPriorityItems: [Item]
var body: some View {
List(items) { item in
ItemRow(item: item)
}
}
}
```
### CRUD Operations
```swift
struct ItemList: View {
@Query private var items: [Item]
@Environment(\.modelContext) private var context
var body: some View {
List {
ForEach(items) { item in
ItemRow(item: item)
}
.onDelete(perform: delete)
}
.toolbar {
Button("Add", action: addItem)
}
}
private func addItem() {
let item = Item(name: "New Item")
context.insert(item)
// Auto-saves
}
private func delete(at offsets: IndexSet) {
for index in offsets {
context.delete(items[index])
}
}
}
```
### Custom Container Configuration
```swift
@main
struct MyApp: App {
let container: ModelContainer
init() {
let schema = Schema([Item.self, Category.self])
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
allowsSave: true,
groupContainer: .identifier("group.com.yourcompany.app")
)
do {
container = try ModelContainer(for: schema, configurations: config)
} catch {
fatalError("Failed to configure SwiftData container: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
```
### iCloud Sync
SwiftData syncs automatically with iCloud when:
1. App has iCloud capability
2. User is signed into iCloud
3. Container uses CloudKit
```swift
let config = ModelConfiguration(
cloudKitDatabase: .automatic
)
```
## Core Data (All iOS Versions)
### Stack Setup
```swift
class CoreDataStack {
static let shared = CoreDataStack()
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "MyApp")
// Enable cloud sync
guard let description = container.persistentStoreDescriptions.first else {
fatalError("No persistent store description")
}
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.yourcompany.app"
)
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Core Data failed to load: \(error)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return container
}()
var viewContext: NSManagedObjectContext {
persistentContainer.viewContext
}
func saveContext() {
let context = viewContext
if context.hasChanges {
do {
try context.save()
} catch {
print("Failed to save context: \(error)")
}
}
}
}
```
### With SwiftUI
```swift
@main
struct MyApp: App {
let coreDataStack = CoreDataStack.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, coreDataStack.viewContext)
}
}
}
struct ItemList: View {
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.createdAt, ascending: false)],
predicate: NSPredicate(format: "isCompleted == NO")
)
private var items: FetchedResults<Item>
@Environment(\.managedObjectContext) private var context
var body: some View {
List(items) { item in
ItemRow(item: item)
}
}
}
```
## File-Based Storage
### Codable Models
```swift
struct UserSettings: Codable {
var theme: Theme
var fontSize: Int
var notificationsEnabled: Bool
enum Theme: String, Codable {
case light, dark, system
}
}
class SettingsStore {
private let fileURL: URL
init() {
let documentsDirectory = FileManager.default.urls(
for: .documentDirectory,
in: .userDomainMask
).first!
fileURL = documentsDirectory.appendingPathComponent("settings.json")
}
func load() -> UserSettings {
guard let data = try? Data(contentsOf: fileURL),
let settings = try? JSONDecoder().decode(UserSettings.self, from: data) else {
return UserSettings(theme: .system, fontSize: 16, notificationsEnabled: true)
}
return settings
}
func save(_ settings: UserSettings) throws {
let data = try JSONEncoder().encode(settings)
try data.write(to: fileURL)
}
}
```
### Document Directory Paths
```swift
extension FileManager {
var documentsDirectory: URL {
urls(for: .documentDirectory, in: .userDomainMask).first!
}
var cachesDirectory: URL {
urls(for: .cachesDirectory, in: .userDomainMask).first!
}
var applicationSupportDirectory: URL {
let url = urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
try? createDirectory(at: url, withIntermediateDirectories: true)
return url
}
}
```
## UserDefaults
### Basic Usage
```swift
// Save
UserDefaults.standard.set("value", forKey: "key")
UserDefaults.standard.set(true, forKey: "hasCompletedOnboarding")
// Load
let value = UserDefaults.standard.string(forKey: "key")
let hasCompletedOnboarding = UserDefaults.standard.bool(forKey: "hasCompletedOnboarding")
```
### @AppStorage
```swift
struct SettingsView: View {
@AppStorage("fontSize") private var fontSize = 16
@AppStorage("isDarkMode") private var isDarkMode = false
@AppStorage("username") private var username = ""
var body: some View {
Form {
Stepper("Font Size: \(fontSize)", value: $fontSize, in: 12...24)
Toggle("Dark Mode", isOn: $isDarkMode)
TextField("Username", text: $username)
}
}
}
```
### Custom Codable Storage
```swift
extension UserDefaults {
func set<T: Codable>(_ value: T, forKey key: String) {
if let data = try? JSONEncoder().encode(value) {
set(data, forKey: key)
}
}
func get<T: Codable>(_ type: T.Type, forKey key: String) -> T? {
guard let data = data(forKey: key) else { return nil }
return try? JSONDecoder().decode(type, from: data)
}
}
// Usage
UserDefaults.standard.set(userProfile, forKey: "userProfile")
let profile = UserDefaults.standard.get(UserProfile.self, forKey: "userProfile")
```
## Keychain (Sensitive Data)
### Simple Wrapper
```swift
import Security
class KeychainService {
enum KeychainError: Error {
case saveFailed(OSStatus)
case loadFailed(OSStatus)
case deleteFailed(OSStatus)
case dataConversionError
}
func save(_ data: Data, for key: String) throws {
// Delete existing
try? delete(key)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
func load(_ key: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
throw KeychainError.loadFailed(status)
}
guard let data = result as? Data else {
throw KeychainError.dataConversionError
}
return data
}
func delete(_ key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.deleteFailed(status)
}
}
}
// String convenience
extension KeychainService {
func saveString(_ value: String, for key: String) throws {
guard let data = value.data(using: .utf8) else {
throw KeychainError.dataConversionError
}
try save(data, for: key)
}
func loadString(_ key: String) throws -> String {
let data = try load(key)
guard let string = String(data: data, encoding: .utf8) else {
throw KeychainError.dataConversionError
}
return string
}
}
```
### Usage
```swift
let keychain = KeychainService()
// Save API token
try keychain.saveString(token, for: "apiToken")
// Load API token
let token = try keychain.loadString("apiToken")
// Delete on logout
try keychain.delete("apiToken")
```
## Migration Strategies
### SwiftData Migrations
```swift
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Item.self]
}
@Model
class Item {
var name: String
init(name: String) { self.name = name }
}
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Item.self]
}
@Model
class Item {
var name: String
var createdAt: Date // New field
init(name: String) {
self.name = name
self.createdAt = Date()
}
}
}
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
}
```

View File

@@ -0,0 +1,473 @@
# Navigation Patterns
NavigationStack, deep linking, and programmatic navigation for iOS apps.
## NavigationStack Basics
### Value-Based Navigation
```swift
struct ContentView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationTitle("Items")
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item, path: $path)
}
.navigationDestination(for: Category.self) { category in
CategoryView(category: category)
}
}
}
}
```
### Programmatic Navigation
```swift
struct ContentView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
VStack {
Button("Go to Settings") {
path.append(Route.settings)
}
Button("Go to Item") {
path.append(items[0])
}
Button("Deep Link") {
// Push multiple screens
path.append(Route.settings)
path.append(SettingsSection.account)
}
}
.navigationDestination(for: Route.self) { route in
switch route {
case .settings:
SettingsView(path: $path)
case .profile:
ProfileView()
}
}
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
}
.navigationDestination(for: SettingsSection.self) { section in
SettingsSectionView(section: section)
}
}
}
func popToRoot() {
path.removeLast(path.count)
}
func popOne() {
if !path.isEmpty {
path.removeLast()
}
}
}
enum Route: Hashable {
case settings
case profile
}
enum SettingsSection: Hashable {
case account
case notifications
case privacy
}
```
## Tab-Based Navigation
### TabView with NavigationStack per Tab
```swift
struct MainTabView: View {
@State private var selectedTab = Tab.home
@State private var homePath = NavigationPath()
@State private var searchPath = NavigationPath()
@State private var profilePath = NavigationPath()
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack(path: $homePath) {
HomeView()
}
.tabItem {
Label("Home", systemImage: "house")
}
.tag(Tab.home)
NavigationStack(path: $searchPath) {
SearchView()
}
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.tag(Tab.search)
NavigationStack(path: $profilePath) {
ProfileView()
}
.tabItem {
Label("Profile", systemImage: "person")
}
.tag(Tab.profile)
}
.onChange(of: selectedTab) { oldTab, newTab in
// Pop to root when re-tapping current tab
if oldTab == newTab {
switch newTab {
case .home: homePath.removeLast(homePath.count)
case .search: searchPath.removeLast(searchPath.count)
case .profile: profilePath.removeLast(profilePath.count)
}
}
}
}
enum Tab {
case home, search, profile
}
}
```
## Deep Linking
### URL Scheme Handling
Configure in Info.plist:
```xml
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
```
Handle in App:
```swift
@main
struct MyApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView()
.environment(appState)
.onOpenURL { url in
handleDeepLink(url)
}
}
}
private func handleDeepLink(_ url: URL) {
// myapp://item/123
// myapp://settings/account
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return }
let pathComponents = components.path.split(separator: "/").map(String.init)
switch pathComponents.first {
case "item":
if let id = pathComponents.dropFirst().first {
appState.navigateToItem(id: id)
}
case "settings":
let section = pathComponents.dropFirst().first
appState.navigateToSettings(section: section)
default:
break
}
}
}
@Observable
class AppState {
var selectedTab: Tab = .home
var homePath = NavigationPath()
func navigateToItem(id: String) {
selectedTab = .home
homePath.removeLast(homePath.count)
if let item = findItem(id: id) {
homePath.append(item)
}
}
func navigateToSettings(section: String?) {
selectedTab = .profile
// Navigate to settings
}
}
```
### Universal Links
Configure in `apple-app-site-association` on your server:
```json
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.yourcompany.app",
"paths": ["/item/*", "/user/*"]
}
]
}
}
```
Add Associated Domains entitlement:
```xml
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
</array>
```
Handle same as URL schemes with `onOpenURL`.
## Modal Presentation
### Sheet Navigation
```swift
struct ContentView: View {
@State private var selectedItem: Item?
@State private var showingNewItem = false
var body: some View {
NavigationStack {
List(items) { item in
Button(item.name) {
selectedItem = item
}
}
.toolbar {
Button {
showingNewItem = true
} label: {
Image(systemName: "plus")
}
}
}
// Item-based presentation
.sheet(item: $selectedItem) { item in
NavigationStack {
ItemDetail(item: item)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
selectedItem = nil
}
}
}
}
}
// Boolean-based presentation
.sheet(isPresented: $showingNewItem) {
NavigationStack {
NewItemView()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
showingNewItem = false
}
}
}
}
}
}
}
```
### Full Screen Cover
```swift
.fullScreenCover(isPresented: $showingOnboarding) {
OnboardingFlow()
}
```
### Detents (Sheet Sizes)
```swift
.sheet(isPresented: $showingOptions) {
OptionsView()
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
```
## Navigation State Persistence
### Codable Navigation Path
```swift
struct ContentView: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
// Content
}
.onAppear {
loadNavigationState()
}
.onChange(of: path) { _, newPath in
saveNavigationState(newPath)
}
}
private func saveNavigationState(_ path: [Route]) {
if let data = try? JSONEncoder().encode(path) {
UserDefaults.standard.set(data, forKey: "navigationPath")
}
}
private func loadNavigationState() {
guard let data = UserDefaults.standard.data(forKey: "navigationPath"),
let savedPath = try? JSONDecoder().decode([Route].self, from: data) else {
return
}
path = savedPath
}
}
enum Route: Codable, Hashable {
case item(id: UUID)
case settings
case profile
}
```
## Navigation Coordinator
For complex apps, centralize navigation logic:
```swift
@Observable
class NavigationCoordinator {
var homePath = NavigationPath()
var searchPath = NavigationPath()
var selectedTab: Tab = .home
enum Tab {
case home, search, profile
}
func showItem(_ item: Item) {
selectedTab = .home
homePath.append(item)
}
func showSearch(query: String) {
selectedTab = .search
searchPath.append(SearchQuery(text: query))
}
func popToRoot() {
switch selectedTab {
case .home:
homePath.removeLast(homePath.count)
case .search:
searchPath.removeLast(searchPath.count)
case .profile:
break
}
}
func handleDeepLink(_ url: URL) {
// Parse and navigate
}
}
// Inject via environment
@main
struct MyApp: App {
@State private var coordinator = NavigationCoordinator()
var body: some Scene {
WindowGroup {
ContentView()
.environment(coordinator)
.onOpenURL { url in
coordinator.handleDeepLink(url)
}
}
}
}
```
## Search Integration
### Searchable Modifier
```swift
struct ItemList: View {
@State private var searchText = ""
@State private var searchScope = SearchScope.all
var filteredItems: [Item] {
items.filter { item in
searchText.isEmpty || item.name.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
NavigationStack {
List(filteredItems) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationTitle("Items")
.searchable(text: $searchText, prompt: "Search items")
.searchScopes($searchScope) {
Text("All").tag(SearchScope.all)
Text("Recent").tag(SearchScope.recent)
Text("Favorites").tag(SearchScope.favorites)
}
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
}
}
}
enum SearchScope {
case all, recent, favorites
}
}
```
### Search Suggestions
```swift
.searchable(text: $searchText) {
ForEach(suggestions) { suggestion in
Text(suggestion.text)
.searchCompletion(suggestion.text)
}
}
```

View File

@@ -0,0 +1,527 @@
# Networking
URLSession patterns, caching, authentication, and offline support.
## Basic Networking Service
```swift
actor NetworkService {
private let session: URLSession
private let decoder: JSONDecoder
private let encoder: JSONEncoder
init(session: URLSession = .shared) {
self.session = session
self.decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
self.encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.keyEncodingStrategy = .convertToSnakeCase
}
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
let request = try endpoint.urlRequest()
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard 200..<300 ~= httpResponse.statusCode else {
throw NetworkError.httpError(httpResponse.statusCode, data)
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw NetworkError.decodingError(error)
}
}
func send<T: Encodable, R: Decodable>(_ body: T, to endpoint: Endpoint) async throws -> R {
var request = try endpoint.urlRequest()
request.httpBody = try encoder.encode(body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard 200..<300 ~= httpResponse.statusCode else {
throw NetworkError.httpError(httpResponse.statusCode, data)
}
return try decoder.decode(R.self, from: data)
}
}
enum NetworkError: LocalizedError {
case invalidURL
case invalidResponse
case httpError(Int, Data)
case decodingError(Error)
case noConnection
case timeout
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid URL"
case .invalidResponse:
return "Invalid server response"
case .httpError(let code, _):
return "Server error (\(code))"
case .decodingError:
return "Failed to parse response"
case .noConnection:
return "No internet connection"
case .timeout:
return "Request timed out"
}
}
}
```
## Endpoint Pattern
```swift
enum Endpoint {
case items
case item(id: String)
case createItem
case updateItem(id: String)
case deleteItem(id: String)
case search(query: String, page: Int)
var baseURL: URL {
URL(string: "https://api.example.com/v1")!
}
var path: String {
switch self {
case .items, .createItem:
return "/items"
case .item(let id), .updateItem(let id), .deleteItem(let id):
return "/items/\(id)"
case .search:
return "/search"
}
}
var method: String {
switch self {
case .items, .item, .search:
return "GET"
case .createItem:
return "POST"
case .updateItem:
return "PUT"
case .deleteItem:
return "DELETE"
}
}
var queryItems: [URLQueryItem]? {
switch self {
case .search(let query, let page):
return [
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "page", value: String(page))
]
default:
return nil
}
}
func urlRequest() throws -> URLRequest {
var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: true)
components?.queryItems = queryItems
guard let url = components?.url else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Accept")
return request
}
}
```
## Authentication
### Bearer Token
```swift
actor AuthenticatedNetworkService {
private let session: URLSession
private let tokenProvider: TokenProvider
init(tokenProvider: TokenProvider) {
self.session = .shared
self.tokenProvider = tokenProvider
}
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
var request = try endpoint.urlRequest()
// Add auth header
let token = try await tokenProvider.validToken()
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
// Handle 401 - token expired
if httpResponse.statusCode == 401 {
// Refresh token and retry
let newToken = try await tokenProvider.refreshToken()
request.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
let (retryData, retryResponse) = try await session.data(for: request)
guard let retryHttpResponse = retryResponse as? HTTPURLResponse,
200..<300 ~= retryHttpResponse.statusCode else {
throw NetworkError.unauthorized
}
return try JSONDecoder().decode(T.self, from: retryData)
}
guard 200..<300 ~= httpResponse.statusCode else {
throw NetworkError.httpError(httpResponse.statusCode, data)
}
return try JSONDecoder().decode(T.self, from: data)
}
}
protocol TokenProvider {
func validToken() async throws -> String
func refreshToken() async throws -> String
}
```
### OAuth 2.0 Flow
```swift
import AuthenticationServices
class OAuthService: NSObject {
func signIn() async throws -> String {
let authURL = URL(string: "https://auth.example.com/authorize?client_id=xxx&redirect_uri=myapp://callback&response_type=code")!
return try await withCheckedThrowingContinuation { continuation in
let session = ASWebAuthenticationSession(
url: authURL,
callbackURLScheme: "myapp"
) { callbackURL, error in
if let error = error {
continuation.resume(throwing: error)
return
}
guard let callbackURL = callbackURL,
let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
.queryItems?.first(where: { $0.name == "code" })?.value else {
continuation.resume(throwing: OAuthError.invalidCallback)
return
}
continuation.resume(returning: code)
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = true
session.start()
}
}
}
extension OAuthService: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first { $0.isKeyWindow }!
}
}
```
## Caching
### URLCache Configuration
```swift
class CachedNetworkService {
private let session: URLSession
init() {
let cache = URLCache(
memoryCapacity: 50 * 1024 * 1024, // 50 MB memory
diskCapacity: 200 * 1024 * 1024 // 200 MB disk
)
let config = URLSessionConfiguration.default
config.urlCache = cache
config.requestCachePolicy = .returnCacheDataElseLoad
self.session = URLSession(configuration: config)
}
func fetch<T: Decodable>(_ endpoint: Endpoint, cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy) async throws -> T {
var request = try endpoint.urlRequest()
request.cachePolicy = cachePolicy
let (data, _) = try await session.data(for: request)
return try JSONDecoder().decode(T.self, from: data)
}
func fetchFresh<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
try await fetch(endpoint, cachePolicy: .reloadIgnoringLocalCacheData)
}
}
```
### Custom Caching
```swift
actor DataCache {
private var cache: [String: CachedItem] = [:]
private let maxAge: TimeInterval
struct CachedItem {
let data: Data
let timestamp: Date
}
init(maxAge: TimeInterval = 300) {
self.maxAge = maxAge
}
func get(_ key: String) -> Data? {
guard let item = cache[key] else { return nil }
guard Date().timeIntervalSince(item.timestamp) < maxAge else {
cache.removeValue(forKey: key)
return nil
}
return item.data
}
func set(_ data: Data, for key: String) {
cache[key] = CachedItem(data: data, timestamp: Date())
}
func invalidate(_ key: String) {
cache.removeValue(forKey: key)
}
func clearAll() {
cache.removeAll()
}
}
```
## Offline Support
### Network Monitor
```swift
import Network
@Observable
class NetworkMonitor {
var isConnected = true
var connectionType: ConnectionType = .wifi
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")
enum ConnectionType {
case wifi, cellular, unknown
}
init() {
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
self?.isConnected = path.status == .satisfied
self?.connectionType = self?.getConnectionType(path) ?? .unknown
}
}
monitor.start(queue: queue)
}
private func getConnectionType(_ path: NWPath) -> ConnectionType {
if path.usesInterfaceType(.wifi) {
return .wifi
} else if path.usesInterfaceType(.cellular) {
return .cellular
}
return .unknown
}
deinit {
monitor.cancel()
}
}
```
### Offline-First Pattern
```swift
actor OfflineFirstService {
private let network: NetworkService
private let storage: StorageService
private let cache: DataCache
func fetchItems() async throws -> [Item] {
// Try cache first
if let cached = await cache.get("items"),
let items = try? JSONDecoder().decode([Item].self, from: cached) {
// Return cached, fetch fresh in background
Task {
try? await fetchAndCacheFresh()
}
return items
}
// Try network
do {
let items: [Item] = try await network.fetch(.items)
await cache.set(try JSONEncoder().encode(items), for: "items")
return items
} catch {
// Fall back to storage
return try await storage.loadItems()
}
}
private func fetchAndCacheFresh() async throws {
let items: [Item] = try await network.fetch(.items)
await cache.set(try JSONEncoder().encode(items), for: "items")
try await storage.saveItems(items)
}
}
```
### Pending Operations Queue
```swift
actor PendingOperationsQueue {
private var operations: [PendingOperation] = []
private let storage: StorageService
struct PendingOperation: Codable {
let id: UUID
let endpoint: String
let method: String
let body: Data?
let createdAt: Date
}
func add(_ operation: PendingOperation) async {
operations.append(operation)
try? await persist()
}
func processAll() async {
for operation in operations {
do {
try await execute(operation)
operations.removeAll { $0.id == operation.id }
} catch {
// Keep in queue for retry
continue
}
}
try? await persist()
}
private func execute(_ operation: PendingOperation) async throws {
// Execute network request
}
private func persist() async throws {
try await storage.savePendingOperations(operations)
}
}
```
## Multipart Upload
```swift
extension NetworkService {
func upload(_ fileData: Data, filename: String, mimeType: String, to endpoint: Endpoint) async throws -> UploadResponse {
let boundary = UUID().uuidString
var request = try endpoint.urlRequest()
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(fileData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
200..<300 ~= httpResponse.statusCode else {
throw NetworkError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0, data)
}
return try JSONDecoder().decode(UploadResponse.self, from: data)
}
}
```
## Download with Progress
```swift
class DownloadService: NSObject, URLSessionDownloadDelegate {
private lazy var session: URLSession = {
URLSession(configuration: .default, delegate: self, delegateQueue: nil)
}()
private var progressHandler: ((Double) -> Void)?
private var completionHandler: ((Result<URL, Error>) -> Void)?
func download(from url: URL, progress: @escaping (Double) -> Void) async throws -> URL {
try await withCheckedThrowingContinuation { continuation in
self.progressHandler = progress
self.completionHandler = { result in
continuation.resume(with: result)
}
session.downloadTask(with: url).resume()
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
completionHandler?(.success(location))
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
DispatchQueue.main.async {
self.progressHandler?(progress)
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
completionHandler?(.failure(error))
}
}
}
```

View File

@@ -0,0 +1,562 @@
# Performance
Instruments, memory management, launch optimization, and battery efficiency.
## Instruments Profiling
### Time Profiler
Find CPU-intensive code:
```bash
# Profile from CLI
xcrun xctrace record \
--template 'Time Profiler' \
--device-name 'iPhone 16' \
--launch MyApp.app \
--output profile.trace
```
Common issues:
- Main thread work during UI updates
- Expensive computations in body
- Synchronous I/O
### Allocations
Track memory usage:
```bash
xcrun xctrace record \
--template 'Allocations' \
--device-name 'iPhone 16' \
--launch MyApp.app \
--output allocations.trace
```
Look for:
- Memory growth over time
- Abandoned memory
- High transient allocations
### Leaks
Find retain cycles:
```bash
xcrun xctrace record \
--template 'Leaks' \
--device-name 'iPhone 16' \
--launch MyApp.app \
--output leaks.trace
```
Common causes:
- Strong reference cycles in closures
- Delegate patterns without weak references
- Timer retain cycles
## Memory Management
### Weak References in Closures
```swift
// Bad - creates retain cycle
class ViewModel {
var timer: Timer?
func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.update() // Strong capture
}
}
}
// Good - weak capture
class ViewModel {
var timer: Timer?
func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.update()
}
}
deinit {
timer?.invalidate()
}
}
```
### Async Task Cancellation
```swift
class ViewModel {
private var loadTask: Task<Void, Never>?
func load() {
loadTask?.cancel()
loadTask = Task { [weak self] in
guard let self else { return }
let items = try? await fetchItems()
// Check cancellation before updating
guard !Task.isCancelled else { return }
await MainActor.run {
self.items = items ?? []
}
}
}
deinit {
loadTask?.cancel()
}
}
```
### Large Data Handling
```swift
// Bad - loads all into memory
let allPhotos = try await fetchAllPhotos()
for photo in allPhotos {
process(photo)
}
// Good - stream processing
for await photo in fetchPhotosStream() {
process(photo)
// Allow UI updates
if shouldYield {
await Task.yield()
}
}
```
## SwiftUI Performance
### Avoid Expensive Body Computations
```swift
// Bad - recomputes on every body call
struct ItemList: View {
let items: [Item]
var body: some View {
let sortedItems = items.sorted { $0.date > $1.date } // Every render!
List(sortedItems) { item in
ItemRow(item: item)
}
}
}
// Good - compute once
struct ItemList: View {
let items: [Item]
var sortedItems: [Item] {
items.sorted { $0.date > $1.date }
}
var body: some View {
List(sortedItems) { item in
ItemRow(item: item)
}
}
}
// Better - use @State or computed in view model
struct ItemList: View {
@State private var sortedItems: [Item] = []
let items: [Item]
var body: some View {
List(sortedItems) { item in
ItemRow(item: item)
}
.onChange(of: items) { _, newItems in
sortedItems = newItems.sorted { $0.date > $1.date }
}
}
}
```
### Optimize List Performance
```swift
// Use stable identifiers
struct Item: Identifiable {
let id: UUID // Stable identifier
var name: String
}
// Explicit id for efficiency
List(items, id: \.id) { item in
ItemRow(item: item)
}
// Lazy loading for large lists
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
}
```
### Equatable Conformance
```swift
// Prevent unnecessary re-renders
struct ItemRow: View, Equatable {
let item: Item
static func == (lhs: ItemRow, rhs: ItemRow) -> Bool {
lhs.item.id == rhs.item.id &&
lhs.item.name == rhs.item.name
}
var body: some View {
Text(item.name)
}
}
// Use in ForEach
ForEach(items) { item in
ItemRow(item: item)
.equatable()
}
```
### Task Modifier Optimization
```swift
// Bad - recreates task on any state change
struct ContentView: View {
@State private var items: [Item] = []
@State private var searchText = ""
var body: some View {
List(filteredItems) { item in
ItemRow(item: item)
}
.task {
items = await fetchItems() // Reruns when searchText changes!
}
}
}
// Good - use task(id:)
struct ContentView: View {
@State private var items: [Item] = []
@State private var searchText = ""
@State private var needsLoad = true
var body: some View {
List(filteredItems) { item in
ItemRow(item: item)
}
.task(id: needsLoad) {
if needsLoad {
items = await fetchItems()
needsLoad = false
}
}
}
}
```
## Launch Time Optimization
### Measure Launch Time
```bash
# Cold launch measurement
xcrun simctl spawn booted log stream --predicate 'subsystem == "com.apple.os.signpost" && category == "PointsOfInterest"'
```
In Instruments: App Launch template
### Defer Non-Critical Work
```swift
@main
struct MyApp: App {
init() {
// Critical only
setupErrorReporting()
}
var body: some Scene {
WindowGroup {
ContentView()
.task {
// Defer non-critical
await setupAnalytics()
await preloadData()
}
}
}
}
```
### Avoid Synchronous Work
```swift
// Bad - blocks launch
@main
struct MyApp: App {
let database = Database.load() // Synchronous I/O
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
// Good - async initialization
@main
struct MyApp: App {
@State private var database: Database?
var body: some Scene {
WindowGroup {
if let database {
ContentView()
.environment(database)
} else {
LaunchScreen()
}
}
.task {
database = await Database.load()
}
}
}
```
### Reduce Dylib Loading
- Minimize third-party dependencies
- Use static linking where possible
- Merge frameworks
## Network Performance
### Request Batching
```swift
// Bad - many small requests
for id in itemIDs {
let item = try await fetchItem(id)
items.append(item)
}
// Good - batch request
let items = try await fetchItems(ids: itemIDs)
```
### Image Loading
```swift
// Use AsyncImage with caching
AsyncImage(url: imageURL) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image.resizable().scaledToFit()
case .failure:
Image(systemName: "photo")
@unknown default:
EmptyView()
}
}
// For better control, use custom caching
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func image(for url: URL) async throws -> UIImage {
if let cached = cache[url] {
return cached
}
let (data, _) = try await URLSession.shared.data(from: url)
let image = UIImage(data: data)!
cache[url] = image
return image
}
}
```
### Prefetching
```swift
struct ItemList: View {
let items: [Item]
let prefetcher = ImagePrefetcher()
var body: some View {
List(items) { item in
ItemRow(item: item)
.onAppear {
// Prefetch next items
let index = items.firstIndex(of: item) ?? 0
let nextItems = items.dropFirst(index + 1).prefix(5)
prefetcher.prefetch(urls: nextItems.compactMap(\.imageURL))
}
}
}
}
```
## Battery Optimization
### Location Updates
```swift
import CoreLocation
class LocationService: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
func startUpdates() {
// Use appropriate accuracy
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters // Not kCLLocationAccuracyBest
// Allow deferred updates
manager.allowsBackgroundLocationUpdates = false
manager.pausesLocationUpdatesAutomatically = true
// Use significant change for background
manager.startMonitoringSignificantLocationChanges()
}
}
```
### Background Tasks
```swift
import BackgroundTasks
func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 minutes
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Could not schedule app refresh: \(error)")
}
}
func handleAppRefresh(task: BGAppRefreshTask) {
// Schedule next refresh
scheduleAppRefresh()
let refreshTask = Task {
do {
try await syncData()
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
task.expirationHandler = {
refreshTask.cancel()
}
}
```
### Network Efficiency
```swift
// Use background URL session for large transfers
let config = URLSessionConfiguration.background(withIdentifier: "com.app.background")
config.isDiscretionary = true // System chooses optimal time
config.allowsCellularAccess = false // WiFi only for large downloads
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
```
## Debugging Performance
### Signposts
```swift
import os
let signposter = OSSignposter()
func processItems() async {
let signpostID = signposter.makeSignpostID()
let state = signposter.beginInterval("Process Items", id: signpostID)
for item in items {
signposter.emitEvent("Processing", id: signpostID, "\(item.name)")
await process(item)
}
signposter.endInterval("Process Items", state)
}
```
### MetricKit
```swift
import MetricKit
class MetricsManager: NSObject, MXMetricManagerSubscriber {
override init() {
super.init()
MXMetricManager.shared.add(self)
}
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
// Process CPU, memory, launch time metrics
if let cpuMetrics = payload.cpuMetrics {
print("CPU time: \(cpuMetrics.cumulativeCPUTime)")
}
}
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
// Process crash and hang diagnostics
}
}
}
```
## Performance Checklist
### Launch
- [ ] < 400ms to first frame
- [ ] No synchronous I/O in init
- [ ] Deferred non-critical setup
### Memory
- [ ] No leaks
- [ ] Stable memory usage
- [ ] No abandoned memory
### UI
- [ ] 60 fps scrolling
- [ ] No main thread blocking
- [ ] Efficient list rendering
### Network
- [ ] Request batching
- [ ] Image caching
- [ ] Proper timeout handling
### Battery
- [ ] Minimal background activity
- [ ] Efficient location usage
- [ ] Discretionary transfers

View File

@@ -0,0 +1,594 @@
# Polish and UX
Haptics, animations, gestures, and micro-interactions for premium iOS apps.
## Haptics
### Impact Feedback
```swift
import UIKit
struct HapticEngine {
// Impact - use for UI element hits
static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
let generator = UIImpactFeedbackGenerator(style: style)
generator.impactOccurred()
}
// Notification - use for outcomes
static func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) {
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(type)
}
// Selection - use for picker/selection changes
static func selection() {
let generator = UISelectionFeedbackGenerator()
generator.selectionChanged()
}
}
// Convenience methods
extension HapticEngine {
static func light() { impact(.light) }
static func medium() { impact(.medium) }
static func heavy() { impact(.heavy) }
static func rigid() { impact(.rigid) }
static func soft() { impact(.soft) }
static func success() { notification(.success) }
static func warning() { notification(.warning) }
static func error() { notification(.error) }
}
```
### Usage Guidelines
```swift
// Button tap
Button("Add Item") {
HapticEngine.light()
addItem()
}
// Successful action
func save() async {
do {
try await saveToDisk()
HapticEngine.success()
} catch {
HapticEngine.error()
}
}
// Toggle
Toggle("Enable", isOn: $isEnabled)
.onChange(of: isEnabled) { _, _ in
HapticEngine.selection()
}
// Destructive action
Button("Delete", role: .destructive) {
HapticEngine.warning()
delete()
}
// Picker change
Picker("Size", selection: $size) {
ForEach(sizes, id: \.self) { size in
Text(size).tag(size)
}
}
.onChange(of: size) { _, _ in
HapticEngine.selection()
}
```
## Animations
### Spring Animations
```swift
// Standard spring (most natural)
withAnimation(.spring(duration: 0.3)) {
isExpanded.toggle()
}
// Bouncy spring
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
showCard = true
}
// Snappy spring
withAnimation(.spring(duration: 0.2, bounce: 0.0)) {
offset = .zero
}
// Custom response and damping
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
scale = 1.0
}
```
### Transitions
```swift
struct ContentView: View {
@State private var showDetail = false
var body: some View {
VStack {
if showDetail {
DetailView()
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
}
}
.animation(.spring(duration: 0.3), value: showDetail)
}
}
// Custom transition
extension AnyTransition {
static var slideAndFade: AnyTransition {
.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .opacity
)
}
}
```
### Phase Animations
```swift
struct PulsingView: View {
@State private var isAnimating = false
var body: some View {
Circle()
.fill(.blue)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.opacity(isAnimating ? 0.8 : 1.0)
.animation(.easeInOut(duration: 1).repeatForever(autoreverses: true), value: isAnimating)
.onAppear {
isAnimating = true
}
}
}
```
### Keyframe Animations
```swift
struct ShakeView: View {
@State private var trigger = false
var body: some View {
Text("Shake me")
.keyframeAnimator(initialValue: 0.0, trigger: trigger) { content, value in
content.offset(x: value)
} keyframes: { _ in
KeyframeTrack {
SpringKeyframe(15, duration: 0.1)
SpringKeyframe(-15, duration: 0.1)
SpringKeyframe(10, duration: 0.1)
SpringKeyframe(-10, duration: 0.1)
SpringKeyframe(0, duration: 0.1)
}
}
.onTapGesture {
trigger.toggle()
}
}
}
```
## Gestures
### Drag Gesture
```swift
struct DraggableCard: View {
@State private var offset = CGSize.zero
@State private var isDragging = false
var body: some View {
RoundedRectangle(cornerRadius: 16)
.fill(.blue)
.frame(width: 200, height: 300)
.offset(offset)
.scaleEffect(isDragging ? 1.05 : 1.0)
.gesture(
DragGesture()
.onChanged { value in
withAnimation(.interactiveSpring()) {
offset = value.translation
isDragging = true
}
}
.onEnded { value in
withAnimation(.spring(duration: 0.3)) {
// Snap back or dismiss based on threshold
if abs(value.translation.width) > 150 {
// Dismiss
offset = CGSize(width: value.translation.width > 0 ? 500 : -500, height: 0)
} else {
offset = .zero
}
isDragging = false
}
}
)
}
}
```
### Long Press with Preview
```swift
struct ItemRow: View {
let item: Item
@State private var isPressed = false
var body: some View {
Text(item.name)
.scaleEffect(isPressed ? 0.95 : 1.0)
.gesture(
LongPressGesture(minimumDuration: 0.5)
.onChanged { _ in
withAnimation(.easeInOut(duration: 0.1)) {
isPressed = true
}
HapticEngine.medium()
}
.onEnded { _ in
withAnimation(.spring(duration: 0.2)) {
isPressed = false
}
showContextMenu()
}
)
}
}
```
### Gesture Priority
```swift
struct ZoomableImage: View {
@State private var scale: CGFloat = 1.0
@State private var offset = CGSize.zero
var body: some View {
Image("photo")
.resizable()
.scaledToFit()
.scaleEffect(scale)
.offset(offset)
.gesture(
// Magnification takes priority
MagnificationGesture()
.onChanged { value in
scale = value
}
.onEnded { _ in
withAnimation {
scale = max(1, scale)
}
}
.simultaneously(with:
DragGesture()
.onChanged { value in
offset = value.translation
}
.onEnded { _ in
withAnimation {
offset = .zero
}
}
)
)
}
}
```
## Loading States
### Skeleton Loading
```swift
struct SkeletonView: View {
@State private var isAnimating = false
var body: some View {
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
colors: [.gray.opacity(0.3), .gray.opacity(0.1), .gray.opacity(0.3)],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(height: 20)
.mask(
Rectangle()
.offset(x: isAnimating ? 300 : -300)
)
.animation(.linear(duration: 1.5).repeatForever(autoreverses: false), value: isAnimating)
.onAppear {
isAnimating = true
}
}
}
struct LoadingListView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
ForEach(0..<5) { _ in
HStack {
SkeletonView()
.frame(width: 50, height: 50)
VStack(alignment: .leading, spacing: 8) {
SkeletonView()
.frame(width: 150)
SkeletonView()
.frame(width: 100)
}
}
}
}
.padding()
}
}
```
### Progress Indicators
```swift
struct ContentLoadingView: View {
let progress: Double
var body: some View {
VStack(spacing: 16) {
// Circular progress
ProgressView(value: progress)
.progressViewStyle(.circular)
// Linear progress with percentage
VStack {
ProgressView(value: progress)
Text("\(Int(progress * 100))%")
.font(.caption)
.foregroundStyle(.secondary)
}
// Custom circular
ZStack {
Circle()
.stroke(.gray.opacity(0.2), lineWidth: 8)
Circle()
.trim(from: 0, to: progress)
.stroke(.blue, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeInOut, value: progress)
}
.frame(width: 60, height: 60)
}
}
}
```
## Micro-interactions
### Button Press Effect
```swift
struct PressableButton: View {
let title: String
let action: () -> Void
@State private var isPressed = false
var body: some View {
Text(title)
.padding()
.background(.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
.scaleEffect(isPressed ? 0.95 : 1.0)
.brightness(isPressed ? -0.1 : 0)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
withAnimation(.easeInOut(duration: 0.1)) {
isPressed = true
}
}
.onEnded { _ in
withAnimation(.spring(duration: 0.2)) {
isPressed = false
}
action()
}
)
}
}
```
### Success Checkmark
```swift
struct SuccessCheckmark: View {
@State private var isComplete = false
var body: some View {
ZStack {
Circle()
.fill(.green)
.frame(width: 80, height: 80)
.scaleEffect(isComplete ? 1 : 0)
Image(systemName: "checkmark")
.font(.system(size: 40, weight: .bold))
.foregroundStyle(.white)
.scaleEffect(isComplete ? 1 : 0)
.rotationEffect(.degrees(isComplete ? 0 : -90))
}
.onAppear {
withAnimation(.spring(duration: 0.5, bounce: 0.4).delay(0.1)) {
isComplete = true
}
HapticEngine.success()
}
}
}
```
### Pull to Refresh Indicator
```swift
struct CustomRefreshView: View {
@Binding var isRefreshing: Bool
var body: some View {
if isRefreshing {
HStack(spacing: 8) {
ProgressView()
Text("Updating...")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
}
}
}
```
## Scroll Effects
### Parallax Header
```swift
struct ParallaxHeader: View {
let minHeight: CGFloat = 200
let maxHeight: CGFloat = 350
var body: some View {
GeometryReader { geometry in
let offset = geometry.frame(in: .global).minY
let height = max(minHeight, maxHeight + offset)
Image("header")
.resizable()
.scaledToFill()
.frame(width: geometry.size.width, height: height)
.clipped()
.offset(y: offset > 0 ? -offset : 0)
}
.frame(height: maxHeight)
}
}
```
### Scroll Position Effects
```swift
struct FadeOnScrollView: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(0..<50) { index in
Text("Item \(index)")
.padding()
.frame(maxWidth: .infinity)
.background(.background.secondary)
.clipShape(RoundedRectangle(cornerRadius: 8))
.scrollTransition { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.3)
.scaleEffect(phase.isIdentity ? 1 : 0.9)
}
}
}
.padding()
}
}
}
```
## Empty States
```swift
struct EmptyStateView: View {
let icon: String
let title: String
let message: String
let actionTitle: String?
let action: (() -> Void)?
var body: some View {
VStack(spacing: 16) {
Image(systemName: icon)
.font(.system(size: 60))
.foregroundStyle(.secondary)
Text(title)
.font(.title2.bold())
Text(message)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if let actionTitle, let action {
Button(actionTitle, action: action)
.buttonStyle(.borderedProminent)
.padding(.top)
}
}
.padding(40)
}
}
// Usage
if items.isEmpty {
EmptyStateView(
icon: "tray",
title: "No Items",
message: "Add your first item to get started",
actionTitle: "Add Item",
action: { showNewItem = true }
)
}
```
## Best Practices
### Respect Reduce Motion
```swift
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
Button("Action") { }
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(reduceMotion ? .none : .spring(), value: isPressed)
}
```
### Consistent Timing
Use consistent animation durations:
- Quick feedback: 0.1-0.2s
- Standard transitions: 0.3s
- Prominent animations: 0.5s
### Haptic Pairing
Always pair animations with appropriate haptics:
- Success animation → success haptic
- Error shake → error haptic
- Selection change → selection haptic

View File

@@ -0,0 +1,468 @@
# Project Scaffolding
Complete setup guide for new iOS projects with CLI-only development workflow.
## XcodeGen Setup (Recommended)
**Install XcodeGen** (one-time):
```bash
brew install xcodegen
```
**Create a new iOS app**:
```bash
mkdir MyApp && cd MyApp
mkdir -p MyApp/{App,Models,Views,Services,Resources} MyAppTests MyAppUITests
# Create project.yml (see template below)
# Create Swift files
xcodegen generate
xcodebuild -project MyApp.xcodeproj -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16' build
```
## project.yml Template
Complete iOS SwiftUI app with tests:
```yaml
name: MyApp
options:
bundleIdPrefix: com.yourcompany
deploymentTarget:
iOS: "18.0"
xcodeVersion: "16.0"
createIntermediateGroups: true
configs:
Debug: debug
Release: release
settings:
base:
SWIFT_VERSION: "5.9"
IPHONEOS_DEPLOYMENT_TARGET: "18.0"
TARGETED_DEVICE_FAMILY: "1,2"
targets:
MyApp:
type: application
platform: iOS
sources:
- MyApp
resources:
- path: MyApp/Resources
excludes:
- "**/.DS_Store"
info:
path: MyApp/Info.plist
properties:
UILaunchScreen: {}
CFBundleName: $(PRODUCT_NAME)
CFBundleIdentifier: $(PRODUCT_BUNDLE_IDENTIFIER)
CFBundleShortVersionString: "1.0"
CFBundleVersion: "1"
UIRequiredDeviceCapabilities:
- armv7
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
entitlements:
path: MyApp/MyApp.entitlements
properties:
aps-environment: development
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp
PRODUCT_NAME: MyApp
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: YOURTEAMID
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
configs:
Debug:
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
SWIFT_OPTIMIZATION_LEVEL: -Onone
Release:
SWIFT_OPTIMIZATION_LEVEL: -Osize
MyAppTests:
type: bundle.unit-test
platform: iOS
sources:
- MyAppTests
dependencies:
- target: MyApp
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp.tests
MyAppUITests:
type: bundle.ui-testing
platform: iOS
sources:
- MyAppUITests
dependencies:
- target: MyApp
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp.uitests
TEST_TARGET_NAME: MyApp
schemes:
MyApp:
build:
targets:
MyApp: all
MyAppTests: [test]
MyAppUITests: [test]
run:
config: Debug
test:
config: Debug
gatherCoverageData: true
targets:
- MyAppTests
- MyAppUITests
profile:
config: Release
archive:
config: Release
```
## project.yml with SwiftData
Add SwiftData support:
```yaml
targets:
MyApp:
# ... existing config ...
settings:
base:
# ... existing settings ...
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) SWIFT_DATA"
dependencies:
- sdk: SwiftData.framework
```
## project.yml with Swift Packages
```yaml
packages:
Alamofire:
url: https://github.com/Alamofire/Alamofire
from: 5.8.0
KeychainAccess:
url: https://github.com/kishikawakatsumi/KeychainAccess
from: 4.2.0
targets:
MyApp:
# ... other config ...
dependencies:
- package: Alamofire
- package: KeychainAccess
```
## Alternative: Xcode GUI
For users who prefer Xcode:
1. File > New > Project > iOS > App
2. Settings: SwiftUI, Swift, SwiftData (optional)
3. Save and close Xcode
---
## File Structure
```
MyApp/
├── MyApp.xcodeproj/
├── MyApp/
│ ├── App/
│ │ ├── MyApp.swift
│ │ ├── AppState.swift
│ │ └── AppDependencies.swift
│ ├── Models/
│ ├── Views/
│ │ ├── ContentView.swift
│ │ ├── Screens/
│ │ └── Components/
│ ├── Services/
│ ├── Utilities/
│ ├── Resources/
│ │ ├── Assets.xcassets/
│ │ ├── Localizable.xcstrings
│ │ └── PrivacyInfo.xcprivacy
│ ├── Info.plist
│ └── MyApp.entitlements
├── MyAppTests/
└── MyAppUITests/
```
## Starter Code
### MyApp.swift
```swift
import SwiftUI
@main
struct MyApp: App {
@State private var appState = AppState()
init() {
configureAppearance()
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(appState)
.task {
await appState.initialize()
}
}
}
private func configureAppearance() {
// Global appearance customization
}
}
```
### AppState.swift
```swift
import SwiftUI
@Observable
class AppState {
// Navigation
var navigationPath = NavigationPath()
var selectedTab: Tab = .home
// App state
var isLoading = false
var error: AppError?
var user: User?
// Feature flags
var isPremium = false
enum Tab: Hashable {
case home, search, profile
}
func initialize() async {
// Load initial data
// Check purchase status
// Request permissions if needed
}
func handleDeepLink(_ url: URL) {
// Parse URL and update navigation
}
}
enum AppError: LocalizedError {
case networkError(Error)
case dataError(String)
case unauthorized
var errorDescription: String? {
switch self {
case .networkError(let error):
return error.localizedDescription
case .dataError(let message):
return message
case .unauthorized:
return "Please sign in to continue"
}
}
}
```
### ContentView.swift
```swift
import SwiftUI
struct ContentView: View {
@Environment(AppState.self) private var appState
var body: some View {
@Bindable var appState = appState
TabView(selection: $appState.selectedTab) {
HomeScreen()
.tabItem {
Label("Home", systemImage: "house")
}
.tag(AppState.Tab.home)
SearchScreen()
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.tag(AppState.Tab.search)
ProfileScreen()
.tabItem {
Label("Profile", systemImage: "person")
}
.tag(AppState.Tab.profile)
}
.overlay {
if appState.isLoading {
LoadingOverlay()
}
}
.alert("Error", isPresented: .constant(appState.error != nil)) {
Button("OK") { appState.error = nil }
} message: {
if let error = appState.error {
Text(error.localizedDescription)
}
}
}
}
```
## Privacy Manifest
Required for App Store submission. Create `PrivacyInfo.xcprivacy`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<!-- Add collected data types here -->
</array>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
```
## Entitlements Template
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Push Notifications -->
<key>aps-environment</key>
<string>development</string>
<!-- App Groups (for shared data) -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.yourcompany.myapp</string>
</array>
</dict>
</plist>
```
## Xcode Project Creation
Create via command line using `xcodegen` or `tuist`, or create in Xcode and immediately close:
```bash
# Option 1: Using xcodegen
brew install xcodegen
# Create project.yml, then:
xcodegen generate
# Option 2: Create in Xcode, configure, close
# File > New > Project > iOS > App
# Configure settings, then close Xcode
```
## Build Configuration
### Development vs Release
```bash
# Debug build
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-configuration Debug \
-destination 'platform=iOS Simulator,name=iPhone 16' \
build
# Release build
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-configuration Release \
-destination 'generic/platform=iOS' \
build
```
### Environment Variables
Use xcconfig files for different environments:
```
// Debug.xcconfig
API_BASE_URL = https://dev-api.example.com
ENABLE_LOGGING = YES
// Release.xcconfig
API_BASE_URL = https://api.example.com
ENABLE_LOGGING = NO
```
Access in code:
```swift
let apiURL = Bundle.main.infoDictionary?["API_BASE_URL"] as? String
```
## Asset Catalog Setup
### App Icon
- Provide 1024x1024 PNG
- Xcode generates all sizes automatically
### Colors
Define semantic colors in Assets.xcassets:
- `AccentColor` - App tint color
- `BackgroundPrimary` - Main background
- `TextPrimary` - Primary text
### SF Symbols
Prefer SF Symbols for icons. Use custom symbols only when necessary.
## Localization Setup
1. Enable localization in project settings
2. Create `Localizable.xcstrings` (Xcode 15+)
3. Use String Catalogs for automatic extraction
```swift
// Strings are automatically extracted
Text("Welcome")
Text("Items: \(count)")
```

View File

@@ -0,0 +1,506 @@
# Push Notifications
APNs setup, registration, rich notifications, and silent push.
## Basic Setup
### Request Permission
```swift
import UserNotifications
class PushService: NSObject {
static let shared = PushService()
func requestPermission() async -> Bool {
let center = UNUserNotificationCenter.current()
center.delegate = self
do {
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
if granted {
await registerForRemoteNotifications()
}
return granted
} catch {
print("Permission request failed: \(error)")
return false
}
}
@MainActor
private func registerForRemoteNotifications() {
UIApplication.shared.registerForRemoteNotifications()
}
func checkPermissionStatus() async -> UNAuthorizationStatus {
let settings = await UNUserNotificationCenter.current().notificationSettings()
return settings.authorizationStatus
}
}
extension PushService: UNUserNotificationCenterDelegate {
// Handle notification when app is in foreground
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
return [.banner, .sound, .badge]
}
// Handle notification tap
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
// Handle action
switch response.actionIdentifier {
case UNNotificationDefaultActionIdentifier:
// User tapped notification
handleNotificationTap(userInfo)
case "REPLY_ACTION":
if let textResponse = response as? UNTextInputNotificationResponse {
handleReply(textResponse.userText, userInfo: userInfo)
}
default:
break
}
}
private func handleNotificationTap(_ userInfo: [AnyHashable: Any]) {
// Navigate to relevant screen
if let itemID = userInfo["item_id"] as? String {
// appState.navigateToItem(id: itemID)
}
}
private func handleReply(_ text: String, userInfo: [AnyHashable: Any]) {
// Send reply
}
}
```
### Handle Device Token
In your App or AppDelegate:
```swift
// Using UIApplicationDelegateAdaptor
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
print("Device Token: \(token)")
// Send to your server
Task {
try? await sendTokenToServer(token)
}
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("Failed to register: \(error)")
}
private func sendTokenToServer(_ token: String) async throws {
// POST to your server
}
}
```
## Rich Notifications
### Notification Content Extension
1. File > New > Target > Notification Content Extension
2. Configure in `Info.plist`:
```xml
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>UNNotificationExtensionCategory</key>
<string>MEDIA_CATEGORY</string>
<key>UNNotificationExtensionInitialContentSizeRatio</key>
<real>0.5</real>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.content-extension</string>
</dict>
```
3. Implement `NotificationViewController`:
```swift
import UIKit
import UserNotifications
import UserNotificationsUI
class NotificationViewController: UIViewController, UNNotificationContentExtension {
@IBOutlet weak var imageView: UIImageView!
@IBOutlet weak var titleLabel: UILabel!
func didReceive(_ notification: UNNotification) {
let content = notification.request.content
titleLabel.text = content.title
// Load attachment
if let attachment = content.attachments.first,
attachment.url.startAccessingSecurityScopedResource() {
defer { attachment.url.stopAccessingSecurityScopedResource() }
if let data = try? Data(contentsOf: attachment.url),
let image = UIImage(data: data) {
imageView.image = image
}
}
}
}
```
### Notification Service Extension
Modify notification content before display:
1. File > New > Target > Notification Service Extension
2. Implement:
```swift
import UserNotifications
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
guard let bestAttemptContent = bestAttemptContent else {
contentHandler(request.content)
return
}
// Download and attach media
if let imageURLString = bestAttemptContent.userInfo["image_url"] as? String,
let imageURL = URL(string: imageURLString) {
downloadImage(from: imageURL) { attachment in
if let attachment = attachment {
bestAttemptContent.attachments = [attachment]
}
contentHandler(bestAttemptContent)
}
} else {
contentHandler(bestAttemptContent)
}
}
override func serviceExtensionTimeWillExpire() {
// Called just before extension is terminated
if let contentHandler = contentHandler,
let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
private func downloadImage(from url: URL, completion: @escaping (UNNotificationAttachment?) -> Void) {
let task = URLSession.shared.downloadTask(with: url) { location, _, error in
guard let location = location, error == nil else {
completion(nil)
return
}
let tempDirectory = FileManager.default.temporaryDirectory
let tempFile = tempDirectory.appendingPathComponent(UUID().uuidString + ".jpg")
do {
try FileManager.default.moveItem(at: location, to: tempFile)
let attachment = try UNNotificationAttachment(identifier: "image", url: tempFile)
completion(attachment)
} catch {
completion(nil)
}
}
task.resume()
}
}
```
## Actions and Categories
### Define Actions
```swift
func registerNotificationCategories() {
// Actions
let replyAction = UNTextInputNotificationAction(
identifier: "REPLY_ACTION",
title: "Reply",
options: [],
textInputButtonTitle: "Send",
textInputPlaceholder: "Type your reply..."
)
let markReadAction = UNNotificationAction(
identifier: "MARK_READ_ACTION",
title: "Mark as Read",
options: []
)
let deleteAction = UNNotificationAction(
identifier: "DELETE_ACTION",
title: "Delete",
options: [.destructive]
)
// Category
let messageCategory = UNNotificationCategory(
identifier: "MESSAGE_CATEGORY",
actions: [replyAction, markReadAction, deleteAction],
intentIdentifiers: [],
options: []
)
// Register
UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
}
```
### Send with Category
```json
{
"aps": {
"alert": {
"title": "New Message",
"body": "You have a new message from John"
},
"category": "MESSAGE_CATEGORY",
"mutable-content": 1
},
"image_url": "https://example.com/image.jpg"
}
```
## Silent Push
For background data updates:
### Configuration
Add to entitlements:
```xml
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
```
### Handle Silent Push
```swift
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {
// Process in background
do {
try await syncData()
return .newData
} catch {
return .failed
}
}
private func syncData() async throws {
// Fetch new data
}
}
```
### Send Silent Push
```json
{
"aps": {
"content-available": 1
},
"data": {
"type": "sync",
"timestamp": "2025-01-01T00:00:00Z"
}
}
```
## Local Notifications
Schedule notifications without server:
```swift
class LocalNotificationService {
func scheduleReminder(title: String, body: String, at date: Date, id: String) async throws {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date)
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
try await UNUserNotificationCenter.current().add(request)
}
func scheduleRepeating(title: String, body: String, hour: Int, minute: Int, id: String) async throws {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
var components = DateComponents()
components.hour = hour
components.minute = minute
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
try await UNUserNotificationCenter.current().add(request)
}
func cancel(_ id: String) {
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id])
}
func cancelAll() {
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
}
}
```
## Badge Management
```swift
extension PushService {
func updateBadge(count: Int) async {
do {
try await UNUserNotificationCenter.current().setBadgeCount(count)
} catch {
print("Failed to set badge: \(error)")
}
}
func clearBadge() async {
await updateBadge(count: 0)
}
}
```
## APNs Server Setup
### Payload Format
```json
{
"aps": {
"alert": {
"title": "Title",
"subtitle": "Subtitle",
"body": "Body text"
},
"badge": 1,
"sound": "default",
"thread-id": "group-id",
"category": "CATEGORY_ID"
},
"custom_key": "custom_value"
}
```
### Sending with JWT
```bash
curl -v \
--header "authorization: bearer $JWT" \
--header "apns-topic: com.yourcompany.app" \
--header "apns-push-type: alert" \
--http2 \
--data '{"aps":{"alert":"Hello"}}' \
https://api.push.apple.com/3/device/$DEVICE_TOKEN
```
## Best Practices
### Request Permission at Right Time
```swift
// Don't request on launch
// Instead, request after value is demonstrated
func onFirstMessageReceived() {
Task {
let granted = await PushService.shared.requestPermission()
if !granted {
showPermissionBenefitsSheet()
}
}
}
```
### Handle Permission Denied
```swift
func showNotificationSettings() {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
```
### Group Notifications
```json
{
"aps": {
"alert": "New message",
"thread-id": "conversation-123"
}
}
```
### Time Sensitive (iOS 15+)
```json
{
"aps": {
"alert": "Your order arrived",
"interruption-level": "time-sensitive"
}
}
```

View File

@@ -0,0 +1,532 @@
# Security
Keychain, secure storage, biometrics, and secure coding practices.
## Keychain
### KeychainService
```swift
import Security
class KeychainService {
enum KeychainError: Error {
case saveFailed(OSStatus)
case loadFailed(OSStatus)
case deleteFailed(OSStatus)
case dataConversionError
case itemNotFound
}
private let service: String
init(service: String = Bundle.main.bundleIdentifier ?? "app") {
self.service = service
}
// MARK: - Data
func save(_ data: Data, for key: String, accessibility: CFString = kSecAttrAccessibleWhenUnlocked) throws {
// Delete existing
try? delete(key)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: accessibility
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
func load(_ key: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status != errSecItemNotFound else {
throw KeychainError.itemNotFound
}
guard status == errSecSuccess else {
throw KeychainError.loadFailed(status)
}
guard let data = result as? Data else {
throw KeychainError.dataConversionError
}
return data
}
func delete(_ key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.deleteFailed(status)
}
}
// MARK: - Convenience
func saveString(_ value: String, for key: String) throws {
guard let data = value.data(using: .utf8) else {
throw KeychainError.dataConversionError
}
try save(data, for: key)
}
func loadString(_ key: String) throws -> String {
let data = try load(key)
guard let string = String(data: data, encoding: .utf8) else {
throw KeychainError.dataConversionError
}
return string
}
func saveCodable<T: Codable>(_ value: T, for key: String) throws {
let data = try JSONEncoder().encode(value)
try save(data, for: key)
}
func loadCodable<T: Codable>(_ type: T.Type, for key: String) throws -> T {
let data = try load(key)
return try JSONDecoder().decode(type, from: data)
}
}
```
### Accessibility Options
```swift
// Available when unlocked
kSecAttrAccessibleWhenUnlocked
// Available when unlocked, not backed up
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
// Available after first unlock (background access)
kSecAttrAccessibleAfterFirstUnlock
// Always available (not recommended)
kSecAttrAccessibleAlways
```
## Biometric Authentication
### Local Authentication
```swift
import LocalAuthentication
class BiometricService {
enum BiometricType {
case none, touchID, faceID
}
var biometricType: BiometricType {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
return .none
}
switch context.biometryType {
case .touchID:
return .touchID
case .faceID:
return .faceID
default:
return .none
}
}
func authenticate(reason: String) async -> Bool {
let context = LAContext()
context.localizedCancelTitle = "Cancel"
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
return false
}
do {
return try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason
)
} catch {
return false
}
}
func authenticateWithFallback(reason: String) async -> Bool {
let context = LAContext()
do {
// Try biometrics first, fall back to passcode
return try await context.evaluatePolicy(
.deviceOwnerAuthentication, // Includes passcode fallback
localizedReason: reason
)
} catch {
return false
}
}
}
```
### Biometric-Protected Keychain
```swift
extension KeychainService {
func saveBiometricProtected(_ data: Data, for key: String) throws {
try? delete(key)
var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet, // Invalidate if biometrics change
&error
) else {
throw error!.takeRetainedValue()
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessControl as String: access
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
func loadBiometricProtected(_ key: String, prompt: String) throws -> Data {
let context = LAContext()
context.localizedReason = prompt
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecUseAuthenticationContext as String: context
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainError.loadFailed(status)
}
return data
}
}
```
## Secure Network Communication
### Certificate Pinning
```swift
class PinnedURLSessionDelegate: NSObject, URLSessionDelegate {
private let pinnedCertificates: [SecCertificate]
init(certificates: [SecCertificate]) {
self.pinnedCertificates = certificates
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
return (.cancelAuthenticationChallenge, nil)
}
// Get server certificate
guard let serverCertificate = SecTrustCopyCertificateChain(serverTrust)?
.first else {
return (.cancelAuthenticationChallenge, nil)
}
// Compare with pinned certificates
let serverCertData = SecCertificateCopyData(serverCertificate) as Data
for pinnedCert in pinnedCertificates {
let pinnedCertData = SecCertificateCopyData(pinnedCert) as Data
if serverCertData == pinnedCertData {
let credential = URLCredential(trust: serverTrust)
return (.useCredential, credential)
}
}
return (.cancelAuthenticationChallenge, nil)
}
}
```
### App Transport Security
In Info.plist (avoid if possible):
```xml
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>legacy-api.example.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.2</string>
</dict>
</dict>
</dict>
```
## Data Protection
### File Protection
```swift
// Protect files on disk
let fileURL = documentsDirectory.appendingPathComponent("sensitive.dat")
try data.write(to: fileURL, options: .completeFileProtection)
// Check protection class
let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path)
let protection = attributes[.protectionKey] as? FileProtectionType
```
### In-Memory Sensitive Data
```swift
// Clear sensitive data when done
var password = "secret"
defer {
password.removeAll() // Clear from memory
}
// For arrays
var sensitiveBytes = [UInt8](repeating: 0, count: 32)
defer {
sensitiveBytes.withUnsafeMutableBytes { ptr in
memset_s(ptr.baseAddress, ptr.count, 0, ptr.count)
}
}
```
## Secure Coding Practices
### Input Validation
```swift
func processInput(_ input: String) throws -> String {
// Validate length
guard input.count <= 1000 else {
throw ValidationError.tooLong
}
// Sanitize HTML
let sanitized = input
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
// Validate format if needed
guard isValidFormat(sanitized) else {
throw ValidationError.invalidFormat
}
return sanitized
}
```
### SQL Injection Prevention
With SwiftData/Core Data, use predicates:
```swift
// Safe - parameterized
let predicate = #Predicate<Item> { $0.name == searchTerm }
// Never do this
// let sql = "SELECT * FROM items WHERE name = '\(searchTerm)'"
```
### Avoid Logging Sensitive Data
```swift
func authenticate(username: String, password: String) async throws {
// Bad
// print("Authenticating \(username) with password \(password)")
// Good
print("Authenticating user: \(username)")
// Use OSLog with privacy
import os
let logger = Logger(subsystem: "com.app", category: "auth")
logger.info("Authenticating user: \(username, privacy: .public)")
logger.debug("Password length: \(password.count)") // Length only, never value
}
```
## Jailbreak Detection
```swift
class SecurityChecker {
func isDeviceCompromised() -> Bool {
// Check for common jailbreak files
let suspiciousPaths = [
"/Applications/Cydia.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/bin/bash",
"/usr/sbin/sshd",
"/etc/apt",
"/private/var/lib/apt/"
]
for path in suspiciousPaths {
if FileManager.default.fileExists(atPath: path) {
return true
}
}
// Check if can write outside sandbox
let testPath = "/private/jailbreak_test.txt"
do {
try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
try FileManager.default.removeItem(atPath: testPath)
return true
} catch {
// Expected - can't write outside sandbox
}
// Check for fork
let forkResult = fork()
if forkResult >= 0 {
// Fork succeeded - jailbroken
return true
}
return false
}
}
```
## App Store Privacy
### Privacy Manifest
Create `PrivacyInfo.xcprivacy`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeEmailAddress</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
```
### App Tracking Transparency
```swift
import AppTrackingTransparency
func requestTrackingPermission() async -> ATTrackingManager.AuthorizationStatus {
await ATTrackingManager.requestTrackingAuthorization()
}
// Check before tracking
if ATTrackingManager.trackingAuthorizationStatus == .authorized {
// Can use IDFA for tracking
}
```
## Security Checklist
### Data Storage
- [ ] Sensitive data in Keychain, not UserDefaults
- [ ] Appropriate Keychain accessibility
- [ ] File protection for sensitive files
- [ ] Clear sensitive data from memory
### Network
- [ ] HTTPS only (ATS)
- [ ] Certificate pinning for sensitive APIs
- [ ] Secure token storage
- [ ] No hardcoded secrets
### Authentication
- [ ] Biometric option available
- [ ] Secure session management
- [ ] Token refresh handling
- [ ] Logout clears all data
### Code
- [ ] Input validation
- [ ] No sensitive data in logs
- [ ] Parameterized queries
- [ ] No hardcoded credentials
### Privacy
- [ ] Privacy manifest complete
- [ ] ATT compliance
- [ ] Minimal data collection
- [ ] Clear privacy policy

View File

@@ -0,0 +1,553 @@
# StoreKit 2
In-app purchases, subscriptions, and paywalls for iOS apps.
## Basic Setup
### Product Configuration
Define products in App Store Connect, then load in app:
```swift
import StoreKit
@Observable
class PurchaseService {
private(set) var products: [Product] = []
private(set) var purchasedProductIDs: Set<String> = []
private(set) var subscriptionStatus: SubscriptionStatus = .unknown
private var transactionListener: Task<Void, Error>?
enum SubscriptionStatus {
case unknown
case subscribed
case expired
case inGracePeriod
case notSubscribed
}
init() {
transactionListener = listenForTransactions()
}
deinit {
transactionListener?.cancel()
}
func loadProducts() async throws {
let productIDs = [
"com.app.premium.monthly",
"com.app.premium.yearly",
"com.app.lifetime"
]
products = try await Product.products(for: productIDs)
.sorted { $0.price < $1.price }
}
func purchase(_ product: Product) async throws -> PurchaseResult {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await updatePurchasedProducts()
await transaction.finish()
return .success
case .userCancelled:
return .cancelled
case .pending:
return .pending
@unknown default:
return .failed
}
}
func restorePurchases() async throws {
try await AppStore.sync()
await updatePurchasedProducts()
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error):
throw StoreError.verificationFailed(error)
case .verified(let safe):
return safe
}
}
func updatePurchasedProducts() async {
var purchased: Set<String> = []
// Check non-consumables and subscriptions
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
purchased.insert(transaction.productID)
}
purchasedProductIDs = purchased
await updateSubscriptionStatus()
}
private func updateSubscriptionStatus() async {
// Check subscription group status
guard let groupID = products.first?.subscription?.subscriptionGroupID else {
subscriptionStatus = .notSubscribed
return
}
do {
let statuses = try await Product.SubscriptionInfo.status(for: groupID)
guard let status = statuses.first else {
subscriptionStatus = .notSubscribed
return
}
switch status.state {
case .subscribed:
subscriptionStatus = .subscribed
case .expired:
subscriptionStatus = .expired
case .inGracePeriod:
subscriptionStatus = .inGracePeriod
case .revoked:
subscriptionStatus = .notSubscribed
default:
subscriptionStatus = .unknown
}
} catch {
subscriptionStatus = .unknown
}
}
private func listenForTransactions() -> Task<Void, Error> {
Task.detached {
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
await self.updatePurchasedProducts()
await transaction.finish()
}
}
}
}
enum PurchaseResult {
case success
case cancelled
case pending
case failed
}
enum StoreError: LocalizedError {
case verificationFailed(Error)
case productNotFound
var errorDescription: String? {
switch self {
case .verificationFailed:
return "Purchase verification failed"
case .productNotFound:
return "Product not found"
}
}
}
```
## Paywall UI
```swift
struct PaywallView: View {
@Environment(PurchaseService.self) private var purchaseService
@Environment(\.dismiss) private var dismiss
@State private var selectedProduct: Product?
@State private var isPurchasing = false
@State private var error: Error?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
headerSection
featuresSection
productsSection
termsSection
}
.padding()
}
.navigationTitle("Go Premium")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") { dismiss() }
}
}
.task {
try? await purchaseService.loadProducts()
}
.alert("Error", isPresented: .constant(error != nil)) {
Button("OK") { error = nil }
} message: {
Text(error?.localizedDescription ?? "")
}
}
}
private var headerSection: some View {
VStack(spacing: 8) {
Image(systemName: "crown.fill")
.font(.system(size: 60))
.foregroundStyle(.yellow)
Text("Unlock Premium")
.font(.title.bold())
Text("Get access to all features")
.foregroundStyle(.secondary)
}
.padding(.top)
}
private var featuresSection: some View {
VStack(alignment: .leading, spacing: 12) {
FeatureRow(icon: "checkmark.circle.fill", title: "Unlimited items")
FeatureRow(icon: "checkmark.circle.fill", title: "Cloud sync")
FeatureRow(icon: "checkmark.circle.fill", title: "Priority support")
FeatureRow(icon: "checkmark.circle.fill", title: "No ads")
}
.padding()
.background(.background.secondary)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private var productsSection: some View {
VStack(spacing: 12) {
ForEach(purchaseService.products) { product in
ProductButton(
product: product,
isSelected: selectedProduct == product,
action: { selectedProduct = product }
)
}
Button {
Task {
await purchase()
}
} label: {
if isPurchasing {
ProgressView()
} else {
Text("Subscribe")
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(selectedProduct == nil || isPurchasing)
Button("Restore Purchases") {
Task {
try? await purchaseService.restorePurchases()
}
}
.font(.caption)
}
}
private var termsSection: some View {
VStack(spacing: 4) {
Text("Subscription automatically renews unless canceled.")
HStack {
Link("Terms", destination: URL(string: "https://example.com/terms")!)
Text("")
Link("Privacy", destination: URL(string: "https://example.com/privacy")!)
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
private func purchase() async {
guard let product = selectedProduct else { return }
isPurchasing = true
defer { isPurchasing = false }
do {
let result = try await purchaseService.purchase(product)
if result == .success {
dismiss()
}
} catch {
self.error = error
}
}
}
struct FeatureRow: View {
let icon: String
let title: String
var body: some View {
HStack {
Image(systemName: icon)
.foregroundStyle(.green)
Text(title)
Spacer()
}
}
}
struct ProductButton: View {
let product: Product
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack {
VStack(alignment: .leading) {
Text(product.displayName)
.font(.headline)
if let subscription = product.subscription {
Text(subscription.subscriptionPeriod.debugDescription)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Text(product.displayPrice)
.font(.headline)
}
.padding()
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: isSelected ? 2 : 1)
)
}
.buttonStyle(.plain)
}
}
```
## Subscription Management
### Check Subscription Status
```swift
extension PurchaseService {
var isSubscribed: Bool {
subscriptionStatus == .subscribed || subscriptionStatus == .inGracePeriod
}
func checkAccess(for feature: Feature) -> Bool {
switch feature {
case .basic:
return true
case .premium:
return isSubscribed || purchasedProductIDs.contains("com.app.lifetime")
}
}
}
enum Feature {
case basic
case premium
}
```
### Show Manage Subscriptions
```swift
Button("Manage Subscription") {
Task {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
try? await AppStore.showManageSubscriptions(in: windowScene)
}
}
}
```
### Handle Subscription Renewal
```swift
extension PurchaseService {
func getSubscriptionRenewalInfo() async -> RenewalInfo? {
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result,
transaction.productType == .autoRenewable else { continue }
guard let renewalInfo = try? await transaction.subscriptionStatus?.renewalInfo,
case .verified(let info) = renewalInfo else { continue }
return RenewalInfo(
willRenew: info.willAutoRenew,
expirationDate: transaction.expirationDate,
isInBillingRetry: info.isInBillingRetry
)
}
return nil
}
}
struct RenewalInfo {
let willRenew: Bool
let expirationDate: Date?
let isInBillingRetry: Bool
}
```
## Consumables
```swift
extension PurchaseService {
func purchaseConsumable(_ product: Product, quantity: Int = 1) async throws {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
// Grant content
await grantConsumable(product.id, quantity: quantity)
// Must finish transaction for consumables
await transaction.finish()
case .userCancelled, .pending:
break
@unknown default:
break
}
}
private func grantConsumable(_ productID: String, quantity: Int) async {
// Add to user's balance (e.g., coins, credits)
// This should be tracked in your own storage
}
}
```
## Promotional Offers
```swift
extension PurchaseService {
func purchaseWithOffer(_ product: Product, offerID: String) async throws -> PurchaseResult {
// Generate signature on your server
guard let keyID = await fetchKeyID(),
let nonce = UUID().uuidString.data(using: .utf8),
let signature = await generateSignature(productID: product.id, offerID: offerID) else {
throw StoreError.offerSigningFailed
}
let result = try await product.purchase(options: [
.promotionalOffer(
offerID: offerID,
keyID: keyID,
nonce: UUID(),
signature: signature,
timestamp: Int(Date().timeIntervalSince1970 * 1000)
)
])
// Handle result same as regular purchase
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await updatePurchasedProducts()
await transaction.finish()
return .success
case .userCancelled:
return .cancelled
case .pending:
return .pending
@unknown default:
return .failed
}
}
}
```
## Testing
### StoreKit Configuration File
Create `Configuration.storekit` for local testing:
1. File > New > File > StoreKit Configuration File
2. Add products matching your App Store Connect configuration
3. Run with: Edit Scheme > Run > Options > StoreKit Configuration
### Test Purchase Scenarios
```swift
#if DEBUG
extension PurchaseService {
func simulatePurchase() async {
purchasedProductIDs.insert("com.app.premium.monthly")
subscriptionStatus = .subscribed
}
func clearPurchases() async {
purchasedProductIDs.removeAll()
subscriptionStatus = .notSubscribed
}
}
#endif
```
### Transaction Manager (Testing)
Use Transaction Manager in Xcode to:
- Clear purchase history
- Simulate subscription expiration
- Test renewal scenarios
- Simulate billing issues
## App Store Server Notifications
Configure in App Store Connect to receive:
- Subscription renewals
- Cancellations
- Refunds
- Grace period events
Handle on your server to update user access accordingly.
## Best Practices
### Always Update UI After Purchase
```swift
func purchase(_ product: Product) async throws -> PurchaseResult {
let result = try await product.purchase()
// ...
await updatePurchasedProducts() // Always update
return result
}
```
### Handle Grace Period
```swift
if purchaseService.subscriptionStatus == .inGracePeriod {
// Show warning but allow access
showGracePeriodBanner()
}
```
### Finish Transactions Promptly
```swift
// Always finish after granting content
await transaction.finish()
```
### Test on Real Device
StoreKit Testing is great for development, but always test with sandbox accounts on real devices before release.

View File

@@ -0,0 +1,549 @@
# SwiftUI Patterns
Modern SwiftUI patterns for iOS 26 with iOS 18 compatibility.
## View Composition
### Small, Focused Views
```swift
// Bad: Massive view
struct ContentView: View {
var body: some View {
VStack {
// 200 lines of UI code
}
}
}
// Good: Composed from smaller views
struct ContentView: View {
var body: some View {
VStack {
HeaderView()
ItemList()
ActionBar()
}
}
}
struct HeaderView: View {
var body: some View {
// Focused implementation
}
}
```
### Extract Subviews
```swift
struct ItemRow: View {
let item: Item
var body: some View {
HStack {
iconView
contentView
Spacer()
chevronView
}
}
private var iconView: some View {
Image(systemName: item.icon)
.foregroundStyle(.accent)
.frame(width: 30)
}
private var contentView: some View {
VStack(alignment: .leading) {
Text(item.name)
.font(.headline)
Text(item.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var chevronView: some View {
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
.font(.caption)
}
}
```
## Async Data Loading
### Task Modifier
```swift
struct ItemList: View {
@State private var items: [Item] = []
@State private var isLoading = true
@State private var error: Error?
var body: some View {
Group {
if isLoading {
ProgressView()
} else if let error {
ErrorView(error: error, retry: load)
} else {
List(items) { item in
ItemRow(item: item)
}
}
}
.task {
await load()
}
}
private func load() async {
isLoading = true
defer { isLoading = false }
do {
items = try await fetchItems()
} catch {
self.error = error
}
}
}
```
### Refresh Control
```swift
struct ItemList: View {
@State private var items: [Item] = []
var body: some View {
List(items) { item in
ItemRow(item: item)
}
.refreshable {
items = try? await fetchItems()
}
}
}
```
### Task with ID
Reload when identifier changes:
```swift
struct ItemDetail: View {
let itemID: UUID
@State private var item: Item?
var body: some View {
Group {
if let item {
ItemContent(item: item)
} else {
ProgressView()
}
}
.task(id: itemID) {
item = try? await fetchItem(id: itemID)
}
}
}
```
## Lists and Grids
### Swipe Actions
```swift
List {
ForEach(items) { item in
ItemRow(item: item)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
delete(item)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
archive(item)
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.orange)
}
.swipeActions(edge: .leading) {
Button {
toggleFavorite(item)
} label: {
Label("Favorite", systemImage: item.isFavorite ? "star.fill" : "star")
}
.tint(.yellow)
}
}
}
```
### Lazy Grids
```swift
struct PhotoGrid: View {
let photos: [Photo]
let columns = [GridItem(.adaptive(minimum: 100), spacing: 2)]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 2) {
ForEach(photos) { photo in
AsyncImage(url: photo.thumbnailURL) { image in
image
.resizable()
.aspectRatio(1, contentMode: .fill)
} placeholder: {
Color.gray.opacity(0.3)
}
.clipped()
}
}
}
}
}
```
### Sections with Headers
```swift
List {
ForEach(groupedItems, id: \.key) { section in
Section(section.key) {
ForEach(section.items) { item in
ItemRow(item: item)
}
}
}
}
.listStyle(.insetGrouped)
```
## Forms and Input
### Form with Validation
```swift
struct ProfileForm: View {
@State private var name = ""
@State private var email = ""
@State private var bio = ""
private var isValid: Bool {
!name.isEmpty && email.contains("@") && email.contains(".")
}
var body: some View {
Form {
Section("Personal Info") {
TextField("Name", text: $name)
.textContentType(.name)
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
Section("About") {
TextField("Bio", text: $bio, axis: .vertical)
.lineLimit(3...6)
}
Section {
Button("Save") {
save()
}
.disabled(!isValid)
}
}
}
}
```
### Pickers
```swift
struct SettingsView: View {
@State private var selectedTheme = Theme.system
@State private var fontSize = 16.0
var body: some View {
Form {
Picker("Theme", selection: $selectedTheme) {
ForEach(Theme.allCases) { theme in
Text(theme.rawValue).tag(theme)
}
}
Section("Text Size") {
Slider(value: $fontSize, in: 12...24, step: 1) {
Text("Font Size")
} minimumValueLabel: {
Text("A").font(.caption)
} maximumValueLabel: {
Text("A").font(.title)
}
.padding(.vertical)
}
}
}
}
```
## Sheets and Alerts
### Sheet Presentation
```swift
struct ContentView: View {
@State private var showingSettings = false
@State private var selectedItem: Item?
var body: some View {
List(items) { item in
Button(item.name) {
selectedItem = item
}
}
.toolbar {
Button {
showingSettings = true
} label: {
Image(systemName: "gear")
}
}
.sheet(isPresented: $showingSettings) {
SettingsView()
}
.sheet(item: $selectedItem) { item in
ItemDetail(item: item)
}
}
}
```
### Confirmation Dialogs
```swift
struct ItemRow: View {
let item: Item
@State private var showingDeleteConfirmation = false
var body: some View {
HStack {
Text(item.name)
Spacer()
Button(role: .destructive) {
showingDeleteConfirmation = true
} label: {
Image(systemName: "trash")
}
}
.confirmationDialog(
"Delete \(item.name)?",
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
delete(item)
}
} message: {
Text("This action cannot be undone.")
}
}
}
```
## iOS 26 Features
### Liquid Glass
```swift
struct GlassCard: View {
var body: some View {
VStack {
Text("Premium Content")
.font(.headline)
}
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16))
// iOS 26 glass effect
.glassEffect()
}
}
// Availability check
struct AdaptiveCard: View {
var body: some View {
if #available(iOS 26, *) {
GlassCard()
} else {
StandardCard()
}
}
}
```
### WebView
```swift
import WebKit
// iOS 26+ native WebView
struct WebContent: View {
let url: URL
var body: some View {
if #available(iOS 26, *) {
WebView(url: url)
.ignoresSafeArea()
} else {
WebViewRepresentable(url: url)
}
}
}
// Fallback for iOS 18
struct WebViewRepresentable: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> WKWebView {
WKWebView()
}
func updateUIView(_ webView: WKWebView, context: Context) {
webView.load(URLRequest(url: url))
}
}
```
### @Animatable Macro
```swift
// iOS 26+
@available(iOS 26, *)
@Animatable
struct PulsingCircle: View {
var scale: Double
var body: some View {
Circle()
.scaleEffect(scale)
}
}
```
## Custom Modifiers
### Reusable Styling
```swift
struct CardModifier: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(.background)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 4, y: 2)
}
}
extension View {
func cardStyle() -> some View {
modifier(CardModifier())
}
}
// Usage
Text("Content")
.cardStyle()
```
### Conditional Modifiers
```swift
extension View {
@ViewBuilder
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
// Usage
Text("Item")
.if(isHighlighted) { view in
view.foregroundStyle(.accent)
}
```
## Preview Techniques
### Multiple Configurations
```swift
#Preview("Light Mode") {
ItemRow(item: .sample)
.preferredColorScheme(.light)
}
#Preview("Dark Mode") {
ItemRow(item: .sample)
.preferredColorScheme(.dark)
}
#Preview("Large Text") {
ItemRow(item: .sample)
.environment(\.sizeCategory, .accessibilityExtraLarge)
}
```
### Interactive Previews
```swift
#Preview {
@Previewable @State var isOn = false
Toggle("Setting", isOn: $isOn)
.padding()
}
```
### Preview with Mock Data
```swift
extension Item {
static let sample = Item(
name: "Sample Item",
subtitle: "Sample subtitle",
icon: "star"
)
static let samples: [Item] = [
Item(name: "First", subtitle: "One", icon: "1.circle"),
Item(name: "Second", subtitle: "Two", icon: "2.circle"),
Item(name: "Third", subtitle: "Three", icon: "3.circle")
]
}
#Preview {
List(Item.samples) { item in
ItemRow(item: item)
}
}
```

View File

@@ -0,0 +1,540 @@
# Testing
Unit tests, UI tests, snapshot tests, and testing patterns for iOS apps.
## Swift Testing (Xcode 16+)
### Basic Tests
```swift
import Testing
@testable import MyApp
@Suite("Item Tests")
struct ItemTests {
@Test("Create item with name")
func createItem() {
let item = Item(name: "Test")
#expect(item.name == "Test")
#expect(item.isCompleted == false)
}
@Test("Toggle completion")
func toggleCompletion() {
var item = Item(name: "Test")
item.isCompleted = true
#expect(item.isCompleted == true)
}
}
```
### Async Tests
```swift
@Test("Fetch items from network")
func fetchItems() async throws {
let service = MockNetworkService()
service.mockResult = [Item(name: "Test")]
let viewModel = ItemListViewModel(networkService: service)
await viewModel.load()
#expect(viewModel.items.count == 1)
#expect(viewModel.items[0].name == "Test")
}
@Test("Handle network error")
func handleNetworkError() async {
let service = MockNetworkService()
service.mockError = NetworkError.noConnection
let viewModel = ItemListViewModel(networkService: service)
await viewModel.load()
#expect(viewModel.items.isEmpty)
#expect(viewModel.error != nil)
}
```
### Parameterized Tests
```swift
@Test("Validate email", arguments: [
("test@example.com", true),
("invalid", false),
("@example.com", false),
("test@", false)
])
func validateEmail(email: String, expected: Bool) {
let isValid = EmailValidator.isValid(email)
#expect(isValid == expected)
}
```
### Test Lifecycle
```swift
@Suite("Database Tests")
struct DatabaseTests {
let database: TestDatabase
init() async throws {
database = try await TestDatabase.create()
}
@Test func insertItem() async throws {
try await database.insert(Item(name: "Test"))
let items = try await database.fetchAll()
#expect(items.count == 1)
}
}
```
## XCTest (Traditional)
### Basic XCTest
```swift
import XCTest
@testable import MyApp
class ItemTests: XCTestCase {
var sut: Item!
override func setUp() {
super.setUp()
sut = Item(name: "Test")
}
override func tearDown() {
sut = nil
super.tearDown()
}
func testCreateItem() {
XCTAssertEqual(sut.name, "Test")
XCTAssertFalse(sut.isCompleted)
}
func testToggleCompletion() {
sut.isCompleted = true
XCTAssertTrue(sut.isCompleted)
}
}
```
### Async XCTest
```swift
func testFetchItems() async throws {
let service = MockNetworkService()
service.mockResult = [Item(name: "Test")]
let viewModel = ItemListViewModel(networkService: service)
await viewModel.load()
XCTAssertEqual(viewModel.items.count, 1)
}
```
## Mocking
### Protocol-Based Mocks
```swift
// Protocol
protocol NetworkServiceProtocol {
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T
}
// Mock
class MockNetworkService: NetworkServiceProtocol {
var mockResult: Any?
var mockError: Error?
var fetchCallCount = 0
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
fetchCallCount += 1
if let error = mockError {
throw error
}
guard let result = mockResult as? T else {
fatalError("Mock result type mismatch")
}
return result
}
}
```
### Testing with Mocks
```swift
@Test func loadItemsCallsNetwork() async {
let mock = MockNetworkService()
mock.mockResult = [Item]()
let viewModel = ItemListViewModel(networkService: mock)
await viewModel.load()
#expect(mock.fetchCallCount == 1)
}
```
## Testing SwiftUI Views
### View Tests with ViewInspector
```swift
import ViewInspector
@testable import MyApp
@Test func itemRowDisplaysName() throws {
let item = Item(name: "Test Item")
let view = ItemRow(item: item)
let text = try view.inspect().hStack().text(0).string()
#expect(text == "Test Item")
}
```
### Testing View Models
```swift
@Test func viewModelUpdatesOnSelection() async {
let viewModel = ItemListViewModel()
viewModel.items = [Item(name: "A"), Item(name: "B")]
viewModel.select(viewModel.items[0])
#expect(viewModel.selectedItem?.name == "A")
}
```
## UI Testing
### Basic UI Test
```swift
import XCTest
class MyAppUITests: XCTestCase {
let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
app.launchArguments = ["--uitesting"]
app.launch()
}
func testAddItem() {
// Tap add button
app.buttons["Add"].tap()
// Enter name
let textField = app.textFields["Item name"]
textField.tap()
textField.typeText("New Item")
// Save
app.buttons["Save"].tap()
// Verify
XCTAssertTrue(app.staticTexts["New Item"].exists)
}
func testSwipeToDelete() {
// Assume item exists
let cell = app.cells["Item Row"].firstMatch
// Swipe and delete
cell.swipeLeft()
app.buttons["Delete"].tap()
// Verify
XCTAssertFalse(cell.exists)
}
}
```
### Accessibility Identifiers
```swift
struct ItemRow: View {
let item: Item
var body: some View {
HStack {
Text(item.name)
}
.accessibilityIdentifier("Item Row")
}
}
struct NewItemView: View {
@State private var name = ""
var body: some View {
TextField("Item name", text: $name)
.accessibilityIdentifier("Item name")
}
}
```
### Launch Arguments for Testing
```swift
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onAppear {
if CommandLine.arguments.contains("--uitesting") {
// Use mock data
// Skip onboarding
// Clear state
}
}
}
}
}
```
## Snapshot Testing
Using swift-snapshot-testing:
```swift
import SnapshotTesting
import XCTest
@testable import MyApp
class SnapshotTests: XCTestCase {
func testItemRow() {
let item = Item(name: "Test", subtitle: "Subtitle")
let view = ItemRow(item: item)
.frame(width: 375)
assertSnapshot(of: view, as: .image)
}
func testItemRowDarkMode() {
let item = Item(name: "Test", subtitle: "Subtitle")
let view = ItemRow(item: item)
.frame(width: 375)
.preferredColorScheme(.dark)
assertSnapshot(of: view, as: .image, named: "dark")
}
func testItemRowLargeText() {
let item = Item(name: "Test", subtitle: "Subtitle")
let view = ItemRow(item: item)
.frame(width: 375)
.environment(\.sizeCategory, .accessibilityExtraLarge)
assertSnapshot(of: view, as: .image, named: "large-text")
}
}
```
## Testing SwiftData
```swift
@Suite("SwiftData Tests")
struct SwiftDataTests {
@Test func insertAndFetch() async throws {
// In-memory container for testing
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Item.self, configurations: config)
let context = container.mainContext
// Insert
let item = Item(name: "Test")
context.insert(item)
try context.save()
// Fetch
let descriptor = FetchDescriptor<Item>()
let items = try context.fetch(descriptor)
#expect(items.count == 1)
#expect(items[0].name == "Test")
}
}
```
## Testing Network Calls
### Using URLProtocol
```swift
class MockURLProtocol: URLProtocol {
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool {
return true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
override func startLoading() {
guard let handler = MockURLProtocol.requestHandler else {
fatalError("Handler not set")
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
@Test func fetchItemsReturnsData() async throws {
// Configure mock
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: config)
let mockItems = [Item(name: "Test")]
let mockData = try JSONEncoder().encode(mockItems)
MockURLProtocol.requestHandler = { request in
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
return (response, mockData)
}
// Test
let service = NetworkService(session: session)
let items: [Item] = try await service.fetch(.items)
#expect(items.count == 1)
}
```
## Test Helpers
### Factory Methods
```swift
extension Item {
static func sample(
name: String = "Sample",
isCompleted: Bool = false,
priority: Int = 0
) -> Item {
Item(name: name, isCompleted: isCompleted, priority: priority)
}
static var samples: [Item] {
[
.sample(name: "First"),
.sample(name: "Second", isCompleted: true),
.sample(name: "Third", priority: 5)
]
}
}
```
### Async Test Utilities
```swift
func waitForCondition(
timeout: TimeInterval = 1.0,
condition: @escaping () -> Bool
) async throws {
let start = Date()
while !condition() {
if Date().timeIntervalSince(start) > timeout {
throw TestError.timeout
}
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
}
}
enum TestError: Error {
case timeout
}
```
## Running Tests from CLI
```bash
# Run all tests
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16'
# Run specific test
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-only-testing:MyAppTests/ItemTests
# With code coverage
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-enableCodeCoverage YES \
-resultBundlePath TestResults.xcresult
```
## Best Practices
### Test Naming
```swift
// Describe what is being tested and expected outcome
@Test func itemListViewModel_load_setsItemsFromNetwork()
@Test func purchaseService_purchaseProduct_updatesEntitlements()
```
### Arrange-Act-Assert
```swift
@Test func toggleCompletion() {
// Arrange
var item = Item(name: "Test")
// Act
item.isCompleted.toggle()
// Assert
#expect(item.isCompleted == true)
}
```
### One Assertion Per Test
Focus each test on a single behavior:
```swift
// Good
@Test func loadSetsItems() async { ... }
@Test func loadSetsLoadingFalse() async { ... }
@Test func loadClearsError() async { ... }
// Avoid
@Test func loadWorks() async {
// Too many assertions
}
```

View File

@@ -0,0 +1,101 @@
# Workflow: Add a Feature to an Existing iOS App
<required_reading>
**Read these NOW:**
1. references/app-architecture.md
2. references/swiftui-patterns.md
**Plus relevant refs based on feature** (see Step 2).
</required_reading>
<process>
## Step 1: Understand the Feature
Ask:
- What should it do?
- Where does it belong in the app?
- Any constraints?
## Step 2: Read Relevant References
| Feature Type | Reference |
|--------------|-----------|
| Data persistence | references/data-persistence.md |
| Networking/API | references/networking.md |
| Push notifications | references/push-notifications.md |
| In-app purchases | references/storekit.md |
| Background tasks | references/background-tasks.md |
| Navigation | references/navigation-patterns.md |
| Polish/UX | references/polish-and-ux.md |
## Step 3: Understand Existing Code
Read:
- App entry point
- State management
- Related views
Identify patterns to follow.
## Step 4: Implement with TDD
1. Write test for new behavior → RED
2. Implement → GREEN
3. Refactor
4. Repeat
## Step 5: Integrate
- Wire up navigation
- Connect to state
- Handle errors
## Step 6: Build and Test
```bash
xcodebuild -scheme AppName -destination 'platform=iOS Simulator,name=iPhone 16' build test
xcrun simctl launch booted com.company.AppName
```
## Step 7: Polish
- Haptic feedback for actions
- Animations for transitions
- Accessibility labels
- Dynamic Type support
</process>
<integration_patterns>
**Adding state:**
```swift
@Observable
class AppState {
var newFeatureData: [NewType] = []
func performNewFeature() { ... }
}
```
**Adding a view:**
```swift
struct NewFeatureView: View {
@Environment(AppState.self) private var appState
var body: some View { ... }
}
```
**Adding navigation:**
```swift
NavigationLink("New Feature", value: NewFeatureDestination())
.navigationDestination(for: NewFeatureDestination.self) { _ in
NewFeatureView()
}
```
**Adding a tab:**
```swift
TabView {
NewFeatureView()
.tabItem { Label("New", systemImage: "star") }
}
```
</integration_patterns>

View File

@@ -0,0 +1,111 @@
# Workflow: Build a New iOS App
<required_reading>
**Read these reference files NOW before writing any code:**
1. references/project-scaffolding.md
2. references/cli-workflow.md
3. references/app-architecture.md
4. references/swiftui-patterns.md
</required_reading>
<process>
## Step 1: Clarify Requirements
Ask the user:
- What does the app do? (core functionality)
- What type? (single-screen, tab-based, navigation-based, data-driven)
- Any specific features? (persistence, networking, push notifications, purchases)
## Step 2: Choose App Archetype
| Type | When to Use | Key Patterns |
|------|-------------|--------------|
| Single-screen utility | One primary function | Minimal navigation |
| Tab-based (TabView) | Multiple equal sections | TabView with 3-5 tabs |
| Navigation-based | Hierarchical content | NavigationStack |
| Data-driven | User content library | SwiftData + @Query |
## Step 3: Scaffold Project
Use XcodeGen:
```bash
mkdir AppName && cd AppName
# Create project.yml (see references/project-scaffolding.md)
# Create Swift files in Sources/
xcodegen generate
```
## Step 4: Implement with TDD
1. Write failing test
2. Run → RED
3. Implement minimal code
4. Run → GREEN
5. Refactor
6. Repeat
## Step 5: Build and Launch
```bash
# Build
xcodebuild -project AppName.xcodeproj -scheme AppName \
-destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | xcsift
# Launch in simulator
xcrun simctl boot "iPhone 16" 2>/dev/null || true
xcrun simctl install booted ./build/Build/Products/Debug-iphonesimulator/AppName.app
xcrun simctl launch booted com.company.AppName
```
## Step 6: Polish
Read references/polish-and-ux.md for:
- Haptic feedback
- Animations
- Accessibility
- Dynamic Type support
</process>
<minimum_viable_app>
```swift
import SwiftUI
@main
struct MyApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView()
.environment(appState)
}
}
}
@Observable
class AppState {
var items: [Item] = []
}
struct ContentView: View {
@Environment(AppState.self) private var appState
var body: some View {
NavigationStack {
List(appState.items) { item in
Text(item.name)
}
.navigationTitle("Items")
}
}
}
```
</minimum_viable_app>
<success_criteria>
- Follows iOS Human Interface Guidelines
- Builds and runs from CLI
- Tests pass
- Launches in simulator
- User can verify UX manually
</success_criteria>

View File

@@ -0,0 +1,115 @@
# Workflow: Debug an Existing iOS App
<required_reading>
**Read these reference files NOW:**
1. references/cli-observability.md
2. references/testing.md
</required_reading>
<philosophy>
Debugging is iterative. Use whatever gets you to root cause fastest:
- Small app, obvious symptom → read relevant code
- Large codebase, unclear cause → use tools to narrow down
- Code looks correct but fails → tools reveal runtime behavior
- After fixing → tools verify the fix
</philosophy>
<process>
## Step 1: Understand the Symptom
Ask:
- What's happening vs expected?
- When? (startup, after action, under load)
- Reproducible?
- Any error messages?
## Step 2: Build and Check for Compile Errors
```bash
xcodebuild -scheme AppName -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | xcsift
```
Fix compile errors first.
## Step 3: Choose Your Approach
**Know where problem is:** → Read that code
**No idea where to start:** → Use tools (Step 4)
**Code looks correct but fails:** → Runtime observation (Step 4)
## Step 4: Runtime Diagnostics
**Launch with logging:**
```bash
xcrun simctl boot "iPhone 16" 2>/dev/null || true
xcrun simctl install booted ./build/Build/Products/Debug-iphonesimulator/AppName.app
xcrun simctl launch --console booted com.company.AppName
```
**Match symptom to tool:**
| Symptom | Tool | Command |
|---------|------|---------|
| Memory leak | leaks | `leaks AppName` (on simulator process) |
| UI freeze | spindump | `spindump AppName` |
| Crash | crash report | Check Console.app or `~/Library/Logs/DiagnosticReports/` |
| Slow | profiler | `xcrun xctrace record --template 'Time Profiler'` |
| Silent failure | console | `xcrun simctl launch --console booted ...` |
## Step 5: Interpret & Read Relevant Code
Tool output tells you WHERE. Now read THAT code.
## Step 6: Fix the Root Cause
Not the symptom. The actual cause.
## Step 7: Verify
```bash
# Rebuild
xcodebuild build ...
# Run same diagnostic
# Confirm issue is resolved
```
## Step 8: Regression Test
Write a test that would catch this bug in future.
</process>
<common_patterns>
## Memory Leaks
**Cause:** Strong reference cycles in closures
**Fix:** `[weak self]` capture
## UI Freezes
**Cause:** Sync work on main thread
**Fix:** `Task { }` or `Task.detached { }`
## Crashes
**Cause:** Force unwrap, index out of bounds
**Fix:** `guard let`, bounds checking
## Silent Failures
**Cause:** Error swallowed, async not awaited
**Fix:** Add logging, check async chains
</common_patterns>
<ios_specific_tools>
```bash
# Console output from simulator
xcrun simctl spawn booted log stream --predicate 'subsystem == "com.company.AppName"'
# Install and launch
xcrun simctl install booted ./App.app
xcrun simctl launch --console booted com.company.AppName
# Screenshot
xcrun simctl io booted screenshot /tmp/screenshot.png
# Video recording
xcrun simctl io booted recordVideo /tmp/video.mp4
```
</ios_specific_tools>

View File

@@ -0,0 +1,125 @@
# Workflow: Optimize iOS App Performance
<required_reading>
**Read NOW:**
1. references/performance.md
2. references/cli-observability.md
</required_reading>
<philosophy>
Measure first, optimize second. Never optimize based on assumptions.
</philosophy>
<process>
## Step 1: Define the Problem
Ask:
- What feels slow?
- Startup? Scrolling? Specific action?
- When did it start?
## Step 2: Measure
**CPU Profiling:**
```bash
xcrun xctrace record \
--template 'Time Profiler' \
--device 'iPhone 16' \
--attach AppName \
--output profile.trace
```
**Memory:**
```bash
xcrun xctrace record --template 'Allocations' ...
```
**Launch time:**
```bash
# Add DYLD_PRINT_STATISTICS=1 to scheme environment
```
## Step 3: Identify Bottlenecks
Look for:
- Functions with high "self time"
- Main thread blocking
- Repeated expensive operations
- Large allocations
**SwiftUI re-renders:**
```swift
var body: some View {
let _ = Self._printChanges()
// ...
}
```
## Step 4: Common Optimizations
### Main Thread
```swift
// Bad
let data = expensiveWork() // blocks UI
// Good
let data = await Task.detached { expensiveWork() }.value
```
### SwiftUI
```swift
// Bad - rebuilds everything
struct BigView: View {
@State var a, b, c, d, e
}
// Good - isolated state
struct BigView: View {
var body: some View {
SubViewA() // has own @State
SubViewB() // has own @State
}
}
```
### Lists
```swift
// Use LazyVStack for long lists
ScrollView {
LazyVStack {
ForEach(items) { ... }
}
}
```
### Images
```swift
AsyncImage(url: url) { image in
image.resizable()
} placeholder: {
ProgressView()
}
```
## Step 5: Measure Again
Did it improve? If not, revert.
## Step 6: Performance Tests
```swift
func testScrollPerformance() {
measure(metrics: [XCTCPUMetric(), XCTMemoryMetric()]) {
// scroll simulation
}
}
```
</process>
<targets>
| Metric | Target | Unacceptable |
|--------|--------|--------------|
| Launch | < 1s | > 2s |
| Scroll | 60 fps | < 30 fps |
| Response | < 100ms | > 500ms |
</targets>

View File

@@ -0,0 +1,122 @@
# Workflow: Ship iOS App
<required_reading>
**Read NOW:**
1. references/app-store.md
2. references/ci-cd.md
</required_reading>
<process>
## Step 1: Pre-Release Checklist
- [ ] Version/build numbers updated
- [ ] No debug code or test data
- [ ] Privacy manifest complete (PrivacyInfo.xcprivacy)
- [ ] App icons all sizes (see references/app-icons.md)
- [ ] Screenshots prepared
- [ ] Release notes written
## Step 2: Archive
```bash
xcodebuild archive \
-project AppName.xcodeproj \
-scheme AppName \
-archivePath ./build/AppName.xcarchive \
-destination 'generic/platform=iOS'
```
## Step 3: Export for Distribution
**For TestFlight/App Store:**
```bash
xcodebuild -exportArchive \
-archivePath ./build/AppName.xcarchive \
-exportPath ./build/export \
-exportOptionsPlist ExportOptions.plist
```
ExportOptions.plist:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>signingStyle</key>
<string>automatic</string>
</dict>
</plist>
```
## Step 4: Upload to App Store Connect
```bash
xcrun altool --upload-app \
-f ./build/export/AppName.ipa \
-t ios \
--apiKey YOUR_KEY_ID \
--apiIssuer YOUR_ISSUER_ID
```
Or use `xcrun notarytool` with App Store Connect API.
## Step 5: TestFlight
1. Wait for processing in App Store Connect
2. Add testers (internal or external)
3. Gather feedback
4. Iterate
## Step 6: App Store Submission
In App Store Connect:
1. Complete app metadata
2. Add screenshots for all device sizes
3. Set pricing
4. Submit for review
## Step 7: Post-Release
- Monitor crash reports
- Respond to reviews
- Plan next version
</process>
<privacy_manifest>
Required since iOS 17. Create `PrivacyInfo.xcprivacy`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
```
</privacy_manifest>
<common_rejections>
| Reason | Fix |
|--------|-----|
| Crash on launch | Test on real device, check entitlements |
| Missing privacy descriptions | Add all NS*UsageDescription keys |
| Broken links | Verify all URLs work |
| Incomplete metadata | Fill all required fields |
| Guideline 4.3 (spam) | Differentiate from existing apps |
</common_rejections>

View File

@@ -0,0 +1,101 @@
# Workflow: Write and Run Tests
<required_reading>
**Read NOW:**
1. references/testing.md
</required_reading>
<philosophy>
Tests are documentation that runs. They let the user verify correctness without reading code.
</philosophy>
<process>
## Step 1: Understand What to Test
**Claude tests (automated):**
- Core logic
- State management
- Service layer
- Edge cases
**User tests (manual):**
- UX feel
- Visual polish
- Real device behavior
## Step 2: Write Tests
### Unit Tests
```swift
import Testing
@testable import AppName
struct ItemTests {
@Test func creation() {
let item = Item(name: "Test")
#expect(item.name == "Test")
}
@Test func validation() {
let empty = Item(name: "")
#expect(!empty.isValid)
}
}
```
### Async Tests
```swift
@Test func fetchItems() async throws {
let service = MockService()
let items = try await service.fetch()
#expect(items.count > 0)
}
```
### State Tests
```swift
@Test func addItem() {
let state = AppState()
state.addItem(Item(name: "New"))
#expect(state.items.count == 1)
}
```
## Step 3: Run Tests
```bash
xcodebuild test \
-project AppName.xcodeproj \
-scheme AppName \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-resultBundlePath TestResults.xcresult
# Summary
xcrun xcresulttool get test-results summary --path TestResults.xcresult
```
## Step 4: Coverage
```bash
xcodebuild test -enableCodeCoverage YES ...
xcrun xccov view --report TestResults.xcresult
```
## Step 5: TDD Cycle
For new features:
1. Write failing test
2. Run → RED
3. Implement minimum
4. Run → GREEN
5. Refactor
6. Repeat
</process>
<coverage_targets>
| Code Type | Target |
|-----------|--------|
| Business logic | 80-100% |
| State management | 70-90% |
| Views | 0% (manual) |
</coverage_targets>

View File

@@ -0,0 +1,157 @@
---
name: build-macos-apps
description: Build professional native macOS apps in Swift with SwiftUI and AppKit. Full lifecycle - build, debug, test, optimize, ship. CLI-only, no Xcode.
---
<essential_principles>
## How We Work
**The user is the product owner. Claude is the developer.**
The user does not write code. The user does not read code. The user describes what they want and judges whether the result is acceptable. Claude implements, verifies, and reports outcomes.
### 1. Prove, Don't Promise
Never say "this should work." Prove it:
```bash
xcodebuild build 2>&1 | xcsift # Build passes
xcodebuild test # Tests pass
open .../App.app # App launches
```
If you didn't run it, you don't know it works.
### 2. Tests for Correctness, Eyes for Quality
| Question | How to Answer |
|----------|---------------|
| Does the logic work? | Write test, see it pass |
| Does it look right? | Launch app, user looks at it |
| Does it feel right? | User uses it |
| Does it crash? | Test + launch |
| Is it fast enough? | Profiler |
Tests verify *correctness*. The user verifies *desirability*.
### 3. Report Outcomes, Not Code
**Bad:** "I refactored DataService to use async/await with weak self capture"
**Good:** "Fixed the memory leak. `leaks` now shows 0 leaks. App tested stable for 5 minutes."
The user doesn't care what you changed. The user cares what's different.
### 4. Small Steps, Always Verified
```
Change → Verify → Report → Next change
```
Never batch up work. Never say "I made several changes." Each change is verified before the next. If something breaks, you know exactly what caused it.
### 5. Ask Before, Not After
Unclear requirement? Ask now.
Multiple valid approaches? Ask which.
Scope creep? Ask if wanted.
Big refactor needed? Ask permission.
Wrong: Build for 30 minutes, then "is this what you wanted?"
Right: "Before I start, does X mean Y or Z?"
### 6. Always Leave It Working
Every stopping point = working state. Tests pass, app launches, changes committed. The user can walk away anytime and come back to something that works.
</essential_principles>
<intake>
**Ask the user:**
What would you like to do?
1. Build a new app
2. Debug an existing app
3. Add a feature
4. Write/run tests
5. Optimize performance
6. Ship/release
7. Something else
**Then read the matching workflow from `workflows/` and follow it.**
</intake>
<routing>
| Response | Workflow |
|----------|----------|
| 1, "new", "create", "build", "start" | `workflows/build-new-app.md` |
| 2, "broken", "fix", "debug", "crash", "bug" | `workflows/debug-app.md` |
| 3, "add", "feature", "implement", "change" | `workflows/add-feature.md` |
| 4, "test", "tests", "TDD", "coverage" | `workflows/write-tests.md` |
| 5, "slow", "optimize", "performance", "fast" | `workflows/optimize-performance.md` |
| 6, "ship", "release", "notarize", "App Store" | `workflows/ship-app.md` |
| 7, other | Clarify, then select workflow or references |
</routing>
<verification_loop>
## After Every Change
```bash
# 1. Does it build?
xcodebuild -scheme AppName build 2>&1 | xcsift
# 2. Do tests pass?
xcodebuild -scheme AppName test
# 3. Does it launch? (if UI changed)
open ./build/Build/Products/Debug/AppName.app
```
Report to the user:
- "Build: ✓"
- "Tests: 12 pass, 0 fail"
- "App launches, ready for you to check [specific thing]"
</verification_loop>
<when_to_test>
## Testing Decision
**Write a test when:**
- Logic that must be correct (calculations, transformations, rules)
- State changes (add, delete, update operations)
- Edge cases that could break (nil, empty, boundaries)
- Bug fix (test reproduces bug, then proves it's fixed)
- Refactoring (tests prove behavior unchanged)
**Skip tests when:**
- Pure UI exploration ("make it blue and see if I like it")
- Rapid prototyping ("just get something on screen")
- Subjective quality ("does this feel right?")
- One-off verification (launch and check manually)
**The principle:** Tests let the user verify correctness without reading code. If the user needs to verify it works, and it's not purely visual, write a test.
</when_to_test>
<reference_index>
## Domain Knowledge
All in `references/`:
**Architecture:** app-architecture, swiftui-patterns, appkit-integration, concurrency-patterns
**Data:** data-persistence, networking
**App Types:** document-apps, shoebox-apps, menu-bar-apps
**System:** system-apis, app-extensions
**Development:** project-scaffolding, cli-workflow, cli-observability, testing-tdd, testing-debugging
**Polish:** design-system, macos-polish, security-code-signing
</reference_index>
<workflows_index>
## Workflows
All in `workflows/`:
| File | Purpose |
|------|---------|
| build-new-app.md | Create new app from scratch |
| debug-app.md | Find and fix bugs |
| add-feature.md | Add to existing app |
| write-tests.md | Write and run tests |
| optimize-performance.md | Profile and speed up |
| ship-app.md | Sign, notarize, distribute |
</workflows_index>

View File

@@ -0,0 +1,632 @@
<overview>
State management, dependency injection, and app structure patterns for macOS apps. Use @Observable for shared state, environment for dependency injection, and structured async/await patterns for concurrency.
</overview>
<recommended_structure>
```
MyApp/
├── App/
│ ├── MyApp.swift # @main entry point
│ ├── AppState.swift # App-wide observable state
│ └── AppCommands.swift # Menu bar commands
├── Models/
│ ├── Item.swift # Data models
│ └── ItemStore.swift # Data access layer
├── Views/
│ ├── ContentView.swift # Main view
│ ├── Sidebar/
│ │ └── SidebarView.swift
│ ├── Detail/
│ │ └── DetailView.swift
│ └── Settings/
│ └── SettingsView.swift
├── Services/
│ ├── NetworkService.swift # API calls
│ ├── StorageService.swift # Persistence
│ └── NotificationService.swift
├── Utilities/
│ └── Extensions.swift
└── Resources/
└── Assets.xcassets
```
</recommended_structure>
<state_management>
<observable_pattern>
Use `@Observable` (macOS 14+) for shared state:
```swift
@Observable
class AppState {
// Published properties - UI updates automatically
var items: [Item] = []
var selectedItemID: UUID?
var isLoading = false
var error: AppError?
// Computed properties
var selectedItem: Item? {
items.first { $0.id == selectedItemID }
}
var hasSelection: Bool {
selectedItemID != nil
}
// Actions
func selectItem(_ id: UUID?) {
selectedItemID = id
}
func addItem(_ item: Item) {
items.append(item)
selectedItemID = item.id
}
func deleteSelected() {
guard let id = selectedItemID else { return }
items.removeAll { $0.id == id }
selectedItemID = nil
}
}
```
</observable_pattern>
<environment_injection>
Inject state at app level:
```swift
@main
struct MyApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView()
.environment(appState)
}
}
}
// Access in any view
struct SidebarView: View {
@Environment(AppState.self) private var appState
var body: some View {
List(appState.items, id: \.id) { item in
Text(item.name)
}
}
}
```
</environment_injection>
<bindable_for_mutations>
Use `@Bindable` for two-way bindings:
```swift
struct DetailView: View {
@Environment(AppState.self) private var appState
var body: some View {
@Bindable var appState = appState
if let item = appState.selectedItem {
TextField("Name", text: Binding(
get: { item.name },
set: { newValue in
if let index = appState.items.firstIndex(where: { $0.id == item.id }) {
appState.items[index].name = newValue
}
}
))
}
}
}
// Or for direct observable property binding
struct SettingsView: View {
@Environment(AppState.self) private var appState
var body: some View {
@Bindable var appState = appState
Toggle("Show Hidden", isOn: $appState.showHidden)
}
}
```
</bindable_for_mutations>
<multiple_state_objects>
Split state by domain:
```swift
@Observable
class UIState {
var sidebarWidth: CGFloat = 250
var inspectorVisible = true
var selectedTab: Tab = .library
}
@Observable
class DataState {
var items: [Item] = []
var isLoading = false
}
@Observable
class NetworkState {
var isConnected = true
var lastSync: Date?
}
@main
struct MyApp: App {
@State private var uiState = UIState()
@State private var dataState = DataState()
@State private var networkState = NetworkState()
var body: some Scene {
WindowGroup {
ContentView()
.environment(uiState)
.environment(dataState)
.environment(networkState)
}
}
}
```
</multiple_state_objects>
</state_management>
<dependency_injection>
<environment_keys>
Define custom environment keys for services:
```swift
// Define protocol
protocol DataStoreProtocol {
func fetchAll() async throws -> [Item]
func save(_ item: Item) async throws
func delete(_ id: UUID) async throws
}
// Live implementation
class LiveDataStore: DataStoreProtocol {
func fetchAll() async throws -> [Item] {
// Real implementation
}
// ...
}
// Environment key
struct DataStoreKey: EnvironmentKey {
static let defaultValue: DataStoreProtocol = LiveDataStore()
}
extension EnvironmentValues {
var dataStore: DataStoreProtocol {
get { self[DataStoreKey.self] }
set { self[DataStoreKey.self] = newValue }
}
}
// Inject
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.dataStore, LiveDataStore())
}
}
}
// Use
struct ItemListView: View {
@Environment(\.dataStore) private var dataStore
@State private var items: [Item] = []
var body: some View {
List(items) { item in
Text(item.name)
}
.task {
items = try? await dataStore.fetchAll() ?? []
}
}
}
```
</environment_keys>
<testing_with_mocks>
```swift
// Mock for testing
class MockDataStore: DataStoreProtocol {
var itemsToReturn: [Item] = []
var shouldThrow = false
func fetchAll() async throws -> [Item] {
if shouldThrow { throw TestError.mockError }
return itemsToReturn
}
// ...
}
// In preview or test
#Preview {
let mockStore = MockDataStore()
mockStore.itemsToReturn = [
Item(name: "Test 1"),
Item(name: "Test 2")
]
return ItemListView()
.environment(\.dataStore, mockStore)
}
```
</testing_with_mocks>
<service_container>
For apps with many services:
```swift
@Observable
class ServiceContainer {
let dataStore: DataStoreProtocol
let networkService: NetworkServiceProtocol
let authService: AuthServiceProtocol
init(
dataStore: DataStoreProtocol = LiveDataStore(),
networkService: NetworkServiceProtocol = LiveNetworkService(),
authService: AuthServiceProtocol = LiveAuthService()
) {
self.dataStore = dataStore
self.networkService = networkService
self.authService = authService
}
// Convenience for testing
static func mock() -> ServiceContainer {
ServiceContainer(
dataStore: MockDataStore(),
networkService: MockNetworkService(),
authService: MockAuthService()
)
}
}
// Inject container
@main
struct MyApp: App {
@State private var services = ServiceContainer()
var body: some Scene {
WindowGroup {
ContentView()
.environment(services)
}
}
}
```
</service_container>
</dependency_injection>
<app_lifecycle>
<app_delegate>
Use AppDelegate for lifecycle events:
```swift
@main
struct MyApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
// Setup logging, register defaults, etc.
registerDefaults()
}
func applicationWillTerminate(_ notification: Notification) {
// Cleanup, save state
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
// Return true for utility apps
return false
}
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
// Custom dock menu
return createDockMenu()
}
private func registerDefaults() {
UserDefaults.standard.register(defaults: [
"defaultName": "Untitled",
"showWelcome": true
])
}
}
```
</app_delegate>
<scene_phase>
React to app state changes:
```swift
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@Environment(AppState.self) private var appState
var body: some View {
MainContent()
.onChange(of: scenePhase) { oldPhase, newPhase in
switch newPhase {
case .active:
// App became active
Task { await appState.refresh() }
case .inactive:
// App going to background
appState.saveState()
case .background:
// App in background
break
@unknown default:
break
}
}
}
}
```
</scene_phase>
</app_lifecycle>
<coordinator_pattern>
For complex navigation flows:
```swift
@Observable
class AppCoordinator {
enum Route: Hashable {
case home
case detail(Item)
case settings
case onboarding
}
var path = NavigationPath()
var sheet: Route?
var alert: AlertState?
func navigate(to route: Route) {
path.append(route)
}
func present(_ route: Route) {
sheet = route
}
func dismiss() {
sheet = nil
}
func popToRoot() {
path = NavigationPath()
}
func showError(_ error: Error) {
alert = AlertState(
title: "Error",
message: error.localizedDescription
)
}
}
struct ContentView: View {
@Environment(AppCoordinator.self) private var coordinator
var body: some View {
@Bindable var coordinator = coordinator
NavigationStack(path: $coordinator.path) {
HomeView()
.navigationDestination(for: AppCoordinator.Route.self) { route in
switch route {
case .home:
HomeView()
case .detail(let item):
DetailView(item: item)
case .settings:
SettingsView()
case .onboarding:
OnboardingView()
}
}
}
.sheet(item: $coordinator.sheet) { route in
// Sheet content
}
}
}
```
</coordinator_pattern>
<error_handling>
<error_types>
Define domain-specific errors:
```swift
enum AppError: LocalizedError {
case networkError(underlying: Error)
case dataCorrupted
case unauthorized
case notFound(String)
case validationFailed(String)
var errorDescription: String? {
switch self {
case .networkError(let error):
return "Network error: \(error.localizedDescription)"
case .dataCorrupted:
return "Data is corrupted and cannot be loaded"
case .unauthorized:
return "You are not authorized to perform this action"
case .notFound(let item):
return "\(item) not found"
case .validationFailed(let message):
return message
}
}
var recoverySuggestion: String? {
switch self {
case .networkError:
return "Check your internet connection and try again"
case .dataCorrupted:
return "Try restarting the app or contact support"
case .unauthorized:
return "Please sign in again"
case .notFound:
return nil
case .validationFailed:
return "Please correct the issue and try again"
}
}
}
```
</error_types>
<error_presentation>
Present errors to user:
```swift
struct ErrorAlert: ViewModifier {
@Binding var error: AppError?
func body(content: Content) -> some View {
content
.alert(
"Error",
isPresented: Binding(
get: { error != nil },
set: { if !$0 { error = nil } }
),
presenting: error
) { _ in
Button("OK", role: .cancel) {}
} message: { error in
VStack {
Text(error.localizedDescription)
if let recovery = error.recoverySuggestion {
Text(recovery)
.font(.caption)
}
}
}
}
}
extension View {
func errorAlert(_ error: Binding<AppError?>) -> some View {
modifier(ErrorAlert(error: error))
}
}
// Usage
struct ContentView: View {
@Environment(AppState.self) private var appState
var body: some View {
@Bindable var appState = appState
MainContent()
.errorAlert($appState.error)
}
}
```
</error_presentation>
</error_handling>
<async_patterns>
<task_management>
```swift
struct ItemListView: View {
@Environment(AppState.self) private var appState
@State private var loadTask: Task<Void, Never>?
var body: some View {
List(appState.items) { item in
Text(item.name)
}
.task {
await loadItems()
}
.refreshable {
await loadItems()
}
.onDisappear {
loadTask?.cancel()
}
}
private func loadItems() async {
loadTask?.cancel()
loadTask = Task {
await appState.loadItems()
}
await loadTask?.value
}
}
```
</task_management>
<async_sequences>
```swift
@Observable
class NotificationListener {
var notifications: [AppNotification] = []
func startListening() async {
for await notification in NotificationCenter.default.notifications(named: .dataChanged) {
guard !Task.isCancelled else { break }
if let userInfo = notification.userInfo,
let appNotification = AppNotification(userInfo: userInfo) {
await MainActor.run {
notifications.append(appNotification)
}
}
}
}
}
```
</async_sequences>
</async_patterns>
<best_practices>
<do>
- Use `@Observable` for shared state (macOS 14+)
- Inject dependencies through environment
- Keep views focused - they ARE the view model in SwiftUI
- Use protocols for testability
- Handle errors at appropriate levels
- Cancel tasks when views disappear
</do>
<avoid>
- Massive centralized state objects
- Passing state through init parameters (use environment)
- Business logic in views (use services)
- Ignoring task cancellation
- Retaining strong references to self in async closures
</avoid>
</best_practices>

View File

@@ -0,0 +1,484 @@
# App Extensions
Share extensions, widgets, Quick Look previews, and Shortcuts for macOS.
<share_extension>
<setup>
1. File > New > Target > Share Extension
2. Configure activation rules in Info.plist
3. Implement share view controller
**Info.plist activation rules**:
```xml
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>10</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
```
</setup>
<share_view_controller>
```swift
import Cocoa
import Social
class ShareViewController: SLComposeServiceViewController {
override func loadView() {
super.loadView()
// Customize title
title = "Save to MyApp"
}
override func didSelectPost() {
// Get shared items
guard let extensionContext = extensionContext else { return }
for item in extensionContext.inputItems as? [NSExtensionItem] ?? [] {
for provider in item.attachments ?? [] {
if provider.hasItemConformingToTypeIdentifier("public.url") {
provider.loadItem(forTypeIdentifier: "public.url") { [weak self] url, error in
if let url = url as? URL {
self?.saveURL(url)
}
}
}
if provider.hasItemConformingToTypeIdentifier("public.image") {
provider.loadItem(forTypeIdentifier: "public.image") { [weak self] image, error in
if let image = image as? NSImage {
self?.saveImage(image)
}
}
}
}
}
extensionContext.completeRequest(returningItems: nil)
}
override func isContentValid() -> Bool {
// Validate content before allowing post
return !contentText.isEmpty
}
override func didSelectCancel() {
extensionContext?.cancelRequest(withError: NSError(domain: "ShareExtension", code: 0))
}
private func saveURL(_ url: URL) {
// Save to shared container
let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.myapp")
var urls = sharedDefaults?.array(forKey: "savedURLs") as? [String] ?? []
urls.append(url.absoluteString)
sharedDefaults?.set(urls, forKey: "savedURLs")
}
private func saveImage(_ image: NSImage) {
// Save to shared container
guard let data = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: data),
let pngData = rep.representation(using: .png, properties: [:]) else { return }
let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.yourcompany.myapp"
)!
let imageURL = containerURL.appendingPathComponent(UUID().uuidString + ".png")
try? pngData.write(to: imageURL)
}
}
```
</share_view_controller>
<app_groups>
Share data between app and extension:
```xml
<!-- Entitlements for both app and extension -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.yourcompany.myapp</string>
</array>
```
```swift
// Shared UserDefaults
let shared = UserDefaults(suiteName: "group.com.yourcompany.myapp")
// Shared container
let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.yourcompany.myapp"
)
```
</app_groups>
</share_extension>
<widgets>
<widget_extension>
1. File > New > Target > Widget Extension
2. Define timeline provider
3. Create widget view
```swift
import WidgetKit
import SwiftUI
// Timeline entry
struct ItemEntry: TimelineEntry {
let date: Date
let items: [Item]
}
// Timeline provider
struct ItemProvider: TimelineProvider {
func placeholder(in context: Context) -> ItemEntry {
ItemEntry(date: Date(), items: [.placeholder])
}
func getSnapshot(in context: Context, completion: @escaping (ItemEntry) -> Void) {
let entry = ItemEntry(date: Date(), items: loadItems())
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<ItemEntry>) -> Void) {
let items = loadItems()
let entry = ItemEntry(date: Date(), items: items)
// Refresh every hour
let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: Date())!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
private func loadItems() -> [Item] {
// Load from shared container
let shared = UserDefaults(suiteName: "group.com.yourcompany.myapp")
// ... deserialize items
return []
}
}
// Widget view
struct ItemWidgetView: View {
var entry: ItemEntry
var body: some View {
VStack(alignment: .leading) {
Text("Recent Items")
.font(.headline)
ForEach(entry.items.prefix(3)) { item in
HStack {
Image(systemName: item.icon)
Text(item.name)
}
.font(.caption)
}
}
.padding()
}
}
// Widget configuration
@main
struct ItemWidget: Widget {
let kind = "ItemWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: ItemProvider()) { entry in
ItemWidgetView(entry: entry)
}
.configurationDisplayName("Recent Items")
.description("Shows your most recent items")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
```
</widget_extension>
<widget_deep_links>
```swift
struct ItemWidgetView: View {
var entry: ItemEntry
var body: some View {
VStack {
ForEach(entry.items) { item in
Link(destination: URL(string: "myapp://item/\(item.id)")!) {
Text(item.name)
}
}
}
.widgetURL(URL(string: "myapp://widget"))
}
}
// Handle in main app
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
handleURL(url)
}
}
}
func handleURL(_ url: URL) {
// Parse myapp://item/123
if url.host == "item", let id = url.pathComponents.last {
// Navigate to item
}
}
}
```
</widget_deep_links>
<update_widget>
```swift
// From main app, tell widget to refresh
import WidgetKit
func itemsChanged() {
WidgetCenter.shared.reloadTimelines(ofKind: "ItemWidget")
}
// Reload all widgets
WidgetCenter.shared.reloadAllTimelines()
```
</update_widget>
</widgets>
<quick_look>
<preview_extension>
1. File > New > Target > Quick Look Preview Extension
2. Implement preview view controller
```swift
import Cocoa
import Quartz
class PreviewViewController: NSViewController, QLPreviewingController {
@IBOutlet var textView: NSTextView!
func preparePreviewOfFile(at url: URL, completionHandler handler: @escaping (Error?) -> Void) {
do {
let content = try loadDocument(at: url)
textView.string = content.text
handler(nil)
} catch {
handler(error)
}
}
private func loadDocument(at url: URL) throws -> DocumentContent {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(DocumentContent.self, from: data)
}
}
```
</preview_extension>
<thumbnail_extension>
1. File > New > Target > Thumbnail Extension
```swift
import QuickLookThumbnailing
class ThumbnailProvider: QLThumbnailProvider {
override func provideThumbnail(
for request: QLFileThumbnailRequest,
_ handler: @escaping (QLThumbnailReply?, Error?) -> Void
) {
let size = request.maximumSize
handler(QLThumbnailReply(contextSize: size) { context -> Bool in
// Draw thumbnail
let content = self.loadContent(at: request.fileURL)
self.drawThumbnail(content, in: context, size: size)
return true
}, nil)
}
private func drawThumbnail(_ content: DocumentContent, in context: CGContext, size: CGSize) {
// Draw background
context.setFillColor(NSColor.white.cgColor)
context.fill(CGRect(origin: .zero, size: size))
// Draw content preview
// ...
}
}
```
</thumbnail_extension>
</quick_look>
<shortcuts>
<app_intents>
```swift
import AppIntents
// Define intent
struct CreateItemIntent: AppIntent {
static var title: LocalizedStringResource = "Create Item"
static var description = IntentDescription("Creates a new item in MyApp")
@Parameter(title: "Name")
var name: String
@Parameter(title: "Folder", optionsProvider: FolderOptionsProvider())
var folder: String?
func perform() async throws -> some IntentResult & ProvidesDialog {
let item = Item(name: name)
if let folderName = folder {
item.folder = findFolder(named: folderName)
}
try await DataService.shared.save(item)
return .result(dialog: "Created \(name)")
}
}
// Options provider
struct FolderOptionsProvider: DynamicOptionsProvider {
func results() async throws -> [String] {
let folders = try await DataService.shared.fetchFolders()
return folders.map { $0.name }
}
}
// Register shortcuts
struct MyAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: CreateItemIntent(),
phrases: [
"Create item in \(.applicationName)",
"New \(.applicationName) item"
],
shortTitle: "Create Item",
systemImageName: "plus.circle"
)
}
}
```
</app_intents>
<entity_queries>
```swift
// Define entity
struct ItemEntity: AppEntity {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Item")
var id: UUID
var name: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}
static var defaultQuery = ItemQuery()
}
// Define query
struct ItemQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [ItemEntity] {
let items = try await DataService.shared.fetchItems(ids: identifiers)
return items.map { ItemEntity(id: $0.id, name: $0.name) }
}
func suggestedEntities() async throws -> [ItemEntity] {
let items = try await DataService.shared.recentItems(limit: 10)
return items.map { ItemEntity(id: $0.id, name: $0.name) }
}
}
// Use in intent
struct OpenItemIntent: AppIntent {
static var title: LocalizedStringResource = "Open Item"
@Parameter(title: "Item")
var item: ItemEntity
func perform() async throws -> some IntentResult {
// Open item in app
NotificationCenter.default.post(
name: .openItem,
object: nil,
userInfo: ["id": item.id]
)
return .result()
}
}
```
</entity_queries>
</shortcuts>
<action_extension>
```swift
import Cocoa
class ActionViewController: NSViewController {
@IBOutlet var textView: NSTextView!
override func viewDidLoad() {
super.viewDidLoad()
// Get input items
for item in extensionContext?.inputItems as? [NSExtensionItem] ?? [] {
for provider in item.attachments ?? [] {
if provider.hasItemConformingToTypeIdentifier("public.text") {
provider.loadItem(forTypeIdentifier: "public.text") { [weak self] text, _ in
DispatchQueue.main.async {
self?.textView.string = text as? String ?? ""
}
}
}
}
}
}
@IBAction func done(_ sender: Any) {
// Return modified content
let outputItem = NSExtensionItem()
outputItem.attachments = [
NSItemProvider(item: textView.string as NSString, typeIdentifier: "public.text")
]
extensionContext?.completeRequest(returningItems: [outputItem])
}
@IBAction func cancel(_ sender: Any) {
extensionContext?.cancelRequest(withError: NSError(domain: "ActionExtension", code: 0))
}
}
```
</action_extension>
<extension_best_practices>
- Share data via App Groups
- Keep extensions lightweight (memory limits)
- Handle errors gracefully
- Test in all contexts (Finder, Safari, etc.)
- Update Info.plist activation rules carefully
- Use WidgetCenter.shared.reloadTimelines() to update widgets
- Define clear App Intents with good phrases
</extension_best_practices>

View File

@@ -0,0 +1,485 @@
# AppKit Integration
When and how to use AppKit alongside SwiftUI for advanced functionality.
<when_to_use_appkit>
Use AppKit (not SwiftUI) when you need:
- Custom drawing with `NSView.draw(_:)`
- Complex text editing (`NSTextView`)
- Drag and drop with custom behaviors
- Low-level event handling
- Popovers with specific positioning
- Custom window chrome
- Backward compatibility (< macOS 13)
**Anti-pattern: Using AppKit to "fix" SwiftUI**
Before reaching for AppKit as a workaround:
1. Search your SwiftUI code for what's declaratively controlling the behavior
2. SwiftUI wrappers (NSHostingView, NSViewRepresentable) manage their wrapped AppKit objects
3. Your AppKit code may run but be overridden by SwiftUI's declarative layer
4. Example: Setting `NSWindow.minSize` is ignored if content view has `.frame(minWidth:)`
**Debugging mindset:**
- SwiftUI's declarative layer = policy
- AppKit's imperative APIs = implementation details
- Policy wins. Check policy first.
Prefer SwiftUI for everything else.
</when_to_use_appkit>
<nsviewrepresentable>
<basic_pattern>
```swift
import SwiftUI
struct CustomCanvasView: NSViewRepresentable {
@Binding var drawing: Drawing
func makeNSView(context: Context) -> CanvasNSView {
let view = CanvasNSView()
view.delegate = context.coordinator
return view
}
func updateNSView(_ nsView: CanvasNSView, context: Context) {
nsView.drawing = drawing
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, CanvasDelegate {
var parent: CustomCanvasView
init(_ parent: CustomCanvasView) {
self.parent = parent
}
func canvasDidUpdate(_ drawing: Drawing) {
parent.drawing = drawing
}
}
}
```
</basic_pattern>
<with_sizeThatFits>
```swift
struct IntrinsicSizeView: NSViewRepresentable {
let text: String
func makeNSView(context: Context) -> NSTextField {
let field = NSTextField(labelWithString: text)
field.setContentHuggingPriority(.required, for: .horizontal)
return field
}
func updateNSView(_ nsView: NSTextField, context: Context) {
nsView.stringValue = text
}
func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSTextField, context: Context) -> CGSize? {
nsView.fittingSize
}
}
```
</with_sizeThatFits>
</nsviewrepresentable>
<custom_nsview>
<drawing_view>
```swift
import AppKit
class CanvasNSView: NSView {
var drawing: Drawing = Drawing() {
didSet { needsDisplay = true }
}
weak var delegate: CanvasDelegate?
override var isFlipped: Bool { true } // Use top-left origin
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
// Background
NSColor.windowBackgroundColor.setFill()
context.fill(bounds)
// Draw content
for path in drawing.paths {
context.setStrokeColor(path.color.cgColor)
context.setLineWidth(path.lineWidth)
context.addPath(path.cgPath)
context.strokePath()
}
}
// Mouse handling
override func mouseDown(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
drawing.startPath(at: point)
needsDisplay = true
}
override func mouseDragged(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
drawing.addPoint(point)
needsDisplay = true
}
override func mouseUp(with event: NSEvent) {
drawing.endPath()
delegate?.canvasDidUpdate(drawing)
}
override var acceptsFirstResponder: Bool { true }
}
protocol CanvasDelegate: AnyObject {
func canvasDidUpdate(_ drawing: Drawing)
}
```
</drawing_view>
<keyboard_handling>
```swift
class KeyHandlingView: NSView {
var onKeyPress: ((NSEvent) -> Bool)?
override var acceptsFirstResponder: Bool { true }
override func keyDown(with event: NSEvent) {
if let handler = onKeyPress, handler(event) {
return // Event handled
}
super.keyDown(with: event)
}
override func flagsChanged(with event: NSEvent) {
// Handle modifier key changes
if event.modifierFlags.contains(.shift) {
// Shift pressed
}
}
}
```
</keyboard_handling>
</custom_nsview>
<nstextview_integration>
<rich_text_editor>
```swift
struct RichTextEditor: NSViewRepresentable {
@Binding var attributedText: NSAttributedString
var isEditable: Bool = true
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSTextView.scrollableTextView()
let textView = scrollView.documentView as! NSTextView
textView.delegate = context.coordinator
textView.isEditable = isEditable
textView.isRichText = true
textView.allowsUndo = true
textView.usesFontPanel = true
textView.usesRuler = true
textView.isRulerVisible = true
// Typography
textView.textContainerInset = NSSize(width: 20, height: 20)
textView.font = .systemFont(ofSize: 14)
return scrollView
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
let textView = nsView.documentView as! NSTextView
if textView.attributedString() != attributedText {
textView.textStorage?.setAttributedString(attributedText)
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, NSTextViewDelegate {
var parent: RichTextEditor
init(_ parent: RichTextEditor) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
parent.attributedText = textView.attributedString()
}
}
}
```
</rich_text_editor>
</nstextview_integration>
<nshostingview>
Use SwiftUI views in AppKit:
```swift
import AppKit
import SwiftUI
class MyWindowController: NSWindowController {
convenience init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false
)
// SwiftUI content in AppKit window
let hostingView = NSHostingView(
rootView: ContentView()
.environment(appState)
)
window.contentView = hostingView
self.init(window: window)
}
}
// In toolbar item
class ToolbarItemController: NSToolbarItem {
override init(itemIdentifier: NSToolbarItem.Identifier) {
super.init(itemIdentifier: itemIdentifier)
let hostingView = NSHostingView(rootView: ToolbarButton())
view = hostingView
}
}
```
</nshostingview>
<drag_and_drop>
<dragging_source>
```swift
class DraggableView: NSView, NSDraggingSource {
var item: Item?
override func mouseDown(with event: NSEvent) {
guard let item = item else { return }
let pasteboardItem = NSPasteboardItem()
pasteboardItem.setString(item.id.uuidString, forType: .string)
let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem)
draggingItem.setDraggingFrame(bounds, contents: snapshot())
beginDraggingSession(with: [draggingItem], event: event, source: self)
}
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
context == .withinApplication ? .move : .copy
}
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
if operation == .move {
// Remove from source
}
}
private func snapshot() -> NSImage {
let image = NSImage(size: bounds.size)
image.lockFocus()
draw(bounds)
image.unlockFocus()
return image
}
}
```
</dragging_source>
<dragging_destination>
```swift
class DropTargetView: NSView {
var onDrop: (([String]) -> Bool)?
override func awakeFromNib() {
super.awakeFromNib()
registerForDraggedTypes([.string, .fileURL])
}
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
.copy
}
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
let pasteboard = sender.draggingPasteboard
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] {
return onDrop?(urls.map { $0.path }) ?? false
}
if let strings = pasteboard.readObjects(forClasses: [NSString.self]) as? [String] {
return onDrop?(strings) ?? false
}
return false
}
}
```
</dragging_destination>
</drag_and_drop>
<window_customization>
<custom_titlebar>
```swift
class CustomWindow: NSWindow {
override init(
contentRect: NSRect,
styleMask style: NSWindow.StyleMask,
backing backingStoreType: NSWindow.BackingStoreType,
defer flag: Bool
) {
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
// Transparent titlebar
titlebarAppearsTransparent = true
titleVisibility = .hidden
// Full-size content
styleMask.insert(.fullSizeContentView)
// Custom background
backgroundColor = .windowBackgroundColor
isOpaque = false
}
}
```
</custom_titlebar>
<access_window_from_swiftui>
```swift
struct WindowAccessor: NSViewRepresentable {
var callback: (NSWindow?) -> Void
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
callback(view.window)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
// Usage
struct ContentView: View {
var body: some View {
MainContent()
.background(WindowAccessor { window in
window?.titlebarAppearsTransparent = true
})
}
}
```
</access_window_from_swiftui>
</window_customization>
<popover>
```swift
class PopoverController {
private var popover: NSPopover?
func show(from view: NSView, content: some View) {
let popover = NSPopover()
popover.contentViewController = NSHostingController(rootView: content)
popover.behavior = .transient
popover.show(
relativeTo: view.bounds,
of: view,
preferredEdge: .minY
)
self.popover = popover
}
func close() {
popover?.close()
popover = nil
}
}
// SwiftUI wrapper
struct PopoverButton<Content: View>: NSViewRepresentable {
@Binding var isPresented: Bool
@ViewBuilder var content: () -> Content
func makeNSView(context: Context) -> NSButton {
let button = NSButton(title: "Show", target: context.coordinator, action: #selector(Coordinator.showPopover))
return button
}
func updateNSView(_ nsView: NSButton, context: Context) {
context.coordinator.isPresented = isPresented
context.coordinator.content = AnyView(content())
if !isPresented {
context.coordinator.popover?.close()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, NSPopoverDelegate {
var parent: PopoverButton
var popover: NSPopover?
var isPresented: Bool = false
var content: AnyView = AnyView(EmptyView())
init(_ parent: PopoverButton) {
self.parent = parent
}
@objc func showPopover(_ sender: NSButton) {
let popover = NSPopover()
popover.contentViewController = NSHostingController(rootView: content)
popover.behavior = .transient
popover.delegate = self
popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY)
self.popover = popover
parent.isPresented = true
}
func popoverDidClose(_ notification: Notification) {
parent.isPresented = false
}
}
}
```
</popover>
<best_practices>
<do>
- Use NSViewRepresentable for custom views
- Use Coordinator for delegate callbacks
- Clean up resources in NSViewRepresentable
- Use NSHostingView to embed SwiftUI in AppKit
</do>
<avoid>
- Using AppKit when SwiftUI suffices
- Forgetting to set acceptsFirstResponder for keyboard input
- Not handling coordinate system (isFlipped)
- Memory leaks from strong delegate references
</avoid>
</best_practices>

View File

@@ -0,0 +1,379 @@
# CLI Observability
Complete debugging and monitoring without opening Xcode. Claude has full visibility into build errors, runtime logs, crashes, memory issues, and network traffic.
<prerequisites>
```bash
# Install observability tools (one-time)
brew tap ldomaradzki/xcsift && brew install xcsift
brew install mitmproxy xcbeautify
```
</prerequisites>
<build_output>
## Build Error Parsing
**xcsift** converts verbose xcodebuild output to token-efficient JSON for AI agents:
```bash
xcodebuild -project MyApp.xcodeproj -scheme MyApp build 2>&1 | xcsift
```
Output includes structured errors with file paths and line numbers:
```json
{
"status": "failed",
"errors": [
{"file": "/path/File.swift", "line": 42, "message": "Type mismatch..."}
]
}
```
**Alternative** (human-readable):
```bash
xcodebuild build 2>&1 | xcbeautify
```
</build_output>
<runtime_logging>
## Runtime Logs
### In-App Logging Pattern
Add to all apps:
```swift
import os
extension Logger {
static let app = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "App")
static let network = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Network")
static let data = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Data")
}
// Usage
Logger.network.debug("Request: \(url)")
Logger.data.error("Save failed: \(error)")
```
### Stream Logs from Running App
```bash
# All logs from your app
log stream --level debug --predicate 'subsystem == "com.yourcompany.MyApp"'
# Filter by category
log stream --level debug \
--predicate 'subsystem == "com.yourcompany.MyApp" AND category == "Network"'
# Errors only
log stream --predicate 'subsystem == "com.yourcompany.MyApp" AND messageType == error'
# JSON output for parsing
log stream --level debug --style json \
--predicate 'subsystem == "com.yourcompany.MyApp"'
```
### Search Historical Logs
```bash
# Last hour
log show --predicate 'subsystem == "com.yourcompany.MyApp"' --last 1h
# Export to file
log show --predicate 'subsystem == "com.yourcompany.MyApp"' --last 1h > logs.txt
```
</runtime_logging>
<crash_analysis>
## Crash Logs
### Find Crashes
```bash
# List crash reports
ls ~/Library/Logs/DiagnosticReports/ | grep MyApp
# View latest crash
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -200
```
### Symbolicate with atos
```bash
# Get load address from "Binary Images:" section of crash report
xcrun atos -arch arm64 \
-o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \
-l 0x104600000 \
0x104605ca4
# Verify dSYM matches
xcrun dwarfdump --uuid MyApp.app.dSYM
```
### Symbolicate with LLDB
```bash
xcrun lldb
(lldb) command script import lldb.macosx.crashlog
(lldb) crashlog /path/to/crash.ips
```
</crash_analysis>
<debugger>
## LLDB Debugging
### Attach to Running App
```bash
# By name
lldb -n MyApp
# By PID
lldb -p $(pgrep MyApp)
```
### Launch and Debug
```bash
lldb ./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp
(lldb) run
```
### Essential Commands
```bash
# Breakpoints
(lldb) breakpoint set --file ContentView.swift --line 42
(lldb) breakpoint set --name "AppState.addItem"
(lldb) breakpoint set --name saveItem --condition 'item.name == "Test"'
# Watchpoints (break when value changes)
(lldb) watchpoint set variable self.items.count
# Execution
(lldb) continue # or 'c'
(lldb) next # step over
(lldb) step # step into
(lldb) finish # step out
# Inspection
(lldb) p variable
(lldb) po object
(lldb) frame variable # all local vars
(lldb) bt # backtrace
(lldb) bt all # all threads
# Evaluate expressions
(lldb) expr self.items.count
(lldb) expr self.items.append(newItem)
```
</debugger>
<memory_debugging>
## Memory Debugging
### Leak Detection
```bash
# Check running process for leaks
leaks MyApp
# Run with leak check at exit
leaks --atExit -- ./MyApp
# With stack traces (shows where leak originated)
MallocStackLogging=1 ./MyApp &
leaks MyApp
```
### Heap Analysis
```bash
# Show heap summary
heap MyApp
# Show allocations of specific class
heap MyApp -class NSString
# Virtual memory regions
vmmap --summary MyApp
```
### Profiling with xctrace
```bash
# List templates
xcrun xctrace list templates
# Time Profiler
xcrun xctrace record \
--template 'Time Profiler' \
--time-limit 30s \
--output profile.trace \
--launch -- ./MyApp.app/Contents/MacOS/MyApp
# Leaks
xcrun xctrace record \
--template 'Leaks' \
--time-limit 5m \
--attach $(pgrep MyApp) \
--output leaks.trace
# Export data
xcrun xctrace export --input profile.trace --toc
```
</memory_debugging>
<sanitizers>
## Sanitizers
Enable via xcodebuild flags:
```bash
# Address Sanitizer (memory errors, buffer overflows)
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-enableAddressSanitizer YES
# Thread Sanitizer (race conditions)
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-enableThreadSanitizer YES
# Undefined Behavior Sanitizer
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-enableUndefinedBehaviorSanitizer YES
```
**Note:** ASAN and TSAN cannot run simultaneously.
</sanitizers>
<network_inspection>
## Network Traffic Inspection
### mitmproxy Setup
```bash
# Run proxy (defaults to localhost:8080)
mitmproxy # TUI
mitmdump # CLI output only
```
### Configure macOS Proxy
```bash
# Enable
networksetup -setwebproxy "Wi-Fi" 127.0.0.1 8080
networksetup -setsecurewebproxy "Wi-Fi" 127.0.0.1 8080
# Disable when done
networksetup -setwebproxystate "Wi-Fi" off
networksetup -setsecurewebproxystate "Wi-Fi" off
```
### Log Traffic
```bash
# Log all requests
mitmdump -w traffic.log
# Filter by domain
mitmdump --filter "~d api.example.com"
# Verbose (show bodies)
mitmdump -v
```
</network_inspection>
<test_results>
## Test Result Parsing
```bash
# Run tests with result bundle
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-resultBundlePath TestResults.xcresult
# Get summary
xcrun xcresulttool get test-results summary --path TestResults.xcresult
# Export as JSON
xcrun xcresulttool get --path TestResults.xcresult --format json > results.json
# Coverage report
xcrun xccov view --report TestResults.xcresult
# Coverage as JSON
xcrun xccov view --report --json TestResults.xcresult > coverage.json
```
</test_results>
<swiftui_debugging>
## SwiftUI Debugging
### Track View Re-evaluation
```swift
var body: some View {
let _ = Self._printChanges() // Logs what caused re-render
VStack {
// ...
}
}
```
### Dump Objects
```swift
let _ = dump(someObject) // Full object hierarchy to console
```
**Note:** No CLI equivalent for Xcode's visual view hierarchy inspector. Use logging extensively.
</swiftui_debugging>
<standard_debug_workflow>
## Standard Debug Workflow
```bash
# 1. Build with error parsing
xcodebuild -project MyApp.xcodeproj -scheme MyApp build 2>&1 | xcsift
# 2. Run with log streaming (background terminal)
log stream --level debug --predicate 'subsystem == "com.yourcompany.MyApp"' &
# 3. Launch app
open ./build/Build/Products/Debug/MyApp.app
# 4. If crash occurs
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -100
# 5. Memory check
leaks MyApp
# 6. Deep debugging
lldb -n MyApp
```
</standard_debug_workflow>
<cli_vs_xcode>
## What CLI Can and Cannot Do
| Task | CLI | Tool |
|------|-----|------|
| Build errors | ✓ | xcsift |
| Runtime logs | ✓ | log stream |
| Crash symbolication | ✓ | atos, lldb |
| Breakpoints/debugging | ✓ | lldb |
| Memory leaks | ✓ | leaks, xctrace |
| CPU profiling | ✓ | xctrace |
| Network inspection | ✓ | mitmproxy |
| Test results | ✓ | xcresulttool |
| Sanitizers | ✓ | xcodebuild flags |
| View hierarchy | ⚠️ | _printChanges() only |
| GPU debugging | ✗ | Requires Xcode |
</cli_vs_xcode>

View File

@@ -0,0 +1,615 @@
# CLI-Only Workflow
Build, run, debug, and monitor macOS apps entirely from command line without opening Xcode.
<prerequisites>
```bash
# Ensure Xcode is installed and selected
xcode-select -p
# Should show: /Applications/Xcode.app/Contents/Developer
# If not, run:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
# Install XcodeGen for project creation
brew install xcodegen
# Optional: prettier build output
brew install xcbeautify
```
</prerequisites>
<create_project>
**Create a new project entirely from CLI**:
```bash
# Create directory structure
mkdir MyApp && cd MyApp
mkdir -p Sources Tests Resources
# Create project.yml (Claude generates this)
cat > project.yml << 'EOF'
name: MyApp
options:
bundleIdPrefix: com.yourcompany
deploymentTarget:
macOS: "14.0"
targets:
MyApp:
type: application
platform: macOS
sources: [Sources]
settings:
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp
DEVELOPMENT_TEAM: YOURTEAMID
EOF
# Create app entry point
cat > Sources/MyApp.swift << 'EOF'
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
Text("Hello, World!")
}
}
}
EOF
# Generate .xcodeproj
xcodegen generate
# Verify
xcodebuild -list -project MyApp.xcodeproj
# Build
xcodebuild -project MyApp.xcodeproj -scheme MyApp build
```
See [project-scaffolding.md](project-scaffolding.md) for complete project.yml templates.
</create_project>
<build>
<list_schemes>
```bash
# See available schemes and targets
xcodebuild -list -project MyApp.xcodeproj
```
</list_schemes>
<build_debug>
```bash
# Build debug configuration
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-configuration Debug \
-derivedDataPath ./build \
build
# Output location
ls ./build/Build/Products/Debug/MyApp.app
```
</build_debug>
<build_release>
```bash
# Build release configuration
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-configuration Release \
-derivedDataPath ./build \
build
```
</build_release>
<build_with_signing>
```bash
# Build with code signing for distribution
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-configuration Release \
-derivedDataPath ./build \
CODE_SIGN_IDENTITY="Developer ID Application: Your Name" \
DEVELOPMENT_TEAM=YOURTEAMID \
build
```
</build_with_signing>
<clean>
```bash
# Clean build artifacts
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
clean
# Remove derived data
rm -rf ./build
```
</clean>
<build_errors>
Build output goes to stdout. Filter for errors:
```bash
xcodebuild -project MyApp.xcodeproj -scheme MyApp build 2>&1 | grep -E "error:|warning:"
```
For prettier output, use xcpretty (install with `gem install xcpretty`):
```bash
xcodebuild -project MyApp.xcodeproj -scheme MyApp build | xcpretty
```
</build_errors>
</build>
<run>
<launch_app>
```bash
# Run the built app
open ./build/Build/Products/Debug/MyApp.app
# Or run directly (shows stdout in terminal)
./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp
```
</launch_app>
<run_with_arguments>
```bash
# Pass command line arguments
./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp --debug-mode
# Pass environment variables
MYAPP_DEBUG=1 ./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp
```
</run_with_arguments>
<background>
```bash
# Run in background (don't bring to front)
open -g ./build/Build/Products/Debug/MyApp.app
# Run hidden (no dock icon)
open -j ./build/Build/Products/Debug/MyApp.app
```
</background>
</run>
<logging>
<os_log_in_code>
Add logging to your Swift code:
```swift
import os
class DataService {
private let logger = Logger(subsystem: "com.yourcompany.MyApp", category: "Data")
func loadItems() async throws -> [Item] {
logger.info("Loading items...")
do {
let items = try await fetchItems()
logger.info("Loaded \(items.count) items")
return items
} catch {
logger.error("Failed to load items: \(error.localizedDescription)")
throw error
}
}
func saveItem(_ item: Item) {
logger.debug("Saving item: \(item.id)")
// ...
}
}
```
**Log levels**:
- `.debug` - Verbose development info
- `.info` - General informational
- `.notice` - Notable conditions
- `.error` - Errors
- `.fault` - Critical failures
</os_log_in_code>
<stream_logs>
```bash
# Stream logs from your app (run while app is running)
log stream --predicate 'subsystem == "com.yourcompany.MyApp"' --level info
# Filter by category
log stream --predicate 'subsystem == "com.yourcompany.MyApp" and category == "Data"'
# Filter by process name
log stream --predicate 'process == "MyApp"' --level debug
# Include debug messages
log stream --predicate 'subsystem == "com.yourcompany.MyApp"' --level debug
# Show only errors
log stream --predicate 'subsystem == "com.yourcompany.MyApp" and messageType == error'
```
</stream_logs>
<search_past_logs>
```bash
# Search recent logs (last hour)
log show --predicate 'subsystem == "com.yourcompany.MyApp"' --last 1h
# Search specific time range
log show --predicate 'subsystem == "com.yourcompany.MyApp"' \
--start "2024-01-15 10:00:00" \
--end "2024-01-15 11:00:00"
# Export to file
log show --predicate 'subsystem == "com.yourcompany.MyApp"' --last 1h > app_logs.txt
```
</search_past_logs>
<system_logs>
```bash
# See app lifecycle events
log stream --predicate 'process == "MyApp" or (sender == "lsd" and message contains "MyApp")'
# Network activity (if using NSURLSession)
log stream --predicate 'subsystem == "com.apple.network" and process == "MyApp"'
# Core Data / SwiftData activity
log stream --predicate 'subsystem == "com.apple.coredata"'
```
</system_logs>
</logging>
<debugging>
<lldb_attach>
```bash
# Start app, then attach lldb
./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp &
# Attach by process name
lldb -n MyApp
# Or attach by PID
lldb -p $(pgrep MyApp)
```
</lldb_attach>
<lldb_launch>
```bash
# Launch app under lldb directly
lldb ./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp
# In lldb:
(lldb) run
```
</lldb_launch>
<common_lldb_commands>
```bash
# In lldb session:
# Set breakpoint by function name
(lldb) breakpoint set --name saveItem
(lldb) b DataService.swift:42
# Set conditional breakpoint
(lldb) breakpoint set --name saveItem --condition 'item.name == "Test"'
# Continue execution
(lldb) continue
(lldb) c
# Step over/into/out
(lldb) next
(lldb) step
(lldb) finish
# Print variable
(lldb) p item
(lldb) po self.items
# Print with format
(lldb) p/x pointer # hex
(lldb) p/t flags # binary
# Backtrace
(lldb) bt
(lldb) bt all # all threads
# List threads
(lldb) thread list
# Switch thread
(lldb) thread select 2
# Frame info
(lldb) frame info
(lldb) frame variable # all local variables
# Watchpoint (break when value changes)
(lldb) watchpoint set variable self.items.count
# Expression evaluation
(lldb) expr self.items.append(newItem)
```
</common_lldb_commands>
<debug_entitlement>
For lldb to attach, your app needs the `get-task-allow` entitlement (included in Debug builds by default):
```xml
<key>com.apple.security.get-task-allow</key>
<true/>
```
If you have attachment issues:
```bash
# Check entitlements
codesign -d --entitlements - ./build/Build/Products/Debug/MyApp.app
```
</debug_entitlement>
</debugging>
<crash_logs>
<locations>
```bash
# User crash logs
ls ~/Library/Logs/DiagnosticReports/
# System crash logs (requires sudo)
ls /Library/Logs/DiagnosticReports/
# Find your app's crashes
ls ~/Library/Logs/DiagnosticReports/ | grep MyApp
```
</locations>
<read_crash>
```bash
# View latest crash
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -200
# Symbolicate (if you have dSYM)
atos -arch arm64 -o ./build/Build/Products/Debug/MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -l 0x100000000 0x100001234
```
</read_crash>
<monitor_crashes>
```bash
# Watch for new crashes
fswatch ~/Library/Logs/DiagnosticReports/ | grep MyApp
```
</monitor_crashes>
</crash_logs>
<profiling>
<instruments_cli>
```bash
# List available templates
instruments -s templates
# Profile CPU usage
instruments -t "Time Profiler" -D trace.trace ./build/Build/Products/Debug/MyApp.app
# Profile memory
instruments -t "Allocations" -D memory.trace ./build/Build/Products/Debug/MyApp.app
# Profile leaks
instruments -t "Leaks" -D leaks.trace ./build/Build/Products/Debug/MyApp.app
```
</instruments_cli>
<signposts>
Add signposts for custom profiling:
```swift
import os
class DataService {
private let signposter = OSSignposter(subsystem: "com.yourcompany.MyApp", category: "Performance")
func loadItems() async throws -> [Item] {
let signpostID = signposter.makeSignpostID()
let state = signposter.beginInterval("Load Items", id: signpostID)
defer {
signposter.endInterval("Load Items", state)
}
return try await fetchItems()
}
}
```
View in Instruments with "os_signpost" instrument.
</signposts>
</profiling>
<code_signing>
<check_signature>
```bash
# Verify signature
codesign -v ./build/Build/Products/Release/MyApp.app
# Show signature details
codesign -dv --verbose=4 ./build/Build/Products/Release/MyApp.app
# Show entitlements
codesign -d --entitlements - ./build/Build/Products/Release/MyApp.app
```
</check_signature>
<sign_manually>
```bash
# Sign with Developer ID (for distribution outside App Store)
codesign --force --sign "Developer ID Application: Your Name (TEAMID)" \
--entitlements MyApp/MyApp.entitlements \
--options runtime \
./build/Build/Products/Release/MyApp.app
```
</sign_manually>
<notarize>
```bash
# Create ZIP for notarization
ditto -c -k --keepParent ./build/Build/Products/Release/MyApp.app MyApp.zip
# Submit for notarization
xcrun notarytool submit MyApp.zip \
--apple-id your@email.com \
--team-id YOURTEAMID \
--password @keychain:AC_PASSWORD \
--wait
# Staple ticket to app
xcrun stapler staple ./build/Build/Products/Release/MyApp.app
```
**Store password in keychain**:
```bash
xcrun notarytool store-credentials --apple-id your@email.com --team-id TEAMID
```
</notarize>
</code_signing>
<testing>
<run_tests>
```bash
# Run all tests
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-derivedDataPath ./build \
test
# Run specific test class
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-only-testing:MyAppTests/DataServiceTests \
test
# Run specific test method
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-only-testing:MyAppTests/DataServiceTests/testLoadItems \
test
```
</run_tests>
<test_output>
```bash
# Pretty test output
xcodebuild test -project MyApp.xcodeproj -scheme MyApp | xcpretty --test
# Generate test report
xcodebuild test -project MyApp.xcodeproj -scheme MyApp \
-resultBundlePath ./TestResults.xcresult
# View result bundle
xcrun xcresulttool get --path ./TestResults.xcresult --format json
```
</test_output>
<test_coverage>
```bash
# Build with coverage
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-enableCodeCoverage YES \
-derivedDataPath ./build \
test
# Generate coverage report
xcrun llvm-cov report \
./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp \
-instr-profile=./build/Build/ProfileData/*/Coverage.profdata
```
</test_coverage>
</testing>
<complete_workflow>
Typical development cycle without opening Xcode:
```bash
# 1. Edit code (in your editor of choice)
# Claude Code, vim, VS Code, etc.
# 2. Build
xcodebuild -project MyApp.xcodeproj -scheme MyApp -configuration Debug -derivedDataPath ./build build 2>&1 | grep -E "error:|warning:" || echo "Build succeeded"
# 3. Run
open ./build/Build/Products/Debug/MyApp.app
# 4. Monitor logs (in separate terminal)
log stream --predicate 'subsystem == "com.yourcompany.MyApp"' --level debug
# 5. If crash, check logs
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -100
# 6. Debug if needed
lldb -n MyApp
# 7. Run tests
xcodebuild -project MyApp.xcodeproj -scheme MyApp test
# 8. Build release
xcodebuild -project MyApp.xcodeproj -scheme MyApp -configuration Release -derivedDataPath ./build build
```
</complete_workflow>
<helper_script>
Create a build script for convenience:
```bash
#!/bin/bash
# build.sh
PROJECT="MyApp.xcodeproj"
SCHEME="MyApp"
CONFIG="${1:-Debug}"
echo "Building $SCHEME ($CONFIG)..."
xcodebuild -project "$PROJECT" \
-scheme "$SCHEME" \
-configuration "$CONFIG" \
-derivedDataPath ./build \
build 2>&1 | tee build.log | grep -E "error:|warning:|BUILD"
if [ ${PIPESTATUS[0]} -eq 0 ]; then
echo "✓ Build succeeded"
echo "App: ./build/Build/Products/$CONFIG/$SCHEME.app"
else
echo "✗ Build failed - see build.log"
exit 1
fi
```
```bash
chmod +x build.sh
./build.sh # Debug build
./build.sh Release # Release build
```
</helper_script>
<useful_aliases>
Add to ~/.zshrc or ~/.bashrc:
```bash
# Build current project
alias xb='xcodebuild -project *.xcodeproj -scheme $(basename *.xcodeproj .xcodeproj) -derivedDataPath ./build build'
# Build and run
alias xbr='xb && open ./build/Build/Products/Debug/*.app'
# Run tests
alias xt='xcodebuild -project *.xcodeproj -scheme $(basename *.xcodeproj .xcodeproj) test'
# Stream logs for current project
alias xl='log stream --predicate "subsystem contains \"$(defaults read ./build/Build/Products/Debug/*.app/Contents/Info.plist CFBundleIdentifier)\"" --level debug'
# Clean
alias xc='xcodebuild -project *.xcodeproj -scheme $(basename *.xcodeproj .xcodeproj) clean && rm -rf ./build'
```
</useful_aliases>

View File

@@ -0,0 +1,538 @@
# Concurrency Patterns
Modern Swift concurrency for responsive, safe macOS apps.
<async_await_basics>
<simple_async>
```swift
// Basic async function
func fetchData() async throws -> [Item] {
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Item].self, from: data)
}
// Call from view
struct ContentView: View {
@State private var items: [Item] = []
var body: some View {
List(items) { item in
Text(item.name)
}
.task {
do {
items = try await fetchData()
} catch {
// Handle error
}
}
}
}
```
</simple_async>
<task_modifier>
```swift
struct ItemListView: View {
@State private var items: [Item] = []
let category: Category
var body: some View {
List(items) { item in
Text(item.name)
}
// .task runs when view appears, cancels when disappears
.task {
await loadItems()
}
// .task(id:) re-runs when id changes
.task(id: category) {
await loadItems(for: category)
}
}
func loadItems(for category: Category? = nil) async {
// Automatically cancelled if view disappears
items = await dataService.fetchItems(category: category)
}
}
```
</task_modifier>
</async_await_basics>
<actors>
<basic_actor>
```swift
// Actor for thread-safe state
actor DataCache {
private var cache: [String: Data] = [:]
func get(_ key: String) -> Data? {
cache[key]
}
func set(_ key: String, data: Data) {
cache[key] = data
}
func clear() {
cache.removeAll()
}
}
// Usage (must await)
let cache = DataCache()
await cache.set("key", data: data)
let cached = await cache.get("key")
```
</basic_actor>
<service_actor>
```swift
actor NetworkService {
private let session: URLSession
private var pendingRequests: [URL: Task<Data, Error>] = [:]
init(session: URLSession = .shared) {
self.session = session
}
func fetch(_ url: URL) async throws -> Data {
// Deduplicate concurrent requests for same URL
if let existing = pendingRequests[url] {
return try await existing.value
}
let task = Task {
let (data, _) = try await session.data(from: url)
return data
}
pendingRequests[url] = task
defer {
pendingRequests[url] = nil
}
return try await task.value
}
}
```
</service_actor>
<nonisolated>
```swift
actor ImageProcessor {
private var processedCount = 0
// Synchronous access for non-isolated properties
nonisolated let maxConcurrent = 4
// Computed property that doesn't need isolation
nonisolated var identifier: String {
"ImageProcessor-\(ObjectIdentifier(self))"
}
func process(_ image: NSImage) async -> NSImage {
processedCount += 1
// Process image...
return processedImage
}
func getCount() -> Int {
processedCount
}
}
```
</nonisolated>
</actors>
<main_actor>
<ui_updates>
```swift
// Mark entire class as @MainActor
@MainActor
@Observable
class AppState {
var items: [Item] = []
var isLoading = false
var error: AppError?
func loadItems() async {
isLoading = true
defer { isLoading = false }
do {
// This call might be on background, result delivered on main
items = try await dataService.fetchAll()
} catch {
self.error = .loadFailed(error)
}
}
}
// Or mark specific functions
class DataProcessor {
@MainActor
func updateUI(with result: ProcessResult) {
// Safe to update UI here
}
func processInBackground() async -> ProcessResult {
// Heavy work here
let result = await heavyComputation()
// Update UI on main actor
await updateUI(with: result)
return result
}
}
```
</ui_updates>
<main_actor_dispatch>
```swift
// From async context
await MainActor.run {
self.items = newItems
}
// Assume main actor (when you know you're on main)
MainActor.assumeIsolated {
self.tableView.reloadData()
}
// Task on main actor
Task { @MainActor in
self.progress = 0.5
}
```
</main_actor_dispatch>
</main_actor>
<structured_concurrency>
<task_groups>
```swift
// Parallel execution with results
func loadAllCategories() async throws -> [Category: [Item]] {
let categories = try await fetchCategories()
return try await withThrowingTaskGroup(of: (Category, [Item]).self) { group in
for category in categories {
group.addTask {
let items = try await self.fetchItems(for: category)
return (category, items)
}
}
var results: [Category: [Item]] = [:]
for try await (category, items) in group {
results[category] = items
}
return results
}
}
```
</task_groups>
<limited_concurrency>
```swift
// Process with limited parallelism
func processImages(_ urls: [URL], maxConcurrent: Int = 4) async throws -> [ProcessedImage] {
var results: [ProcessedImage] = []
try await withThrowingTaskGroup(of: ProcessedImage.self) { group in
var iterator = urls.makeIterator()
// Start initial batch
for _ in 0..<min(maxConcurrent, urls.count) {
if let url = iterator.next() {
group.addTask {
try await self.processImage(at: url)
}
}
}
// As each completes, add another
for try await result in group {
results.append(result)
if let url = iterator.next() {
group.addTask {
try await self.processImage(at: url)
}
}
}
}
return results
}
```
</limited_concurrency>
<async_let>
```swift
// Concurrent bindings
func loadDashboard() async throws -> Dashboard {
async let user = fetchUser()
async let projects = fetchProjects()
async let notifications = fetchNotifications()
// All three run concurrently, await results together
return try await Dashboard(
user: user,
projects: projects,
notifications: notifications
)
}
```
</async_let>
</structured_concurrency>
<async_sequences>
<for_await>
```swift
// Iterate async sequence
func monitorChanges() async {
for await change in fileMonitor.changes {
await processChange(change)
}
}
// With notifications
func observeNotifications() async {
let notifications = NotificationCenter.default.notifications(named: .dataChanged)
for await notification in notifications {
guard !Task.isCancelled else { break }
await handleNotification(notification)
}
}
```
</for_await>
<custom_async_sequence>
```swift
struct CountdownSequence: AsyncSequence {
typealias Element = Int
let start: Int
struct AsyncIterator: AsyncIteratorProtocol {
var current: Int
mutating func next() async -> Int? {
guard current > 0 else { return nil }
try? await Task.sleep(for: .seconds(1))
defer { current -= 1 }
return current
}
}
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(current: start)
}
}
// Usage
for await count in CountdownSequence(start: 10) {
print(count)
}
```
</custom_async_sequence>
<async_stream>
```swift
// Bridge callback-based API
func fileChanges(at path: String) -> AsyncStream<FileChange> {
AsyncStream { continuation in
let monitor = FileMonitor(path: path) { change in
continuation.yield(change)
}
monitor.start()
continuation.onTermination = { _ in
monitor.stop()
}
}
}
// Throwing version
func networkEvents() -> AsyncThrowingStream<NetworkEvent, Error> {
AsyncThrowingStream { continuation in
let connection = NetworkConnection()
connection.onEvent = { event in
continuation.yield(event)
}
connection.onError = { error in
continuation.finish(throwing: error)
}
connection.onComplete = {
continuation.finish()
}
connection.start()
continuation.onTermination = { _ in
connection.cancel()
}
}
}
```
</async_stream>
</async_sequences>
<cancellation>
<checking_cancellation>
```swift
func processLargeDataset(_ items: [Item]) async throws -> [Result] {
var results: [Result] = []
for item in items {
// Check for cancellation
try Task.checkCancellation()
// Or check without throwing
if Task.isCancelled {
break
}
let result = await process(item)
results.append(result)
}
return results
}
```
</checking_cancellation>
<cancellation_handlers>
```swift
func downloadFile(_ url: URL) async throws -> Data {
let task = URLSession.shared.dataTask(with: url)
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
task.completionHandler = { data, _, error in
if let error = error {
continuation.resume(throwing: error)
} else if let data = data {
continuation.resume(returning: data)
}
}
task.resume()
}
} onCancel: {
task.cancel()
}
}
```
</cancellation_handlers>
<task_cancellation>
```swift
class ViewModel {
private var loadTask: Task<Void, Never>?
func load() {
// Cancel previous load
loadTask?.cancel()
loadTask = Task {
await performLoad()
}
}
func cancel() {
loadTask?.cancel()
loadTask = nil
}
deinit {
loadTask?.cancel()
}
}
```
</task_cancellation>
</cancellation>
<sendable>
<sendable_types>
```swift
// Value types are Sendable by default if all properties are Sendable
struct Item: Sendable {
let id: UUID
let name: String
let count: Int
}
// Classes must be explicitly Sendable
final class ImmutableConfig: Sendable {
let apiKey: String
let baseURL: URL
init(apiKey: String, baseURL: URL) {
self.apiKey = apiKey
self.baseURL = baseURL
}
}
// Actors are automatically Sendable
actor Counter: Sendable {
var count = 0
}
// Mark as @unchecked Sendable when you manage thread safety yourself
final class ThreadSafeCache: @unchecked Sendable {
private let lock = NSLock()
private var storage: [String: Data] = [:]
func get(_ key: String) -> Data? {
lock.lock()
defer { lock.unlock() }
return storage[key]
}
}
```
</sendable_types>
<sending_closures>
```swift
// Closures that cross actor boundaries must be @Sendable
func processInBackground(work: @Sendable @escaping () async -> Void) {
Task.detached {
await work()
}
}
// Capture only Sendable values
let items = items // Must be Sendable
Task {
await process(items)
}
```
</sending_closures>
</sendable>
<best_practices>
<do>
- Use `.task` modifier for view-related async work
- Use actors for shared mutable state
- Mark UI-updating code with `@MainActor`
- Check `Task.isCancelled` in long operations
- Use structured concurrency (task groups, async let) over unstructured
- Cancel tasks when no longer needed
</do>
<avoid>
- Creating detached tasks unnecessarily (loses structured concurrency benefits)
- Blocking actors with synchronous work
- Ignoring cancellation in long-running operations
- Passing non-Sendable types across actor boundaries
- Using `DispatchQueue` when async/await works
</avoid>
</best_practices>

View File

@@ -0,0 +1,700 @@
# Data Persistence
Patterns for persisting data in macOS apps using SwiftData, Core Data, and file-based storage.
<choosing_persistence>
**SwiftData** (macOS 14+): Best for new apps
- Declarative schema in code
- Tight SwiftUI integration
- Automatic iCloud sync
- Less boilerplate
**Core Data**: Best for complex needs or backward compatibility
- Visual schema editor
- Fine-grained migration control
- More mature ecosystem
- Works on older macOS
**File-based (Codable)**: Best for documents or simple data
- JSON/plist storage
- No database overhead
- Portable data
- Good for document-based apps
**UserDefaults**: Preferences and small state only
- Not for app data
**Keychain**: Sensitive data only
- Passwords, tokens, keys
</choosing_persistence>
<swiftdata>
<model_definition>
```swift
import SwiftData
@Model
class Project {
var name: String
var createdAt: Date
var isArchived: Bool
@Relationship(deleteRule: .cascade, inverse: \Task.project)
var tasks: [Task]
@Attribute(.externalStorage)
var thumbnail: Data?
// Computed properties are fine
var activeTasks: [Task] {
tasks.filter { !$0.isComplete }
}
init(name: String) {
self.name = name
self.createdAt = Date()
self.isArchived = false
self.tasks = []
}
}
@Model
class Task {
var title: String
var isComplete: Bool
var dueDate: Date?
var priority: Priority
var project: Project?
enum Priority: Int, Codable {
case low = 0
case medium = 1
case high = 2
}
init(title: String, priority: Priority = .medium) {
self.title = title
self.isComplete = false
self.priority = priority
}
}
```
</model_definition>
<container_setup>
```swift
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: Project.self)
}
}
// Custom configuration
@main
struct MyApp: App {
let container: ModelContainer
init() {
let schema = Schema([Project.self, Task.self])
let config = ModelConfiguration(
"MyApp",
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .automatic
)
do {
container = try ModelContainer(for: schema, configurations: config)
} catch {
fatalError("Failed to create container: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
```
</container_setup>
<querying>
```swift
struct ProjectListView: View {
// Basic query
@Query private var projects: [Project]
// Filtered and sorted
@Query(
filter: #Predicate<Project> { !$0.isArchived },
sort: \Project.createdAt,
order: .reverse
) private var activeProjects: [Project]
// Dynamic filter
@Query private var allProjects: [Project]
var filteredProjects: [Project] {
if searchText.isEmpty {
return allProjects
}
return allProjects.filter {
$0.name.localizedCaseInsensitiveContains(searchText)
}
}
@State private var searchText = ""
var body: some View {
List(filteredProjects) { project in
Text(project.name)
}
.searchable(text: $searchText)
}
}
```
</querying>
<relationship_patterns>
<critical_rule>
**When adding items to relationships, set the inverse relationship property, then insert into context.** Don't manually append to arrays.
</critical_rule>
<adding_to_relationships>
```swift
// CORRECT: Set inverse, then insert
func addCard(to column: Column, title: String) {
let card = Card(title: title, position: 1.0)
card.column = column // Set the inverse relationship
modelContext.insert(card) // Insert into context
// SwiftData automatically updates column.cards
}
// WRONG: Don't manually append to arrays
func addCardWrong(to column: Column, title: String) {
let card = Card(title: title, position: 1.0)
column.cards.append(card) // This can cause issues
modelContext.insert(card)
}
```
</adding_to_relationships>
<when_to_insert>
**Always call `modelContext.insert()` for new objects.** SwiftData needs this to track the object.
```swift
// Creating a new item - MUST insert
let card = Card(title: "New")
card.column = column
modelContext.insert(card) // Required!
// Modifying existing item - no insert needed
existingCard.title = "Updated" // SwiftData tracks this automatically
// Moving item between parents
card.column = newColumn // Just update the relationship
// No insert needed for existing objects
```
</when_to_insert>
<relationship_definition>
```swift
@Model
class Column {
var name: String
var position: Double
// Define relationship with inverse
@Relationship(deleteRule: .cascade, inverse: \Card.column)
var cards: [Card] = []
init(name: String, position: Double) {
self.name = name
self.position = position
}
}
@Model
class Card {
var title: String
var position: Double
// The inverse side - this is what you SET when adding
var column: Column?
init(title: String, position: Double) {
self.title = title
self.position = position
}
}
```
</relationship_definition>
<common_pitfalls>
**Pitfall 1: Not setting inverse relationship**
```swift
// WRONG - card won't appear in column.cards
let card = Card(title: "New", position: 1.0)
modelContext.insert(card) // Missing: card.column = column
```
**Pitfall 2: Manually managing both sides**
```swift
// WRONG - redundant and can cause issues
card.column = column
column.cards.append(card) // Don't do this
modelContext.insert(card)
```
**Pitfall 3: Forgetting to insert**
```swift
// WRONG - object won't persist
let card = Card(title: "New", position: 1.0)
card.column = column
// Missing: modelContext.insert(card)
```
</common_pitfalls>
<reordering_items>
```swift
// For drag-and-drop reordering within same parent
func moveCard(_ card: Card, to newPosition: Double) {
card.position = newPosition
// SwiftData tracks the change automatically
}
// Moving between parents (e.g., column to column)
func moveCard(_ card: Card, to newColumn: Column, position: Double) {
card.column = newColumn
card.position = position
// No insert needed - card already exists
}
```
</reordering_items>
</relationship_patterns>
<crud_operations>
```swift
struct ProjectListView: View {
@Environment(\.modelContext) private var context
@Query private var projects: [Project]
var body: some View {
List {
ForEach(projects) { project in
Text(project.name)
}
.onDelete(perform: deleteProjects)
}
.toolbar {
Button("Add") {
addProject()
}
}
}
private func addProject() {
let project = Project(name: "New Project")
context.insert(project)
// Auto-saves
}
private func deleteProjects(at offsets: IndexSet) {
for index in offsets {
context.delete(projects[index])
}
}
}
// In a service
actor DataService {
private let context: ModelContext
init(container: ModelContainer) {
self.context = ModelContext(container)
}
func fetchProjects() throws -> [Project] {
let descriptor = FetchDescriptor<Project>(
predicate: #Predicate { !$0.isArchived },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
return try context.fetch(descriptor)
}
func save(_ project: Project) throws {
context.insert(project)
try context.save()
}
}
```
</crud_operations>
<icloud_sync>
```swift
// Enable in ModelConfiguration
let config = ModelConfiguration(
cloudKitDatabase: .automatic // or .private("containerID")
)
// Handle sync status
struct SyncStatusView: View {
@Environment(\.modelContext) private var context
var body: some View {
// SwiftData handles sync automatically
// Monitor with NotificationCenter for CKAccountChanged
Text("Syncing...")
}
}
```
</icloud_sync>
</swiftdata>
<core_data>
<stack_setup>
```swift
class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "MyApp")
if inMemory {
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Failed to load store: \(error)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
var viewContext: NSManagedObjectContext {
container.viewContext
}
func newBackgroundContext() -> NSManagedObjectContext {
container.newBackgroundContext()
}
}
```
</stack_setup>
<fetch_request>
```swift
struct ProjectListView: View {
@Environment(\.managedObjectContext) private var context
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \CDProject.createdAt, ascending: false)],
predicate: NSPredicate(format: "isArchived == NO")
)
private var projects: FetchedResults<CDProject>
var body: some View {
List(projects) { project in
Text(project.name ?? "Untitled")
}
}
}
```
</fetch_request>
<crud_operations_coredata>
```swift
// Create
func createProject(name: String) {
let project = CDProject(context: context)
project.id = UUID()
project.name = name
project.createdAt = Date()
do {
try context.save()
} catch {
context.rollback()
}
}
// Update
func updateProject(_ project: CDProject, name: String) {
project.name = name
try? context.save()
}
// Delete
func deleteProject(_ project: CDProject) {
context.delete(project)
try? context.save()
}
// Background operations
func importProjects(_ data: [ProjectData]) async throws {
let context = PersistenceController.shared.newBackgroundContext()
try await context.perform {
for item in data {
let project = CDProject(context: context)
project.id = UUID()
project.name = item.name
}
try context.save()
}
}
```
</crud_operations_coredata>
</core_data>
<file_based>
<codable_storage>
```swift
struct AppData: Codable {
var items: [Item]
var lastModified: Date
}
class FileStorage {
private let fileURL: URL
init() {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let appFolder = appSupport.appendingPathComponent("MyApp", isDirectory: true)
// Create directory if needed
try? FileManager.default.createDirectory(at: appFolder, withIntermediateDirectories: true)
fileURL = appFolder.appendingPathComponent("data.json")
}
func load() throws -> AppData {
let data = try Data(contentsOf: fileURL)
return try JSONDecoder().decode(AppData.self, from: data)
}
func save(_ appData: AppData) throws {
let data = try JSONEncoder().encode(appData)
try data.write(to: fileURL, options: .atomic)
}
}
```
</codable_storage>
<document_storage>
For document-based apps, see [document-apps.md](document-apps.md).
```swift
struct ProjectDocument: FileDocument {
static var readableContentTypes: [UTType] { [.json] }
var project: Project
init(project: Project = Project()) {
self.project = project
}
init(configuration: ReadConfiguration) throws {
guard let data = configuration.file.regularFileContents else {
throw CocoaError(.fileReadCorruptFile)
}
project = try JSONDecoder().decode(Project.self, from: data)
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let data = try JSONEncoder().encode(project)
return FileWrapper(regularFileWithContents: data)
}
}
```
</document_storage>
</file_based>
<keychain>
```swift
import Security
class KeychainService {
static let shared = KeychainService()
func save(key: String, data: Data) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
func load(key: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainError.loadFailed(status)
}
return data
}
func delete(key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.deleteFailed(status)
}
}
}
enum KeychainError: Error {
case saveFailed(OSStatus)
case loadFailed(OSStatus)
case deleteFailed(OSStatus)
}
// Usage
let token = "secret-token".data(using: .utf8)!
try KeychainService.shared.save(key: "api-token", data: token)
```
</keychain>
<user_defaults>
```swift
// Using @AppStorage
struct SettingsView: View {
@AppStorage("theme") private var theme = "system"
@AppStorage("fontSize") private var fontSize = 14.0
var body: some View {
Form {
Picker("Theme", selection: $theme) {
Text("System").tag("system")
Text("Light").tag("light")
Text("Dark").tag("dark")
}
Slider(value: $fontSize, in: 10...24) {
Text("Font Size: \(Int(fontSize))")
}
}
}
}
// Type-safe wrapper
extension UserDefaults {
enum Keys {
static let theme = "theme"
static let recentFiles = "recentFiles"
}
var theme: String {
get { string(forKey: Keys.theme) ?? "system" }
set { set(newValue, forKey: Keys.theme) }
}
var recentFiles: [URL] {
get {
guard let data = data(forKey: Keys.recentFiles),
let urls = try? JSONDecoder().decode([URL].self, from: data)
else { return [] }
return urls
}
set {
let data = try? JSONEncoder().encode(newValue)
set(data, forKey: Keys.recentFiles)
}
}
}
```
</user_defaults>
<migration>
<swiftdata_migration>
```swift
// SwiftData handles lightweight migrations automatically
// For complex migrations, use VersionedSchema
enum MyAppSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Project.self]
}
@Model
class Project {
var name: String
init(name: String) { self.name = name }
}
}
enum MyAppSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Project.self]
}
@Model
class Project {
var name: String
var createdAt: Date // New property
init(name: String) {
self.name = name
self.createdAt = Date()
}
}
}
enum MyAppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[MyAppSchemaV1.self, MyAppSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: MyAppSchemaV1.self,
toVersion: MyAppSchemaV2.self
)
}
```
</swiftdata_migration>
</migration>
<best_practices>
- Use SwiftData for new apps targeting macOS 14+
- Use background contexts for heavy operations
- Handle migration explicitly for production apps
- Don't store large blobs in database (use @Attribute(.externalStorage))
- Use transactions for multiple related changes
- Test persistence with in-memory stores
</best_practices>

View File

@@ -0,0 +1,420 @@
# Design System
Colors, typography, spacing, and visual patterns for professional macOS apps.
<semantic_colors>
```swift
import SwiftUI
extension Color {
// Use semantic colors that adapt to light/dark mode
static let background = Color(NSColor.windowBackgroundColor)
static let secondaryBackground = Color(NSColor.controlBackgroundColor)
static let tertiaryBackground = Color(NSColor.underPageBackgroundColor)
// Text
static let primaryText = Color(NSColor.labelColor)
static let secondaryText = Color(NSColor.secondaryLabelColor)
static let tertiaryText = Color(NSColor.tertiaryLabelColor)
static let quaternaryText = Color(NSColor.quaternaryLabelColor)
// Controls
static let controlAccent = Color.accentColor
static let controlBackground = Color(NSColor.controlColor)
static let selectedContent = Color(NSColor.selectedContentBackgroundColor)
// Separators
static let separator = Color(NSColor.separatorColor)
static let gridLine = Color(NSColor.gridColor)
}
// Usage
Text("Hello")
.foregroundStyle(.primaryText)
.background(.background)
```
</semantic_colors>
<custom_colors>
```swift
extension Color {
// Define once, use everywhere
static let appPrimary = Color("AppPrimary") // From asset catalog
static let appSecondary = Color("AppSecondary")
// Or programmatic
static let success = Color(red: 0.2, green: 0.8, blue: 0.4)
static let warning = Color(red: 1.0, green: 0.8, blue: 0.0)
static let error = Color(red: 0.9, green: 0.3, blue: 0.3)
}
// Asset catalog with light/dark variants
// Assets.xcassets/AppPrimary.colorset/Contents.json:
/*
{
"colors" : [
{
"color" : { "color-space" : "srgb", "components" : { "red" : "0.2", "green" : "0.5", "blue" : "1.0" } },
"idiom" : "universal"
},
{
"appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ],
"color" : { "color-space" : "srgb", "components" : { "red" : "0.4", "green" : "0.7", "blue" : "1.0" } },
"idiom" : "universal"
}
]
}
*/
```
</custom_colors>
<typography>
```swift
extension Font {
// System fonts
static let displayLarge = Font.system(size: 34, weight: .bold, design: .default)
static let displayMedium = Font.system(size: 28, weight: .semibold)
static let displaySmall = Font.system(size: 22, weight: .semibold)
static let headlineLarge = Font.system(size: 17, weight: .semibold)
static let headlineMedium = Font.system(size: 15, weight: .semibold)
static let headlineSmall = Font.system(size: 13, weight: .semibold)
static let bodyLarge = Font.system(size: 15, weight: .regular)
static let bodyMedium = Font.system(size: 13, weight: .regular)
static let bodySmall = Font.system(size: 11, weight: .regular)
// Monospace for code
static let codeLarge = Font.system(size: 14, weight: .regular, design: .monospaced)
static let codeMedium = Font.system(size: 12, weight: .regular, design: .monospaced)
static let codeSmall = Font.system(size: 10, weight: .regular, design: .monospaced)
}
// Usage
Text("Title")
.font(.displayMedium)
Text("Body text")
.font(.bodyMedium)
Text("let x = 42")
.font(.codeMedium)
```
</typography>
<spacing>
```swift
enum Spacing {
static let xxxs: CGFloat = 2
static let xxs: CGFloat = 4
static let xs: CGFloat = 8
static let sm: CGFloat = 12
static let md: CGFloat = 16
static let lg: CGFloat = 24
static let xl: CGFloat = 32
static let xxl: CGFloat = 48
static let xxxl: CGFloat = 64
}
// Usage
VStack(spacing: Spacing.md) {
Text("Title")
Text("Subtitle")
}
.padding(Spacing.lg)
HStack(spacing: Spacing.sm) {
Image(systemName: "star")
Text("Favorite")
}
```
</spacing>
<corner_radius>
```swift
enum CornerRadius {
static let small: CGFloat = 4
static let medium: CGFloat = 8
static let large: CGFloat = 12
static let xlarge: CGFloat = 16
}
// Usage
RoundedRectangle(cornerRadius: CornerRadius.medium)
.fill(.secondaryBackground)
Text("Tag")
.padding(.horizontal, Spacing.sm)
.padding(.vertical, Spacing.xxs)
.background(.controlBackground, in: RoundedRectangle(cornerRadius: CornerRadius.small))
```
</corner_radius>
<shadows>
```swift
extension View {
func cardShadow() -> some View {
shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
}
func elevatedShadow() -> some View {
shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4)
}
func subtleShadow() -> some View {
shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
}
}
// Usage
CardView()
.cardShadow()
```
</shadows>
<component_styles>
<buttons>
```swift
struct PrimaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headlineMedium)
.foregroundStyle(.white)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.background(
RoundedRectangle(cornerRadius: CornerRadius.medium)
.fill(Color.accentColor)
)
.opacity(configuration.isPressed ? 0.8 : 1.0)
}
}
struct SecondaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headlineMedium)
.foregroundStyle(.accentColor)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.background(
RoundedRectangle(cornerRadius: CornerRadius.medium)
.stroke(Color.accentColor, lineWidth: 1)
)
.opacity(configuration.isPressed ? 0.8 : 1.0)
}
}
// Usage
Button("Save") { save() }
.buttonStyle(PrimaryButtonStyle())
Button("Cancel") { cancel() }
.buttonStyle(SecondaryButtonStyle())
```
</buttons>
<cards>
```swift
struct CardStyle: ViewModifier {
func body(content: Content) -> some View {
content
.padding(Spacing.md)
.background(
RoundedRectangle(cornerRadius: CornerRadius.large)
.fill(.secondaryBackground)
)
.cardShadow()
}
}
extension View {
func cardStyle() -> some View {
modifier(CardStyle())
}
}
// Usage
VStack {
Text("Card Title")
Text("Card content")
}
.cardStyle()
```
</cards>
<list_rows>
```swift
struct ItemRow: View {
let item: Item
let isSelected: Bool
var body: some View {
HStack(spacing: Spacing.sm) {
Image(systemName: item.icon)
.foregroundStyle(isSelected ? .white : .secondaryText)
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(item.name)
.font(.headlineSmall)
.foregroundStyle(isSelected ? .white : .primaryText)
Text(item.subtitle)
.font(.bodySmall)
.foregroundStyle(isSelected ? .white.opacity(0.8) : .secondaryText)
}
Spacer()
Text(item.date.formatted(date: .abbreviated, time: .omitted))
.font(.bodySmall)
.foregroundStyle(isSelected ? .white.opacity(0.8) : .tertiaryText)
}
.padding(.horizontal, Spacing.sm)
.padding(.vertical, Spacing.xs)
.background(
RoundedRectangle(cornerRadius: CornerRadius.small)
.fill(isSelected ? Color.accentColor : .clear)
)
}
}
```
</list_rows>
<text_fields>
```swift
struct StyledTextField: View {
let placeholder: String
@Binding var text: String
var body: some View {
TextField(placeholder, text: $text)
.textFieldStyle(.plain)
.font(.bodyMedium)
.padding(Spacing.sm)
.background(
RoundedRectangle(cornerRadius: CornerRadius.medium)
.fill(.controlBackground)
)
.overlay(
RoundedRectangle(cornerRadius: CornerRadius.medium)
.stroke(.separator, lineWidth: 1)
)
}
}
```
</text_fields>
</component_styles>
<icons>
```swift
// Use SF Symbols
Image(systemName: "doc.text")
Image(systemName: "folder.fill")
Image(systemName: "gear")
// Consistent sizing
Image(systemName: "star")
.font(.system(size: 16, weight: .medium))
// With colors
Image(systemName: "checkmark.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.green)
// Multicolor
Image(systemName: "externaldrive.badge.checkmark")
.symbolRenderingMode(.multicolor)
```
</icons>
<animations>
```swift
// Standard durations
enum AnimationDuration {
static let fast: Double = 0.15
static let normal: Double = 0.25
static let slow: Double = 0.4
}
// Common animations
extension Animation {
static let defaultSpring = Animation.spring(response: 0.3, dampingFraction: 0.7)
static let quickSpring = Animation.spring(response: 0.2, dampingFraction: 0.8)
static let gentleSpring = Animation.spring(response: 0.5, dampingFraction: 0.7)
static let easeOut = Animation.easeOut(duration: AnimationDuration.normal)
static let easeIn = Animation.easeIn(duration: AnimationDuration.normal)
}
// Usage
withAnimation(.defaultSpring) {
isExpanded.toggle()
}
// Respect reduce motion
struct AnimationSettings {
static var prefersReducedMotion: Bool {
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
}
static func animation(_ animation: Animation) -> Animation? {
prefersReducedMotion ? nil : animation
}
}
```
</animations>
<dark_mode>
```swift
// Automatic adaptation
struct ContentView: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack {
// Semantic colors adapt automatically
Text("Title")
.foregroundStyle(.primaryText)
.background(.background)
// Manual override when needed
Image("logo")
.colorInvert() // Only if needed
}
}
}
// Force scheme for preview
#Preview("Dark Mode") {
ContentView()
.preferredColorScheme(.dark)
}
```
</dark_mode>
<accessibility>
```swift
// Dynamic type support
Text("Title")
.font(.headline) // Scales with user settings
// Custom fonts with scaling
@ScaledMetric(relativeTo: .body) var customSize: CGFloat = 14
Text("Custom")
.font(.system(size: customSize))
// Contrast
Button("Action") { }
.foregroundStyle(.white)
.background(.accentColor) // Ensure contrast ratio >= 4.5:1
// Reduce transparency
@Environment(\.accessibilityReduceTransparency) var reduceTransparency
VStack {
// content
}
.background(reduceTransparency ? .background : .background.opacity(0.8))
```
</accessibility>

View File

@@ -0,0 +1,445 @@
# Document-Based Apps
Apps where users create, open, and save discrete files (like TextEdit, Pages, Xcode).
<when_to_use>
Use document-based architecture when:
- Users explicitly create/open/save files
- Multiple documents open simultaneously
- Files shared with other apps
- Standard document behaviors expected (Recent Documents, autosave, versions)
Do NOT use when:
- Single internal database (use shoebox pattern)
- No user-facing files
</when_to_use>
<swiftui_document_group>
<basic_setup>
```swift
import SwiftUI
import UniformTypeIdentifiers
@main
struct MyDocumentApp: App {
var body: some Scene {
DocumentGroup(newDocument: MyDocument()) { file in
DocumentView(document: file.$document)
}
.commands {
DocumentCommands()
}
}
}
struct MyDocument: FileDocument {
// Supported types
static var readableContentTypes: [UTType] { [.myDocument] }
static var writableContentTypes: [UTType] { [.myDocument] }
// Document data
var content: DocumentContent
// New document
init() {
content = DocumentContent()
}
// Load from file
init(configuration: ReadConfiguration) throws {
guard let data = configuration.file.regularFileContents else {
throw CocoaError(.fileReadCorruptFile)
}
content = try JSONDecoder().decode(DocumentContent.self, from: data)
}
// Save to file
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let data = try JSONEncoder().encode(content)
return FileWrapper(regularFileWithContents: data)
}
}
// Custom UTType
extension UTType {
static var myDocument: UTType {
UTType(exportedAs: "com.yourcompany.myapp.document")
}
}
```
</basic_setup>
<document_view>
```swift
struct DocumentView: View {
@Binding var document: MyDocument
@FocusedBinding(\.document) private var focusedDocument
var body: some View {
TextEditor(text: $document.content.text)
.focusedSceneValue(\.document, $document)
}
}
// Focused values for commands
struct DocumentFocusedValueKey: FocusedValueKey {
typealias Value = Binding<MyDocument>
}
extension FocusedValues {
var document: Binding<MyDocument>? {
get { self[DocumentFocusedValueKey.self] }
set { self[DocumentFocusedValueKey.self] = newValue }
}
}
```
</document_view>
<document_commands>
```swift
struct DocumentCommands: Commands {
@FocusedBinding(\.document) private var document
var body: some Commands {
CommandMenu("Format") {
Button("Bold") {
document?.wrappedValue.content.toggleBold()
}
.keyboardShortcut("b", modifiers: .command)
.disabled(document == nil)
Button("Italic") {
document?.wrappedValue.content.toggleItalic()
}
.keyboardShortcut("i", modifiers: .command)
.disabled(document == nil)
}
}
}
```
</document_commands>
<reference_file_document>
For documents referencing external files:
```swift
struct ProjectDocument: ReferenceFileDocument {
static var readableContentTypes: [UTType] { [.myProject] }
var project: Project
init() {
project = Project()
}
init(configuration: ReadConfiguration) throws {
guard let data = configuration.file.regularFileContents else {
throw CocoaError(.fileReadCorruptFile)
}
project = try JSONDecoder().decode(Project.self, from: data)
}
func snapshot(contentType: UTType) throws -> Project {
project
}
func fileWrapper(snapshot: Project, configuration: WriteConfiguration) throws -> FileWrapper {
let data = try JSONEncoder().encode(snapshot)
return FileWrapper(regularFileWithContents: data)
}
}
```
</reference_file_document>
</swiftui_document_group>
<info_plist_document_types>
Configure document types in Info.plist:
```xml
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>My Document</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>com.yourcompany.myapp.document</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.yourcompany.myapp.document</string>
<key>UTTypeDescription</key>
<string>My Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.content</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>mydoc</string>
</array>
</dict>
</dict>
</array>
```
</info_plist_document_types>
<nsdocument_appkit>
For more control, use NSDocument:
<nsdocument_subclass>
```swift
import AppKit
class Document: NSDocument {
var content = DocumentContent()
override class var autosavesInPlace: Bool { true }
override func makeWindowControllers() {
let contentView = DocumentView(document: self)
let hostingController = NSHostingController(rootView: contentView)
let window = NSWindow(contentViewController: hostingController)
window.setContentSize(NSSize(width: 800, height: 600))
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
let windowController = NSWindowController(window: window)
addWindowController(windowController)
}
override func data(ofType typeName: String) throws -> Data {
try JSONEncoder().encode(content)
}
override func read(from data: Data, ofType typeName: String) throws {
content = try JSONDecoder().decode(DocumentContent.self, from: data)
}
}
```
</nsdocument_subclass>
<undo_support>
```swift
class Document: NSDocument {
var content = DocumentContent() {
didSet {
updateChangeCount(.changeDone)
}
}
func updateContent(_ newContent: DocumentContent) {
let oldContent = content
undoManager?.registerUndo(withTarget: self) { document in
document.updateContent(oldContent)
}
undoManager?.setActionName("Update Content")
content = newContent
}
}
```
</undo_support>
<nsdocument_lifecycle>
```swift
class Document: NSDocument {
// Called when document is first opened
override func windowControllerDidLoadNib(_ windowController: NSWindowController) {
super.windowControllerDidLoadNib(windowController)
// Setup UI
}
// Called before saving
override func prepareSavePanel(_ savePanel: NSSavePanel) -> Bool {
savePanel.allowedContentTypes = [.myDocument]
savePanel.allowsOtherFileTypes = false
return true
}
// Called after saving
override func save(to url: URL, ofType typeName: String, for saveOperation: NSDocument.SaveOperationType, completionHandler: @escaping (Error?) -> Void) {
super.save(to: url, ofType: typeName, for: saveOperation) { error in
if error == nil {
// Post-save actions
}
completionHandler(error)
}
}
// Handle close with unsaved changes
override func canClose(withDelegate delegate: Any, shouldClose shouldCloseSelector: Selector?, contextInfo: UnsafeMutableRawPointer?) {
// Custom save confirmation
super.canClose(withDelegate: delegate, shouldClose: shouldCloseSelector, contextInfo: contextInfo)
}
}
```
</nsdocument_lifecycle>
</nsdocument_appkit>
<package_documents>
For documents containing multiple files (like .pages):
```swift
struct PackageDocument: FileDocument {
static var readableContentTypes: [UTType] { [.myPackage] }
var mainContent: MainContent
var assets: [String: Data]
init(configuration: ReadConfiguration) throws {
guard let directory = configuration.file.fileWrappers else {
throw CocoaError(.fileReadCorruptFile)
}
// Read main content
guard let mainData = directory["content.json"]?.regularFileContents else {
throw CocoaError(.fileReadCorruptFile)
}
mainContent = try JSONDecoder().decode(MainContent.self, from: mainData)
// Read assets
assets = [:]
if let assetsDir = directory["Assets"]?.fileWrappers {
for (name, wrapper) in assetsDir {
if let data = wrapper.regularFileContents {
assets[name] = data
}
}
}
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let directory = FileWrapper(directoryWithFileWrappers: [:])
// Write main content
let mainData = try JSONEncoder().encode(mainContent)
directory.addRegularFile(withContents: mainData, preferredFilename: "content.json")
// Write assets
let assetsDir = FileWrapper(directoryWithFileWrappers: [:])
for (name, data) in assets {
assetsDir.addRegularFile(withContents: data, preferredFilename: name)
}
directory.addFileWrapper(assetsDir)
assetsDir.preferredFilename = "Assets"
return directory
}
}
// UTType for package
extension UTType {
static var myPackage: UTType {
UTType(exportedAs: "com.yourcompany.myapp.package", conformingTo: .package)
}
}
```
</package_documents>
<recent_documents>
```swift
// NSDocumentController manages Recent Documents automatically
// Custom recent documents menu
struct AppCommands: Commands {
var body: some Commands {
CommandGroup(after: .newItem) {
Menu("Open Recent") {
ForEach(recentDocuments, id: \.self) { url in
Button(url.lastPathComponent) {
NSDocumentController.shared.openDocument(
withContentsOf: url,
display: true
) { _, _, _ in }
}
}
if !recentDocuments.isEmpty {
Divider()
Button("Clear Menu") {
NSDocumentController.shared.clearRecentDocuments(nil)
}
}
}
}
}
var recentDocuments: [URL] {
NSDocumentController.shared.recentDocumentURLs
}
}
```
</recent_documents>
<export_import>
```swift
struct DocumentView: View {
@Binding var document: MyDocument
@State private var showingExporter = false
@State private var showingImporter = false
var body: some View {
MainContent(document: $document)
.toolbar {
Button("Export") { showingExporter = true }
Button("Import") { showingImporter = true }
}
.fileExporter(
isPresented: $showingExporter,
document: document,
contentType: .pdf,
defaultFilename: "Export"
) { result in
switch result {
case .success(let url):
print("Exported to \(url)")
case .failure(let error):
print("Export failed: \(error)")
}
}
.fileImporter(
isPresented: $showingImporter,
allowedContentTypes: [.plainText, .json],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
importFile(urls.first!)
case .failure(let error):
print("Import failed: \(error)")
}
}
}
}
// Export to different format
extension MyDocument {
func exportAsPDF() -> Data {
// Generate PDF from content
let renderer = ImageRenderer(content: ContentPreview(content: content))
return renderer.render { size, render in
var box = CGRect(origin: .zero, size: size)
guard let context = CGContext(consumer: CGDataConsumer(data: NSMutableData() as CFMutableData)!, mediaBox: &box, nil) else { return }
context.beginPDFPage(nil)
render(context)
context.endPDFPage()
context.closePDF()
} ?? Data()
}
}
```
</export_import>

View File

@@ -0,0 +1,555 @@
# macOS Polish
Details that make apps feel native and professional.
<keyboard_shortcuts>
<standard_shortcuts>
```swift
import SwiftUI
struct AppCommands: Commands {
var body: some Commands {
// File operations
CommandGroup(replacing: .saveItem) {
Button("Save") { save() }
.keyboardShortcut("s", modifiers: .command)
Button("Save As...") { saveAs() }
.keyboardShortcut("s", modifiers: [.command, .shift])
}
// Edit operations (usually automatic)
// Z Undo, X Cut, C Copy, V Paste, A Select All
// View menu
CommandMenu("View") {
Button("Zoom In") { zoomIn() }
.keyboardShortcut("+", modifiers: .command)
Button("Zoom Out") { zoomOut() }
.keyboardShortcut("-", modifiers: .command)
Button("Actual Size") { resetZoom() }
.keyboardShortcut("0", modifiers: .command)
Divider()
Button("Toggle Sidebar") { toggleSidebar() }
.keyboardShortcut("s", modifiers: [.command, .control])
Button("Toggle Inspector") { toggleInspector() }
.keyboardShortcut("i", modifiers: [.command, .option])
}
// Custom menu
CommandMenu("Actions") {
Button("Run") { run() }
.keyboardShortcut("r", modifiers: .command)
Button("Build") { build() }
.keyboardShortcut("b", modifiers: .command)
}
}
}
```
</standard_shortcuts>
<view_shortcuts>
```swift
struct ContentView: View {
var body: some View {
MainContent()
.onKeyPress(.space) {
togglePlay()
return .handled
}
.onKeyPress(.delete) {
deleteSelected()
return .handled
}
.onKeyPress(.escape) {
clearSelection()
return .handled
}
.onKeyPress("f", modifiers: .command) {
focusSearch()
return .handled
}
}
}
```
</view_shortcuts>
</keyboard_shortcuts>
<menu_bar>
```swift
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
// Replace standard items
CommandGroup(replacing: .newItem) {
Button("New Project") { newProject() }
.keyboardShortcut("n", modifiers: .command)
Button("New from Template...") { newFromTemplate() }
.keyboardShortcut("n", modifiers: [.command, .shift])
}
// Add after existing group
CommandGroup(after: .importExport) {
Button("Import...") { importFile() }
.keyboardShortcut("i", modifiers: [.command, .shift])
Button("Export...") { exportFile() }
.keyboardShortcut("e", modifiers: [.command, .shift])
}
// Add entire menu
CommandMenu("Project") {
Button("Build") { build() }
.keyboardShortcut("b", modifiers: .command)
Button("Run") { run() }
.keyboardShortcut("r", modifiers: .command)
Divider()
Button("Clean") { clean() }
.keyboardShortcut("k", modifiers: [.command, .shift])
}
// Add to Help menu
CommandGroup(after: .help) {
Button("Keyboard Shortcuts") { showShortcuts() }
.keyboardShortcut("/", modifiers: .command)
}
}
}
}
```
</menu_bar>
<context_menus>
```swift
struct ItemRow: View {
let item: Item
var body: some View {
Text(item.name)
.contextMenu {
Button("Open") { open(item) }
Button("Open in New Window") { openInNewWindow(item) }
Divider()
Button("Duplicate") { duplicate(item) }
.keyboardShortcut("d", modifiers: .command)
Button("Rename") { rename(item) }
Divider()
Button("Delete", role: .destructive) { delete(item) }
}
}
}
```
</context_menus>
<window_management>
<multiple_windows>
```swift
@main
struct MyApp: App {
var body: some Scene {
// Main document window
DocumentGroup(newDocument: MyDocument()) { file in
DocumentView(document: file.$document)
}
// Auxiliary windows
Window("Inspector", id: "inspector") {
InspectorView()
}
.windowResizability(.contentSize)
.defaultPosition(.trailing)
.keyboardShortcut("i", modifiers: [.command, .option])
// Floating utility
Window("Quick Entry", id: "quick-entry") {
QuickEntryView()
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
Settings {
SettingsView()
}
}
}
// Open window from view
struct ContentView: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
Button("Show Inspector") {
openWindow(id: "inspector")
}
}
}
```
</multiple_windows>
<window_state>
```swift
// Save and restore window state
class WindowStateManager {
static func save(_ window: NSWindow, key: String) {
let frame = window.frame
UserDefaults.standard.set(NSStringFromRect(frame), forKey: "window.\(key).frame")
}
static func restore(_ window: NSWindow, key: String) {
guard let frameString = UserDefaults.standard.string(forKey: "window.\(key).frame"),
let frame = NSRectFromString(frameString) as NSRect? else { return }
window.setFrame(frame, display: true)
}
}
// Window delegate
class WindowDelegate: NSObject, NSWindowDelegate {
func windowWillClose(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
WindowStateManager.save(window, key: "main")
}
}
```
</window_state>
</window_management>
<dock_menu>
```swift
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
let menu = NSMenu()
menu.addItem(NSMenuItem(
title: "New Project",
action: #selector(newProject),
keyEquivalent: ""
))
menu.addItem(NSMenuItem.separator())
// Recent items
let recentProjects = RecentProjectsManager.shared.projects
for project in recentProjects.prefix(5) {
let item = NSMenuItem(
title: project.name,
action: #selector(openRecent(_:)),
keyEquivalent: ""
)
item.representedObject = project.url
menu.addItem(item)
}
return menu
}
@objc private func newProject() {
NSDocumentController.shared.newDocument(nil)
}
@objc private func openRecent(_ sender: NSMenuItem) {
guard let url = sender.representedObject as? URL else { return }
NSDocumentController.shared.openDocument(
withContentsOf: url,
display: true
) { _, _, _ in }
}
}
```
</dock_menu>
<accessibility>
<voiceover>
```swift
struct ItemRow: View {
let item: Item
var body: some View {
HStack {
Image(systemName: item.icon)
VStack(alignment: .leading) {
Text(item.name)
Text(item.date.formatted())
.font(.caption)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(item.name), \(item.date.formatted())")
.accessibilityHint("Double-tap to open")
.accessibilityAddTraits(.isButton)
}
}
```
</voiceover>
<custom_rotors>
```swift
struct NoteListView: View {
let notes: [Note]
@State private var selectedNote: Note?
var body: some View {
List(notes, selection: $selectedNote) { note in
NoteRow(note: note)
}
.accessibilityRotor("Pinned Notes") {
ForEach(notes.filter { $0.isPinned }) { note in
AccessibilityRotorEntry(note.title, id: note.id) {
selectedNote = note
}
}
}
.accessibilityRotor("Recent Notes") {
ForEach(notes.sorted { $0.modifiedAt > $1.modifiedAt }.prefix(10)) { note in
AccessibilityRotorEntry("\(note.title), modified \(note.modifiedAt.formatted())", id: note.id) {
selectedNote = note
}
}
}
}
}
```
</custom_rotors>
<reduced_motion>
```swift
struct AnimationHelper {
static var prefersReducedMotion: Bool {
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
}
static func animation(_ animation: Animation) -> Animation? {
prefersReducedMotion ? nil : animation
}
}
// Usage
withAnimation(AnimationHelper.animation(.spring())) {
isExpanded.toggle()
}
```
</reduced_motion>
</accessibility>
<user_defaults>
```swift
extension UserDefaults {
enum Keys {
static let theme = "theme"
static let fontSize = "fontSize"
static let recentFiles = "recentFiles"
static let windowFrame = "windowFrame"
}
var theme: String {
get { string(forKey: Keys.theme) ?? "system" }
set { set(newValue, forKey: Keys.theme) }
}
var fontSize: Double {
get { double(forKey: Keys.fontSize).nonZero ?? 14.0 }
set { set(newValue, forKey: Keys.fontSize) }
}
var recentFiles: [URL] {
get {
guard let data = data(forKey: Keys.recentFiles),
let urls = try? JSONDecoder().decode([URL].self, from: data)
else { return [] }
return urls
}
set {
let data = try? JSONEncoder().encode(newValue)
set(data, forKey: Keys.recentFiles)
}
}
}
extension Double {
var nonZero: Double? { self == 0 ? nil : self }
}
// Register defaults at launch
func registerDefaults() {
UserDefaults.standard.register(defaults: [
UserDefaults.Keys.theme: "system",
UserDefaults.Keys.fontSize: 14.0
])
}
```
</user_defaults>
<error_presentation>
```swift
struct ErrorPresenter: ViewModifier {
@Binding var error: AppError?
func body(content: Content) -> some View {
content
.alert(
"Error",
isPresented: Binding(
get: { error != nil },
set: { if !$0 { error = nil } }
),
presenting: error
) { _ in
Button("OK", role: .cancel) {}
} message: { error in
Text(error.localizedDescription)
}
}
}
extension View {
func errorAlert(_ error: Binding<AppError?>) -> some View {
modifier(ErrorPresenter(error: error))
}
}
// Usage
ContentView()
.errorAlert($appState.error)
```
</error_presentation>
<onboarding>
```swift
struct OnboardingView: View {
@AppStorage("hasSeenOnboarding") private var hasSeenOnboarding = false
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 24) {
Image(systemName: "star.fill")
.font(.system(size: 64))
.foregroundStyle(.accentColor)
Text("Welcome to MyApp")
.font(.largeTitle)
VStack(alignment: .leading, spacing: 16) {
FeatureRow(icon: "doc.text", title: "Create Documents", description: "Organize your work in documents")
FeatureRow(icon: "folder", title: "Stay Organized", description: "Use folders and tags")
FeatureRow(icon: "cloud", title: "Sync Everywhere", description: "Access on all your devices")
}
Button("Get Started") {
hasSeenOnboarding = true
dismiss()
}
.buttonStyle(.borderedProminent)
}
.padding(40)
.frame(width: 500)
}
}
struct FeatureRow: View {
let icon: String
let title: String
let description: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.title2)
.frame(width: 40)
.foregroundStyle(.accentColor)
VStack(alignment: .leading) {
Text(title).fontWeight(.medium)
Text(description).foregroundStyle(.secondary)
}
}
}
}
```
</onboarding>
<sparkle_updates>
```swift
// Add Sparkle package for auto-updates
// https://github.com/sparkle-project/Sparkle
import Sparkle
class UpdaterManager {
private var updater: SPUUpdater?
func setup() {
let controller = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
updater = controller.updater
}
func checkForUpdates() {
updater?.checkForUpdates()
}
}
// In commands
CommandGroup(after: .appInfo) {
Button("Check for Updates...") {
updaterManager.checkForUpdates()
}
}
```
</sparkle_updates>
<app_lifecycle>
```swift
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
// Register defaults
registerDefaults()
// Setup services
setupServices()
// Check for updates
checkForUpdates()
}
func applicationWillTerminate(_ notification: Notification) {
// Save state
saveApplicationState()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
// Return false for document-based or menu bar apps
return false
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
if !flag {
// Reopen main window
NSDocumentController.shared.newDocument(nil)
}
return true
}
}
```
</app_lifecycle>

View File

@@ -0,0 +1,424 @@
# Menu Bar Apps
Status bar utilities with quick access and minimal UI.
<when_to_use>
Use menu bar pattern when:
- Quick actions or status display
- Background functionality
- Minimal persistent UI
- System-level utilities
Examples: Rectangle, Bartender, system utilities
</when_to_use>
<basic_setup>
```swift
import SwiftUI
@main
struct MenuBarApp: App {
var body: some Scene {
MenuBarExtra("MyApp", systemImage: "star.fill") {
MenuContent()
}
.menuBarExtraStyle(.window) // or .menu
// Optional settings window
Settings {
SettingsView()
}
}
}
struct MenuContent: View {
@AppStorage("isEnabled") private var isEnabled = true
@Environment(\.openSettings) private var openSettings
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Toggle("Enabled", isOn: $isEnabled)
Divider()
Button("Settings...") {
openSettings()
}
.keyboardShortcut(",", modifiers: .command)
Button("Quit") {
NSApplication.shared.terminate(nil)
}
.keyboardShortcut("q", modifiers: .command)
}
.padding()
.frame(width: 200)
}
}
```
</basic_setup>
<menu_styles>
<window_style>
Rich UI with any SwiftUI content:
```swift
MenuBarExtra("MyApp", systemImage: "star.fill") {
WindowStyleContent()
}
.menuBarExtraStyle(.window)
struct WindowStyleContent: View {
var body: some View {
VStack(spacing: 16) {
// Header
HStack {
Image(systemName: "star.fill")
.font(.title)
Text("MyApp")
.font(.headline)
}
Divider()
// Content
List {
ForEach(items) { item in
ItemRow(item: item)
}
}
.frame(height: 200)
// Actions
HStack {
Button("Action 1") { }
Button("Action 2") { }
}
}
.padding()
.frame(width: 300)
}
}
```
</window_style>
<menu_style>
Standard menu appearance:
```swift
MenuBarExtra("MyApp", systemImage: "star.fill") {
Button("Action 1") { performAction1() }
.keyboardShortcut("1")
Button("Action 2") { performAction2() }
.keyboardShortcut("2")
Divider()
Menu("Submenu") {
Button("Sub-action 1") { }
Button("Sub-action 2") { }
}
Divider()
Button("Quit") {
NSApplication.shared.terminate(nil)
}
.keyboardShortcut("q", modifiers: .command)
}
.menuBarExtraStyle(.menu)
```
</menu_style>
</menu_styles>
<dynamic_icon>
```swift
@main
struct MenuBarApp: App {
@State private var status: AppStatus = .idle
var body: some Scene {
MenuBarExtra {
MenuContent(status: $status)
} label: {
switch status {
case .idle:
Image(systemName: "circle")
case .active:
Image(systemName: "circle.fill")
case .error:
Image(systemName: "exclamationmark.circle")
}
}
}
}
enum AppStatus {
case idle, active, error
}
// Or with text
MenuBarExtra {
Content()
} label: {
Label("\(count)", systemImage: "bell.fill")
}
```
</dynamic_icon>
<background_only>
App without dock icon (menu bar only):
```swift
// Info.plist
// <key>LSUIElement</key>
// <true/>
@main
struct MenuBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
MenuBarExtra("MyApp", systemImage: "star.fill") {
MenuContent()
}
Settings {
SettingsView()
}
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
// Clicking dock icon (if visible) shows settings
if !flag {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
return true
}
}
```
</background_only>
<global_shortcuts>
```swift
import Carbon
class ShortcutManager {
static let shared = ShortcutManager()
private var hotKeyRef: EventHotKeyRef?
private var callback: (() -> Void)?
func register(keyCode: UInt32, modifiers: UInt32, action: @escaping () -> Void) {
self.callback = action
var hotKeyID = EventHotKeyID()
hotKeyID.signature = OSType("MYAP".fourCharCodeValue)
hotKeyID.id = 1
var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
InstallEventHandler(GetApplicationEventTarget(), { _, event, userData -> OSStatus in
guard let userData = userData else { return OSStatus(eventNotHandledErr) }
let manager = Unmanaged<ShortcutManager>.fromOpaque(userData).takeUnretainedValue()
manager.callback?()
return noErr
}, 1, &eventType, Unmanaged.passUnretained(self).toOpaque(), nil)
RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef)
}
func unregister() {
if let ref = hotKeyRef {
UnregisterEventHotKey(ref)
}
}
}
extension String {
var fourCharCodeValue: FourCharCode {
var result: FourCharCode = 0
for char in utf8.prefix(4) {
result = (result << 8) + FourCharCode(char)
}
return result
}
}
// Usage
ShortcutManager.shared.register(
keyCode: UInt32(kVK_ANSI_M),
modifiers: UInt32(cmdKey | optionKey)
) {
// Toggle menu bar app
}
```
</global_shortcuts>
<with_main_window>
Menu bar app with optional main window:
```swift
@main
struct MenuBarApp: App {
@State private var showMainWindow = false
var body: some Scene {
MenuBarExtra("MyApp", systemImage: "star.fill") {
MenuContent(showMainWindow: $showMainWindow)
}
Window("MyApp", id: "main") {
MainWindowContent()
}
.defaultSize(width: 600, height: 400)
Settings {
SettingsView()
}
}
}
struct MenuContent: View {
@Binding var showMainWindow: Bool
@Environment(\.openWindow) private var openWindow
var body: some View {
VStack {
Button("Show Window") {
openWindow(id: "main")
}
// Quick actions...
}
.padding()
}
}
```
</with_main_window>
<persistent_state>
```swift
struct MenuContent: View {
@AppStorage("isEnabled") private var isEnabled = true
@AppStorage("checkInterval") private var checkInterval = 60
@AppStorage("notificationsEnabled") private var notifications = true
var body: some View {
VStack(alignment: .leading) {
Toggle("Enabled", isOn: $isEnabled)
Picker("Check every", selection: $checkInterval) {
Text("1 min").tag(60)
Text("5 min").tag(300)
Text("15 min").tag(900)
}
Toggle("Notifications", isOn: $notifications)
}
.padding()
}
}
```
</persistent_state>
<popover_from_menu_bar>
Custom popover positioning:
```swift
class PopoverManager: NSObject {
private var statusItem: NSStatusItem?
private var popover = NSPopover()
func setup() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
button.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "MyApp")
button.action = #selector(togglePopover)
button.target = self
}
popover.contentViewController = NSHostingController(rootView: PopoverContent())
popover.behavior = .transient
}
@objc func togglePopover() {
if popover.isShown {
popover.close()
} else if let button = statusItem?.button {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}
}
}
```
</popover_from_menu_bar>
<timer_background_task>
```swift
@Observable
class BackgroundService {
private var timer: Timer?
var lastCheck: Date?
var status: String = "Idle"
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
Task {
await self?.performCheck()
}
}
}
func stop() {
timer?.invalidate()
timer = nil
}
private func performCheck() async {
status = "Checking..."
// Do work
await Task.sleep(for: .seconds(2))
lastCheck = Date()
status = "OK"
}
}
struct MenuContent: View {
@State private var service = BackgroundService()
var body: some View {
VStack {
Text("Status: \(service.status)")
if let lastCheck = service.lastCheck {
Text("Last: \(lastCheck.formatted())")
.font(.caption)
}
Button("Check Now") {
Task { await service.performCheck() }
}
}
.padding()
.onAppear {
service.start()
}
}
}
```
</timer_background_task>
<best_practices>
- Keep menu content minimal and fast
- Use .window style for rich UI, .menu for simple actions
- Provide keyboard shortcuts for common actions
- Save state with @AppStorage
- Include "Quit" option always
- Use background-only (LSUIElement) when appropriate
- Provide settings window for configuration
- Show status in icon when possible (dynamic icon)
</best_practices>

View File

@@ -0,0 +1,549 @@
# Networking
URLSession patterns for API calls, authentication, caching, and offline support.
<basic_requests>
<async_await>
```swift
actor NetworkService {
private let session: URLSession
private let decoder: JSONDecoder
init(session: URLSession = .shared) {
self.session = session
self.decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
}
func fetch<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard 200..<300 ~= httpResponse.statusCode else {
throw NetworkError.httpError(httpResponse.statusCode, data)
}
return try decoder.decode(T.self, from: data)
}
func fetchData(_ request: URLRequest) async throws -> Data {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
200..<300 ~= httpResponse.statusCode else {
throw NetworkError.requestFailed
}
return data
}
}
enum NetworkError: Error {
case invalidResponse
case httpError(Int, Data)
case requestFailed
case decodingError(Error)
}
```
</async_await>
<request_building>
```swift
struct Endpoint {
let path: String
let method: HTTPMethod
let queryItems: [URLQueryItem]?
let body: Data?
let headers: [String: String]?
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
var request: URLRequest {
var components = URLComponents()
components.scheme = "https"
components.host = "api.example.com"
components.path = path
components.queryItems = queryItems
var request = URLRequest(url: components.url!)
request.httpMethod = method.rawValue
request.httpBody = body
// Default headers
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
// Custom headers
headers?.forEach { request.setValue($1, forHTTPHeaderField: $0) }
return request
}
}
// Usage
extension Endpoint {
static func projects() -> Endpoint {
Endpoint(path: "/v1/projects", method: .get, queryItems: nil, body: nil, headers: nil)
}
static func project(id: UUID) -> Endpoint {
Endpoint(path: "/v1/projects/\(id)", method: .get, queryItems: nil, body: nil, headers: nil)
}
static func createProject(_ project: CreateProjectRequest) -> Endpoint {
let body = try? JSONEncoder().encode(project)
return Endpoint(path: "/v1/projects", method: .post, queryItems: nil, body: body, headers: nil)
}
}
```
</request_building>
</basic_requests>
<authentication>
<bearer_token>
```swift
actor AuthenticatedNetworkService {
private let session: URLSession
private var token: String?
init() {
let config = URLSessionConfiguration.default
config.httpAdditionalHeaders = [
"User-Agent": "MyApp/1.0"
]
self.session = URLSession(configuration: config)
}
func setToken(_ token: String) {
self.token = token
}
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
var request = endpoint.request
if let token = token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
if httpResponse.statusCode == 401 {
throw NetworkError.unauthorized
}
guard 200..<300 ~= httpResponse.statusCode else {
throw NetworkError.httpError(httpResponse.statusCode, data)
}
return try JSONDecoder().decode(T.self, from: data)
}
}
```
</bearer_token>
<oauth_refresh>
```swift
actor OAuthService {
private var accessToken: String?
private var refreshToken: String?
private var tokenExpiry: Date?
private var isRefreshing = false
func validToken() async throws -> String {
// Return existing valid token
if let token = accessToken,
let expiry = tokenExpiry,
expiry > Date().addingTimeInterval(60) {
return token
}
// Refresh if needed
return try await refreshAccessToken()
}
private func refreshAccessToken() async throws -> String {
guard !isRefreshing else {
// Wait for in-progress refresh
try await Task.sleep(for: .milliseconds(100))
return try await validToken()
}
isRefreshing = true
defer { isRefreshing = false }
guard let refresh = refreshToken else {
throw AuthError.noRefreshToken
}
let request = Endpoint.refreshToken(refresh).request
let (data, _) = try await URLSession.shared.data(for: request)
let response = try JSONDecoder().decode(TokenResponse.self, from: data)
accessToken = response.accessToken
refreshToken = response.refreshToken
tokenExpiry = Date().addingTimeInterval(TimeInterval(response.expiresIn))
// Save to keychain
try saveTokens()
return response.accessToken
}
}
```
</oauth_refresh>
</authentication>
<caching>
<urlcache>
```swift
// Configure cache in URLSession
let config = URLSessionConfiguration.default
config.urlCache = URLCache(
memoryCapacity: 50 * 1024 * 1024, // 50 MB memory
diskCapacity: 100 * 1024 * 1024, // 100 MB disk
diskPath: "network_cache"
)
config.requestCachePolicy = .returnCacheDataElseLoad
let session = URLSession(configuration: config)
```
</urlcache>
<custom_cache>
```swift
actor ResponseCache {
private var cache: [String: CachedResponse] = [:]
private let maxAge: TimeInterval
init(maxAge: TimeInterval = 300) { // 5 minutes default
self.maxAge = maxAge
}
func get<T: Decodable>(_ key: String) -> T? {
guard let cached = cache[key],
Date().timeIntervalSince(cached.timestamp) < maxAge else {
cache[key] = nil
return nil
}
return try? JSONDecoder().decode(T.self, from: cached.data)
}
func set<T: Encodable>(_ value: T, for key: String) {
guard let data = try? JSONEncoder().encode(value) else { return }
cache[key] = CachedResponse(data: data, timestamp: Date())
}
func invalidate(_ key: String) {
cache[key] = nil
}
func clear() {
cache.removeAll()
}
}
struct CachedResponse {
let data: Data
let timestamp: Date
}
// Usage
actor CachedNetworkService {
private let network: NetworkService
private let cache = ResponseCache()
func fetchProjects(forceRefresh: Bool = false) async throws -> [Project] {
let cacheKey = "projects"
if !forceRefresh, let cached: [Project] = await cache.get(cacheKey) {
return cached
}
let projects: [Project] = try await network.fetch(Endpoint.projects().request)
await cache.set(projects, for: cacheKey)
return projects
}
}
```
</custom_cache>
</caching>
<offline_support>
```swift
@Observable
class OfflineAwareService {
private let network: NetworkService
private let storage: LocalStorage
var isOnline = true
init(network: NetworkService, storage: LocalStorage) {
self.network = network
self.storage = storage
monitorConnectivity()
}
func fetchProjects() async throws -> [Project] {
if isOnline {
do {
let projects = try await network.fetch(Endpoint.projects().request)
try storage.save(projects, for: "projects")
return projects
} catch {
// Fall back to cache on network error
if let cached = try? storage.load("projects") as [Project] {
return cached
}
throw error
}
} else {
// Offline: use cache
guard let cached = try? storage.load("projects") as [Project] else {
throw NetworkError.offline
}
return cached
}
}
private func monitorConnectivity() {
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor in
self?.isOnline = path.status == .satisfied
}
}
monitor.start(queue: .global())
}
}
```
</offline_support>
<upload_download>
<file_upload>
```swift
actor UploadService {
func upload(file: URL, to endpoint: Endpoint) async throws -> UploadResponse {
var request = endpoint.request
let boundary = UUID().uuidString
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
let data = try Data(contentsOf: file)
let body = createMultipartBody(
data: data,
filename: file.lastPathComponent,
boundary: boundary
)
request.httpBody = body
let (responseData, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(UploadResponse.self, from: responseData)
}
private func createMultipartBody(data: Data, filename: String, boundary: String) -> Data {
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
body.append(data)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
return body
}
}
```
</file_upload>
<file_download>
```swift
actor DownloadService {
func download(from url: URL, to destination: URL) async throws {
let (tempURL, response) = try await URLSession.shared.download(from: url)
guard let httpResponse = response as? HTTPURLResponse,
200..<300 ~= httpResponse.statusCode else {
throw NetworkError.downloadFailed
}
// Move to destination
let fileManager = FileManager.default
if fileManager.fileExists(atPath: destination.path) {
try fileManager.removeItem(at: destination)
}
try fileManager.moveItem(at: tempURL, to: destination)
}
func downloadWithProgress(from url: URL) -> AsyncThrowingStream<DownloadProgress, Error> {
AsyncThrowingStream { continuation in
let task = URLSession.shared.downloadTask(with: url) { tempURL, response, error in
if let error = error {
continuation.finish(throwing: error)
return
}
guard let tempURL = tempURL else {
continuation.finish(throwing: NetworkError.downloadFailed)
return
}
continuation.yield(.completed(tempURL))
continuation.finish()
}
// Observe progress
let observation = task.progress.observe(\.fractionCompleted) { progress, _ in
continuation.yield(.progress(progress.fractionCompleted))
}
continuation.onTermination = { _ in
observation.invalidate()
task.cancel()
}
task.resume()
}
}
}
enum DownloadProgress {
case progress(Double)
case completed(URL)
}
```
</file_download>
</upload_download>
<error_handling>
```swift
enum NetworkError: LocalizedError {
case invalidResponse
case httpError(Int, Data)
case unauthorized
case offline
case timeout
case decodingError(Error)
var errorDescription: String? {
switch self {
case .invalidResponse:
return "Invalid server response"
case .httpError(let code, _):
return "Server error: \(code)"
case .unauthorized:
return "Authentication required"
case .offline:
return "No internet connection"
case .timeout:
return "Request timed out"
case .decodingError(let error):
return "Data error: \(error.localizedDescription)"
}
}
var isRetryable: Bool {
switch self {
case .httpError(let code, _):
return code >= 500
case .timeout, .offline:
return true
default:
return false
}
}
}
// Retry logic
func fetchWithRetry<T: Decodable>(
_ request: URLRequest,
maxAttempts: Int = 3
) async throws -> T {
var lastError: Error?
for attempt in 1...maxAttempts {
do {
return try await network.fetch(request)
} catch let error as NetworkError where error.isRetryable {
lastError = error
let delay = pow(2.0, Double(attempt - 1)) // Exponential backoff
try await Task.sleep(for: .seconds(delay))
} catch {
throw error
}
}
throw lastError ?? NetworkError.requestFailed
}
```
</error_handling>
<testing>
```swift
// Mock URLProtocol for testing
class MockURLProtocol: URLProtocol {
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool {
true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
guard let handler = MockURLProtocol.requestHandler else {
fatalError("Handler not set")
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
// Test setup
func testFetchProjects() async throws {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: config)
MockURLProtocol.requestHandler = { request in
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
let data = try JSONEncoder().encode([Project(name: "Test")])
return (response, data)
}
let service = NetworkService(session: session)
let projects: [Project] = try await service.fetch(Endpoint.projects().request)
XCTAssertEqual(projects.count, 1)
}
```
</testing>

View File

@@ -0,0 +1,585 @@
# Project Scaffolding
Complete setup for new macOS Swift apps with all necessary files and configurations.
<new_project_checklist>
1. Create project.yml for XcodeGen
2. Create Swift source files
3. Run `xcodegen generate`
4. Configure signing (DEVELOPMENT_TEAM)
5. Build and verify with `xcodebuild`
</new_project_checklist>
<xcodegen_setup>
**Install XcodeGen** (one-time):
```bash
brew install xcodegen
```
**Create a new macOS app**:
```bash
mkdir MyApp && cd MyApp
mkdir -p Sources Tests Resources
# Create project.yml (see template below)
# Create Swift files
xcodegen generate
xcodebuild -project MyApp.xcodeproj -scheme MyApp build
```
</xcodegen_setup>
<project_yml_template>
**project.yml** - Complete macOS SwiftUI app template:
```yaml
name: MyApp
options:
bundleIdPrefix: com.yourcompany
deploymentTarget:
macOS: "14.0"
xcodeVersion: "15.0"
createIntermediateGroups: true
configs:
Debug: debug
Release: release
settings:
base:
SWIFT_VERSION: "5.9"
MACOSX_DEPLOYMENT_TARGET: "14.0"
targets:
MyApp:
type: application
platform: macOS
sources:
- Sources
resources:
- Resources
info:
path: Sources/Info.plist
properties:
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
CFBundleName: $(PRODUCT_NAME)
CFBundleIdentifier: $(PRODUCT_BUNDLE_IDENTIFIER)
CFBundleShortVersionString: "1.0"
CFBundleVersion: "1"
LSApplicationCategoryType: public.app-category.utilities
NSPrincipalClass: NSApplication
NSHighResolutionCapable: true
entitlements:
path: Sources/MyApp.entitlements
properties:
com.apple.security.app-sandbox: true
com.apple.security.network.client: true
com.apple.security.files.user-selected.read-write: true
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp
PRODUCT_NAME: MyApp
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: YOURTEAMID
configs:
Debug:
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
SWIFT_OPTIMIZATION_LEVEL: -Onone
CODE_SIGN_ENTITLEMENTS: Sources/MyApp.entitlements
Release:
SWIFT_OPTIMIZATION_LEVEL: -Osize
MyAppTests:
type: bundle.unit-test
platform: macOS
sources:
- Tests
dependencies:
- target: MyApp
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp.tests
schemes:
MyApp:
build:
targets:
MyApp: all
MyAppTests: [test]
run:
config: Debug
test:
config: Debug
gatherCoverageData: true
targets:
- MyAppTests
profile:
config: Release
archive:
config: Release
```
</project_yml_template>
<project_yml_swiftdata>
**project.yml with SwiftData**:
Add to target settings:
```yaml
settings:
base:
# ... existing settings ...
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) SWIFT_DATA"
dependencies:
- sdk: SwiftData.framework
```
</project_yml_swiftdata>
<project_yml_packages>
**Adding Swift Package dependencies**:
```yaml
packages:
Alamofire:
url: https://github.com/Alamofire/Alamofire
from: 5.8.0
KeychainAccess:
url: https://github.com/kishikawakatsumi/KeychainAccess
from: 4.2.0
targets:
MyApp:
# ... other config ...
dependencies:
- package: Alamofire
- package: KeychainAccess
```
</project_yml_packages>
<alternative_xcode_template>
**Alternative: Xcode GUI method**
For users who prefer Xcode:
1. File > New > Project > macOS > App
2. Settings: SwiftUI, Swift, SwiftData (optional)
3. Save to desired location
</alternative_xcode_template>
<minimal_file_structure>
```
MyApp/
├── MyApp.xcodeproj/
│ └── project.pbxproj
├── MyApp/
│ ├── MyApp.swift # App entry point
│ ├── ContentView.swift # Main view
│ ├── Info.plist
│ ├── MyApp.entitlements
│ └── Assets.xcassets/
│ ├── Contents.json
│ ├── AppIcon.appiconset/
│ │ └── Contents.json
│ └── AccentColor.colorset/
│ └── Contents.json
└── MyAppTests/
└── MyAppTests.swift
```
</minimal_file_structure>
<starter_code>
<app_entry_point>
**MyApp.swift**:
```swift
import SwiftUI
@main
struct MyApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView()
.environment(appState)
}
.commands {
CommandGroup(replacing: .newItem) { } // Remove default New
}
Settings {
SettingsView()
}
}
}
```
</app_entry_point>
<app_state>
**AppState.swift**:
```swift
import SwiftUI
@Observable
class AppState {
var items: [Item] = []
var selectedItemID: UUID?
var searchText = ""
var selectedItem: Item? {
items.first { $0.id == selectedItemID }
}
var filteredItems: [Item] {
if searchText.isEmpty {
return items
}
return items.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
func addItem(_ name: String) {
let item = Item(name: name)
items.append(item)
selectedItemID = item.id
}
func deleteItem(_ item: Item) {
items.removeAll { $0.id == item.id }
if selectedItemID == item.id {
selectedItemID = nil
}
}
}
struct Item: Identifiable, Hashable {
let id = UUID()
var name: String
var createdAt = Date()
}
```
</app_state>
<content_view>
**ContentView.swift**:
```swift
import SwiftUI
struct ContentView: View {
@Environment(AppState.self) private var appState
var body: some View {
@Bindable var appState = appState
NavigationSplitView {
SidebarView()
} detail: {
DetailView()
}
.searchable(text: $appState.searchText)
.navigationTitle("MyApp")
}
}
struct SidebarView: View {
@Environment(AppState.self) private var appState
var body: some View {
@Bindable var appState = appState
List(appState.filteredItems, selection: $appState.selectedItemID) { item in
Text(item.name)
.tag(item.id)
}
.toolbar {
ToolbarItem {
Button(action: addItem) {
Label("Add", systemImage: "plus")
}
}
}
}
private func addItem() {
appState.addItem("New Item")
}
}
struct DetailView: View {
@Environment(AppState.self) private var appState
var body: some View {
if let item = appState.selectedItem {
VStack {
Text(item.name)
.font(.title)
Text(item.createdAt.formatted())
.foregroundStyle(.secondary)
}
.padding()
} else {
ContentUnavailableView("No Selection", systemImage: "sidebar.left")
}
}
}
```
</content_view>
<settings_view>
**SettingsView.swift**:
```swift
import SwiftUI
struct SettingsView: View {
var body: some View {
TabView {
GeneralSettingsView()
.tabItem {
Label("General", systemImage: "gear")
}
AdvancedSettingsView()
.tabItem {
Label("Advanced", systemImage: "slider.horizontal.3")
}
}
.frame(width: 450, height: 250)
}
}
struct GeneralSettingsView: View {
@AppStorage("showWelcome") private var showWelcome = true
@AppStorage("defaultName") private var defaultName = "Untitled"
var body: some View {
Form {
Toggle("Show welcome screen on launch", isOn: $showWelcome)
TextField("Default item name", text: $defaultName)
}
.padding()
}
}
struct AdvancedSettingsView: View {
@AppStorage("enableLogging") private var enableLogging = false
var body: some View {
Form {
Toggle("Enable debug logging", isOn: $enableLogging)
}
.padding()
}
}
```
</settings_view>
</starter_code>
<info_plist>
**Info.plist** (complete template):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleDisplayName</key>
<string>MyApp</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2024 Your Name. All rights reserved.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
</dict>
</plist>
```
**Common category types**:
- `public.app-category.productivity`
- `public.app-category.developer-tools`
- `public.app-category.utilities`
- `public.app-category.music`
- `public.app-category.graphics-design`
</info_plist>
<entitlements>
**MyApp.entitlements** (sandbox with network):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>
```
**Debug entitlements** (add for debug builds):
```xml
<key>com.apple.security.get-task-allow</key>
<true/>
```
</entitlements>
<assets_catalog>
**Assets.xcassets/Contents.json**:
```json
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
```
**Assets.xcassets/AppIcon.appiconset/Contents.json**:
```json
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
```
**Assets.xcassets/AccentColor.colorset/Contents.json**:
```json
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
```
</assets_catalog>
<swift_packages>
Add dependencies via Package.swift or Xcode:
**Common packages**:
```swift
// In Xcode: File > Add Package Dependencies
// Networking
.package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0")
// Logging
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0")
// Keychain
.package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", from: "4.2.0")
// Syntax highlighting
.package(url: "https://github.com/raspu/Highlightr.git", from: "2.1.0")
```
**Add via CLI**:
```bash
# Edit project to add package dependency
# (Easier to do once in Xcode, then clone for future projects)
```
</swift_packages>
<verify_setup>
```bash
# Verify project configuration
xcodebuild -list -project MyApp.xcodeproj
# Build
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-configuration Debug \
-derivedDataPath ./build \
build
# Run
open ./build/Build/Products/Debug/MyApp.app
# Check signing
codesign -dv ./build/Build/Products/Debug/MyApp.app
```
</verify_setup>
<next_steps>
After scaffolding:
1. **Define your data model**: Create models in Models/ folder
2. **Choose persistence**: SwiftData, Core Data, or file-based
3. **Design main UI**: Sidebar + detail or single-window layout
4. **Add menu commands**: Edit AppCommands.swift
5. **Configure logging**: Set up os.Logger with appropriate subsystem
6. **Write tests**: Unit tests for models, integration tests for services
See [cli-workflow.md](cli-workflow.md) for build/run/debug workflow.
</next_steps>

View File

@@ -0,0 +1,524 @@
# Security & Code Signing
Secure coding, keychain, code signing, and notarization for macOS apps.
<keychain>
<save_retrieve>
```swift
import Security
class KeychainService {
enum KeychainError: Error {
case itemNotFound
case duplicateItem
case unexpectedStatus(OSStatus)
}
static let shared = KeychainService()
private let service = Bundle.main.bundleIdentifier!
// Save data
func save(key: String, data: Data) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
// Delete existing item first
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
// Retrieve data
func load(key: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
if status == errSecItemNotFound {
throw KeychainError.itemNotFound
}
throw KeychainError.unexpectedStatus(status)
}
guard let data = result as? Data else {
throw KeychainError.itemNotFound
}
return data
}
// Delete item
func delete(key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unexpectedStatus(status)
}
}
// Update existing item
func update(key: String, data: Data) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
let attributes: [String: Any] = [
kSecValueData as String: data
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
}
// Convenience methods for strings
extension KeychainService {
func saveString(_ string: String, for key: String) throws {
guard let data = string.data(using: .utf8) else { return }
try save(key: key, data: data)
}
func loadString(for key: String) throws -> String {
let data = try load(key: key)
guard let string = String(data: data, encoding: .utf8) else {
throw KeychainError.itemNotFound
}
return string
}
}
```
</save_retrieve>
<keychain_access_groups>
Share keychain items between apps:
```swift
// In entitlements
/*
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.yourcompany.shared</string>
</array>
*/
// When saving
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecAttrAccessGroup as String: "TEAMID.com.yourcompany.shared",
kSecValueData as String: data
]
```
</keychain_access_groups>
<keychain_access_control>
```swift
// Require user presence (Touch ID / password)
func saveSecure(key: String, data: Data) throws {
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.userPresence,
nil
)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessControl as String: access as Any
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
```
</keychain_access_control>
</keychain>
<secure_coding>
<input_validation>
```swift
// Validate user input
func validateUsername(_ username: String) throws -> String {
// Check length
guard username.count >= 3, username.count <= 50 else {
throw ValidationError.invalidLength
}
// Check characters
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_-"))
guard username.unicodeScalars.allSatisfy({ allowed.contains($0) }) else {
throw ValidationError.invalidCharacters
}
return username
}
// Sanitize for display
func sanitizeHTML(_ input: String) -> String {
input
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&#39;")
}
```
</input_validation>
<secure_random>
```swift
import Security
// Generate secure random bytes
func secureRandomBytes(count: Int) -> Data? {
var bytes = [UInt8](repeating: 0, count: count)
let result = SecRandomCopyBytes(kSecRandomDefault, count, &bytes)
guard result == errSecSuccess else { return nil }
return Data(bytes)
}
// Generate secure token
func generateToken(length: Int = 32) -> String? {
guard let data = secureRandomBytes(count: length) else { return nil }
return data.base64EncodedString()
}
```
</secure_random>
<cryptography>
```swift
import CryptoKit
// Hash data
func hash(_ data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
// Encrypt with symmetric key
func encrypt(_ data: Data, key: SymmetricKey) throws -> Data {
try AES.GCM.seal(data, using: key).combined!
}
func decrypt(_ data: Data, key: SymmetricKey) throws -> Data {
let box = try AES.GCM.SealedBox(combined: data)
return try AES.GCM.open(box, using: key)
}
// Generate key from password
func deriveKey(from password: String, salt: Data) -> SymmetricKey {
let passwordData = Data(password.utf8)
let key = HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: passwordData),
salt: salt,
info: Data("MyApp".utf8),
outputByteCount: 32
)
return key
}
```
</cryptography>
<secure_file_storage>
```swift
// Store sensitive files with data protection
func saveSecureFile(_ data: Data, to url: URL) throws {
try data.write(to: url, options: [.atomic, .completeFileProtection])
}
// Read with security scope
func readSecureFile(at url: URL) throws -> Data {
let accessing = url.startAccessingSecurityScopedResource()
defer {
if accessing {
url.stopAccessingSecurityScopedResource()
}
}
return try Data(contentsOf: url)
}
```
</secure_file_storage>
</secure_coding>
<app_sandbox>
<entitlements>
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Enable sandbox -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Network -->
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- File access -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<!-- Hardware -->
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<!-- Inter-app -->
<key>com.apple.security.automation.apple-events</key>
<true/>
<!-- Temporary exception (avoid if possible) -->
<key>com.apple.security.temporary-exception.files.home-relative-path.read-write</key>
<array>
<string>/Library/Application Support/MyApp/</string>
</array>
</dict>
</plist>
```
</entitlements>
<request_permission>
```swift
// Request camera permission
import AVFoundation
func requestCameraAccess() async -> Bool {
await AVCaptureDevice.requestAccess(for: .video)
}
// Request microphone permission
func requestMicrophoneAccess() async -> Bool {
await AVCaptureDevice.requestAccess(for: .audio)
}
// Check status
func checkCameraAuthorization() -> AVAuthorizationStatus {
AVCaptureDevice.authorizationStatus(for: .video)
}
```
</request_permission>
</app_sandbox>
<code_signing>
<signing_identity>
```bash
# List available signing identities
security find-identity -v -p codesigning
# Sign app with Developer ID
codesign --force --options runtime \
--sign "Developer ID Application: Your Name (TEAMID)" \
--entitlements MyApp/MyApp.entitlements \
MyApp.app
# Verify signature
codesign --verify --verbose=4 MyApp.app
# Display signature info
codesign -dv --verbose=4 MyApp.app
# Show entitlements
codesign -d --entitlements - MyApp.app
```
</signing_identity>
<hardened_runtime>
```xml
<!-- Required for notarization -->
<!-- Hardened runtime entitlements -->
<!-- Allow JIT (for JavaScript engines) -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- Allow unsigned executable memory (rare) -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Disable library validation (for plugins) -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Allow DYLD environment variables -->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
```
</hardened_runtime>
</code_signing>
<notarization>
<notarize_app>
```bash
# Create ZIP for notarization
ditto -c -k --keepParent MyApp.app MyApp.zip
# Submit for notarization
xcrun notarytool submit MyApp.zip \
--apple-id your@email.com \
--team-id YOURTEAMID \
--password @keychain:AC_PASSWORD \
--wait
# Check status
xcrun notarytool info <submission-id> \
--apple-id your@email.com \
--team-id YOURTEAMID \
--password @keychain:AC_PASSWORD
# View log
xcrun notarytool log <submission-id> \
--apple-id your@email.com \
--team-id YOURTEAMID \
--password @keychain:AC_PASSWORD
# Staple ticket
xcrun stapler staple MyApp.app
# Verify notarization
spctl --assess --verbose=4 --type execute MyApp.app
```
</notarize_app>
<store_credentials>
```bash
# Store notarization credentials in keychain
xcrun notarytool store-credentials "AC_PASSWORD" \
--apple-id your@email.com \
--team-id YOURTEAMID \
--password <app-specific-password>
# Use stored credentials
xcrun notarytool submit MyApp.zip \
--keychain-profile "AC_PASSWORD" \
--wait
```
</store_credentials>
<dmg_notarization>
```bash
# Create DMG
hdiutil create -volname "MyApp" -srcfolder MyApp.app -ov -format UDZO MyApp.dmg
# Sign DMG
codesign --force --sign "Developer ID Application: Your Name (TEAMID)" MyApp.dmg
# Notarize DMG
xcrun notarytool submit MyApp.dmg \
--keychain-profile "AC_PASSWORD" \
--wait
# Staple DMG
xcrun stapler staple MyApp.dmg
```
</dmg_notarization>
</notarization>
<transport_security>
```swift
// HTTPS only (default in iOS 9+ / macOS 10.11+)
// Add exceptions in Info.plist if needed
/*
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
*/
// Certificate pinning
class PinnedSessionDelegate: NSObject, URLSessionDelegate {
let pinnedCertificates: [Data]
init(certificates: [Data]) {
self.pinnedCertificates = certificates
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust,
let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let serverCertData = SecCertificateCopyData(certificate) as Data
if pinnedCertificates.contains(serverCertData) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
```
</transport_security>
<best_practices>
<security_checklist>
- Store secrets in Keychain, never in UserDefaults or files
- Use App Transport Security (HTTPS only)
- Validate all user input
- Use secure random for tokens/keys
- Enable hardened runtime
- Sign and notarize for distribution
- Request only necessary entitlements
- Clear sensitive data from memory when done
</security_checklist>
<common_mistakes>
- Storing API keys in code (use Keychain or secure config)
- Logging sensitive data
- Using `print()` for sensitive values in production
- Not validating server certificates
- Weak password hashing (use bcrypt/scrypt/Argon2)
- Storing passwords instead of hashes
</common_mistakes>
</best_practices>

View File

@@ -0,0 +1,522 @@
# Shoebox/Library Apps
Apps with internal database and sidebar navigation (like Notes, Photos, Music).
<when_to_use>
Use shoebox pattern when:
- Single library of items (not separate files)
- No explicit save (auto-save everything)
- Import/export rather than open/save
- Sidebar navigation (folders, tags, smart folders)
- iCloud sync across devices
Do NOT use when:
- Users need to manage individual files
- Files shared with other apps directly
</when_to_use>
<basic_structure>
```swift
@main
struct LibraryApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Note.self, Folder.self, Tag.self])
.commands {
LibraryCommands()
}
}
}
struct ContentView: View {
@State private var selectedFolder: Folder?
@State private var selectedNote: Note?
@State private var searchText = ""
var body: some View {
NavigationSplitView {
SidebarView(selection: $selectedFolder)
} content: {
NoteListView(folder: selectedFolder, selection: $selectedNote)
} detail: {
if let note = selectedNote {
NoteEditorView(note: note)
} else {
ContentUnavailableView("Select a Note", systemImage: "note.text")
}
}
.searchable(text: $searchText)
}
}
```
</basic_structure>
<data_model>
```swift
import SwiftData
@Model
class Note {
var title: String
var content: String
var createdAt: Date
var modifiedAt: Date
var isPinned: Bool
@Relationship(inverse: \Folder.notes)
var folder: Folder?
@Relationship
var tags: [Tag]
init(title: String = "New Note") {
self.title = title
self.content = ""
self.createdAt = Date()
self.modifiedAt = Date()
self.isPinned = false
self.tags = []
}
}
@Model
class Folder {
var name: String
var icon: String
var sortOrder: Int
@Relationship(deleteRule: .cascade)
var notes: [Note]
var isSmartFolder: Bool
var predicate: String? // For smart folders
init(name: String, icon: String = "folder") {
self.name = name
self.icon = icon
self.sortOrder = 0
self.notes = []
self.isSmartFolder = false
}
}
@Model
class Tag {
var name: String
var color: String
@Relationship(inverse: \Note.tags)
var notes: [Note]
init(name: String, color: String = "blue") {
self.name = name
self.color = color
self.notes = []
}
}
```
</data_model>
<sidebar>
```swift
struct SidebarView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Folder.sortOrder) private var folders: [Folder]
@Binding var selection: Folder?
var body: some View {
List(selection: $selection) {
Section("Library") {
Label("All Notes", systemImage: "note.text")
.tag(nil as Folder?)
Label("Recently Deleted", systemImage: "trash")
}
Section("Folders") {
ForEach(folders.filter { !$0.isSmartFolder }) { folder in
Label(folder.name, systemImage: folder.icon)
.tag(folder as Folder?)
.contextMenu {
Button("Rename") { renameFolder(folder) }
Button("Delete", role: .destructive) { deleteFolder(folder) }
}
}
.onMove(perform: moveFolders)
}
Section("Smart Folders") {
ForEach(folders.filter { $0.isSmartFolder }) { folder in
Label(folder.name, systemImage: "folder.badge.gearshape")
.tag(folder as Folder?)
}
}
Section("Tags") {
TagsSection()
}
}
.listStyle(.sidebar)
.toolbar {
ToolbarItem {
Button(action: addFolder) {
Label("New Folder", systemImage: "folder.badge.plus")
}
}
}
}
private func addFolder() {
let folder = Folder(name: "New Folder")
folder.sortOrder = folders.count
context.insert(folder)
}
private func deleteFolder(_ folder: Folder) {
context.delete(folder)
}
private func moveFolders(from source: IndexSet, to destination: Int) {
var reordered = folders.filter { !$0.isSmartFolder }
reordered.move(fromOffsets: source, toOffset: destination)
for (index, folder) in reordered.enumerated() {
folder.sortOrder = index
}
}
}
```
</sidebar>
<note_list>
```swift
struct NoteListView: View {
let folder: Folder?
@Binding var selection: Note?
@Environment(\.modelContext) private var context
@Query private var allNotes: [Note]
var filteredNotes: [Note] {
let sorted = allNotes.sorted {
if $0.isPinned != $1.isPinned {
return $0.isPinned
}
return $0.modifiedAt > $1.modifiedAt
}
if let folder = folder {
return sorted.filter { $0.folder == folder }
}
return sorted
}
var body: some View {
List(filteredNotes, selection: $selection) { note in
NoteRow(note: note)
.tag(note)
.contextMenu {
Button(note.isPinned ? "Unpin" : "Pin") {
note.isPinned.toggle()
}
Divider()
Button("Delete", role: .destructive) {
context.delete(note)
}
}
}
.toolbar {
ToolbarItem {
Button(action: addNote) {
Label("New Note", systemImage: "square.and.pencil")
}
}
}
}
private func addNote() {
let note = Note()
note.folder = folder
context.insert(note)
selection = note
}
}
struct NoteRow: View {
let note: Note
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
if note.isPinned {
Image(systemName: "pin.fill")
.foregroundStyle(.orange)
.font(.caption)
}
Text(note.title.isEmpty ? "New Note" : note.title)
.fontWeight(.medium)
}
Text(note.modifiedAt.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundStyle(.secondary)
Text(note.content.prefix(100))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
.padding(.vertical, 4)
}
}
```
</note_list>
<editor>
```swift
struct NoteEditorView: View {
@Bindable var note: Note
@FocusState private var isFocused: Bool
var body: some View {
VStack(spacing: 0) {
// Title
TextField("Title", text: $note.title)
.textFieldStyle(.plain)
.font(.title)
.padding()
Divider()
// Content
TextEditor(text: $note.content)
.font(.body)
.focused($isFocused)
.padding()
}
.onChange(of: note.title) { _, _ in
note.modifiedAt = Date()
}
.onChange(of: note.content) { _, _ in
note.modifiedAt = Date()
}
.toolbar {
ToolbarItem {
Menu {
TagPickerMenu(note: note)
} label: {
Label("Tags", systemImage: "tag")
}
}
ToolbarItem {
ShareLink(item: note.content)
}
}
}
}
```
</editor>
<smart_folders>
```swift
struct SmartFolderSetup {
static func createDefaultSmartFolders(context: ModelContext) {
// Today
let today = Folder(name: "Today", icon: "calendar")
today.isSmartFolder = true
today.predicate = "modifiedAt >= startOfToday"
context.insert(today)
// This Week
let week = Folder(name: "This Week", icon: "calendar.badge.clock")
week.isSmartFolder = true
week.predicate = "modifiedAt >= startOfWeek"
context.insert(week)
// Pinned
let pinned = Folder(name: "Pinned", icon: "pin")
pinned.isSmartFolder = true
pinned.predicate = "isPinned == true"
context.insert(pinned)
}
}
// Query based on smart folder predicate
func notesForSmartFolder(_ folder: Folder) -> [Note] {
switch folder.predicate {
case "isPinned == true":
return allNotes.filter { $0.isPinned }
case "modifiedAt >= startOfToday":
let start = Calendar.current.startOfDay(for: Date())
return allNotes.filter { $0.modifiedAt >= start }
default:
return []
}
}
```
</smart_folders>
<import_export>
```swift
struct LibraryCommands: Commands {
@Environment(\.modelContext) private var context
var body: some Commands {
CommandGroup(after: .importExport) {
Button("Import Notes...") {
importNotes()
}
.keyboardShortcut("i", modifiers: [.command, .shift])
Button("Export All Notes...") {
exportNotes()
}
.keyboardShortcut("e", modifiers: [.command, .shift])
}
}
private func importNotes() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.json, .plainText]
panel.allowsMultipleSelection = true
if panel.runModal() == .OK {
for url in panel.urls {
importFile(url)
}
}
}
private func exportNotes() {
let panel = NSSavePanel()
panel.allowedContentTypes = [.json]
panel.nameFieldStringValue = "Notes Export.json"
if panel.runModal() == .OK, let url = panel.url {
let descriptor = FetchDescriptor<Note>()
if let notes = try? context.fetch(descriptor) {
let exportData = notes.map { NoteExport(note: $0) }
if let data = try? JSONEncoder().encode(exportData) {
try? data.write(to: url)
}
}
}
}
}
struct NoteExport: Codable {
let title: String
let content: String
let createdAt: Date
let modifiedAt: Date
init(note: Note) {
self.title = note.title
self.content = note.content
self.createdAt = note.createdAt
self.modifiedAt = note.modifiedAt
}
}
```
</import_export>
<search>
```swift
struct ContentView: View {
@State private var searchText = ""
@Query private var allNotes: [Note]
var searchResults: [Note] {
if searchText.isEmpty {
return []
}
return allNotes.filter { note in
note.title.localizedCaseInsensitiveContains(searchText) ||
note.content.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
NavigationSplitView {
// ...
}
.searchable(text: $searchText, placement: .toolbar)
.searchSuggestions {
if !searchText.isEmpty {
ForEach(searchResults.prefix(5)) { note in
Button {
selectedNote = note
} label: {
VStack(alignment: .leading) {
Text(note.title)
Text(note.modifiedAt.formatted())
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
}
}
```
</search>
<icloud_sync>
```swift
// Configure container for iCloud
@main
struct LibraryApp: App {
let container: ModelContainer
init() {
let schema = Schema([Note.self, Folder.self, Tag.self])
let config = ModelConfiguration(
"Library",
schema: schema,
cloudKitDatabase: .automatic
)
do {
container = try ModelContainer(for: schema, configurations: config)
} catch {
fatalError("Failed to create container: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
// Handle sync status
struct SyncStatusIndicator: View {
@State private var isSyncing = false
var body: some View {
if isSyncing {
ProgressView()
.scaleEffect(0.5)
} else {
Image(systemName: "checkmark.icloud")
.foregroundStyle(.green)
}
}
}
```
</icloud_sync>
<best_practices>
- Auto-save on every change (no explicit save)
- Provide import/export for data portability
- Use sidebar for navigation (folders, tags, smart folders)
- Support search across all content
- Show modification dates, not explicit "save"
- Use SwiftData with iCloud for seamless sync
- Provide trash/restore instead of permanent delete
</best_practices>

View File

@@ -0,0 +1,905 @@
<overview>
Modern SwiftUI patterns for macOS apps. Covers @Bindable usage, navigation (NavigationSplitView, NavigationStack), windows, toolbars, menus, lists/tables, forms, sheets/alerts, drag & drop, focus management, and keyboard shortcuts.
</overview>
<sections>
Reference sections:
- observation_rules - @Bindable, @Observable, environment patterns
- navigation - NavigationSplitView, NavigationStack, drill-down
- windows - WindowGroup, Settings, auxiliary windows
- toolbar - Toolbar items, customizable toolbars
- menus - App commands, context menus
- lists_and_tables - List selection, Table, OutlineGroup
- forms - Settings forms, validation
- sheets_and_alerts - Sheets, confirmation dialogs, file dialogs
- drag_and_drop - Draggable items, drop targets, reorderable lists
- focus_and_keyboard - Focus state, keyboard shortcuts
- previews - Preview patterns
</sections>
<observation_rules>
<passing_model_objects>
**Critical rule for SwiftData @Model objects**: Use `@Bindable` when the child view needs to observe property changes or create bindings. Use `let` only for static display.
```swift
// CORRECT: Use @Bindable when observing changes or binding
struct CardView: View {
@Bindable var card: Card // Use this for @Model objects
var body: some View {
VStack {
TextField("Title", text: $card.title) // Binding works
Text(card.description) // Observes changes
}
}
}
// WRONG: Using let breaks observation
struct CardViewBroken: View {
let card: Card // Won't observe property changes!
var body: some View {
Text(card.title) // May not update when card.title changes
}
}
```
</passing_model_objects>
<when_to_use_bindable>
**Use `@Bindable` when:**
- Passing @Model objects to child views that observe changes
- Creating bindings to model properties ($model.property)
- The view should update when model properties change
**Use `let` when:**
- Passing simple value types (structs, enums)
- The view only needs the value at the moment of creation
- You explicitly don't want reactivity
```swift
// @Model objects - use @Bindable
struct ColumnView: View {
@Bindable var column: Column // SwiftData model
var body: some View {
VStack {
Text(column.name) // Updates when column.name changes
ForEach(column.cards) { card in
CardView(card: card) // Pass model, use @Bindable in CardView
}
}
}
}
// Value types - use let
struct BadgeView: View {
let count: Int // Value type, let is fine
var body: some View {
Text("\(count)")
}
}
```
</when_to_use_bindable>
<environment_to_bindable>
When accessing @Observable from environment, create local @Bindable for bindings:
```swift
struct SidebarView: View {
@Environment(AppState.self) private var appState
var body: some View {
// Create local @Bindable for bindings
@Bindable var appState = appState
List(appState.items, selection: $appState.selectedID) { item in
Text(item.name)
}
}
}
```
</environment_to_bindable>
</observation_rules>
<navigation>
<navigation_split_view>
Standard three-column layout:
```swift
struct ContentView: View {
@State private var selectedFolder: Folder?
@State private var selectedItem: Item?
var body: some View {
NavigationSplitView {
// Sidebar
SidebarView(selection: $selectedFolder)
} content: {
// Content list
if let folder = selectedFolder {
ItemListView(folder: folder, selection: $selectedItem)
} else {
ContentUnavailableView("Select a Folder", systemImage: "folder")
}
} detail: {
// Detail
if let item = selectedItem {
DetailView(item: item)
} else {
ContentUnavailableView("Select an Item", systemImage: "doc")
}
}
.navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 300)
}
}
```
</navigation_split_view>
<two_column_layout>
```swift
struct ContentView: View {
@State private var selectedItem: Item?
var body: some View {
NavigationSplitView {
SidebarView(selection: $selectedItem)
.navigationSplitViewColumnWidth(min: 200, ideal: 250)
} detail: {
if let item = selectedItem {
DetailView(item: item)
} else {
ContentUnavailableView("No Selection", systemImage: "sidebar.left")
}
}
}
}
```
</two_column_layout>
<navigation_stack>
For drill-down navigation:
```swift
struct BrowseView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
CategoryListView()
.navigationDestination(for: Category.self) { category in
ItemListView(category: category)
}
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
}
}
}
```
</navigation_stack>
</navigation>
<windows>
<multiple_window_types>
```swift
@main
struct MyApp: App {
var body: some Scene {
// Main window
WindowGroup {
ContentView()
}
.commands {
AppCommands()
}
// Auxiliary window
Window("Inspector", id: "inspector") {
InspectorView()
}
.windowResizability(.contentSize)
.defaultPosition(.trailing)
.keyboardShortcut("i", modifiers: [.command, .option])
// Utility window
Window("Quick Entry", id: "quick-entry") {
QuickEntryView()
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
// Settings
Settings {
SettingsView()
}
}
}
```
</multiple_window_types>
<window_control>
Open windows programmatically:
```swift
struct ContentView: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
Button("Show Inspector") {
openWindow(id: "inspector")
}
}
}
```
</window_control>
<document_group>
For document-based apps:
```swift
@main
struct MyApp: App {
var body: some Scene {
DocumentGroup(newDocument: MyDocument()) { file in
DocumentView(document: file.$document)
}
.commands {
DocumentCommands()
}
}
}
```
</document_group>
<debugging_swiftui_appkit>
**Meta-principle: Declarative overrides Imperative**
When SwiftUI wraps AppKit (via NSHostingView, NSViewRepresentable, etc.), SwiftUI's declarative layer manages the AppKit objects underneath. Your AppKit code may be "correct" but irrelevant if SwiftUI is controlling that concern.
**Debugging pattern:**
1. Issue occurs (e.g., window won't respect constraints, focus not working, layout broken)
2.**Wrong approach:** Jump to AppKit APIs to "fix" it imperatively
3.**Right approach:** Check SwiftUI layer first - what's declaratively controlling this?
4. **Why:** The wrapper controls the wrapped. Higher abstraction wins.
**Example scenario - Window sizing:**
- Symptom: `NSWindow.minSize` code runs but window still resizes smaller
- Wrong: Add more AppKit code, observers, notifications to "force" it
- Right: Search codebase for `.frame(minWidth:)` on content view - that's what's actually controlling it
- Lesson: NSHostingView manages window constraints based on SwiftUI content
**This pattern applies broadly:**
- Window sizing → Check `.frame()`, `.windowResizability()` before `NSWindow` properties
- Focus management → Check `@FocusState`, `.focused()` before `NSResponder` chain
- Layout constraints → Check SwiftUI layout modifiers before Auto Layout
- Toolbar → Check `.toolbar {}` before `NSToolbar` setup
**When to actually use AppKit:**
Only when SwiftUI doesn't provide the capability (custom drawing, specialized controls, backward compatibility). Not as a workaround when SwiftUI "doesn't work" - you probably haven't found SwiftUI's way yet.
</debugging_swiftui_appkit>
</windows>
<toolbar>
<toolbar_content>
```swift
struct ContentView: View {
@State private var searchText = ""
var body: some View {
NavigationSplitView {
SidebarView()
} detail: {
DetailView()
}
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button(action: addItem) {
Label("Add", systemImage: "plus")
}
Button(action: deleteItem) {
Label("Delete", systemImage: "trash")
}
}
ToolbarItem(placement: .navigation) {
Button(action: toggleSidebar) {
Label("Toggle Sidebar", systemImage: "sidebar.left")
}
}
}
.searchable(text: $searchText, placement: .toolbar)
}
private func toggleSidebar() {
NSApp.keyWindow?.firstResponder?.tryToPerform(
#selector(NSSplitViewController.toggleSidebar(_:)),
with: nil
)
}
}
```
</toolbar_content>
<customizable_toolbar>
```swift
struct ContentView: View {
var body: some View {
MainContent()
.toolbar(id: "main") {
ToolbarItem(id: "add", placement: .primaryAction) {
Button(action: add) {
Label("Add", systemImage: "plus")
}
}
ToolbarItem(id: "share", placement: .secondaryAction) {
ShareLink(item: currentItem)
}
ToolbarItem(id: "spacer", placement: .automatic) {
Spacer()
}
}
.toolbarRole(.editor)
}
}
```
</customizable_toolbar>
</toolbar>
<menus>
<app_commands>
```swift
struct AppCommands: Commands {
@Environment(\.openWindow) private var openWindow
var body: some Commands {
// Replace standard menu items
CommandGroup(replacing: .newItem) {
Button("New Project") {
// Create new project
}
.keyboardShortcut("n", modifiers: .command)
}
// Add new menu
CommandMenu("View") {
Button("Show Inspector") {
openWindow(id: "inspector")
}
.keyboardShortcut("i", modifiers: [.command, .option])
Divider()
Button("Zoom In") {
// Zoom in
}
.keyboardShortcut("+", modifiers: .command)
Button("Zoom Out") {
// Zoom out
}
.keyboardShortcut("-", modifiers: .command)
}
// Add to existing menu
CommandGroup(after: .sidebar) {
Button("Toggle Inspector") {
// Toggle
}
.keyboardShortcut("i", modifiers: .command)
}
}
}
```
</app_commands>
<context_menus>
```swift
struct ItemRow: View {
let item: Item
let onDelete: () -> Void
let onDuplicate: () -> Void
var body: some View {
HStack {
Text(item.name)
Spacer()
}
.contextMenu {
Button("Duplicate") {
onDuplicate()
}
Button("Delete", role: .destructive) {
onDelete()
}
Divider()
Menu("Move to") {
ForEach(folders) { folder in
Button(folder.name) {
move(to: folder)
}
}
}
}
}
}
```
</context_menus>
</menus>
<lists_and_tables>
<list_selection>
```swift
struct SidebarView: View {
@Environment(AppState.self) private var appState
var body: some View {
@Bindable var appState = appState
List(appState.items, selection: $appState.selectedItemID) { item in
Label(item.name, systemImage: item.icon)
.tag(item.id)
}
.listStyle(.sidebar)
}
}
```
</list_selection>
<table>
```swift
struct ItemTableView: View {
@Environment(AppState.self) private var appState
@State private var sortOrder = [KeyPathComparator(\Item.name)]
var body: some View {
@Bindable var appState = appState
Table(appState.items, selection: $appState.selectedItemIDs, sortOrder: $sortOrder) {
TableColumn("Name", value: \.name) { item in
Text(item.name)
}
TableColumn("Date", value: \.createdAt) { item in
Text(item.createdAt.formatted(date: .abbreviated, time: .shortened))
}
.width(min: 100, ideal: 150)
TableColumn("Size", value: \.size) { item in
Text(ByteCountFormatter.string(fromByteCount: item.size, countStyle: .file))
}
.width(80)
}
.onChange(of: sortOrder) {
appState.items.sort(using: sortOrder)
}
}
}
```
</table>
<outline_group>
For hierarchical data:
```swift
struct OutlineView: View {
let rootItems: [TreeItem]
var body: some View {
List {
OutlineGroup(rootItems, children: \.children) { item in
Label(item.name, systemImage: item.icon)
}
}
}
}
struct TreeItem: Identifiable {
let id = UUID()
var name: String
var icon: String
var children: [TreeItem]?
}
```
</outline_group>
</lists_and_tables>
<forms>
<settings_form>
```swift
struct SettingsView: View {
@AppStorage("autoSave") private var autoSave = true
@AppStorage("saveInterval") private var saveInterval = 5
@AppStorage("theme") private var theme = "system"
var body: some View {
Form {
Section("General") {
Toggle("Auto-save documents", isOn: $autoSave)
if autoSave {
Stepper("Save every \(saveInterval) minutes", value: $saveInterval, in: 1...60)
}
}
Section("Appearance") {
Picker("Theme", selection: $theme) {
Text("System").tag("system")
Text("Light").tag("light")
Text("Dark").tag("dark")
}
.pickerStyle(.radioGroup)
}
}
.formStyle(.grouped)
.frame(width: 400)
.padding()
}
}
```
</settings_form>
<validation>
```swift
struct EditItemView: View {
@Binding var item: Item
@State private var isValid = true
var body: some View {
Form {
TextField("Name", text: $item.name)
.onChange(of: item.name) {
isValid = !item.name.isEmpty
}
if !isValid {
Text("Name is required")
.foregroundStyle(.red)
.font(.caption)
}
}
}
}
```
</validation>
</forms>
<sheets_and_alerts>
<sheet>
```swift
struct ContentView: View {
@State private var showingSheet = false
@State private var itemToEdit: Item?
var body: some View {
MainContent()
.sheet(isPresented: $showingSheet) {
SheetContent()
}
.sheet(item: $itemToEdit) { item in
EditItemView(item: item)
}
}
}
```
</sheet>
<confirmation_dialog>
```swift
struct ItemRow: View {
let item: Item
@State private var showingDeleteConfirmation = false
var body: some View {
Text(item.name)
.confirmationDialog(
"Delete \(item.name)?",
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
deleteItem()
}
} message: {
Text("This action cannot be undone.")
}
}
}
```
</confirmation_dialog>
<file_dialogs>
```swift
struct ContentView: View {
@State private var showingImporter = false
@State private var showingExporter = false
var body: some View {
VStack {
Button("Import") {
showingImporter = true
}
Button("Export") {
showingExporter = true
}
}
.fileImporter(
isPresented: $showingImporter,
allowedContentTypes: [.json, .plainText],
allowsMultipleSelection: true
) { result in
switch result {
case .success(let urls):
importFiles(urls)
case .failure(let error):
handleError(error)
}
}
.fileExporter(
isPresented: $showingExporter,
document: exportDocument,
contentType: .json,
defaultFilename: "export.json"
) { result in
// Handle result
}
}
}
```
</file_dialogs>
</sheets_and_alerts>
<drag_and_drop>
<draggable>
```swift
struct DraggableItem: View {
let item: Item
var body: some View {
Text(item.name)
.draggable(item.id.uuidString) {
// Preview
Label(item.name, systemImage: item.icon)
.padding()
.background(.regularMaterial)
.cornerRadius(8)
}
}
}
```
</draggable>
<drop_target>
```swift
struct DropTargetView: View {
@State private var isTargeted = false
var body: some View {
Rectangle()
.fill(isTargeted ? Color.accentColor.opacity(0.3) : Color.clear)
.dropDestination(for: String.self) { items, location in
for itemID in items {
handleDrop(itemID)
}
return true
} isTargeted: { targeted in
isTargeted = targeted
}
}
}
```
</drop_target>
<reorderable_list>
```swift
struct ReorderableList: View {
@State private var items = ["A", "B", "C", "D"]
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text(item)
}
.onMove { from, to in
items.move(fromOffsets: from, toOffset: to)
}
}
}
}
```
</reorderable_list>
</drag_and_drop>
<focus_and_keyboard>
<focus_state>
```swift
struct EditForm: View {
@State private var name = ""
@State private var description = ""
@FocusState private var focusedField: Field?
enum Field {
case name, description
}
var body: some View {
Form {
TextField("Name", text: $name)
.focused($focusedField, equals: .name)
TextField("Description", text: $description)
.focused($focusedField, equals: .description)
}
.onSubmit {
switch focusedField {
case .name:
focusedField = .description
case .description:
save()
case nil:
break
}
}
.onAppear {
focusedField = .name
}
}
}
```
</focus_state>
<keyboard_shortcuts>
**CRITICAL: Menu commands required for reliable keyboard shortcuts**
`.onKeyPress()` handlers ALONE are unreliable in SwiftUI. You MUST define menu commands with `.keyboardShortcut()` for keyboard shortcuts to work properly.
<correct_pattern>
**Step 1: Define menu command in App or WindowGroup:**
```swift
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
CommandMenu("Edit") {
EditLoopButton()
Divider()
DeleteButton()
}
}
}
}
// Menu command buttons with keyboard shortcuts
struct EditLoopButton: View {
@FocusedValue(\.selectedItem) private var selectedItem
var body: some View {
Button("Edit Item") {
// Perform action
}
.keyboardShortcut("e", modifiers: [])
.disabled(selectedItem == nil)
}
}
struct DeleteButton: View {
@FocusedValue(\.selectedItem) private var selectedItem
var body: some View {
Button("Delete Item") {
// Perform deletion
}
.keyboardShortcut(.delete, modifiers: [])
.disabled(selectedItem == nil)
}
}
```
**Step 2: Expose state via FocusedValues:**
```swift
// Define focused value keys
struct SelectedItemKey: FocusedValueKey {
typealias Value = Binding<Item?>
}
extension FocusedValues {
var selectedItem: Binding<Item?>? {
get { self[SelectedItemKey.self] }
set { self[SelectedItemKey.self] = newValue }
}
}
// In your view, expose the state
struct ContentView: View {
@State private var selectedItem: Item?
var body: some View {
ItemList(selection: $selectedItem)
.focusedSceneValue(\.selectedItem, $selectedItem)
}
}
```
**Why menu commands are required:**
- `.keyboardShortcut()` on menu buttons registers shortcuts at the system level
- `.onKeyPress()` alone only works when the view hierarchy receives events
- System menus (Edit, View, etc.) can intercept keys before `.onKeyPress()` fires
- Menu commands show shortcuts in the menu bar for discoverability
</correct_pattern>
<onKeyPress_usage>
**When to use `.onKeyPress()`:**
Use for keyboard **input** (typing, arrow keys for navigation):
```swift
struct ContentView: View {
@FocusState private var isInputFocused: Bool
var body: some View {
MainContent()
.onKeyPress(.upArrow) {
guard !isInputFocused else { return .ignored }
selectPrevious()
return .handled
}
.onKeyPress(.downArrow) {
guard !isInputFocused else { return .ignored }
selectNext()
return .handled
}
.onKeyPress(characters: .alphanumerics) { press in
guard !isInputFocused else { return .ignored }
handleTypeahead(press.characters)
return .handled
}
}
}
```
**Always check focus state** to prevent interfering with text input.
</onKeyPress_usage>
</keyboard_shortcuts>
</focus_and_keyboard>
<previews>
```swift
#Preview("Default") {
ContentView()
.environment(AppState())
}
#Preview("With Data") {
let state = AppState()
state.items = [
Item(name: "First"),
Item(name: "Second")
]
return ContentView()
.environment(state)
}
#Preview("Dark Mode") {
ContentView()
.environment(AppState())
.preferredColorScheme(.dark)
}
#Preview(traits: .fixedLayout(width: 800, height: 600)) {
ContentView()
.environment(AppState())
}
```
</previews>

View File

@@ -0,0 +1,532 @@
# System APIs
macOS system integration: file system, notifications, services, and automation.
<file_system>
<standard_directories>
```swift
let fileManager = FileManager.default
// App Support (persistent app data)
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let appFolder = appSupport.appendingPathComponent("MyApp", isDirectory: true)
// Documents (user documents)
let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
// Caches (temporary, can be deleted)
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
// Temporary (short-lived)
let temp = fileManager.temporaryDirectory
// Create directories
try? fileManager.createDirectory(at: appFolder, withIntermediateDirectories: true)
```
</standard_directories>
<file_operations>
```swift
// Read
let data = try Data(contentsOf: fileURL)
let string = try String(contentsOf: fileURL)
// Write
try data.write(to: fileURL, options: .atomic)
try string.write(to: fileURL, atomically: true, encoding: .utf8)
// Copy/Move
try fileManager.copyItem(at: source, to: destination)
try fileManager.moveItem(at: source, to: destination)
// Delete
try fileManager.removeItem(at: fileURL)
// Check existence
let exists = fileManager.fileExists(atPath: path)
// List directory
let contents = try fileManager.contentsOfDirectory(
at: folderURL,
includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey],
options: [.skipsHiddenFiles]
)
```
</file_operations>
<file_monitoring>
```swift
import CoreServices
class FileWatcher {
private var stream: FSEventStreamRef?
private var callback: () -> Void
init(path: String, onChange: @escaping () -> Void) {
self.callback = onChange
var context = FSEventStreamContext()
context.info = Unmanaged.passUnretained(self).toOpaque()
let paths = [path] as CFArray
stream = FSEventStreamCreate(
nil,
{ _, info, numEvents, eventPaths, _, _ in
guard let info = info else { return }
let watcher = Unmanaged<FileWatcher>.fromOpaque(info).takeUnretainedValue()
DispatchQueue.main.async {
watcher.callback()
}
},
&context,
paths,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.5, // Latency in seconds
FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents)
)
FSEventStreamSetDispatchQueue(stream!, DispatchQueue.global())
FSEventStreamStart(stream!)
}
deinit {
if let stream = stream {
FSEventStreamStop(stream)
FSEventStreamInvalidate(stream)
FSEventStreamRelease(stream)
}
}
}
// Usage
let watcher = FileWatcher(path: "/path/to/watch") {
print("Files changed!")
}
```
</file_monitoring>
<security_scoped_bookmarks>
For sandboxed apps to retain file access:
```swift
class BookmarkManager {
func saveBookmark(for url: URL) throws -> Data {
// User selected this file via NSOpenPanel
let bookmark = try url.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
return bookmark
}
func resolveBookmark(_ data: Data) throws -> URL {
var isStale = false
let url = try URL(
resolvingBookmarkData: data,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
// Start accessing
guard url.startAccessingSecurityScopedResource() else {
throw BookmarkError.accessDenied
}
// Remember to call stopAccessingSecurityScopedResource() when done
return url
}
}
```
</security_scoped_bookmarks>
</file_system>
<notifications>
<local_notifications>
```swift
import UserNotifications
class NotificationService {
private let center = UNUserNotificationCenter.current()
func requestPermission() async -> Bool {
do {
return try await center.requestAuthorization(options: [.alert, .sound, .badge])
} catch {
return false
}
}
func scheduleNotification(
title: String,
body: String,
at date: Date,
identifier: String
) async throws {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date)
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
try await center.add(request)
}
func scheduleImmediateNotification(title: String, body: String) async throws {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
try await center.add(request)
}
func cancelNotification(identifier: String) {
center.removePendingNotificationRequests(withIdentifiers: [identifier])
}
}
```
</local_notifications>
<notification_handling>
```swift
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
UNUserNotificationCenter.current().delegate = self
}
// Called when notification arrives while app is in foreground
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
[.banner, .sound]
}
// Called when user interacts with notification
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let identifier = response.notification.request.identifier
// Handle the notification tap
handleNotificationAction(identifier)
}
}
```
</notification_handling>
</notifications>
<launch_at_login>
```swift
import ServiceManagement
class LaunchAtLoginManager {
var isEnabled: Bool {
get {
SMAppService.mainApp.status == .enabled
}
set {
do {
if newValue {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
} catch {
print("Failed to update launch at login: \(error)")
}
}
}
}
// SwiftUI binding
struct SettingsView: View {
@State private var launchAtLogin = LaunchAtLoginManager()
var body: some View {
Toggle("Launch at Login", isOn: Binding(
get: { launchAtLogin.isEnabled },
set: { launchAtLogin.isEnabled = $0 }
))
}
}
```
</launch_at_login>
<nsworkspace>
```swift
import AppKit
let workspace = NSWorkspace.shared
// Open URL in browser
workspace.open(URL(string: "https://example.com")!)
// Open file with default app
workspace.open(fileURL)
// Open file with specific app
workspace.open(
[fileURL],
withApplicationAt: appURL,
configuration: NSWorkspace.OpenConfiguration()
)
// Reveal in Finder
workspace.activateFileViewerSelecting([fileURL])
// Get app for file type
if let appURL = workspace.urlForApplication(toOpen: fileURL) {
print("Default app: \(appURL)")
}
// Get running apps
let runningApps = workspace.runningApplications
for app in runningApps {
print("\(app.localizedName ?? "Unknown"): \(app.bundleIdentifier ?? "")")
}
// Get frontmost app
if let frontmost = workspace.frontmostApplication {
print("Frontmost: \(frontmost.localizedName ?? "")")
}
// Observe app launches
NotificationCenter.default.addObserver(
forName: NSWorkspace.didLaunchApplicationNotification,
object: workspace,
queue: .main
) { notification in
if let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication {
print("Launched: \(app.localizedName ?? "")")
}
}
```
</nsworkspace>
<process_management>
```swift
import Foundation
// Run shell command
func runCommand(_ command: String) async throws -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-c", command]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8) ?? ""
}
// Launch app
func launchApp(bundleIdentifier: String) {
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) {
NSWorkspace.shared.openApplication(at: url, configuration: NSWorkspace.OpenConfiguration())
}
}
// Check if app is running
func isAppRunning(bundleIdentifier: String) -> Bool {
NSWorkspace.shared.runningApplications.contains {
$0.bundleIdentifier == bundleIdentifier
}
}
```
</process_management>
<clipboard>
```swift
import AppKit
let pasteboard = NSPasteboard.general
// Write text
pasteboard.clearContents()
pasteboard.setString("Hello", forType: .string)
// Read text
if let string = pasteboard.string(forType: .string) {
print(string)
}
// Write URL
pasteboard.clearContents()
pasteboard.writeObjects([url as NSURL])
// Read URLs
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] {
print(urls)
}
// Write image
pasteboard.clearContents()
pasteboard.writeObjects([image])
// Monitor clipboard
class ClipboardMonitor {
private var timer: Timer?
private var lastChangeCount = 0
func start(onChange: @escaping (String?) -> Void) {
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
let changeCount = NSPasteboard.general.changeCount
if changeCount != self.lastChangeCount {
self.lastChangeCount = changeCount
onChange(NSPasteboard.general.string(forType: .string))
}
}
}
func stop() {
timer?.invalidate()
}
}
```
</clipboard>
<apple_events>
```swift
import AppKit
// Tell another app to do something (requires com.apple.security.automation.apple-events)
func tellFinderToEmptyTrash() {
let script = """
tell application "Finder"
empty trash
end tell
"""
var error: NSDictionary?
if let scriptObject = NSAppleScript(source: script) {
scriptObject.executeAndReturnError(&error)
if let error = error {
print("AppleScript error: \(error)")
}
}
}
// Get data from another app
func getFinderSelection() -> [URL] {
let script = """
tell application "Finder"
set selectedItems to selection
set itemPaths to {}
repeat with anItem in selectedItems
set end of itemPaths to POSIX path of (anItem as text)
end repeat
return itemPaths
end tell
"""
var error: NSDictionary?
if let scriptObject = NSAppleScript(source: script),
let result = scriptObject.executeAndReturnError(&error).coerce(toDescriptorType: typeAEList) {
var urls: [URL] = []
for i in 1...result.numberOfItems {
if let path = result.atIndex(i)?.stringValue {
urls.append(URL(fileURLWithPath: path))
}
}
return urls
}
return []
}
```
</apple_events>
<services>
<providing_services>
```swift
// Info.plist
/*
<key>NSServices</key>
<array>
<dict>
<key>NSMessage</key>
<string>processText</string>
<key>NSPortName</key>
<string>MyApp</string>
<key>NSSendTypes</key>
<array>
<string>public.plain-text</string>
</array>
<key>NSReturnTypes</key>
<array>
<string>public.plain-text</string>
</array>
<key>NSMenuItem</key>
<dict>
<key>default</key>
<string>Process with MyApp</string>
</dict>
</dict>
</array>
*/
class ServiceProvider: NSObject {
@objc func processText(
_ pboard: NSPasteboard,
userData: String,
error: AutoreleasingUnsafeMutablePointer<NSString?>
) {
guard let string = pboard.string(forType: .string) else {
error.pointee = "No text found" as NSString
return
}
// Process the text
let processed = string.uppercased()
// Return result
pboard.clearContents()
pboard.setString(processed, forType: .string)
}
}
// Register in AppDelegate
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.servicesProvider = ServiceProvider()
NSUpdateDynamicServices()
}
```
</providing_services>
</services>
<accessibility>
```swift
import AppKit
// Check if app has accessibility permissions
func hasAccessibilityPermission() -> Bool {
AXIsProcessTrusted()
}
// Request permission
func requestAccessibilityPermission() {
let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue(): true] as CFDictionary
AXIsProcessTrustedWithOptions(options)
}
// Check display settings
let workspace = NSWorkspace.shared
let reduceMotion = workspace.accessibilityDisplayShouldReduceMotion
let reduceTransparency = workspace.accessibilityDisplayShouldReduceTransparency
let increaseContrast = workspace.accessibilityDisplayShouldIncreaseContrast
```
</accessibility>

View File

@@ -0,0 +1,612 @@
# Testing and Debugging
Patterns for unit testing, UI testing, and debugging macOS apps.
<unit_testing>
<basic_test>
```swift
import XCTest
@testable import MyApp
final class DataServiceTests: XCTestCase {
var sut: DataService!
override func setUp() {
super.setUp()
sut = DataService()
}
override func tearDown() {
sut = nil
super.tearDown()
}
func testAddItem() {
// Given
let item = Item(name: "Test")
// When
sut.addItem(item)
// Then
XCTAssertEqual(sut.items.count, 1)
XCTAssertEqual(sut.items.first?.name, "Test")
}
func testDeleteItem() {
// Given
let item = Item(name: "Test")
sut.addItem(item)
// When
sut.deleteItem(item.id)
// Then
XCTAssertTrue(sut.items.isEmpty)
}
}
```
</basic_test>
<async_testing>
```swift
final class NetworkServiceTests: XCTestCase {
var sut: NetworkService!
var mockSession: MockURLSession!
override func setUp() {
super.setUp()
mockSession = MockURLSession()
sut = NetworkService(session: mockSession)
}
func testFetchProjects() async throws {
// Given
let expectedProjects = [Project(name: "Test")]
mockSession.data = try JSONEncoder().encode(expectedProjects)
mockSession.response = HTTPURLResponse(
url: URL(string: "https://api.example.com")!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)
// When
let projects: [Project] = try await sut.fetch(Endpoint.projects().request)
// Then
XCTAssertEqual(projects.count, 1)
XCTAssertEqual(projects.first?.name, "Test")
}
func testFetchError() async {
// Given
mockSession.error = NetworkError.timeout
// When/Then
do {
let _: [Project] = try await sut.fetch(Endpoint.projects().request)
XCTFail("Expected error")
} catch {
XCTAssertTrue(error is NetworkError)
}
}
}
```
</async_testing>
<testing_observables>
```swift
final class AppStateTests: XCTestCase {
func testAddItem() {
// Given
let sut = AppState()
// When
sut.addItem(Item(name: "Test"))
// Then
XCTAssertEqual(sut.items.count, 1)
}
func testSelectedItem() {
// Given
let sut = AppState()
let item = Item(name: "Test")
sut.items = [item]
// When
sut.selectedItemID = item.id
// Then
XCTAssertEqual(sut.selectedItem?.name, "Test")
}
}
```
</testing_observables>
<mock_dependencies>
```swift
// Protocol for testability
protocol DataStoreProtocol {
func fetchAll() async throws -> [Item]
func save(_ item: Item) async throws
}
// Mock implementation
class MockDataStore: DataStoreProtocol {
var itemsToReturn: [Item] = []
var savedItems: [Item] = []
var shouldThrow = false
func fetchAll() async throws -> [Item] {
if shouldThrow { throw TestError.mock }
return itemsToReturn
}
func save(_ item: Item) async throws {
if shouldThrow { throw TestError.mock }
savedItems.append(item)
}
}
enum TestError: Error {
case mock
}
// Test using mock
final class ViewModelTests: XCTestCase {
func testLoadItems() async throws {
// Given
let mockStore = MockDataStore()
mockStore.itemsToReturn = [Item(name: "Test")]
let sut = ViewModel(dataStore: mockStore)
// When
await sut.loadItems()
// Then
XCTAssertEqual(sut.items.count, 1)
}
}
```
</mock_dependencies>
<testing_swiftdata>
```swift
final class SwiftDataTests: XCTestCase {
var container: ModelContainer!
var context: ModelContext!
override func setUp() {
super.setUp()
let schema = Schema([Project.self, Task.self])
let config = ModelConfiguration(isStoredInMemoryOnly: true)
container = try! ModelContainer(for: schema, configurations: config)
context = ModelContext(container)
}
func testCreateProject() throws {
// Given
let project = Project(name: "Test")
// When
context.insert(project)
try context.save()
// Then
let descriptor = FetchDescriptor<Project>()
let projects = try context.fetch(descriptor)
XCTAssertEqual(projects.count, 1)
XCTAssertEqual(projects.first?.name, "Test")
}
func testCascadeDelete() throws {
// Given
let project = Project(name: "Test")
let task = Task(title: "Task")
task.project = project
context.insert(project)
context.insert(task)
try context.save()
// When
context.delete(project)
try context.save()
// Then
let tasks = try context.fetch(FetchDescriptor<Task>())
XCTAssertTrue(tasks.isEmpty)
}
}
```
</testing_swiftdata>
</unit_testing>
<swiftdata_debugging>
<verify_relationships>
When SwiftData items aren't appearing or relationships seem broken:
```swift
// Debug print to verify relationships
func debugRelationships(for column: Column) {
print("=== Column: \(column.name) ===")
print("Cards count: \(column.cards.count)")
for card in column.cards {
print(" - Card: \(card.title)")
print(" Card's column: \(card.column?.name ?? "NIL")")
}
}
// Verify inverse relationships are set
func verifyCard(_ card: Card) {
if card.column == nil {
print("⚠️ Card '\(card.title)' has no column set!")
} else {
let inParentArray = card.column!.cards.contains { $0.id == card.id }
print("Card in column.cards: \(inParentArray)")
}
}
```
</verify_relationships>
<common_swiftdata_issues>
**Issue: Items not appearing in list**
Symptoms: Added items don't show, count is 0
Debug steps:
```swift
// 1. Check modelContext has the item
let descriptor = FetchDescriptor<Card>()
let allCards = try? modelContext.fetch(descriptor)
print("Total cards in context: \(allCards?.count ?? 0)")
// 2. Check relationship is set
if let card = allCards?.first {
print("Card column: \(card.column?.name ?? "NIL")")
}
// 3. Check parent's array
print("Column.cards count: \(column.cards.count)")
```
Common causes:
- Forgot `modelContext.insert(item)` for new objects
- Didn't set inverse relationship (`card.column = column`)
- Using wrong modelContext (view context vs background context)
</common_swiftdata_issues>
<inspect_database>
```swift
// Print database location
func printDatabaseLocation() {
let url = URL.applicationSupportDirectory
.appendingPathComponent("default.store")
print("Database: \(url.path)")
}
// Dump all items of a type
func dumpAllItems<T: PersistentModel>(_ type: T.Type, context: ModelContext) {
let descriptor = FetchDescriptor<T>()
if let items = try? context.fetch(descriptor) {
print("=== \(String(describing: T.self)) (\(items.count)) ===")
for item in items {
print(" \(item)")
}
}
}
// Usage
dumpAllItems(Column.self, context: modelContext)
dumpAllItems(Card.self, context: modelContext)
```
</inspect_database>
<logging_swiftdata_operations>
```swift
import os
let dataLogger = Logger(subsystem: "com.yourapp", category: "SwiftData")
// Log when adding items
func addCard(to column: Column, title: String) {
let card = Card(title: title, position: 1.0)
card.column = column
modelContext.insert(card)
dataLogger.debug("Added card '\(title)' to column '\(column.name)'")
dataLogger.debug("Column now has \(column.cards.count) cards")
}
// Log when relationships change
func moveCard(_ card: Card, to newColumn: Column) {
let oldColumn = card.column?.name ?? "none"
card.column = newColumn
dataLogger.debug("Moved '\(card.title)' from '\(oldColumn)' to '\(newColumn.name)'")
}
// View logs in Console.app or:
// log stream --predicate 'subsystem == "com.yourapp" AND category == "SwiftData"' --level debug
```
</logging_swiftdata_operations>
<symptom_cause_table>
**Quick reference for common SwiftData symptoms:**
| Symptom | Likely Cause | Fix |
|---------|--------------|-----|
| Items don't appear | Missing `insert()` | Call `modelContext.insert(item)` |
| Items appear once then disappear | Inverse relationship not set | Set `child.parent = parent` before insert |
| Changes don't persist | Wrong context | Use same modelContext throughout |
| @Query returns empty | Schema mismatch | Verify @Model matches container schema |
| Cascade delete fails | Missing deleteRule | Add `@Relationship(deleteRule: .cascade)` |
| Relationship array always empty | Not using inverse | Set inverse on child, not append on parent |
</symptom_cause_table>
</swiftdata_debugging>
<ui_testing>
```swift
import XCTest
final class MyAppUITests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}
func testAddItem() {
// Tap add button
app.buttons["Add"].click()
// Verify item appears in list
XCTAssertTrue(app.staticTexts["New Item"].exists)
}
func testRenameItem() {
// Add item first
app.buttons["Add"].click()
// Select and rename
app.staticTexts["New Item"].click()
let textField = app.textFields["Name"]
textField.click()
textField.typeText("Renamed Item")
// Verify
XCTAssertTrue(app.staticTexts["Renamed Item"].exists)
}
func testDeleteItem() {
// Add item
app.buttons["Add"].click()
// Right-click and delete
app.staticTexts["New Item"].rightClick()
app.menuItems["Delete"].click()
// Verify deleted
XCTAssertFalse(app.staticTexts["New Item"].exists)
}
}
```
</ui_testing>
<debugging>
<os_log>
```swift
import os
let logger = Logger(subsystem: "com.yourcompany.MyApp", category: "General")
// Log levels
logger.debug("Debug info")
logger.info("General info")
logger.notice("Notable event")
logger.error("Error occurred")
logger.fault("Critical failure")
// With interpolation
logger.info("Loaded \(items.count) items")
// Privacy for sensitive data
logger.info("User: \(username, privacy: .private)")
// In console
// log stream --predicate 'subsystem == "com.yourcompany.MyApp"' --level debug
```
</os_log>
<signposts>
```swift
import os
let signposter = OSSignposter(subsystem: "com.yourcompany.MyApp", category: "Performance")
func loadData() async {
let signpostID = signposter.makeSignpostID()
let state = signposter.beginInterval("Load Data", id: signpostID)
// Work
await fetchFromNetwork()
signposter.endInterval("Load Data", state)
}
// Interval with metadata
func processItem(_ item: Item) {
let state = signposter.beginInterval("Process Item", id: signposter.makeSignpostID())
// Work
process(item)
signposter.endInterval("Process Item", state, "Processed \(item.name)")
}
```
</signposts>
<breakpoint_actions>
```swift
// Symbolic breakpoints in Xcode:
// - Symbol: `-[NSException raise]` to catch all exceptions
// - Symbol: `UIViewAlertForUnsatisfiableConstraints` for layout issues
// In code, trigger debugger
func criticalFunction() {
guard condition else {
#if DEBUG
raise(SIGINT) // Triggers breakpoint
#endif
return
}
}
```
</breakpoint_actions>
<memory_debugging>
```swift
// Check for leaks with weak references
class DebugHelper {
static func trackDeallocation<T: AnyObject>(_ object: T, name: String) {
let observer = DeallocObserver(name: name)
objc_setAssociatedObject(object, "deallocObserver", observer, .OBJC_ASSOCIATION_RETAIN)
}
}
class DeallocObserver {
let name: String
init(name: String) {
self.name = name
}
deinit {
print("\(name) deallocated")
}
}
// Usage in tests
func testNoMemoryLeak() {
weak var weakRef: ViewModel?
autoreleasepool {
let vm = ViewModel()
weakRef = vm
DebugHelper.trackDeallocation(vm, name: "ViewModel")
}
XCTAssertNil(weakRef, "ViewModel should be deallocated")
}
```
</memory_debugging>
</debugging>
<common_issues>
<memory_leaks>
**Symptom**: Memory grows over time, objects not deallocated
**Common causes**:
- Strong reference cycles in closures
- Delegate not weak
- NotificationCenter observers not removed
**Fix**:
```swift
// Use [weak self]
someService.fetch { [weak self] result in
self?.handle(result)
}
// Weak delegates
weak var delegate: MyDelegate?
// Remove observers
deinit {
NotificationCenter.default.removeObserver(self)
}
```
</memory_leaks>
<main_thread_violations>
**Symptom**: Purple warnings, UI not updating, crashes
**Fix**:
```swift
// Ensure UI updates on main thread
Task { @MainActor in
self.items = fetchedItems
}
// Or use DispatchQueue
DispatchQueue.main.async {
self.tableView.reloadData()
}
```
</main_thread_violations>
<swiftui_not_updating>
**Symptom**: View doesn't reflect state changes
**Common causes**:
- Missing @Observable
- Property not being tracked
- Binding not connected
**Fix**:
```swift
// Ensure class is @Observable
@Observable
class AppState {
var items: [Item] = [] // This will be tracked
}
// Use @Bindable for mutations
@Bindable var appState = appState
TextField("Name", text: $appState.name)
```
</swiftui_not_updating>
</common_issues>
<test_coverage>
```bash
# Build with coverage
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-enableCodeCoverage YES \
-derivedDataPath ./build \
test
# View coverage report
xcrun xccov view --report ./build/Logs/Test/*.xcresult
```
</test_coverage>
<performance_testing>
```swift
func testPerformanceLoadLargeDataset() {
measure {
let items = (0..<10000).map { Item(name: "Item \($0)") }
sut.items = items
}
}
// With options
func testPerformanceWithMetrics() {
let metrics: [XCTMetric] = [
XCTClockMetric(),
XCTMemoryMetric(),
XCTCPUMetric()
]
measure(metrics: metrics) {
performHeavyOperation()
}
}
```
</performance_testing>

View File

@@ -0,0 +1,222 @@
<overview>
Test-Driven Development patterns for macOS apps. Write tests first, implement minimal code to pass, refactor while keeping tests green. Covers SwiftData testing, network mocking, @Observable state testing, and UI testing patterns.
</overview>
<tdd_workflow>
Test-Driven Development cycle for macOS apps:
1. **Write failing test** - Specify expected behavior
2. **Run test** - Verify RED (fails as expected)
3. **Implement** - Minimal code to pass
4. **Run test** - Verify GREEN (passes)
5. **Refactor** - Clean up while keeping green
6. **Run suite** - Ensure no regressions
Repeat for each feature. Keep tests running fast.
</tdd_workflow>
<test_organization>
```
MyApp/
├── MyApp/
│ └── ... (production code)
└── MyAppTests/
├── ModelTests/
│ ├── ItemTests.swift
│ └── ItemStoreTests.swift
├── ServiceTests/
│ ├── NetworkServiceTests.swift
│ └── StorageServiceTests.swift
└── ViewModelTests/
└── AppStateTests.swift
```
Group tests by layer. One test file per production file/class.
</test_organization>
<testing_swiftdata>
SwiftData requires ModelContainer. Create in-memory container for tests:
```swift
@MainActor
class ItemTests: XCTestCase {
var container: ModelContainer!
var context: ModelContext!
override func setUp() async throws {
// In-memory container (doesn't persist)
let schema = Schema([Item.self, Tag.self])
let config = ModelConfiguration(isStoredInMemoryOnly: true)
container = try ModelContainer(for: schema, configurations: config)
context = ModelContext(container)
}
override func tearDown() {
container = nil
context = nil
}
func testCreateItem() throws {
let item = Item(name: "Test")
context.insert(item)
try context.save()
let fetched = try context.fetch(FetchDescriptor<Item>())
XCTAssertEqual(fetched.count, 1)
XCTAssertEqual(fetched.first?.name, "Test")
}
}
```
</testing_swiftdata>
<testing_relationships>
Critical: Test relationship behavior with in-memory container:
```swift
func testDeletingParentCascadesToChildren() throws {
let parent = Parent(name: "Parent")
let child1 = Child(name: "Child1")
let child2 = Child(name: "Child2")
child1.parent = parent
child2.parent = parent
context.insert(parent)
context.insert(child1)
context.insert(child2)
try context.save()
context.delete(parent)
try context.save()
let children = try context.fetch(FetchDescriptor<Child>())
XCTAssertEqual(children.count, 0) // Cascade delete worked
}
```
</testing_relationships>
<mocking_network>
```swift
protocol NetworkSession {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: NetworkSession {}
class MockNetworkSession: NetworkSession {
var mockData: Data?
var mockResponse: URLResponse?
var mockError: Error?
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
if let error = mockError { throw error }
return (mockData ?? Data(), mockResponse ?? URLResponse())
}
}
// Test
func testFetchItems() async throws {
let json = """
[{"id": 1, "name": "Test"}]
""".data(using: .utf8)!
let mock = MockNetworkSession()
mock.mockData = json
mock.mockResponse = HTTPURLResponse(url: URL(string: "https://api.example.com")!,
statusCode: 200,
httpVersion: nil,
headerFields: nil)
let service = NetworkService(session: mock)
let items = try await service.fetchItems()
XCTAssertEqual(items.count, 1)
XCTAssertEqual(items.first?.name, "Test")
}
```
</mocking_network>
<testing_observable>
Test @Observable state changes:
```swift
func testAppStateUpdatesOnAdd() {
let appState = AppState()
XCTAssertEqual(appState.items.count, 0)
appState.addItem(Item(name: "Test"))
XCTAssertEqual(appState.items.count, 1)
XCTAssertEqual(appState.items.first?.name, "Test")
}
func testSelectionChanges() {
let appState = AppState()
let item = Item(name: "Test")
appState.addItem(item)
appState.selectedItemID = item.id
XCTAssertEqual(appState.selectedItem?.id, item.id)
}
```
</testing_observable>
<ui_testing>
Use XCUITest for critical user flows:
```swift
class MyAppUITests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
app = XCUIApplication()
app.launch()
}
func testAddItemFlow() {
app.buttons["Add"].click()
let nameField = app.textFields["Name"]
nameField.click()
nameField.typeText("New Item")
app.buttons["Save"].click()
XCTAssertTrue(app.staticTexts["New Item"].exists)
}
}
```
Keep UI tests minimal (slow, brittle). Test critical flows only.
</ui_testing>
<what_not_to_test>
Don't test:
- SwiftUI framework itself
- URLSession (Apple's code)
- File system (use mocks)
Do test:
- Your business logic
- State management
- Data transformations
- Service layer with mocks
</what_not_to_test>
<running_tests>
```bash
# Run all tests
xcodebuild test -scheme MyApp -destination 'platform=macOS'
# Run unit tests only (fast)
xcodebuild test -scheme MyApp -destination 'platform=macOS' -only-testing:MyAppTests
# Run UI tests only (slow)
xcodebuild test -scheme MyApp -destination 'platform=macOS' -only-testing:MyAppUITests
# Watch mode
find . -name "*.swift" | entr xcodebuild test -scheme MyApp -destination 'platform=macOS' -only-testing:MyAppTests
```
</running_tests>

View File

@@ -0,0 +1,145 @@
# Workflow: Add a Feature to an Existing App
<required_reading>
**Read these reference files NOW:**
1. references/app-architecture.md
2. references/swiftui-patterns.md
**Plus relevant refs based on feature type** (see Step 2).
</required_reading>
<process>
## Step 1: Understand the Feature
Ask the user:
- What should the feature do?
- Where in the app does it belong?
- Any specific requirements or constraints?
## Step 2: Read Relevant References
Based on feature type, read additional references:
| Feature Type | Additional References |
|--------------|----------------------|
| Data persistence | references/data-persistence.md |
| Networking/API | references/networking.md |
| File handling | references/document-apps.md |
| Background tasks | references/concurrency-patterns.md |
| System integration | references/system-apis.md |
| Menu bar | references/menu-bar-apps.md |
| Extensions | references/app-extensions.md |
| UI polish | references/design-system.md, references/macos-polish.md |
## Step 3: Understand Existing Code
Read the relevant parts of the existing codebase:
- App entry point (usually AppName.swift or AppNameApp.swift)
- State management (AppState, models)
- Existing views related to the feature area
Identify:
- How state flows through the app
- Existing patterns to follow
- Where the new feature fits
## Step 4: Plan the Implementation
Before writing code:
1. Identify new files/types needed
2. Identify existing files to modify
3. Plan the data flow
4. Consider edge cases
## Step 5: Implement with TDD
Follow test-driven development:
1. Write failing test for new behavior
2. Run → RED
3. Implement minimal code
4. Run → GREEN
5. Refactor
6. Repeat
## Step 6: Integrate
- Wire up new views to navigation
- Connect to existing state management
- Add menu items/shortcuts if applicable
- Handle errors gracefully
## Step 7: Build and Test
```bash
# Build
xcodebuild -project AppName.xcodeproj -scheme AppName build 2>&1 | xcsift
# Run tests
xcodebuild test -project AppName.xcodeproj -scheme AppName
# Launch for manual testing
open ./build/Build/Products/Debug/AppName.app
```
## Step 8: Polish
- Add keyboard shortcuts (references/macos-polish.md)
- Ensure accessibility
- Match existing UI patterns
</process>
<integration_patterns>
**Adding to state:**
```swift
// In AppState
@Observable
class AppState {
// Add new property
var newFeatureData: [NewType] = []
// Add new methods
func performNewFeature() { ... }
}
```
**Adding a new view:**
```swift
struct NewFeatureView: View {
@Environment(AppState.self) private var appState
var body: some View {
// Use existing patterns from app
}
}
```
**Adding to navigation:**
```swift
// In existing NavigationSplitView or similar
NavigationLink("New Feature", destination: NewFeatureView())
```
**Adding menu command:**
```swift
struct AppCommands: Commands {
var body: some Commands {
CommandGroup(after: .newItem) {
Button("New Feature Action") {
// action
}
.keyboardShortcut("N", modifiers: [.command, .shift])
}
}
}
```
</integration_patterns>
<success_criteria>
Feature is complete when:
- Functionality works as specified
- Tests pass
- Follows existing code patterns
- UI matches app style
- Keyboard shortcuts work
- No regressions in existing features
</success_criteria>

View File

@@ -0,0 +1,98 @@
# Workflow: Build a New macOS App
<required_reading>
**Read these reference files NOW before writing any code:**
1. references/project-scaffolding.md
2. references/cli-workflow.md
3. references/app-architecture.md
4. references/swiftui-patterns.md
</required_reading>
<process>
## Step 1: Clarify Requirements
Ask the user:
- What does the app do? (core functionality)
- What type of app? (document-based, shoebox/library, menu bar utility, single-window)
- Any specific features needed? (persistence, networking, system integration)
## Step 2: Choose App Archetype
Based on requirements, select:
| Type | When to Use | Reference |
|------|-------------|-----------|
| Document-based | User creates/saves files | references/document-apps.md |
| Shoebox/Library | Internal database, no explicit save | references/shoebox-apps.md |
| Menu bar utility | Background functionality, quick actions | references/menu-bar-apps.md |
| Single-window | Focused task, simple UI | (use base patterns) |
Read the relevant app type reference if not single-window.
## Step 3: Scaffold Project
Use XcodeGen (recommended):
```bash
# Create project structure
mkdir -p AppName/Sources
cd AppName
# Create project.yml (see references/project-scaffolding.md for template)
# Create Swift files
# Generate xcodeproj
xcodegen generate
```
## Step 4: Implement with TDD
Follow test-driven development:
1. Write failing test
2. Run → RED
3. Implement minimal code
4. Run → GREEN
5. Refactor
6. Repeat
See references/testing-tdd.md for patterns.
## Step 5: Build and Verify
```bash
# Build
xcodebuild -project AppName.xcodeproj -scheme AppName build 2>&1 | xcsift
# Run
open ./build/Build/Products/Debug/AppName.app
```
## Step 6: Polish
Read references/macos-polish.md for:
- Keyboard shortcuts
- Menu bar integration
- Accessibility
- State restoration
</process>
<anti_patterns>
Avoid:
- Massive view models - views ARE the view model in SwiftUI
- Fighting SwiftUI - use declarative patterns
- Ignoring platform conventions - standard shortcuts, menus, windows
- Blocking main thread - async/await for all I/O
- Hard-coded paths - use FileManager APIs
- Retain cycles - use `[weak self]` in escaping closures
</anti_patterns>
<success_criteria>
A well-built macOS app:
- Follows macOS conventions (menu bar, shortcuts, window behavior)
- Uses SwiftUI for UI with AppKit integration where needed
- Manages state with @Observable and environment
- Persists data appropriately
- Handles errors gracefully
- Supports accessibility
- Builds and runs from CLI without opening Xcode
- Feels native and responsive
</success_criteria>

View File

@@ -0,0 +1,198 @@
# Workflow: Debug an Existing macOS App
<required_reading>
**Read these reference files NOW:**
1. references/cli-observability.md
2. references/testing-debugging.md
</required_reading>
<philosophy>
Debugging is iterative. Use whatever gets you to the root cause fastest:
- Small app, obvious symptom → read relevant code
- Large codebase, unclear cause → use tools to narrow down
- Code looks correct but fails → tools reveal runtime behavior
- After fixing → tools verify the fix
The goal is root cause, not following a ritual.
</philosophy>
<process>
## Step 1: Understand the Symptom
Ask the user or observe:
- What's the actual behavior vs expected?
- When does it happen? (startup, after action, under load)
- Is it reproducible?
- Any error messages?
## Step 2: Build and Check for Compile Errors
```bash
cd /path/to/app
xcodebuild -project AppName.xcodeproj -scheme AppName -derivedDataPath ./build build 2>&1 | xcsift
```
Fix any compile errors first. They're the easiest wins.
## Step 3: Choose Your Approach
**If you know roughly where the problem is:**
→ Read that code, form hypothesis, test it
**If you have no idea where to start:**
→ Use tools to narrow down (Step 4)
**If code looks correct but behavior is wrong:**
→ Runtime observation (Step 4) reveals what's actually happening
## Step 4: Runtime Diagnostics
Launch with log streaming:
```bash
# Terminal 1: stream logs
log stream --level debug --predicate 'subsystem == "com.company.AppName"'
# Terminal 2: launch
open ./build/Build/Products/Debug/AppName.app
```
**Match symptom to tool:**
| Symptom | Tool | Command |
|---------|------|---------|
| Memory growing / leak suspected | leaks | `leaks AppName` |
| UI freezes / hangs | spindump | `spindump AppName -o /tmp/hang.txt` |
| Crash | crash report | `cat ~/Library/Logs/DiagnosticReports/AppName_*.ips` |
| Slow performance | time profiler | `xcrun xctrace record --template 'Time Profiler' --attach AppName` |
| Race condition suspected | thread sanitizer | Build with `-enableThreadSanitizer YES` |
| Nothing happens / silent failure | logs | Check log stream output |
**Interact with the app** to trigger the issue. Use `cliclick` if available:
```bash
cliclick c:500,300 # click at coordinates
```
## Step 5: Interpret Tool Output
| Tool Shows | Likely Cause | Where to Look |
|------------|--------------|---------------|
| Leaked object: DataService | Retain cycle | Closures capturing self in DataService |
| Main thread blocked in computeX | Sync work on main | That function - needs async |
| Crash at force unwrap | Nil where unexpected | The unwrap site + data flow to it |
| Thread sanitizer warning | Data race | Shared mutable state without sync |
| High CPU in function X | Hot path | That function - algorithm or loop issue |
## Step 6: Read Relevant Code
Now you know where to look. Read that specific code:
- Understand what it's trying to do
- Identify the flaw
- Consider edge cases
## Step 7: Fix the Root Cause
Not the symptom. The actual cause.
**Bad:** Add nil check to prevent crash
**Good:** Fix why the value is nil in the first place
**Bad:** Add try/catch to swallow error
**Good:** Fix what's causing the error
## Step 8: Verify the Fix
Use the same diagnostic that found the issue:
```bash
# Rebuild
xcodebuild -project AppName.xcodeproj -scheme AppName build
# Launch and test
open ./build/Build/Products/Debug/AppName.app
# Run same diagnostic
leaks AppName # should show 0 leaks now
```
## Step 9: Prevent Regression
If the bug was significant, write a test:
```bash
xcodebuild test -project AppName.xcodeproj -scheme AppName
```
</process>
<common_patterns>
## Memory Leaks
**Symptom:** Memory grows over time, `leaks` shows retained objects
**Common causes:**
- Closure captures `self` strongly: `{ self.doThing() }`
- Delegate not weak: `var delegate: SomeProtocol`
- Timer not invalidated
**Fix:** `[weak self]`, `weak var delegate`, `timer.invalidate()`
## UI Freezes
**Symptom:** App hangs, spinning beachball, spindump shows main thread blocked
**Common causes:**
- Sync network call on main thread
- Heavy computation on main thread
- Deadlock from incorrect async/await usage
**Fix:** `Task { }`, `Task.detached { }`, check actor isolation
## Crashes
**Symptom:** App terminates, crash report generated
**Common causes:**
- Force unwrap of nil: `value!`
- Array index out of bounds
- Unhandled error
**Fix:** `guard let`, bounds checking, proper error handling
## Silent Failures
**Symptom:** Nothing happens, no error, no crash
**Common causes:**
- Error silently caught and ignored
- Async task never awaited
- Condition always false
**Fix:** Add logging, check control flow, verify async chains
## Performance Issues
**Symptom:** Slow, high CPU, laggy UI
**Common causes:**
- O(n²) or worse algorithm
- Unnecessary re-renders in SwiftUI
- Repeated expensive operations
**Fix:** Better algorithm, memoization, `let _ = Self._printChanges()`
</common_patterns>
<tools_quick_reference>
```bash
# Build errors (structured JSON)
xcodebuild build 2>&1 | xcsift
# Real-time logs
log stream --level debug --predicate 'subsystem == "com.company.App"'
# Memory leaks
leaks AppName
# UI hangs
spindump AppName -o /tmp/hang.txt
# Crash reports
cat ~/Library/Logs/DiagnosticReports/AppName_*.ips | head -100
# Memory regions
vmmap --summary AppName
# Heap analysis
heap AppName
# Attach debugger
lldb -n AppName
# CPU profiling
xcrun xctrace record --template 'Time Profiler' --attach AppName
# Thread issues (build flag)
xcodebuild build -enableThreadSanitizer YES
```
</tools_quick_reference>

View File

@@ -0,0 +1,244 @@
# Workflow: Optimize App Performance
<required_reading>
**Read these reference files NOW:**
1. references/cli-observability.md
2. references/concurrency-patterns.md
3. references/swiftui-patterns.md
</required_reading>
<philosophy>
Measure first, optimize second. Never optimize based on assumptions.
Profile → Identify bottleneck → Fix → Measure again → Repeat
</philosophy>
<process>
## Step 1: Define the Problem
Ask the user:
- What feels slow? (startup, specific action, scrolling, etc.)
- How slow? (seconds, milliseconds, "laggy")
- When did it start? (always, after recent change, with more data)
## Step 2: Measure Current Performance
**CPU Profiling:**
```bash
# Record 30 seconds of activity
xcrun xctrace record \
--template 'Time Profiler' \
--time-limit 30s \
--output profile.trace \
--launch -- ./build/Build/Products/Debug/AppName.app/Contents/MacOS/AppName
```
**Memory:**
```bash
# While app is running
vmmap --summary AppName
heap AppName
leaks AppName
```
**Startup time:**
```bash
# Measure launch to first frame
time open -W ./build/Build/Products/Debug/AppName.app
```
## Step 3: Identify Bottlenecks
**From Time Profiler:**
- Look for functions with high "self time"
- Check main thread for blocking operations
- Look for repeated calls that could be cached
**From memory tools:**
- Large allocations that could be lazy-loaded
- Objects retained longer than needed
- Duplicate data in memory
**SwiftUI re-renders:**
```swift
// Add to any view to see why it re-renders
var body: some View {
let _ = Self._printChanges()
// ...
}
```
## Step 4: Common Optimizations
### Main Thread
**Problem:** Heavy work on main thread
```swift
// Bad
func loadData() {
let data = expensiveComputation() // blocks UI
self.items = data
}
// Good
func loadData() async {
let data = await Task.detached {
expensiveComputation()
}.value
await MainActor.run {
self.items = data
}
}
```
### SwiftUI
**Problem:** Unnecessary re-renders
```swift
// Bad - entire view rebuilds when any state changes
struct ListView: View {
@State var items: [Item]
@State var searchText: String
// ...
}
// Good - extract subviews with their own state
struct ListView: View {
var body: some View {
VStack {
SearchBar() // has its own @State
ItemList() // only rebuilds when items change
}
}
}
```
**Problem:** Expensive computation in body
```swift
// Bad
var body: some View {
List(items.sorted().filtered()) // runs every render
// Good
var sortedItems: [Item] { // or use .task modifier
items.sorted().filtered()
}
var body: some View {
List(sortedItems)
}
```
### Data Loading
**Problem:** Loading all data upfront
```swift
// Bad
init() {
self.allItems = loadEverything() // slow startup
}
// Good - lazy loading
func loadItemsIfNeeded() async {
guard items.isEmpty else { return }
items = await loadItems()
}
```
**Problem:** No caching
```swift
// Bad
func getImage(for url: URL) async -> NSImage {
return await downloadImage(url) // downloads every time
}
// Good
private var imageCache: [URL: NSImage] = [:]
func getImage(for url: URL) async -> NSImage {
if let cached = imageCache[url] { return cached }
let image = await downloadImage(url)
imageCache[url] = image
return image
}
```
### Collections
**Problem:** O(n²) operations
```swift
// Bad - O(n) lookup in array
items.first { $0.id == targetId }
// Good - O(1) lookup with dictionary
itemsById[targetId]
```
**Problem:** Repeated filtering
```swift
// Bad
let activeItems = items.filter { $0.isActive } // called repeatedly
// Good - compute once, update when needed
@Published var activeItems: [Item] = []
func updateActiveItems() {
activeItems = items.filter { $0.isActive }
}
```
## Step 5: Measure Again
After each optimization:
```bash
# Re-run profiler
xcrun xctrace record --template 'Time Profiler' ...
# Compare metrics
```
Did it actually improve? If not, revert and try different approach.
## Step 6: Prevent Regression
Add performance tests:
```swift
func testStartupPerformance() {
measure {
// startup code
}
}
func testScrollingPerformance() {
measure(metrics: [XCTCPUMetric(), XCTMemoryMetric()]) {
// scroll simulation
}
}
```
</process>
<performance_targets>
| Metric | Target | Unacceptable |
|--------|--------|--------------|
| App launch | < 1 second | > 3 seconds |
| Button response | < 100ms | > 500ms |
| List scrolling | 60 fps | < 30 fps |
| Memory (idle) | < 100MB | > 500MB |
| Memory growth | Stable | Unbounded |
</performance_targets>
<tools_reference>
```bash
# CPU profiling
xcrun xctrace record --template 'Time Profiler' --attach AppName
# Memory snapshot
vmmap --summary AppName
heap AppName
# Allocations over time
xcrun xctrace record --template 'Allocations' --attach AppName
# Energy impact
xcrun xctrace record --template 'Energy Log' --attach AppName
# System trace (comprehensive)
xcrun xctrace record --template 'System Trace' --attach AppName
```
</tools_reference>

View File

@@ -0,0 +1,159 @@
# Workflow: Ship/Release a macOS App
<required_reading>
**Read these reference files NOW:**
1. references/security-code-signing.md
2. references/cli-workflow.md
</required_reading>
<process>
## Step 1: Prepare for Release
Ensure the app is ready:
- All features complete and tested
- No debug code or test data
- Version and build numbers updated in Info.plist
- App icon and assets finalized
```bash
# Verify build succeeds
xcodebuild -project AppName.xcodeproj -scheme AppName -configuration Release build
```
## Step 2: Choose Distribution Method
| Method | Use When | Requires |
|--------|----------|----------|
| Direct distribution | Sharing with specific users, beta testing | Developer ID signing + notarization |
| App Store | Public distribution, paid apps | App Store Connect account, review |
| TestFlight | Beta testing at scale | App Store Connect |
## Step 3: Code Signing
**For Direct Distribution (Developer ID):**
```bash
# Archive
xcodebuild -project AppName.xcodeproj \
-scheme AppName \
-configuration Release \
-archivePath ./build/AppName.xcarchive \
archive
# Export with Developer ID
xcodebuild -exportArchive \
-archivePath ./build/AppName.xcarchive \
-exportPath ./build/export \
-exportOptionsPlist ExportOptions.plist
```
ExportOptions.plist for Developer ID:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>developer-id</string>
<key>signingStyle</key>
<string>automatic</string>
</dict>
</plist>
```
**For App Store:**
```xml
<key>method</key>
<string>app-store</string>
```
## Step 4: Notarization (Direct Distribution)
Required for apps distributed outside the App Store:
```bash
# Submit for notarization
xcrun notarytool submit ./build/export/AppName.app.zip \
--apple-id "your@email.com" \
--team-id "TEAMID" \
--password "@keychain:AC_PASSWORD" \
--wait
# Staple the ticket
xcrun stapler staple ./build/export/AppName.app
```
## Step 5: Create DMG (Direct Distribution)
```bash
# Create DMG
hdiutil create -volname "AppName" \
-srcfolder ./build/export/AppName.app \
-ov -format UDZO \
./build/AppName.dmg
# Notarize the DMG too
xcrun notarytool submit ./build/AppName.dmg \
--apple-id "your@email.com" \
--team-id "TEAMID" \
--password "@keychain:AC_PASSWORD" \
--wait
xcrun stapler staple ./build/AppName.dmg
```
## Step 6: App Store Submission
```bash
# Validate
xcrun altool --validate-app \
-f ./build/export/AppName.pkg \
-t macos \
--apiKey KEY_ID \
--apiIssuer ISSUER_ID
# Upload
xcrun altool --upload-app \
-f ./build/export/AppName.pkg \
-t macos \
--apiKey KEY_ID \
--apiIssuer ISSUER_ID
```
Then complete submission in App Store Connect.
## Step 7: Verify Release
**For direct distribution:**
```bash
# Verify signature
codesign -dv --verbose=4 ./build/export/AppName.app
# Verify notarization
spctl -a -vv ./build/export/AppName.app
```
**For App Store:**
- Check App Store Connect for review status
- Test TestFlight build if applicable
</process>
<checklist>
Before shipping:
- [ ] Version number incremented
- [ ] Release notes written
- [ ] Debug logging disabled or minimized
- [ ] All entitlements correct and minimal
- [ ] Privacy descriptions in Info.plist
- [ ] App icon complete (all sizes)
- [ ] Screenshots prepared (if App Store)
- [ ] Tested on clean install
</checklist>
<common_issues>
| Issue | Cause | Fix |
|-------|-------|-----|
| Notarization fails | Unsigned frameworks, hardened runtime issues | Check all embedded binaries are signed |
| "App is damaged" | Not notarized or stapled | Run notarytool and stapler |
| Gatekeeper blocks | Missing Developer ID | Sign with Developer ID certificate |
| App Store rejection | Missing entitlement descriptions, privacy issues | Add usage descriptions to Info.plist |
</common_issues>

View File

@@ -0,0 +1,258 @@
# Workflow: Write and Run Tests
<required_reading>
**Read these reference files NOW:**
1. references/testing-tdd.md
2. references/testing-debugging.md
</required_reading>
<philosophy>
Tests are documentation that runs. Write tests that:
- Describe what the code should do
- Catch regressions before users do
- Enable confident refactoring
</philosophy>
<process>
## Step 1: Understand What to Test
Ask the user:
- New tests for existing code?
- Tests for new feature (TDD)?
- Fix a bug with regression test?
**What Claude tests (automated):**
- Core logic (data transforms, calculations, algorithms)
- State management (models, relationships)
- Service layer (mocked dependencies)
- Edge cases (nil, empty, boundaries)
**What user tests (manual):**
- UX feel and visual polish
- Real hardware/device integration
- Performance under real conditions
## Step 2: Set Up Test Target
If tests don't exist yet:
```bash
# Add test target to project.yml (XcodeGen)
targets:
AppNameTests:
type: bundle.unit-test
platform: macOS
sources:
- path: Tests
dependencies:
- target: AppName
```
Or create test files manually in Xcode's test target.
## Step 3: Write Tests
### Unit Tests (Logic)
```swift
import Testing
@testable import AppName
struct ItemTests {
@Test func itemCreation() {
let item = Item(name: "Test", value: 42)
#expect(item.name == "Test")
#expect(item.value == 42)
}
@Test func itemValidation() {
let emptyItem = Item(name: "", value: 0)
#expect(!emptyItem.isValid)
}
@Test(arguments: [0, -1, 1000001])
func invalidValues(value: Int) {
let item = Item(name: "Test", value: value)
#expect(!item.isValid)
}
}
```
### State Tests
```swift
struct AppStateTests {
@Test func addItem() {
let state = AppState()
let item = Item(name: "New", value: 10)
state.addItem(item)
#expect(state.items.count == 1)
#expect(state.items.first?.name == "New")
}
@Test func deleteItem() {
let state = AppState()
let item = Item(name: "ToDelete", value: 1)
state.addItem(item)
state.deleteItem(item)
#expect(state.items.isEmpty)
}
}
```
### Async Tests
```swift
struct NetworkTests {
@Test func fetchItems() async throws {
let service = MockDataService()
service.mockItems = [Item(name: "Fetched", value: 5)]
let items = try await service.fetchItems()
#expect(items.count == 1)
}
@Test func fetchHandlesError() async {
let service = MockDataService()
service.shouldFail = true
await #expect(throws: NetworkError.self) {
try await service.fetchItems()
}
}
}
```
### Edge Cases
```swift
struct EdgeCaseTests {
@Test func emptyList() {
let state = AppState()
#expect(state.items.isEmpty)
#expect(state.selectedItem == nil)
}
@Test func nilHandling() {
let item: Item? = nil
#expect(item?.name == nil)
}
@Test func boundaryValues() {
let item = Item(name: String(repeating: "a", count: 10000), value: Int.max)
#expect(item.isValid) // or test truncation behavior
}
}
```
## Step 4: Run Tests
```bash
# Run all tests
xcodebuild test \
-project AppName.xcodeproj \
-scheme AppName \
-resultBundlePath TestResults.xcresult
# Run specific test
xcodebuild test \
-project AppName.xcodeproj \
-scheme AppName \
-only-testing:AppNameTests/ItemTests/testItemCreation
# View results
xcrun xcresulttool get test-results summary --path TestResults.xcresult
```
## Step 5: Coverage Report
```bash
# Generate coverage
xcodebuild test \
-project AppName.xcodeproj \
-scheme AppName \
-enableCodeCoverage YES \
-resultBundlePath TestResults.xcresult
# View coverage
xcrun xccov view --report TestResults.xcresult
# Coverage as JSON
xcrun xccov view --report --json TestResults.xcresult > coverage.json
```
## Step 6: TDD Cycle
For new features:
1. **Red:** Write failing test for desired behavior
2. **Green:** Write minimum code to pass
3. **Refactor:** Clean up while keeping tests green
4. **Repeat:** Next behavior
</process>
<test_patterns>
### Arrange-Act-Assert
```swift
@Test func pattern() {
// Arrange
let state = AppState()
let item = Item(name: "Test", value: 1)
// Act
state.addItem(item)
// Assert
#expect(state.items.contains(item))
}
```
### Mocking Dependencies
```swift
protocol DataServiceProtocol {
func fetchItems() async throws -> [Item]
}
class MockDataService: DataServiceProtocol {
var mockItems: [Item] = []
var shouldFail = false
func fetchItems() async throws -> [Item] {
if shouldFail { throw TestError.mock }
return mockItems
}
}
```
### Testing SwiftUI State
```swift
@Test func viewModelState() {
let state = AppState()
state.items = [Item(name: "A", value: 1), Item(name: "B", value: 2)]
state.selectedItem = state.items.first
#expect(state.selectedItem?.name == "A")
}
```
</test_patterns>
<what_not_to_test>
- SwiftUI view rendering (use previews + manual testing)
- Apple framework behavior
- Simple getters/setters with no logic
- Private implementation details (test via public interface)
</what_not_to_test>
<coverage_targets>
| Code Type | Target Coverage |
|-----------|-----------------|
| Business logic | 80-100% |
| State management | 70-90% |
| Utilities/helpers | 60-80% |
| Views | 0% (manual test) |
| Generated code | 0% |
</coverage_targets>