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
vCardFilePayloadembeds 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
- File > New > Target > App Clip
- Name:
BusinessCardClip - Bundle ID:
com.mbrucedogs.BusinessCard.Clip - 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
- Register App Clip in App Store Connect
- 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
- Unit Tests: CloudKit upload/fetch logic, vCard parsing, error handling
- Integration Tests: Full upload/fetch cycle (requires CloudKit)
- Device Tests: App Clip invocation via QR scan, contact save
Documentation Updates
When implementation begins, update:
README.md- Add App Clip feature descriptionai_implementation.md- Add CloudKit and App Clip architectureROADMAP.md- Mark App Clip phases as completed
Last updated: January 10, 2026