# OPUS Plan - App Clip for BusinessCard ## Goal Enable recipients to scan a QR code, open an App Clip, preview a business card with photo, and save to Contacts without installing the full app. --- ## What OPUS Changes from CODEX | Area | CODEX Approach | OPUS Improvement | |------|---------------|------------------| | **Photo Storage** | Stores `vCardData` + separate `photoData` CKAsset | Store `vCardData` only (photo already embedded as base64) | | **CloudKit Database** | Suggests private, mentions public | **Public database required** for App Clip access | | **Open Questions** | Lists 5 unresolved questions | All resolved with sensible defaults | | **TTL** | "3 days vs 7 days?" | **7 days** (re-scans common, privacy still protected) | | **Post-Save Behavior** | "Delete immediately or retain?" | **Retain until expiry** (allows multiple recipients) | | **Preview Style** | "Minimal or full?" | **Minimal** (faster load, cleaner UX) | | **Record Consumption** | Tracks `consumedAt` | **Remove consumption tracking** (over-engineering) | | **Target Size** | "<15MB" | **<10MB** for noticeably faster App Clip launch | | **Protocols** | 3 separate protocols | **2 protocols** (combine upload/cleanup in main app) | | **Code Skeletons** | Placeholders | **Concrete implementations** using existing `vCardFilePayload` | --- ## Resolved Decisions - **Domain**: Use placeholder `cards.example.com` (update in entitlements before release) - **TTL**: 7 days - **Database**: Public (App Clips cannot access private databases) - **Photo Storage**: vCard only (existing `vCardFilePayload` embeds photo as base64) - **Post-Save**: Keep record until expired - **Preview**: Minimal card (photo, name, role, company) - **App Clip Size**: Target <10MB --- ## Architecture Overview ```mermaid flowchart TB subgraph MainApp [Main App] ShareCardView --> AppClipShareState AppClipShareState --> SharedCardCloudKitService SharedCardCloudKitService --> CloudKitPublic end subgraph CloudKitPublic [CloudKit Public Database] SharedCardRecord[SharedCard Record] end subgraph AppClipTarget [App Clip] AppClipRootView --> AppClipCardStore AppClipCardStore --> AppClipCloudKitService AppClipCloudKitService --> CloudKitPublic AppClipCardStore --> ContactSaveService ContactSaveService --> Contacts end QRCode[QR Code] -->|scan| AppClipRootView ShareCardView -->|generate| QRCode ``` --- ## Phase 1: CloudKit Setup (Main App) ### 1.1 Enable CloudKit Capability In Xcode, ensure the iOS target has: - iCloud capability enabled - CloudKit checked - Container: `iCloud.com.mbrucedogs.BusinessCard` ### 1.2 Create CloudKit Record Model File: `BusinessCard/Models/SharedCardRecord.swift` ```swift import CloudKit /// Represents a shared card in CloudKit public database. /// NOT a SwiftData model - uses raw CKRecord for ephemeral sharing. struct SharedCardRecord: Sendable { let recordID: CKRecord.ID let vCardData: String let expiresAt: Date let createdAt: Date static let recordType = "SharedCard" enum Field: String { case vCardData case expiresAt case createdAt } init(recordID: CKRecord.ID, vCardData: String, expiresAt: Date, createdAt: Date = .now) { self.recordID = recordID self.vCardData = vCardData self.expiresAt = expiresAt self.createdAt = createdAt } init?(record: CKRecord) { guard let vCardData = record[Field.vCardData.rawValue] as? String, let expiresAt = record[Field.expiresAt.rawValue] as? Date else { return nil } self.recordID = record.recordID self.vCardData = vCardData self.expiresAt = expiresAt self.createdAt = (record[Field.createdAt.rawValue] as? Date) ?? record.creationDate ?? .now } func toCKRecord() -> CKRecord { let record = CKRecord(recordType: Self.recordType, recordID: recordID) record[Field.vCardData.rawValue] = vCardData record[Field.expiresAt.rawValue] = expiresAt record[Field.createdAt.rawValue] = createdAt return record } } ``` ### 1.3 Create Upload Result Model File: `BusinessCard/Models/SharedCardUploadResult.swift` ```swift import Foundation struct SharedCardUploadResult: Sendable { let recordName: String let appClipURL: URL let expiresAt: Date } ``` ### 1.4 Create Protocol File: `BusinessCard/Protocols/SharedCardProviding.swift` ```swift import Foundation /// Provides shared card upload and cleanup functionality. protocol SharedCardProviding: Sendable { func uploadSharedCard(_ card: BusinessCard) async throws -> SharedCardUploadResult func cleanupExpiredCards() async } ``` ### 1.5 Create CloudKit Service File: `BusinessCard/Services/SharedCardCloudKitService.swift` ```swift import CloudKit import Foundation struct SharedCardCloudKitService: SharedCardProviding { private let container: CKContainer private let database: CKDatabase private let ttlDays: Int private let appClipDomain: String init( containerID: String = "iCloud.com.mbrucedogs.BusinessCard", ttlDays: Int = 7, appClipDomain: String = "cards.example.com" ) { self.container = CKContainer(identifier: containerID) self.database = container.publicCloudDatabase self.ttlDays = ttlDays self.appClipDomain = appClipDomain } @MainActor func uploadSharedCard(_ card: BusinessCard) async throws -> SharedCardUploadResult { let vCardData = card.vCardFilePayload let expiresAt = Date.now.addingTimeInterval(TimeInterval(ttlDays * 24 * 60 * 60)) let recordID = CKRecord.ID(recordName: UUID().uuidString) let sharedCard = SharedCardRecord( recordID: recordID, vCardData: vCardData, expiresAt: expiresAt ) let record = sharedCard.toCKRecord() _ = try await database.save(record) guard let appClipURL = URL(string: "https://\(appClipDomain)/appclip?id=\(recordID.recordName)") else { throw SharedCardError.invalidURL } return SharedCardUploadResult( recordName: recordID.recordName, appClipURL: appClipURL, expiresAt: expiresAt ) } func cleanupExpiredCards() async { let predicate = NSPredicate(format: "expiresAt < %@", Date.now as NSDate) let query = CKQuery(recordType: SharedCardRecord.recordType, predicate: predicate) do { let (results, _) = try await database.records(matching: query) let recordIDs = results.compactMap { result -> CKRecord.ID? in guard case .success = result.1 else { return nil } return result.0 } guard !recordIDs.isEmpty else { return } let operation = CKModifyRecordsOperation(recordsToSave: nil, recordIDsToDelete: recordIDs) operation.qualityOfService = .utility database.add(operation) } catch { // Cleanup is best-effort; log but don't throw } } } enum SharedCardError: Error, LocalizedError { case invalidURL case uploadFailed(Error) case fetchFailed(Error) case recordNotFound case recordExpired var errorDescription: String? { switch self { case .invalidURL: return String(localized: "Failed to create share URL") case .uploadFailed(let error): return String(localized: "Upload failed: \(error.localizedDescription)") case .fetchFailed(let error): return String(localized: "Could not load card: \(error.localizedDescription)") case .recordNotFound: return String(localized: "Card not found") case .recordExpired: return String(localized: "This card has expired") } } } ``` ### 1.6 Add Cleanup to AppState Update `BusinessCard/State/AppState.swift` to trigger cleanup on launch: ```swift // Add to AppState init or a dedicated startup method Task { await SharedCardCloudKitService().cleanupExpiredCards() } ``` --- ## Phase 2: Main App UI Integration ### 2.1 Create Share State File: `BusinessCard/State/AppClipShareState.swift` ```swift import Foundation @MainActor @Observable final class AppClipShareState { private let service: SharedCardProviding var isUploading = false var uploadResult: SharedCardUploadResult? var errorMessage: String? var hasAppClipURL: Bool { uploadResult != nil } init(service: SharedCardProviding = SharedCardCloudKitService()) { self.service = service } func shareViaAppClip(card: BusinessCard) async { isUploading = true errorMessage = nil uploadResult = nil defer { isUploading = false } do { uploadResult = try await service.uploadSharedCard(card) } catch { errorMessage = error.localizedDescription } } func reset() { uploadResult = nil errorMessage = nil } } ``` ### 2.2 Update ShareCardView Add to `BusinessCard/Views/ShareCardView.swift`: - New "Share via App Clip" section - Upload progress indicator - QR code display for App Clip URL - Error state with retry ```swift // Add state property @State private var appClipState = AppClipShareState() // Add UI section Section { if appClipState.isUploading { ProgressView() .accessibilityLabel(Text("Uploading card")) } else if let result = appClipState.uploadResult { // Show QR code with result.appClipURL QRCodeView(content: result.appClipURL.absoluteString) Text("Expires in 7 days") .font(.caption) .foregroundStyle(.secondary) } else { Button("Share via App Clip") { Task { await appClipState.shareViaAppClip(card: card) } } } if let error = appClipState.errorMessage { Text(error) .foregroundStyle(.red) .font(.caption) } } header: { Text("App Clip (includes photo)") } ``` --- ## Phase 3: App Clip Target ### 3.1 Create App Clip Target 1. File > New > Target > App Clip 2. Name: `BusinessCardClip` 3. Bundle ID: `com.mbrucedogs.BusinessCard.Clip` 4. Keep size <10MB (no heavy assets) ### 3.2 App Clip Folder Structure ``` BusinessCardClip/ ├── BusinessCardClipApp.swift ├── Design/ │ └── ClipDesignConstants.swift ├── Models/ │ └── SharedCardSnapshot.swift ├── Services/ │ ├── ClipCloudKitService.swift │ └── ContactSaveService.swift ├── State/ │ └── ClipCardStore.swift ├── Views/ │ ├── ClipRootView.swift │ └── Components/ │ ├── ClipLoadingView.swift │ ├── ClipCardPreview.swift │ ├── ClipSuccessView.swift │ └── ClipErrorView.swift └── Resources/ └── Localizable.xcstrings ``` ### 3.3 App Clip Models File: `BusinessCardClip/Models/SharedCardSnapshot.swift` ```swift import Foundation struct SharedCardSnapshot: Sendable { let recordName: String let vCardData: String let displayName: String let role: String let company: String let photoData: Data? init(recordName: String, vCardData: String) { self.recordName = recordName self.vCardData = vCardData // Parse display fields from vCard let lines = vCardData.components(separatedBy: .newlines) self.displayName = Self.parseField("FN:", from: lines) ?? "Contact" self.role = Self.parseField("TITLE:", from: lines) ?? "" self.company = Self.parseField("ORG:", from: lines)?.components(separatedBy: ";").first ?? "" self.photoData = Self.parsePhoto(from: lines) } private static func parseField(_ prefix: String, from lines: [String]) -> String? { lines.first { $0.hasPrefix(prefix) }?.dropFirst(prefix.count).trimmingCharacters(in: .whitespaces) } private static func parsePhoto(from lines: [String]) -> Data? { guard let photoLine = lines.first(where: { $0.hasPrefix("PHOTO;") }), let base64Start = photoLine.range(of: ":")?.upperBound else { return nil } let base64String = String(photoLine[base64Start...]) return Data(base64Encoded: base64String) } } ``` ### 3.4 App Clip CloudKit Service File: `BusinessCardClip/Services/ClipCloudKitService.swift` ```swift import CloudKit struct ClipCloudKitService: Sendable { private let database: CKDatabase init(containerID: String = "iCloud.com.mbrucedogs.BusinessCard") { self.database = CKContainer(identifier: containerID).publicCloudDatabase } func fetchSharedCard(recordName: String) async throws -> SharedCardSnapshot { let recordID = CKRecord.ID(recordName: recordName) let record: CKRecord do { record = try await database.record(for: recordID) } catch { throw ClipError.fetchFailed } guard let vCardData = record["vCardData"] as? String else { throw ClipError.invalidRecord } if let expiresAt = record["expiresAt"] as? Date, expiresAt < .now { throw ClipError.expired } return SharedCardSnapshot(recordName: recordName, vCardData: vCardData) } } enum ClipError: Error, LocalizedError { case fetchFailed case invalidRecord case expired case contactSaveFailed case contactsAccessDenied var errorDescription: String? { switch self { case .fetchFailed: return String(localized: "Could not load card") case .invalidRecord: return String(localized: "Invalid card data") case .expired: return String(localized: "This card has expired") case .contactSaveFailed: return String(localized: "Failed to save contact") case .contactsAccessDenied: return String(localized: "Contacts access required") } } } ``` ### 3.5 Contact Save Service File: `BusinessCardClip/Services/ContactSaveService.swift` ```swift import Contacts struct ContactSaveService: Sendable { func saveContact(vCardData: String) async throws { let store = CNContactStore() let authorized = try await store.requestAccess(for: .contacts) guard authorized else { throw ClipError.contactsAccessDenied } guard let data = vCardData.data(using: .utf8), let contact = try CNContactVCardSerialization.contacts(with: data).first else { throw ClipError.invalidRecord } let mutableContact = contact.mutableCopy() as? CNMutableContact ?? CNMutableContact() let saveRequest = CNSaveRequest() saveRequest.add(mutableContact, toContainerWithIdentifier: nil) do { try store.execute(saveRequest) } catch { throw ClipError.contactSaveFailed } } } ``` ### 3.6 App Clip State File: `BusinessCardClip/State/ClipCardStore.swift` ```swift import Foundation @MainActor @Observable final class ClipCardStore { enum State { case loading case loaded(SharedCardSnapshot) case saved case error(String) } private let cloudKit: ClipCloudKitService private let contactSave: ContactSaveService var state: State = .loading var snapshot: SharedCardSnapshot? { if case .loaded(let snap) = state { return snap } return nil } init(cloudKit: ClipCloudKitService = ClipCloudKitService(), contactSave: ContactSaveService = ContactSaveService()) { self.cloudKit = cloudKit self.contactSave = contactSave } func load(recordName: String) async { state = .loading do { let snapshot = try await cloudKit.fetchSharedCard(recordName: recordName) state = .loaded(snapshot) } catch { state = .error(error.localizedDescription) } } func saveToContacts() async { guard let snapshot else { return } do { try await contactSave.saveContact(vCardData: snapshot.vCardData) state = .saved } catch { state = .error(error.localizedDescription) } } } ``` ### 3.7 App Clip Root View File: `BusinessCardClip/Views/ClipRootView.swift` ```swift import SwiftUI struct ClipRootView: View { @State private var store = ClipCardStore() let recordName: String var body: some View { Group { switch store.state { case .loading: ClipLoadingView() case .loaded(let snapshot): ClipCardPreview(snapshot: snapshot) { Task { await store.saveToContacts() } } case .saved: ClipSuccessView() case .error(let message): ClipErrorView(message: message) { Task { await store.load(recordName: recordName) } } } } .task { await store.load(recordName: recordName) } } } ``` ### 3.8 App Clip Entry Point File: `BusinessCardClip/BusinessCardClipApp.swift` ```swift import SwiftUI @main struct BusinessCardClipApp: App { var body: some Scene { WindowGroup { ContentView() .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in handleUserActivity(activity) } } } private func handleUserActivity(_ activity: NSUserActivity) { guard let url = activity.webpageURL, let components = URLComponents(url: url, resolvingAgainstBaseURL: true), let recordName = components.queryItems?.first(where: { $0.name == "id" })?.value else { return } // Pass recordName to root view via environment or state } } ``` --- ## Phase 4: Configuration ### 4.1 Associated Domains Add to both iOS app and App Clip entitlements: ``` appclips:cards.example.com ``` ### 4.2 Server Configuration Create `.well-known/apple-app-site-association` on `cards.example.com`: ```json { "appclips": { "apps": ["TEAMID.com.mbrucedogs.BusinessCard.Clip"] } } ``` ### 4.3 App Store Connect 1. Register App Clip in App Store Connect 2. Configure App Clip Experience: - Title: "BusinessCard" - Subtitle: "View and save contact" - Action: "Open" - Invocation URL: `https://cards.example.com/appclip` --- ## Files to Create | File | Location | |------|----------| | `SharedCardRecord.swift` | `BusinessCard/Models/` | | `SharedCardUploadResult.swift` | `BusinessCard/Models/` | | `SharedCardProviding.swift` | `BusinessCard/Protocols/` | | `SharedCardCloudKitService.swift` | `BusinessCard/Services/` | | `AppClipShareState.swift` | `BusinessCard/State/` | | `SharedCardSnapshot.swift` | `BusinessCardClip/Models/` | | `ClipCloudKitService.swift` | `BusinessCardClip/Services/` | | `ContactSaveService.swift` | `BusinessCardClip/Services/` | | `ClipCardStore.swift` | `BusinessCardClip/State/` | | `ClipRootView.swift` | `BusinessCardClip/Views/` | | Component views | `BusinessCardClip/Views/Components/` | ## Files to Update | File | Changes | |------|---------| | `ShareCardView.swift` | Add App Clip share section | | `AppState.swift` | Add cleanup call on init | | `BusinessCard.entitlements` | Add Associated Domains | --- ## Testing Strategy 1. **Unit Tests**: CloudKit upload/fetch logic, vCard parsing, error handling 2. **Integration Tests**: Full upload/fetch cycle (requires CloudKit) 3. **Device Tests**: App Clip invocation via QR scan, contact save --- ## Documentation Updates When implementation begins, update: - `README.md` - Add App Clip feature description - `ai_implementation.md` - Add CloudKit and App Clip architecture - `ROADMAP.md` - Mark App Clip phases as completed --- *Last updated: January 10, 2026*