BusinessCard/OPUS-Plan-AppClip.md

20 KiB

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

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

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

import Foundation

struct SharedCardUploadResult: Sendable {
    let recordName: String
    let appClipURL: URL
    let expiresAt: Date
}

1.4 Create Protocol

File: BusinessCard/Protocols/SharedCardProviding.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

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:

// 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

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
// 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

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

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

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

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

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

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:

{
  "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