Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
7e508be092
commit
4445b59832
359
DevAccount-Migration.md
Normal file
359
DevAccount-Migration.md
Normal file
@ -0,0 +1,359 @@
|
||||
# Developer Account Migration Guide
|
||||
|
||||
This document provides a comprehensive guide for migrating the BusinessCard app from the personal `mbrucedogs` developer account to a new Apple Developer account.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Account Information
|
||||
- **Current Team ID**: `6R7KLBPBLZ`
|
||||
- **Current Bundle ID Prefix**: `com.mbrucedogs`
|
||||
|
||||
### Targets and Bundle Identifiers
|
||||
|
||||
| Target | Current Bundle ID |
|
||||
|--------|------------------|
|
||||
| iOS App | `com.mbrucedogs.BusinessCard` |
|
||||
| Watch App | `com.mbrucedogs.BusinessCard.watchkitapp` |
|
||||
| Tests | `com.mbrucedogs.BusinessCardTests` |
|
||||
| UI Tests | `com.mbrucedogs.BusinessCardUITests` |
|
||||
| App Clip (planned) | `com.mbrucedogs.BusinessCard.Clip` |
|
||||
|
||||
### Entitlements and Services
|
||||
|
||||
| Service | Current Identifier | Migration Impact |
|
||||
|---------|-------------------|------------------|
|
||||
| CloudKit Container | `iCloud.com.mbrucedogs.BusinessCard` | **HIGH** - Data loss risk |
|
||||
| App Group | `group.com.mbrucedogs.BusinessCard` | Medium - Local data only |
|
||||
| Push Notifications | `aps-environment: development` | Low - Re-register |
|
||||
| Associated Domains (planned) | `appclips:cards.example.com` | Low - Domain-based |
|
||||
|
||||
---
|
||||
|
||||
## Migration Complexity Rating: MODERATE-HIGH
|
||||
|
||||
The main complexity comes from CloudKit. If users have data stored in CloudKit, changing the container identifier means:
|
||||
- Users lose access to their synced data
|
||||
- No automatic migration path exists
|
||||
- Manual data export/import would be required
|
||||
|
||||
---
|
||||
|
||||
## Pre-Migration Checklist
|
||||
|
||||
### Before You Start
|
||||
|
||||
- [ ] Create the new Apple Developer Account
|
||||
- [ ] Note the new Team ID (found in Membership section of developer portal)
|
||||
- [ ] Decide on new bundle ID prefix (e.g., `com.newcompany`)
|
||||
- [ ] Plan for CloudKit data migration (if app is in production)
|
||||
- [ ] If app is NOT yet released, migration is straightforward
|
||||
|
||||
---
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### Step 1: Update Team ID in Xcode
|
||||
|
||||
**Files affected**: `project.pbxproj`
|
||||
|
||||
1. Open project in Xcode
|
||||
2. Select project in navigator
|
||||
3. For each target, go to **Signing & Capabilities**
|
||||
4. Change **Team** dropdown to new account
|
||||
5. Xcode will update `DEVELOPMENT_TEAM` in 9 places
|
||||
|
||||
**Or manually replace in** `BusinessCard.xcodeproj/project.pbxproj`:
|
||||
```
|
||||
Find: DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||
Replace: DEVELOPMENT_TEAM = NEW_TEAM_ID;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Update Bundle Identifiers
|
||||
|
||||
**Files affected**: `project.pbxproj`
|
||||
|
||||
| Old | New |
|
||||
|-----|-----|
|
||||
| `com.mbrucedogs.BusinessCard` | `com.newcompany.BusinessCard` |
|
||||
| `com.mbrucedogs.BusinessCard.watchkitapp` | `com.newcompany.BusinessCard.watchkitapp` |
|
||||
| `com.mbrucedogs.BusinessCardTests` | `com.newcompany.BusinessCardTests` |
|
||||
| `com.mbrucedogs.BusinessCardUITests` | `com.newcompany.BusinessCardUITests` |
|
||||
|
||||
1. In Xcode, select each target
|
||||
2. Update **Bundle Identifier** in General tab
|
||||
3. Verify watch app bundle ID is prefixed with iOS app bundle ID
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Update CloudKit Container
|
||||
|
||||
**Files affected**: `BusinessCard/BusinessCard.entitlements`
|
||||
|
||||
```xml
|
||||
<!-- Old -->
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.com.mbrucedogs.BusinessCard</string>
|
||||
</array>
|
||||
|
||||
<!-- New -->
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.com.newcompany.BusinessCard</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
**CRITICAL**: This creates a NEW, EMPTY CloudKit container. See Data Migration section below.
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Update App Group
|
||||
|
||||
**Files affected**: `BusinessCard/BusinessCard.entitlements`
|
||||
|
||||
```xml
|
||||
<!-- Old -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.mbrucedogs.BusinessCard</string>
|
||||
</array>
|
||||
|
||||
<!-- New -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.newcompany.BusinessCard</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
**Impact**: App Group is used for sharing data between the iOS app and extensions (not watch). Local data in the old group will be inaccessible.
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Register New Identifiers in Developer Portal
|
||||
|
||||
In the new Apple Developer account portal:
|
||||
|
||||
1. **Identifiers > App IDs**
|
||||
- Register `com.newcompany.BusinessCard`
|
||||
- Register `com.newcompany.BusinessCard.watchkitapp`
|
||||
- Register `com.newcompany.BusinessCard.Clip` (for App Clip)
|
||||
|
||||
2. **Identifiers > iCloud Containers**
|
||||
- Create `iCloud.com.newcompany.BusinessCard`
|
||||
|
||||
3. **Identifiers > App Groups**
|
||||
- Create `group.com.newcompany.BusinessCard`
|
||||
|
||||
4. **Certificates, Identifiers & Profiles**
|
||||
- Create new provisioning profiles for all targets
|
||||
- Xcode can auto-manage this if "Automatically manage signing" is enabled
|
||||
|
||||
---
|
||||
|
||||
### Step 6: Update Code References
|
||||
|
||||
Search and replace in codebase for hardcoded identifiers.
|
||||
|
||||
**Files to check** (based on grep results):
|
||||
- `BusinessCard/BusinessCardApp.swift`
|
||||
- `README.md`
|
||||
- `ai_implementation.md`
|
||||
- Any service files referencing container IDs
|
||||
|
||||
**Search for**:
|
||||
```
|
||||
com.mbrucedogs
|
||||
iCloud.com.mbrucedogs
|
||||
group.com.mbrucedogs
|
||||
```
|
||||
|
||||
**Example in SharedCardCloudKitService** (from OPUS plan):
|
||||
```swift
|
||||
// Update default parameter
|
||||
init(containerID: String = "iCloud.com.newcompany.BusinessCard", ...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 7: Update Associated Domains (If App Clip Implemented)
|
||||
|
||||
**Files affected**: Both iOS app and App Clip entitlements
|
||||
|
||||
The domain itself (`cards.example.com`) doesn't change, but the server's `apple-app-site-association` file needs the new Team ID:
|
||||
|
||||
```json
|
||||
{
|
||||
"appclips": {
|
||||
"apps": ["NEW_TEAM_ID.com.newcompany.BusinessCard.Clip"]
|
||||
},
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [{
|
||||
"appID": "NEW_TEAM_ID.com.newcompany.BusinessCard",
|
||||
"paths": ["/appclip/*"]
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CloudKit Data Migration
|
||||
|
||||
### Scenario A: App Not Yet Released
|
||||
|
||||
**Effort**: None
|
||||
|
||||
If the app hasn't been released to users, there's no data to migrate. Simply use the new container.
|
||||
|
||||
### Scenario B: App Released, Minimal Users
|
||||
|
||||
**Effort**: Low
|
||||
|
||||
1. Communicate to users that data will reset
|
||||
2. Provide in-app export feature before migration (export to vCard files)
|
||||
3. After update, users re-import their cards
|
||||
|
||||
### Scenario C: App Released, Significant Users
|
||||
|
||||
**Effort**: HIGH
|
||||
|
||||
CloudKit doesn't support container migration. Options:
|
||||
|
||||
#### Option 1: Dual Container (Recommended)
|
||||
|
||||
Keep access to both containers temporarily:
|
||||
|
||||
```xml
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.com.mbrucedogs.BusinessCard</string>
|
||||
<string>iCloud.com.newcompany.BusinessCard</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
Then implement in-app migration:
|
||||
1. On launch, check old container for data
|
||||
2. Copy records to new container
|
||||
3. Delete from old container
|
||||
4. Remove old container access in future update
|
||||
|
||||
**Complexity**: Requires both accounts to grant container access, or the old account must add the new Team ID to the old container's permissions.
|
||||
|
||||
#### Option 2: Backend Migration Service
|
||||
|
||||
1. Build a server that can access both containers
|
||||
2. Migrate data server-side
|
||||
3. Coordinate with app update
|
||||
|
||||
**Complexity**: Requires server infrastructure and CloudKit server-to-server keys.
|
||||
|
||||
#### Option 3: User-Initiated Export/Import
|
||||
|
||||
1. Add "Export All Cards" feature (before migration)
|
||||
2. Export to files (vCard, JSON, or iCloud Drive)
|
||||
3. Release update with new container
|
||||
4. Add "Import Cards" feature
|
||||
5. Users manually export/import
|
||||
|
||||
**Complexity**: Lowest technical effort, but poor user experience.
|
||||
|
||||
---
|
||||
|
||||
## SwiftData Considerations
|
||||
|
||||
SwiftData with CloudKit uses the CloudKit container for sync. When the container changes:
|
||||
|
||||
- **Private database records**: Lost (unless migrated)
|
||||
- **Local SwiftData store**: Remains on device but stops syncing
|
||||
- **New installs**: Start fresh with empty database
|
||||
|
||||
If using SwiftData's automatic CloudKit sync, consider:
|
||||
1. Disable CloudKit sync temporarily during migration
|
||||
2. Keep local data intact
|
||||
3. Re-enable sync with new container
|
||||
4. Local data will upload to new container
|
||||
|
||||
---
|
||||
|
||||
## Watch App Considerations
|
||||
|
||||
The watch app uses WatchConnectivity (not App Groups) for data sync, so:
|
||||
- **Bundle ID change**: Required (must match iOS app prefix)
|
||||
- **Data sync**: Unaffected (syncs from iOS app)
|
||||
- **Re-pairing**: May need to reinstall watch app after bundle ID change
|
||||
|
||||
---
|
||||
|
||||
## App Store Considerations
|
||||
|
||||
### Same Account, New Bundle ID
|
||||
- This is a NEW app listing
|
||||
- Lose reviews, ratings, download history
|
||||
- Can reference old app in description
|
||||
|
||||
### App Transfer (Same Bundle ID)
|
||||
- If keeping the same bundle ID but transferring ownership
|
||||
- Use App Store Connect's "Transfer App" feature
|
||||
- Preserves reviews, ratings, users
|
||||
- **Requires**: Both accounts in good standing, no active TestFlight, no pending updates
|
||||
|
||||
### Recommendation
|
||||
|
||||
If possible, use **App Transfer** to maintain continuity. This requires:
|
||||
1. Old account initiates transfer
|
||||
2. New account accepts transfer
|
||||
3. Bundle ID stays the same
|
||||
4. Only Team ID changes in project
|
||||
5. CloudKit container permissions may need updates
|
||||
|
||||
---
|
||||
|
||||
## Complete File Change Summary
|
||||
|
||||
| File | Changes Required |
|
||||
|------|------------------|
|
||||
| `project.pbxproj` | Team ID (9 places), Bundle IDs (6 places) |
|
||||
| `BusinessCard.entitlements` | CloudKit container, App Group |
|
||||
| `BusinessCardWatch.entitlements` | None (currently empty) |
|
||||
| `SharedCardCloudKitService.swift` | Container ID parameter |
|
||||
| `README.md` | Update any bundle ID references |
|
||||
| `ai_implementation.md` | Update any bundle ID references |
|
||||
| Server AASA file | Update Team ID |
|
||||
|
||||
---
|
||||
|
||||
## Testing After Migration
|
||||
|
||||
1. **Clean build**: Delete derived data, clean build folder
|
||||
2. **Fresh install**: Delete app from device, reinstall
|
||||
3. **CloudKit Dashboard**: Verify new container is receiving data
|
||||
4. **Watch app**: Verify watch installs and syncs correctly
|
||||
5. **Push notifications**: Verify registration with new bundle ID
|
||||
6. **App Clip**: Test invocation URL with new Team ID
|
||||
|
||||
---
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
| Phase | Duration |
|
||||
|-------|----------|
|
||||
| Preparation (new account setup) | 1-2 days |
|
||||
| Code changes | 1-2 hours |
|
||||
| Testing | 1-2 days |
|
||||
| CloudKit data migration (if needed) | 1-2 weeks |
|
||||
| App Store transition | 1-3 days |
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
**If the app is not yet released**: Migrate now while it's simple.
|
||||
|
||||
**If the app is released**: Consider using App Transfer to keep the same bundle ID and minimize disruption. You'll still need to update the Team ID and potentially CloudKit container access.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: January 10, 2026*
|
||||
713
OPUS-Plan-AppClip.md
Normal file
713
OPUS-Plan-AppClip.md
Normal file
@ -0,0 +1,713 @@
|
||||
# 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*
|
||||
Loading…
Reference in New Issue
Block a user