Release v1.18.0: Add iOS-APP-developer and promptfoo-evaluation skills
### Added - **New Skill**: iOS-APP-developer v1.1.0 - iOS development with XcodeGen, SwiftUI, and SPM - XcodeGen project.yml configuration - SPM dependency resolution - Device deployment and code signing - Camera/AVFoundation debugging - iOS version compatibility handling - Library not loaded @rpath framework error fixes - State machine testing patterns for @MainActor classes - Bundled references: xcodegen-full.md, camera-avfoundation.md, swiftui-compatibility.md, testing-mainactor.md - **New Skill**: promptfoo-evaluation v1.0.0 - LLM evaluation framework using Promptfoo - Promptfoo configuration (promptfooconfig.yaml) - Python custom assertions - llm-rubric for LLM-as-judge evaluations - Few-shot example management - Model comparison and prompt testing - Bundled reference: promptfoo_api.md ### Changed - Updated marketplace version from 1.16.0 to 1.18.0 - Updated marketplace skills count from 23 to 25 - Updated skill-creator to v1.2.2: - Fixed best practices documentation URL (platform.claude.com) - Enhanced quick_validate.py to exclude file:// prefixed paths from validation - Updated marketplace.json metadata description to include new skills 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
4
iOS-APP-developer/.security-scan-passed
Normal file
4
iOS-APP-developer/.security-scan-passed
Normal file
@@ -0,0 +1,4 @@
|
||||
Security scan passed
|
||||
Scanned at: 2025-12-15T22:23:52.306779
|
||||
Tool: gitleaks + pattern-based validation
|
||||
Content hash: 1e3661252b87a1effd6b4e458364ab4e7b8bd6356360aea058593e70ed0edd11
|
||||
305
iOS-APP-developer/SKILL.md
Normal file
305
iOS-APP-developer/SKILL.md
Normal file
@@ -0,0 +1,305 @@
|
||||
---
|
||||
name: developing-ios-apps
|
||||
description: Develops iOS applications with XcodeGen, SwiftUI, and SPM. Triggers on XcodeGen project.yml configuration, SPM dependency issues, device deployment problems, code signing errors, camera/AVFoundation debugging, iOS version compatibility, or "Library not loaded @rpath" framework errors. Use when building iOS apps, fixing Xcode build failures, or deploying to real devices.
|
||||
---
|
||||
|
||||
# iOS App Development
|
||||
|
||||
Build, configure, and deploy iOS applications using XcodeGen and Swift Package Manager.
|
||||
|
||||
## Critical Warnings
|
||||
|
||||
| Issue | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| "Library not loaded: @rpath/Framework" | XcodeGen doesn't auto-embed SPM dynamic frameworks | **Build in Xcode GUI first** (not xcodebuild). See [Troubleshooting](#spm-dynamic-framework-not-embedded) |
|
||||
| `xcodegen generate` loses signing | Overwrites project settings | Configure in `project.yml` target settings, not global |
|
||||
| Command-line signing fails | Free Apple ID limitation | Use Xcode GUI or paid developer account ($99/yr) |
|
||||
| "Cannot be set when automaticallyAdjustsVideoMirroring is YES" | Setting `isVideoMirrored` without disabling automatic | Set `automaticallyAdjustsVideoMirroring = false` first. See [Camera](#camera--avfoundation) |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Generate project | `xcodegen generate` |
|
||||
| Build simulator | `xcodebuild -destination 'platform=iOS Simulator,name=iPhone 17' build` |
|
||||
| Build device (paid account) | `xcodebuild -destination 'platform=iOS,name=DEVICE' -allowProvisioningUpdates build` |
|
||||
| Clean DerivedData | `rm -rf ~/Library/Developer/Xcode/DerivedData/PROJECT-*` |
|
||||
| Find device name | `xcrun xctrace list devices` |
|
||||
|
||||
## XcodeGen Configuration
|
||||
|
||||
### Minimal project.yml
|
||||
|
||||
```yaml
|
||||
name: AppName
|
||||
options:
|
||||
bundleIdPrefix: com.company
|
||||
deploymentTarget:
|
||||
iOS: "16.0"
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "6.0"
|
||||
|
||||
packages:
|
||||
SomePackage:
|
||||
url: https://github.com/org/repo
|
||||
from: "1.0.0"
|
||||
|
||||
targets:
|
||||
AppName:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: AppName
|
||||
settings:
|
||||
base:
|
||||
INFOPLIST_FILE: AppName/Info.plist
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.company.appname
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: TEAM_ID_HERE
|
||||
dependencies:
|
||||
- package: SomePackage
|
||||
```
|
||||
|
||||
### Code Signing Configuration
|
||||
|
||||
**Personal (free) account**: Works in Xcode GUI only. Command-line builds require paid account.
|
||||
|
||||
```yaml
|
||||
# In target settings
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: TEAM_ID # Get from Xcode → Settings → Accounts
|
||||
```
|
||||
|
||||
**Get Team ID**:
|
||||
```bash
|
||||
security find-identity -v -p codesigning | head -3
|
||||
```
|
||||
|
||||
## iOS Version Compatibility
|
||||
|
||||
### API Changes by Version
|
||||
|
||||
| iOS 17+ Only | iOS 16 Compatible |
|
||||
|--------------|-------------------|
|
||||
| `.onChange { old, new in }` | `.onChange { new in }` |
|
||||
| `ContentUnavailableView` | Custom VStack |
|
||||
| `AVAudioApplication` | `AVAudioSession` |
|
||||
| `@Observable` macro | `@ObservableObject` |
|
||||
| SwiftData | CoreData/Realm |
|
||||
|
||||
### Lowering Deployment Target
|
||||
|
||||
1. Update `project.yml`:
|
||||
```yaml
|
||||
deploymentTarget:
|
||||
iOS: "16.0"
|
||||
```
|
||||
|
||||
2. Fix incompatible APIs:
|
||||
```swift
|
||||
// iOS 17
|
||||
.onChange(of: value) { oldValue, newValue in }
|
||||
// iOS 16
|
||||
.onChange(of: value) { newValue in }
|
||||
|
||||
// iOS 17
|
||||
ContentUnavailableView("Title", systemImage: "icon")
|
||||
// iOS 16
|
||||
VStack {
|
||||
Image(systemName: "icon").font(.system(size: 48))
|
||||
Text("Title").font(.title2.bold())
|
||||
}
|
||||
|
||||
// iOS 17
|
||||
AVAudioApplication.shared.recordPermission
|
||||
// iOS 16
|
||||
AVAudioSession.sharedInstance().recordPermission
|
||||
```
|
||||
|
||||
3. Regenerate: `xcodegen generate`
|
||||
|
||||
## Device Deployment
|
||||
|
||||
### First-time Setup
|
||||
|
||||
1. Connect device via USB
|
||||
2. Trust computer on device
|
||||
3. In Xcode: Settings → Accounts → Add Apple ID
|
||||
4. Select device in scheme dropdown
|
||||
5. Run (`Cmd + R`)
|
||||
6. On device: Settings → General → VPN & Device Management → Trust
|
||||
|
||||
### Command-line Build (requires paid account)
|
||||
|
||||
```bash
|
||||
xcodebuild \
|
||||
-project App.xcodeproj \
|
||||
-scheme App \
|
||||
-destination 'platform=iOS,name=DeviceName' \
|
||||
-allowProvisioningUpdates \
|
||||
build
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Error | Solution |
|
||||
|-------|----------|
|
||||
| "Library not loaded: @rpath/Framework" | SPM dynamic framework not embedded. Build in Xcode GUI first, then CLI works |
|
||||
| "No Account for Team" | Add Apple ID in Xcode Settings → Accounts |
|
||||
| "Provisioning profile not found" | Free account limitation. Use Xcode GUI or get paid account |
|
||||
| Device not listed | Reconnect USB, trust computer on device, restart Xcode |
|
||||
| DerivedData won't delete | Close Xcode first: `pkill -9 Xcode && rm -rf ~/Library/Developer/Xcode/DerivedData/PROJECT-*` |
|
||||
|
||||
### Free vs Paid Developer Account
|
||||
|
||||
| Feature | Free Apple ID | Paid ($99/year) |
|
||||
|---------|---------------|-----------------|
|
||||
| Xcode GUI builds | ✅ | ✅ |
|
||||
| Command-line builds | ❌ | ✅ |
|
||||
| App validity | 7 days | 1 year |
|
||||
| App Store | ❌ | ✅ |
|
||||
| CI/CD | ❌ | ✅ |
|
||||
|
||||
## SPM Dependencies
|
||||
|
||||
### SPM Dynamic Framework Not Embedded
|
||||
|
||||
**Root Cause**: XcodeGen doesn't generate the "Embed Frameworks" build phase for SPM dynamic frameworks (like RealmSwift, Realm). The app builds successfully but crashes on launch with:
|
||||
|
||||
```
|
||||
dyld: Library not loaded: @rpath/RealmSwift.framework/RealmSwift
|
||||
Referenced from: /var/containers/Bundle/Application/.../App.app/App
|
||||
Reason: image not found
|
||||
```
|
||||
|
||||
**Why This Happens**:
|
||||
- Static frameworks (most SPM packages) are linked into the binary - no embedding needed
|
||||
- Dynamic frameworks (RealmSwift, etc.) must be copied into the app bundle
|
||||
- XcodeGen generates link phase but NOT embed phase for SPM packages
|
||||
- `embed: true` in project.yml causes build errors (XcodeGen limitation)
|
||||
|
||||
**The Fix** (Manual, one-time per project):
|
||||
1. Open project in Xcode GUI
|
||||
2. Select target → General → Frameworks, Libraries
|
||||
3. Find the dynamic framework (RealmSwift)
|
||||
4. Change "Do Not Embed" → "Embed & Sign"
|
||||
5. Build and run from Xcode GUI first
|
||||
|
||||
**After Manual Fix**: Command-line builds (`xcodebuild`) will work because Xcode persists the embed setting in project.pbxproj.
|
||||
|
||||
**Identifying Dynamic Frameworks**:
|
||||
```bash
|
||||
# Check if a framework is dynamic
|
||||
file ~/Library/Developer/Xcode/DerivedData/PROJECT-*/Build/Products/Debug-iphoneos/FRAMEWORK.framework/FRAMEWORK
|
||||
# Dynamic: "Mach-O 64-bit dynamically linked shared library"
|
||||
# Static: "current ar archive"
|
||||
```
|
||||
|
||||
### Adding Packages
|
||||
|
||||
```yaml
|
||||
packages:
|
||||
AudioKit:
|
||||
url: https://github.com/AudioKit/AudioKit
|
||||
from: "5.6.5"
|
||||
RealmSwift:
|
||||
url: https://github.com/realm/realm-swift
|
||||
from: "10.54.6"
|
||||
|
||||
targets:
|
||||
App:
|
||||
dependencies:
|
||||
- package: AudioKit
|
||||
- package: RealmSwift
|
||||
product: RealmSwift # Explicit product name when package has multiple
|
||||
```
|
||||
|
||||
### Resolving Dependencies (China proxy)
|
||||
|
||||
```bash
|
||||
git config --global http.proxy http://127.0.0.1:1082
|
||||
git config --global https.proxy http://127.0.0.1:1082
|
||||
xcodebuild -scmProvider system -resolvePackageDependencies
|
||||
```
|
||||
|
||||
**Never clear global SPM cache** (`~/Library/Caches/org.swift.swiftpm`). Re-downloading is slow.
|
||||
|
||||
## Camera / AVFoundation
|
||||
|
||||
Camera preview requires real device (simulator has no camera).
|
||||
|
||||
### Quick Debugging Checklist
|
||||
|
||||
1. **Permission**: Added `NSCameraUsageDescription` to Info.plist?
|
||||
2. **Device**: Running on real device, not simulator?
|
||||
3. **Session running**: `session.startRunning()` called on background thread?
|
||||
4. **View size**: UIViewRepresentable has non-zero bounds?
|
||||
5. **Video mirroring**: Disabled `automaticallyAdjustsVideoMirroring` before setting `isVideoMirrored`?
|
||||
|
||||
### Video Mirroring (Front Camera)
|
||||
|
||||
**CRITICAL**: Must disable automatic adjustment before setting manual mirroring:
|
||||
|
||||
```swift
|
||||
// WRONG - crashes with "Cannot be set when automaticallyAdjustsVideoMirroring is YES"
|
||||
connection.isVideoMirrored = true
|
||||
|
||||
// CORRECT - disable automatic first
|
||||
connection.automaticallyAdjustsVideoMirroring = false
|
||||
connection.isVideoMirrored = true
|
||||
```
|
||||
|
||||
### UIViewRepresentable Sizing Issue
|
||||
|
||||
UIViewRepresentable in ZStack may have zero bounds. Fix with explicit frame:
|
||||
|
||||
```swift
|
||||
// BAD: UIViewRepresentable may get zero size in ZStack
|
||||
ZStack {
|
||||
CameraPreviewView(session: session) // May be invisible!
|
||||
OtherContent()
|
||||
}
|
||||
|
||||
// GOOD: Explicit sizing
|
||||
ZStack {
|
||||
GeometryReader { geo in
|
||||
CameraPreviewView(session: session)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
OtherContent()
|
||||
}
|
||||
```
|
||||
|
||||
### Debug Logging Pattern
|
||||
|
||||
Add logging to trace camera flow:
|
||||
|
||||
```swift
|
||||
import os
|
||||
private let logger = Logger(subsystem: "com.app", category: "Camera")
|
||||
|
||||
func start() async {
|
||||
logger.info("start() called, isRunning=\(self.isRunning)")
|
||||
// ... setup code ...
|
||||
logger.info("session.startRunning() completed")
|
||||
}
|
||||
|
||||
// For CGRect (doesn't conform to CustomStringConvertible)
|
||||
logger.info("bounds=\(NSCoder.string(for: self.bounds))")
|
||||
```
|
||||
|
||||
Filter in Console.app by subsystem.
|
||||
|
||||
**For detailed camera implementation**: See [references/camera-avfoundation.md](references/camera-avfoundation.md)
|
||||
|
||||
## Resources
|
||||
|
||||
- [references/xcodegen-full.md](references/xcodegen-full.md) - Complete project.yml options
|
||||
- [references/swiftui-compatibility.md](references/swiftui-compatibility.md) - iOS version API differences
|
||||
- [references/camera-avfoundation.md](references/camera-avfoundation.md) - Camera preview debugging
|
||||
- [references/testing-mainactor.md](references/testing-mainactor.md) - Testing @MainActor classes (state machines, regression tests)
|
||||
292
iOS-APP-developer/references/camera-avfoundation.md
Normal file
292
iOS-APP-developer/references/camera-avfoundation.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Camera / AVFoundation Reference
|
||||
|
||||
## Camera Preview Implementation
|
||||
|
||||
### Complete Working Example
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import os
|
||||
|
||||
private let logger = Logger(subsystem: "com.app", category: "Camera")
|
||||
|
||||
// MARK: - Session Manager
|
||||
|
||||
@MainActor
|
||||
final class CameraSessionManager: ObservableObject {
|
||||
@Published private(set) var isRunning = false
|
||||
@Published private(set) var error: CameraError?
|
||||
|
||||
let session = AVCaptureSession()
|
||||
private var videoInput: AVCaptureDeviceInput?
|
||||
|
||||
enum CameraError: LocalizedError {
|
||||
case noCamera
|
||||
case setupFailed(String)
|
||||
case permissionDenied
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noCamera: return "No camera available"
|
||||
case .setupFailed(let reason): return "Setup failed: \(reason)"
|
||||
case .permissionDenied: return "Camera permission denied"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func start() async {
|
||||
logger.info("start() called, isRunning=\(self.isRunning)")
|
||||
guard !isRunning else { return }
|
||||
|
||||
// Check permission
|
||||
guard await requestPermission() else {
|
||||
error = .permissionDenied
|
||||
return
|
||||
}
|
||||
|
||||
// Get camera
|
||||
guard let device = AVCaptureDevice.default(
|
||||
.builtInWideAngleCamera,
|
||||
for: .video,
|
||||
position: .front
|
||||
) else {
|
||||
logger.error("No front camera available")
|
||||
error = .noCamera
|
||||
return
|
||||
}
|
||||
|
||||
// Configure session
|
||||
session.beginConfiguration()
|
||||
session.sessionPreset = .high
|
||||
|
||||
do {
|
||||
let input = try AVCaptureDeviceInput(device: device)
|
||||
if session.canAddInput(input) {
|
||||
session.addInput(input)
|
||||
videoInput = input
|
||||
}
|
||||
session.commitConfiguration()
|
||||
|
||||
// Start on background thread
|
||||
await withCheckedContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.session.startRunning()
|
||||
DispatchQueue.main.async {
|
||||
self?.isRunning = true
|
||||
logger.info("Camera session started")
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
session.commitConfiguration()
|
||||
self.error = .setupFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard isRunning else { return }
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.session.stopRunning()
|
||||
DispatchQueue.main.async {
|
||||
self?.isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func requestPermission() async -> Bool {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized: return true
|
||||
case .notDetermined:
|
||||
return await AVCaptureDevice.requestAccess(for: .video)
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI View
|
||||
|
||||
struct CameraPreviewView: UIViewRepresentable {
|
||||
let session: AVCaptureSession
|
||||
|
||||
func makeUIView(context: Context) -> CameraPreviewUIView {
|
||||
let view = CameraPreviewUIView()
|
||||
view.backgroundColor = .black
|
||||
view.session = session
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {}
|
||||
}
|
||||
|
||||
final class CameraPreviewUIView: UIView {
|
||||
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
||||
|
||||
var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
|
||||
|
||||
var session: AVCaptureSession? {
|
||||
get { previewLayer.session }
|
||||
set {
|
||||
previewLayer.session = newValue
|
||||
previewLayer.videoGravity = .resizeAspectFill
|
||||
configureMirroring()
|
||||
}
|
||||
}
|
||||
|
||||
private func configureMirroring() {
|
||||
guard let connection = previewLayer.connection,
|
||||
connection.isVideoMirroringSupported else { return }
|
||||
// CRITICAL: Must disable automatic adjustment BEFORE setting manual mirroring
|
||||
// Without this, iOS throws: "Cannot be set when automaticallyAdjustsVideoMirroring is YES"
|
||||
connection.automaticallyAdjustsVideoMirroring = false
|
||||
connection.isVideoMirrored = true
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
previewLayer.frame = bounds
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Usage in SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var cameraManager = CameraSessionManager()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// CRITICAL: Use GeometryReader for proper sizing
|
||||
GeometryReader { geo in
|
||||
CameraPreviewView(session: cameraManager.session)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Overlay content here
|
||||
}
|
||||
.onAppear {
|
||||
Task { await cameraManager.start() }
|
||||
}
|
||||
.onDisappear {
|
||||
cameraManager.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue: Camera preview shows nothing
|
||||
|
||||
**Debug steps:**
|
||||
|
||||
1. Check if running on simulator (camera not available):
|
||||
```swift
|
||||
#if targetEnvironment(simulator)
|
||||
logger.warning("Camera not available on simulator")
|
||||
#endif
|
||||
```
|
||||
|
||||
2. Add logging to trace execution:
|
||||
```swift
|
||||
logger.info("Permission status: \(AVCaptureDevice.authorizationStatus(for: .video).rawValue)")
|
||||
logger.info("Session running: \(session.isRunning)")
|
||||
logger.info("Preview layer bounds: \(previewLayer.bounds)")
|
||||
```
|
||||
|
||||
3. Verify Info.plist has camera permission:
|
||||
```xml
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Camera access for preview</string>
|
||||
```
|
||||
|
||||
### Issue: UIViewRepresentable has zero size
|
||||
|
||||
**Cause**: In ZStack, UIViewRepresentable doesn't expand like SwiftUI views.
|
||||
|
||||
**Solution**: Wrap in GeometryReader with explicit frame:
|
||||
```swift
|
||||
GeometryReader { geo in
|
||||
CameraPreviewView(session: session)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
```
|
||||
|
||||
### Issue: Preview layer connection is nil
|
||||
|
||||
**Cause**: Connection isn't established until session is running and layer is in view hierarchy.
|
||||
|
||||
**Solution**: Configure mirroring in layoutSubviews:
|
||||
```swift
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
previewLayer.frame = bounds
|
||||
// Retry mirroring here
|
||||
configureMirroring()
|
||||
}
|
||||
|
||||
private func configureMirroring() {
|
||||
guard let conn = previewLayer.connection,
|
||||
conn.isVideoMirroringSupported else { return }
|
||||
conn.automaticallyAdjustsVideoMirroring = false
|
||||
conn.isVideoMirrored = true
|
||||
}
|
||||
```
|
||||
|
||||
### Issue: Crash on setVideoMirrored
|
||||
|
||||
**Error**: `*** -[AVCaptureConnection setVideoMirrored:] Cannot be set when automaticallyAdjustsVideoMirroring is YES`
|
||||
|
||||
**Cause**: iOS automatically adjusts mirroring by default. Setting `isVideoMirrored` while automatic adjustment is enabled throws an exception.
|
||||
|
||||
**Solution**: Always disable automatic adjustment first:
|
||||
```swift
|
||||
// WRONG - crashes on some devices
|
||||
connection.isVideoMirrored = true
|
||||
|
||||
// CORRECT - disable automatic first
|
||||
connection.automaticallyAdjustsVideoMirroring = false
|
||||
connection.isVideoMirrored = true
|
||||
```
|
||||
|
||||
**Affected Devices**: Primarily older devices (iPhone X, etc.) but can affect any device.
|
||||
|
||||
### Issue: Swift 6 concurrency errors with AVCaptureSession
|
||||
|
||||
**Error**: "cannot access property 'session' with non-Sendable type from nonisolated deinit"
|
||||
|
||||
**Solution**: Don't access session in deinit. Use explicit stop() call:
|
||||
```swift
|
||||
deinit {
|
||||
// Don't access session here
|
||||
// Cleanup handled by stop() call from view
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging with Console.app
|
||||
|
||||
1. Open Console.app
|
||||
2. Select your device
|
||||
3. Filter by:
|
||||
- Subsystem: `com.yourapp`
|
||||
- Category: `Camera`
|
||||
4. Look for the log sequence:
|
||||
```
|
||||
start() called, isRunning=false
|
||||
Permission granted
|
||||
Found front camera: Front Camera
|
||||
Camera session started
|
||||
```
|
||||
|
||||
## Camera + Audio Conflict
|
||||
|
||||
If using AudioKit or AVAudioEngine, camera audio input may conflict.
|
||||
|
||||
**Solution**: Use video-only input, no audio:
|
||||
```swift
|
||||
// Only add video input, skip audio
|
||||
let videoInput = try AVCaptureDeviceInput(device: videoDevice)
|
||||
session.addInput(videoInput)
|
||||
// Do NOT add audio input
|
||||
```
|
||||
190
iOS-APP-developer/references/swiftui-compatibility.md
Normal file
190
iOS-APP-developer/references/swiftui-compatibility.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# SwiftUI iOS Version Compatibility
|
||||
|
||||
## iOS 17 vs iOS 16 API Differences
|
||||
|
||||
### View Modifiers
|
||||
|
||||
#### onChange
|
||||
|
||||
```swift
|
||||
// iOS 17+ (dual parameter)
|
||||
.onChange(of: value) { oldValue, newValue in
|
||||
// Can compare old and new
|
||||
}
|
||||
|
||||
// iOS 16 (single parameter)
|
||||
.onChange(of: value) { newValue in
|
||||
// Only new value available
|
||||
}
|
||||
```
|
||||
|
||||
#### sensoryFeedback (iOS 17+)
|
||||
|
||||
```swift
|
||||
// iOS 17+
|
||||
.sensoryFeedback(.impact, trigger: triggerValue)
|
||||
|
||||
// iOS 16 fallback
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
```
|
||||
|
||||
### Views
|
||||
|
||||
#### ContentUnavailableView (iOS 17+)
|
||||
|
||||
```swift
|
||||
// iOS 17+
|
||||
ContentUnavailableView(
|
||||
"No Results",
|
||||
systemImage: "magnifyingglass",
|
||||
description: Text("Try a different search")
|
||||
)
|
||||
|
||||
// iOS 16 fallback
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No Results")
|
||||
.font(.title2.bold())
|
||||
Text("Try a different search")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
```
|
||||
|
||||
#### Inspector (iOS 17+)
|
||||
|
||||
```swift
|
||||
// iOS 17+
|
||||
.inspector(isPresented: $showInspector) {
|
||||
InspectorContent()
|
||||
}
|
||||
|
||||
// iOS 16 fallback: Use sheet or sidebar
|
||||
.sheet(isPresented: $showInspector) {
|
||||
InspectorContent()
|
||||
}
|
||||
```
|
||||
|
||||
### Observation
|
||||
|
||||
#### @Observable Macro (iOS 17+)
|
||||
|
||||
```swift
|
||||
// iOS 17+ with @Observable
|
||||
@Observable
|
||||
class ViewModel {
|
||||
var count = 0
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
var viewModel = ViewModel()
|
||||
var body: some View {
|
||||
Text("\(viewModel.count)")
|
||||
}
|
||||
}
|
||||
|
||||
// iOS 16 with ObservableObject
|
||||
class ViewModel: ObservableObject {
|
||||
@Published var count = 0
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject var viewModel = ViewModel()
|
||||
var body: some View {
|
||||
Text("\(viewModel.count)")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Audio
|
||||
|
||||
#### AVAudioApplication (iOS 17+)
|
||||
|
||||
```swift
|
||||
// iOS 17+
|
||||
let permission = AVAudioApplication.shared.recordPermission
|
||||
AVAudioApplication.requestRecordPermission { granted in }
|
||||
|
||||
// iOS 16
|
||||
let permission = AVAudioSession.sharedInstance().recordPermission
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in }
|
||||
```
|
||||
|
||||
### Animations
|
||||
|
||||
#### Symbol Effects (iOS 17+)
|
||||
|
||||
```swift
|
||||
// iOS 17+
|
||||
Image(systemName: "heart.fill")
|
||||
.symbolEffect(.bounce, value: isFavorite)
|
||||
|
||||
// iOS 16 fallback
|
||||
Image(systemName: "heart.fill")
|
||||
.scaleEffect(isFavorite ? 1.2 : 1.0)
|
||||
.animation(.spring(), value: isFavorite)
|
||||
```
|
||||
|
||||
### Data
|
||||
|
||||
#### SwiftData (iOS 17+)
|
||||
|
||||
```swift
|
||||
// iOS 17+ with SwiftData
|
||||
@Model
|
||||
class Item {
|
||||
var name: String
|
||||
var timestamp: Date
|
||||
}
|
||||
|
||||
// iOS 16: Use CoreData or third-party (Realm)
|
||||
// CoreData: NSManagedObject subclass
|
||||
// Realm: Object subclass with @Persisted properties
|
||||
```
|
||||
|
||||
## Conditional Compilation
|
||||
|
||||
For features that must use iOS 17 APIs when available:
|
||||
|
||||
```swift
|
||||
if #available(iOS 17.0, *) {
|
||||
ContentUnavailableView("Title", systemImage: "icon")
|
||||
} else {
|
||||
LegacyEmptyView()
|
||||
}
|
||||
```
|
||||
|
||||
For view modifiers:
|
||||
|
||||
```swift
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func onChangeCompat<V: Equatable>(of value: V, perform: @escaping (V) -> Void) -> some View {
|
||||
if #available(iOS 17.0, *) {
|
||||
self.onChange(of: value) { _, newValue in
|
||||
perform(newValue)
|
||||
}
|
||||
} else {
|
||||
self.onChange(of: value, perform: perform)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Minimum Deployment Targets by Feature
|
||||
|
||||
| Feature | Minimum iOS |
|
||||
|---------|-------------|
|
||||
| SwiftUI basics | 13.0 |
|
||||
| @StateObject | 14.0 |
|
||||
| AsyncImage | 15.0 |
|
||||
| .searchable | 15.0 |
|
||||
| NavigationStack | 16.0 |
|
||||
| .navigationDestination | 16.0 |
|
||||
| @Observable | 17.0 |
|
||||
| ContentUnavailableView | 17.0 |
|
||||
| SwiftData | 17.0 |
|
||||
| .onChange (dual param) | 17.0 |
|
||||
146
iOS-APP-developer/references/testing-mainactor.md
Normal file
146
iOS-APP-developer/references/testing-mainactor.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Testing @MainActor Classes
|
||||
|
||||
## The Problem
|
||||
|
||||
Testing `@MainActor` classes like `ObservableObject` controllers in Swift 6 is challenging because:
|
||||
|
||||
1. `setUp()` and `tearDown()` are nonisolated
|
||||
2. Properties with `private(set)` can't be set from tests
|
||||
3. Direct property access from tests triggers concurrency errors
|
||||
|
||||
## Solution: setStateForTesting Pattern
|
||||
|
||||
Add a DEBUG-only method to allow tests to set internal state:
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class TrainingSessionController: ObservableObject {
|
||||
@Published private(set) var state: TrainingState = .idle
|
||||
|
||||
// ... rest of controller ...
|
||||
|
||||
// MARK: - Testing Support
|
||||
|
||||
#if DEBUG
|
||||
/// Set state directly for testing purposes only
|
||||
func setStateForTesting(_ newState: TrainingState) {
|
||||
state = newState
|
||||
}
|
||||
#endif
|
||||
}
|
||||
```
|
||||
|
||||
## Test Class Structure
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
@testable import YourApp
|
||||
|
||||
@MainActor
|
||||
final class TrainingSessionControllerTests: XCTestCase {
|
||||
|
||||
var controller: TrainingSessionController!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
controller = TrainingSessionController()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
controller = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testConfigureFromFailedStateAutoResets() {
|
||||
// Arrange: Set to failed state
|
||||
controller.setStateForTesting(.failed("Recording too short"))
|
||||
|
||||
// Act: Configure should recover
|
||||
controller.configure(with: PhaseConfig.default)
|
||||
|
||||
// Assert: Should be back in idle
|
||||
XCTAssertEqual(controller.state, .idle)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## State Machine Testing Patterns
|
||||
|
||||
### Testing State Transitions
|
||||
|
||||
```swift
|
||||
func testStateTransitions() {
|
||||
// Test each state's behavior
|
||||
let states: [TrainingState] = [.idle, .completed, .failed("error")]
|
||||
|
||||
for state in states {
|
||||
controller.setStateForTesting(state)
|
||||
controller.configure(with: PhaseConfig.default)
|
||||
|
||||
// Verify expected outcome
|
||||
XCTAssertTrue(controller.canStart, "\(state) should allow starting")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Regression Tests
|
||||
|
||||
For bugs that have been fixed, add specific regression tests:
|
||||
|
||||
```swift
|
||||
/// Regression test for: State machine dead-lock after recording failure
|
||||
/// Bug: After error, controller stayed in failed state forever
|
||||
func testRegressionFailedStateDeadLock() {
|
||||
// Simulate the bug scenario
|
||||
controller.configure(with: PhaseConfig.default)
|
||||
controller.setStateForTesting(.failed("录音太短"))
|
||||
|
||||
// The fix: configure() should auto-reset from failed state
|
||||
controller.configure(with: PhaseConfig.default)
|
||||
|
||||
XCTAssertEqual(controller.state, .idle,
|
||||
"REGRESSION: Failed state should not block configure()")
|
||||
}
|
||||
```
|
||||
|
||||
### State Machine Invariants
|
||||
|
||||
Test invariants that should always hold:
|
||||
|
||||
```swift
|
||||
/// No terminal state should become a "dead end"
|
||||
func testAllTerminalStatesAreRecoverable() {
|
||||
let terminalStates: [TrainingState] = [
|
||||
.idle,
|
||||
.completed,
|
||||
.failed("test error")
|
||||
]
|
||||
|
||||
for state in terminalStates {
|
||||
controller.setStateForTesting(state)
|
||||
// Action that should recover
|
||||
controller.configure(with: PhaseConfig.default)
|
||||
|
||||
// Verify recovery
|
||||
XCTAssertTrue(canConfigure(),
|
||||
"\(state) should be recoverable via configure()")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Why This Pattern Works
|
||||
|
||||
1. **`#if DEBUG`**: Method only exists in test builds, zero production overhead
|
||||
2. **Explicit method**: Makes test-only state manipulation obvious and searchable
|
||||
3. **MainActor compatible**: Method is part of the @MainActor class
|
||||
4. **Swift 6 safe**: Avoids concurrency errors by staying on the main actor
|
||||
|
||||
## Alternative: Internal Setter
|
||||
|
||||
If you prefer, you can use `internal(set)` instead of `private(set)`:
|
||||
|
||||
```swift
|
||||
@Published internal(set) var state: TrainingState = .idle
|
||||
```
|
||||
|
||||
However, this is redundant since properties are already internal by default. The `setStateForTesting()` pattern is more explicit about test-only intent.
|
||||
196
iOS-APP-developer/references/xcodegen-full.md
Normal file
196
iOS-APP-developer/references/xcodegen-full.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# XcodeGen Complete Reference
|
||||
|
||||
## Full project.yml Structure
|
||||
|
||||
```yaml
|
||||
name: ProjectName
|
||||
|
||||
options:
|
||||
bundleIdPrefix: com.company
|
||||
deploymentTarget:
|
||||
iOS: "16.0"
|
||||
macOS: "13.0"
|
||||
xcodeVersion: "16.0"
|
||||
generateEmptyDirectories: true
|
||||
createIntermediateGroups: true
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "6.0"
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
|
||||
packages:
|
||||
# Basic package
|
||||
PackageName:
|
||||
url: https://github.com/org/repo
|
||||
from: "1.0.0"
|
||||
|
||||
# Exact version
|
||||
ExactPackage:
|
||||
url: https://github.com/org/repo
|
||||
exactVersion: "2.0.0"
|
||||
|
||||
# Branch
|
||||
BranchPackage:
|
||||
url: https://github.com/org/repo
|
||||
branch: main
|
||||
|
||||
# Local package
|
||||
LocalPackage:
|
||||
path: ../LocalPackage
|
||||
|
||||
targets:
|
||||
MainApp:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Sources
|
||||
excludes:
|
||||
- "**/.DS_Store"
|
||||
- "**/Tests/**"
|
||||
- path: Resources
|
||||
type: folder
|
||||
|
||||
settings:
|
||||
base:
|
||||
INFOPLIST_FILE: Sources/Info.plist
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.company.app
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||
LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks"
|
||||
ENABLE_BITCODE: NO
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: TEAM_ID
|
||||
configs:
|
||||
Debug:
|
||||
SWIFT_OPTIMIZATION_LEVEL: -Onone
|
||||
Release:
|
||||
SWIFT_OPTIMIZATION_LEVEL: -O
|
||||
|
||||
dependencies:
|
||||
# SPM package
|
||||
- package: PackageName
|
||||
|
||||
# SPM package with explicit product
|
||||
- package: Firebase
|
||||
product: FirebaseAnalytics
|
||||
|
||||
# Another target
|
||||
- target: Framework
|
||||
|
||||
# System framework
|
||||
- framework: UIKit.framework
|
||||
|
||||
# SDK
|
||||
- sdk: CoreLocation.framework
|
||||
|
||||
preBuildScripts:
|
||||
- name: "Run Script"
|
||||
script: |
|
||||
echo "Pre-build script"
|
||||
runOnlyWhenInstalling: false
|
||||
|
||||
postBuildScripts:
|
||||
- name: "Post Build"
|
||||
script: |
|
||||
echo "Post-build script"
|
||||
|
||||
Tests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Tests
|
||||
dependencies:
|
||||
- target: MainApp
|
||||
settings:
|
||||
base:
|
||||
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/MainApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MainApp"
|
||||
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||
```
|
||||
|
||||
## Target Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `application` | iOS/macOS app |
|
||||
| `framework` | Dynamic framework |
|
||||
| `staticFramework` | Static framework |
|
||||
| `bundle.unit-test` | Unit test bundle |
|
||||
| `bundle.ui-testing` | UI test bundle |
|
||||
| `app-extension` | App extension |
|
||||
| `watch2-app` | watchOS app |
|
||||
| `widget-extension` | Widget extension |
|
||||
|
||||
## Build Settings Reference
|
||||
|
||||
### Common Settings
|
||||
|
||||
```yaml
|
||||
settings:
|
||||
base:
|
||||
# Versioning
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
|
||||
# Swift
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
|
||||
# Signing
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: TEAM_ID
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
|
||||
# Deployment
|
||||
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
|
||||
TARGETED_DEVICE_FAMILY: "1,2" # 1=iPhone, 2=iPad
|
||||
|
||||
# Build
|
||||
ENABLE_BITCODE: NO
|
||||
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
|
||||
|
||||
# Paths
|
||||
LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks"
|
||||
```
|
||||
|
||||
### Per-Configuration Settings
|
||||
|
||||
```yaml
|
||||
settings:
|
||||
configs:
|
||||
Debug:
|
||||
SWIFT_OPTIMIZATION_LEVEL: -Onone
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG
|
||||
MTL_ENABLE_DEBUG_INFO: INCLUDE_SOURCE
|
||||
Release:
|
||||
SWIFT_OPTIMIZATION_LEVEL: -O
|
||||
SWIFT_COMPILATION_MODE: wholemodule
|
||||
VALIDATE_PRODUCT: YES
|
||||
```
|
||||
|
||||
## Info.plist Keys
|
||||
|
||||
Common keys to add:
|
||||
|
||||
```xml
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Camera access description</string>
|
||||
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Microphone access description</string>
|
||||
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Photo library access description</string>
|
||||
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>location</string>
|
||||
</array>
|
||||
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
```
|
||||
Reference in New Issue
Block a user