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