Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
0abf25c876
commit
44724914b4
654
APPCLIP-DEPLOYMENT.md
Normal file
654
APPCLIP-DEPLOYMENT.md
Normal file
@ -0,0 +1,654 @@
|
||||
# App Clip Deployment Guide
|
||||
|
||||
This guide walks you through setting up the domain, Cloudflare Worker, and App Store Connect configuration needed for the BusinessCard App Clip to work.
|
||||
|
||||
**Total Cost: ~$10-15/year** (domain only - everything else is free)
|
||||
**Time: ~1-2 hours**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
User scans QR code
|
||||
↓
|
||||
https://yourdomain.com/card/abc123
|
||||
↓
|
||||
Cloudflare Worker receives request
|
||||
↓
|
||||
┌───────────────────────────────────────┐
|
||||
│ Serves AASA file to Apple │
|
||||
│ (verifies App Clip ownership) │
|
||||
└───────────────────────────────────────┘
|
||||
↓
|
||||
┌───────────┴───────────┐
|
||||
↓ ↓
|
||||
iOS Android/Browser
|
||||
↓ ↓
|
||||
App Clip launches Worker fetches vCard
|
||||
(native experience) from CloudKit, returns
|
||||
.vcf file download
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Register a Domain (~10 minutes)
|
||||
|
||||
### Step 1.1: Choose a Domain Name
|
||||
|
||||
Pick something short and memorable. Examples:
|
||||
- `cards.yourlastname.com` (if you own a personal domain)
|
||||
- `bizcards.io`
|
||||
- `cardshare.app`
|
||||
- `vcards.link`
|
||||
|
||||
### Step 1.2: Register the Domain
|
||||
|
||||
**Recommended Registrars** (cheapest, no upselling):
|
||||
|
||||
| Registrar | .com Price | .io Price | .app Price |
|
||||
|-----------|------------|-----------|------------|
|
||||
| [Cloudflare Registrar](https://www.cloudflare.com/products/registrar/) | ~$10/yr | ~$35/yr | ~$15/yr |
|
||||
| [Porkbun](https://porkbun.com) | ~$10/yr | ~$33/yr | ~$15/yr |
|
||||
| [Namecheap](https://namecheap.com) | ~$11/yr | ~$35/yr | ~$16/yr |
|
||||
|
||||
**Recommendation**: Register directly with Cloudflare - simplest setup since you'll use Cloudflare Workers anyway.
|
||||
|
||||
### Step 1.3: If Not Using Cloudflare Registrar
|
||||
|
||||
If you registered elsewhere, add the domain to Cloudflare:
|
||||
|
||||
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com)
|
||||
2. Click "Add a Site"
|
||||
3. Enter your domain
|
||||
4. Select the **Free** plan
|
||||
5. Cloudflare will give you 2 nameservers (e.g., `ada.ns.cloudflare.com`)
|
||||
6. Go to your registrar and update nameservers to Cloudflare's
|
||||
7. Wait for DNS propagation (5 min - 24 hours, usually fast)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Create CloudKit Public Database Schema (~15 minutes)
|
||||
|
||||
The App Clip needs to read shared cards from CloudKit's public database.
|
||||
|
||||
### Step 2.1: Open CloudKit Console
|
||||
|
||||
1. Go to [CloudKit Console](https://icloud.developer.apple.com/dashboard)
|
||||
2. Sign in with your Apple Developer account
|
||||
3. Select your container: `iCloud.com.mbrucedogs.BusinessCard`
|
||||
|
||||
### Step 2.2: Create the Record Type
|
||||
|
||||
1. Click **Schema** in the sidebar
|
||||
2. Click **Record Types**
|
||||
3. Click **+** to create new record type
|
||||
4. Name: `SharedCard`
|
||||
5. Add these fields:
|
||||
|
||||
| Field Name | Type |
|
||||
|------------|------|
|
||||
| `vCardData` | String |
|
||||
| `expiresAt` | Date/Time |
|
||||
| `createdAt` | Date/Time |
|
||||
|
||||
6. Click **Save**
|
||||
|
||||
### Step 2.3: Set Security Roles (IMPORTANT)
|
||||
|
||||
1. Click **Security Roles** under your record type
|
||||
2. For the **Public** database:
|
||||
- **Authenticated Users**: Read, Write (for uploading from main app)
|
||||
- **World**: Read (for App Clip to fetch without sign-in)
|
||||
|
||||
3. Click **Save**
|
||||
|
||||
### Step 2.4: Deploy to Production
|
||||
|
||||
1. Click **Deploy Schema to Production...**
|
||||
2. Confirm the deployment
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Set Up Cloudflare Worker (~30 minutes)
|
||||
|
||||
### Step 3.1: Create the Worker
|
||||
|
||||
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com)
|
||||
2. Click **Workers & Pages** in the sidebar
|
||||
3. Click **Create Application**
|
||||
4. Click **Create Worker**
|
||||
5. Name it: `businesscard-share` (or similar)
|
||||
6. Click **Deploy** (creates with placeholder code)
|
||||
|
||||
### Step 3.2: Configure Worker Code
|
||||
|
||||
1. Click **Edit Code** (or "Quick Edit")
|
||||
2. Replace all code with:
|
||||
|
||||
```javascript
|
||||
// BusinessCard App Clip & vCard Worker
|
||||
// Handles iOS App Clip verification and Android vCard downloads
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION - Update these values
|
||||
// ============================================================================
|
||||
|
||||
const CONFIG = {
|
||||
// Your Apple Developer Team ID (find in Apple Developer portal)
|
||||
teamId: '6R7KLBPBLZ',
|
||||
|
||||
// Your App Clip bundle identifier
|
||||
appClipBundleId: 'com.mbrucedogs.BusinessCard.Clip',
|
||||
|
||||
// Your CloudKit container identifier
|
||||
cloudKitContainer: 'iCloud.com.mbrucedogs.BusinessCard',
|
||||
|
||||
// CloudKit environment ('development' or 'production')
|
||||
cloudKitEnvironment: 'production',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// MAIN HANDLER
|
||||
// ============================================================================
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Handle AASA file for Apple App Clip verification
|
||||
if (url.pathname === '/.well-known/apple-app-site-association') {
|
||||
return handleAASA();
|
||||
}
|
||||
|
||||
// Handle card requests (both App Clip and vCard download)
|
||||
if (url.pathname === '/appclip' || url.pathname.startsWith('/card/')) {
|
||||
return handleCardRequest(request, url);
|
||||
}
|
||||
|
||||
// Handle root - show a simple landing page
|
||||
if (url.pathname === '/') {
|
||||
return handleLandingPage();
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AASA HANDLER - Apple App Clip Verification
|
||||
// ============================================================================
|
||||
|
||||
function handleAASA() {
|
||||
const aasa = {
|
||||
appclips: {
|
||||
apps: [`${CONFIG.teamId}.${CONFIG.appClipBundleId}`]
|
||||
}
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(aasa), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=86400', // Cache for 24 hours
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CARD REQUEST HANDLER
|
||||
// ============================================================================
|
||||
|
||||
async function handleCardRequest(request, url) {
|
||||
// Extract record ID from URL
|
||||
// Supports: /appclip?id=xxx or /card/xxx
|
||||
let recordId = url.searchParams.get('id');
|
||||
if (!recordId && url.pathname.startsWith('/card/')) {
|
||||
recordId = url.pathname.split('/')[2];
|
||||
}
|
||||
|
||||
if (!recordId) {
|
||||
return new Response('Missing card ID', { status: 400 });
|
||||
}
|
||||
|
||||
// Check if this is an iOS device (App Clip will handle it natively)
|
||||
const userAgent = request.headers.get('User-Agent') || '';
|
||||
const isIOS = /iPhone|iPad|iPod/.test(userAgent);
|
||||
|
||||
// For iOS, we could return a page that triggers App Clip,
|
||||
// but iOS typically intercepts the URL before reaching here.
|
||||
// This fallback is mainly for Android/desktop browsers.
|
||||
|
||||
if (isIOS) {
|
||||
// Return a simple page that might help if App Clip doesn't auto-launch
|
||||
return handleIOSFallback(recordId);
|
||||
}
|
||||
|
||||
// For non-iOS devices, fetch the vCard from CloudKit and serve it
|
||||
return handleVCardDownload(recordId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// iOS FALLBACK - In case App Clip doesn't auto-launch
|
||||
// ============================================================================
|
||||
|
||||
function handleIOSFallback(recordId) {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="apple-itunes-app" content="app-clip-bundle-id=${CONFIG.appClipBundleId}">
|
||||
<title>Opening BusinessCard...</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.container { max-width: 400px; }
|
||||
h1 { font-size: 24px; margin-bottom: 16px; }
|
||||
p { font-size: 16px; opacity: 0.8; line-height: 1.5; }
|
||||
.loader {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 24px auto;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="loader"></div>
|
||||
<h1>Opening BusinessCard</h1>
|
||||
<p>If the app doesn't open automatically, make sure you have iOS 14 or later.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return new Response(html, {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VCARD DOWNLOAD - For Android/Desktop browsers
|
||||
// ============================================================================
|
||||
|
||||
async function handleVCardDownload(recordId) {
|
||||
try {
|
||||
// Fetch the record from CloudKit public database
|
||||
const vCardData = await fetchFromCloudKit(recordId);
|
||||
|
||||
if (!vCardData) {
|
||||
return handleNotFound();
|
||||
}
|
||||
|
||||
// Extract name from vCard for filename
|
||||
const nameMatch = vCardData.match(/FN:(.+)/);
|
||||
const name = nameMatch ? nameMatch[1].trim().replace(/[^a-zA-Z0-9]/g, '_') : 'contact';
|
||||
|
||||
// Return as downloadable vCard file
|
||||
return new Response(vCardData, {
|
||||
headers: {
|
||||
'Content-Type': 'text/vcard; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${name}.vcf"`,
|
||||
'Cache-Control': 'private, max-age=300', // Cache for 5 minutes
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching vCard:', error);
|
||||
return handleError();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLOUDKIT FETCH
|
||||
// ============================================================================
|
||||
|
||||
async function fetchFromCloudKit(recordName) {
|
||||
// CloudKit Web Services public database query
|
||||
// Note: For public database reads, no authentication is needed
|
||||
|
||||
const ckURL = `https://api.apple-cloudkit.com/database/1/${CONFIG.cloudKitContainer}/${CONFIG.cloudKitEnvironment}/public/records/lookup`;
|
||||
|
||||
const response = await fetch(ckURL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
records: [{ recordName }]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('CloudKit error:', await response.text());
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.records || data.records.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = data.records[0];
|
||||
|
||||
// Check if expired
|
||||
if (record.fields?.expiresAt?.value) {
|
||||
const expiresAt = new Date(record.fields.expiresAt.value);
|
||||
if (expiresAt < new Date()) {
|
||||
return null; // Expired
|
||||
}
|
||||
}
|
||||
|
||||
return record.fields?.vCardData?.value || null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ERROR PAGES
|
||||
// ============================================================================
|
||||
|
||||
function handleNotFound() {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Card Not Found</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.container { max-width: 400px; }
|
||||
h1 { font-size: 48px; margin-bottom: 8px; }
|
||||
h2 { font-size: 24px; margin-bottom: 16px; font-weight: 500; }
|
||||
p { font-size: 16px; opacity: 0.8; line-height: 1.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>😕</h1>
|
||||
<h2>Card Not Found</h2>
|
||||
<p>This business card may have expired or been removed. Shared cards are available for 7 days.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return new Response(html, {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||
});
|
||||
}
|
||||
|
||||
function handleError() {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.container { max-width: 400px; }
|
||||
h1 { font-size: 48px; margin-bottom: 8px; }
|
||||
h2 { font-size: 24px; margin-bottom: 16px; font-weight: 500; }
|
||||
p { font-size: 16px; opacity: 0.8; line-height: 1.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>⚠️</h1>
|
||||
<h2>Something Went Wrong</h2>
|
||||
<p>We couldn't load this business card. Please try again or ask the sender for a new link.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return new Response(html, {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LANDING PAGE
|
||||
// ============================================================================
|
||||
|
||||
function handleLandingPage() {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BusinessCard</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.container { max-width: 400px; }
|
||||
h1 { font-size: 32px; margin-bottom: 16px; }
|
||||
p { font-size: 18px; opacity: 0.8; line-height: 1.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📇 BusinessCard</h1>
|
||||
<p>Share your digital business card with anyone, even if they don't have the app.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return new Response(html, {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
3. Click **Save and Deploy**
|
||||
|
||||
### Step 3.3: Update Configuration Values
|
||||
|
||||
In the worker code, update these values at the top:
|
||||
|
||||
```javascript
|
||||
const CONFIG = {
|
||||
teamId: '6R7KLBPBLZ', // Your Team ID from Apple Developer Portal
|
||||
appClipBundleId: 'com.mbrucedogs.BusinessCard.Clip', // From Base.xcconfig
|
||||
cloudKitContainer: 'iCloud.com.mbrucedogs.BusinessCard', // From Base.xcconfig
|
||||
cloudKitEnvironment: 'production', // or 'development' for testing
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3.4: Add Custom Domain to Worker
|
||||
|
||||
1. In the Worker settings, click **Triggers** tab
|
||||
2. Click **Add Custom Domain**
|
||||
3. Enter your domain (e.g., `cards.yourdomain.com` or just `yourdomain.com`)
|
||||
4. Cloudflare will automatically configure DNS
|
||||
|
||||
### Step 3.5: Verify AASA File
|
||||
|
||||
Open in browser: `https://yourdomain.com/.well-known/apple-app-site-association`
|
||||
|
||||
You should see:
|
||||
```json
|
||||
{"appclips":{"apps":["6R7KLBPBLZ.com.mbrucedogs.BusinessCard.Clip"]}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Configure App Store Connect (~20 minutes)
|
||||
|
||||
### Step 4.1: Register App Clip Bundle ID
|
||||
|
||||
1. Go to [Apple Developer Portal](https://developer.apple.com/account)
|
||||
2. Click **Certificates, Identifiers & Profiles**
|
||||
3. Click **Identifiers** → **+**
|
||||
4. Select **App IDs** → **App Clip**
|
||||
5. Enter:
|
||||
- Description: `BusinessCard App Clip`
|
||||
- Bundle ID: `com.mbrucedogs.BusinessCard.Clip`
|
||||
6. Enable capabilities:
|
||||
- **Associated Domains** ✅
|
||||
- **iCloud** (CloudKit) ✅
|
||||
7. Click **Continue** → **Register**
|
||||
|
||||
### Step 4.2: Create App Store Connect Record
|
||||
|
||||
1. Go to [App Store Connect](https://appstoreconnect.apple.com)
|
||||
2. Select your BusinessCard app
|
||||
3. Under **App Clip** section (or create if first time)
|
||||
4. Configure the App Clip Experience:
|
||||
- **Title**: BusinessCard
|
||||
- **Subtitle**: View & save contact
|
||||
- **Action**: Open
|
||||
|
||||
### Step 4.3: Add Invocation URLs
|
||||
|
||||
1. In App Clip settings, click **Advanced App Clip Experiences**
|
||||
2. Add your URL pattern:
|
||||
- URL: `https://yourdomain.com/appclip`
|
||||
- Action: Open
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Update Xcode Project (~10 minutes)
|
||||
|
||||
### Step 5.1: Update Base.xcconfig
|
||||
|
||||
Open `BusinessCard/Configuration/Base.xcconfig` and update:
|
||||
|
||||
```
|
||||
APPCLIP_DOMAIN = yourdomain.com
|
||||
```
|
||||
|
||||
Replace `yourdomain.com` with your actual domain.
|
||||
|
||||
### Step 5.2: Build and Upload to TestFlight
|
||||
|
||||
1. Select **BusinessCard** scheme (main app - it includes the App Clip)
|
||||
2. Product → Archive
|
||||
3. Distribute to App Store Connect
|
||||
4. Upload to TestFlight
|
||||
|
||||
### Step 5.3: Test with TestFlight
|
||||
|
||||
1. Install the app via TestFlight on a real device
|
||||
2. Open the app → Share tab
|
||||
3. Generate an App Clip link for a card
|
||||
4. Scan the QR code with another iOS device's camera
|
||||
5. App Clip should launch and show the card preview
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Testing Checklist
|
||||
|
||||
### Local Testing (Simulator)
|
||||
|
||||
- [ ] Main app builds successfully
|
||||
- [ ] App Clip target builds successfully
|
||||
- [ ] Can generate App Clip URL in Share view
|
||||
- [ ] CloudKit record is created
|
||||
|
||||
### TestFlight Testing
|
||||
|
||||
- [ ] App + App Clip installed via TestFlight
|
||||
- [ ] QR code scans and App Clip launches
|
||||
- [ ] Card preview shows correctly with photo
|
||||
- [ ] "Save to Contacts" works
|
||||
- [ ] Android device downloads .vcf file from same URL
|
||||
|
||||
### Production Readiness
|
||||
|
||||
- [ ] AASA file accessible at domain
|
||||
- [ ] CloudKit production schema deployed
|
||||
- [ ] App Clip Experience configured in App Store Connect
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### App Clip Doesn't Launch When Scanning QR
|
||||
|
||||
1. **Check AASA file**: Visit `https://yourdomain.com/.well-known/apple-app-site-association`
|
||||
2. **Validate with Apple**: Use [AASA Validator](https://branch.io/resources/aasa-validator/)
|
||||
3. **Check entitlements**: Ensure `appclips:yourdomain.com` is in both app and clip entitlements
|
||||
4. **Wait for cache**: Apple caches AASA files; may take up to 24 hours
|
||||
|
||||
### CloudKit Fetch Fails
|
||||
|
||||
1. **Check environment**: Using `development` vs `production`?
|
||||
2. **Check permissions**: Public database needs "World: Read" permission
|
||||
3. **Check record exists**: Verify in CloudKit Console
|
||||
|
||||
### Android vCard Download Fails
|
||||
|
||||
1. **Check Cloudflare Worker logs**: Workers → your-worker → Logs
|
||||
2. **Test CloudKit endpoint**: Try the fetch manually in worker
|
||||
3. **Check CORS**: Shouldn't be needed for downloads but verify
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Step | What | Time |
|
||||
|------|------|------|
|
||||
| 1 | Register domain | 10 min |
|
||||
| 2 | CloudKit schema | 15 min |
|
||||
| 3 | Cloudflare Worker | 30 min |
|
||||
| 4 | App Store Connect | 20 min |
|
||||
| 5 | Xcode + TestFlight | 10 min |
|
||||
| 6 | Testing | 15 min |
|
||||
| **Total** | | **~2 hours** |
|
||||
|
||||
**Ongoing Cost**: ~$10-15/year (domain only)
|
||||
|
||||
---
|
||||
|
||||
*Last updated: January 10, 2026*
|
||||
@ -9,6 +9,7 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
EA837E672F107D6800077F87 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA837E662F107D6800077F87 /* Bedrock */; };
|
||||
EAAE892A2F12DE110075BC8A /* BusinessCardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
EACLIP0012F200000000001 /* BusinessCardClip.app in Embed App Clips */ = {isa = PBXBuildFile; fileRef = EACLIP0012F200000000002 /* BusinessCardClip.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@ -33,6 +34,13 @@
|
||||
remoteGlobalIDString = EA837F972F11B16400077F87;
|
||||
remoteInfo = "BusinessCardWatch Watch App";
|
||||
};
|
||||
EACLIP0012F200000000003 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = EA83791B2F105F2600077F87 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = EACLIP0012F200000000004;
|
||||
remoteInfo = BusinessCardClip;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@ -47,6 +55,17 @@
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EACLIP0012F200000000005 /* Embed App Clips */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "$(CONTENTS_FOLDER_PATH)/AppClips";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
EACLIP0012F200000000001 /* BusinessCardClip.app in Embed App Clips */,
|
||||
);
|
||||
name = "Embed App Clips";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@ -54,10 +73,9 @@
|
||||
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusinessCardTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusinessCardUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "BusinessCardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EACONFIG0012F200000000001 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BusinessCard/Configuration/Base.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
EACONFIG0012F200000000002 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BusinessCard/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
EACONFIG0012F200000000003 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BusinessCard/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
EACONFIG0012F200000000004 /* Watch.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "BusinessCardWatch Watch App/Configuration/Watch.xcconfig"; sourceTree = SOURCE_ROOT; };
|
||||
EACLIP0012F200000000002 /* BusinessCardClip.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BusinessCardClip.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EACONFIG0012F200000000002 /* BusinessCard/Configuration/Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BusinessCard/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
EACONFIG0012F200000000003 /* BusinessCard/Configuration/Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BusinessCard/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@ -68,6 +86,13 @@
|
||||
);
|
||||
target = EA8379222F105F2600077F87 /* BusinessCard */;
|
||||
};
|
||||
EACLIP0012F20000000000E /* Exceptions for "BusinessCardClip" folder in "BusinessCardClip" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = EACLIP0012F200000000004 /* BusinessCardClip */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@ -94,6 +119,14 @@
|
||||
path = "BusinessCardWatch Watch App";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EACLIP0012F200000000006 /* BusinessCardClip */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
EACLIP0012F20000000000E /* Exceptions for "BusinessCardClip" folder in "BusinessCardClip" target */,
|
||||
);
|
||||
path = BusinessCardClip;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@ -126,6 +159,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EACLIP0012F200000000007 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@ -136,8 +176,10 @@
|
||||
EA8379332F105F2800077F87 /* BusinessCardTests */,
|
||||
EA83793D2F105F2800077F87 /* BusinessCardUITests */,
|
||||
EA837F992F11B16400077F87 /* BusinessCardWatch Watch App */,
|
||||
EACLIP0012F200000000006 /* BusinessCardClip */,
|
||||
EAAE89292F12DE110075BC8A /* Frameworks */,
|
||||
EA8379242F105F2600077F87 /* Products */,
|
||||
EADCDC1C2F12F7EA007991B3 /* Recovered References */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@ -148,6 +190,7 @@
|
||||
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */,
|
||||
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */,
|
||||
EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */,
|
||||
EACLIP0012F200000000002 /* BusinessCardClip.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@ -159,6 +202,15 @@
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EADCDC1C2F12F7EA007991B3 /* Recovered References */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EACONFIG0012F200000000002 /* BusinessCard/Configuration/Debug.xcconfig */,
|
||||
EACONFIG0012F200000000003 /* BusinessCard/Configuration/Release.xcconfig */,
|
||||
);
|
||||
name = "Recovered References";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@ -170,11 +222,13 @@
|
||||
EA8379202F105F2600077F87 /* Frameworks */,
|
||||
EA8379212F105F2600077F87 /* Resources */,
|
||||
EAAE892D2F12DE110075BC8A /* Embed Watch Content */,
|
||||
EACLIP0012F200000000005 /* Embed App Clips */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
EAAE892C2F12DE110075BC8A /* PBXTargetDependency */,
|
||||
EACLIP0012F200000000008 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
EA8379252F105F2600077F87 /* BusinessCard */,
|
||||
@ -255,6 +309,28 @@
|
||||
productReference = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
EACLIP0012F200000000004 /* BusinessCardClip */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = EACLIP0012F200000000009 /* Build configuration list for PBXNativeTarget "BusinessCardClip" */;
|
||||
buildPhases = (
|
||||
EACLIP0012F20000000000A /* Sources */,
|
||||
EACLIP0012F200000000007 /* Frameworks */,
|
||||
EACLIP0012F20000000000B /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
EACLIP0012F200000000006 /* BusinessCardClip */,
|
||||
);
|
||||
name = BusinessCardClip;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = BusinessCardClip;
|
||||
productReference = EACLIP0012F200000000002 /* BusinessCardClip.app */;
|
||||
productType = "com.apple.product-type.application.on-demand-install-capable";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@ -279,6 +355,9 @@
|
||||
EA837F972F11B16400077F87 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
EACLIP0012F200000000004 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = EA83791E2F105F2600077F87 /* Build configuration list for PBXProject "BusinessCard" */;
|
||||
@ -304,6 +383,7 @@
|
||||
EA83792F2F105F2800077F87 /* BusinessCardTests */,
|
||||
EA8379392F105F2800077F87 /* BusinessCardUITests */,
|
||||
EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */,
|
||||
EACLIP0012F200000000004 /* BusinessCardClip */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@ -337,6 +417,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EACLIP0012F20000000000B /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@ -368,6 +455,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EACLIP0012F20000000000A /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
@ -386,12 +480,17 @@
|
||||
target = EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */;
|
||||
targetProxy = EAAE892B2F12DE110075BC8A /* PBXContainerItemProxy */;
|
||||
};
|
||||
EACLIP0012F200000000008 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = EACLIP0012F200000000004 /* BusinessCardClip */;
|
||||
targetProxy = EACLIP0012F200000000003 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
EA8379422F105F2800077F87 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = EACONFIG0012F200000000002 /* Debug.xcconfig */;
|
||||
baseConfigurationReference = EACONFIG0012F200000000002 /* BusinessCard/Configuration/Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
@ -456,7 +555,7 @@
|
||||
};
|
||||
EA8379432F105F2800077F87 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = EACONFIG0012F200000000003 /* Release.xcconfig */;
|
||||
baseConfigurationReference = EACONFIG0012F200000000003 /* BusinessCard/Configuration/Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
@ -736,6 +835,80 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
EACLIP0012F20000000000C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = BusinessCardClip/BusinessCardClip.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusinessCardClip/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = BusinessCard;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(APPCLIP_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 6.2;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
EACLIP0012F20000000000D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = BusinessCardClip/BusinessCardClip.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BusinessCardClip/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = BusinessCard;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(APPCLIP_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 6.2;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@ -784,6 +957,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
EACLIP0012F200000000009 /* Build configuration list for PBXNativeTarget "BusinessCardClip" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
EACLIP0012F20000000000C /* Debug */,
|
||||
EACLIP0012F20000000000D /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
|
||||
@ -5,6 +5,11 @@
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>BusinessCard.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
<key>BusinessCardClip.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
@ -12,7 +17,7 @@
|
||||
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
@ -4,6 +4,10 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>appclips:$(APPCLIP_DOMAIN)</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
|
||||
|
||||
44
BusinessCard/Models/SharedCardRecord.swift
Normal file
44
BusinessCard/Models/SharedCardRecord.swift
Normal file
@ -0,0 +1,44 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
13
BusinessCard/Models/SharedCardUploadResult.swift
Normal file
13
BusinessCard/Models/SharedCardUploadResult.swift
Normal file
@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
/// Result of uploading a shared card to CloudKit for App Clip access.
|
||||
struct SharedCardUploadResult: Sendable {
|
||||
/// The CloudKit record name (UUID) used to fetch the card.
|
||||
let recordName: String
|
||||
|
||||
/// The URL that invokes the App Clip when scanned.
|
||||
let appClipURL: URL
|
||||
|
||||
/// When the shared card expires and will be deleted.
|
||||
let expiresAt: Date
|
||||
}
|
||||
14
BusinessCard/Protocols/SharedCardProviding.swift
Normal file
14
BusinessCard/Protocols/SharedCardProviding.swift
Normal file
@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
/// Provides shared card upload and cleanup functionality for App Clip sharing.
|
||||
protocol SharedCardProviding: Sendable {
|
||||
/// Uploads a business card to CloudKit public database for App Clip access.
|
||||
/// - Parameter card: The business card to share.
|
||||
/// - Returns: Upload result containing the App Clip URL and expiration date.
|
||||
@MainActor
|
||||
func uploadSharedCard(_ card: BusinessCard) async throws -> SharedCardUploadResult
|
||||
|
||||
/// Cleans up expired shared cards from CloudKit.
|
||||
/// This is a best-effort operation that does not throw errors.
|
||||
func cleanupExpiredCards() async
|
||||
}
|
||||
96
BusinessCard/Services/SharedCardCloudKitService.swift
Normal file
96
BusinessCard/Services/SharedCardCloudKitService.swift
Normal file
@ -0,0 +1,96 @@
|
||||
import CloudKit
|
||||
import Foundation
|
||||
|
||||
/// Service for uploading and managing shared cards in CloudKit public database.
|
||||
struct SharedCardCloudKitService: SharedCardProviding {
|
||||
private let container: CKContainer
|
||||
private let database: CKDatabase
|
||||
private let ttlDays: Int
|
||||
|
||||
init(
|
||||
containerID: String = AppIdentifiers.cloudKitContainerIdentifier,
|
||||
ttlDays: Int = 7
|
||||
) {
|
||||
self.container = CKContainer(identifier: containerID)
|
||||
self.database = container.publicCloudDatabase
|
||||
self.ttlDays = ttlDays
|
||||
}
|
||||
|
||||
@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()
|
||||
|
||||
do {
|
||||
_ = try await database.save(record)
|
||||
} catch {
|
||||
throw SharedCardError.uploadFailed(error)
|
||||
}
|
||||
|
||||
guard let appClipURL = AppIdentifiers.appClipURL(recordName: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Types
|
||||
|
||||
/// Errors that can occur during shared card operations.
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
42
BusinessCard/State/AppClipShareState.swift
Normal file
42
BusinessCard/State/AppClipShareState.swift
Normal file
@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
/// State for managing App Clip card sharing.
|
||||
/// Handles upload to CloudKit and provides the resulting App Clip URL.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppClipShareState {
|
||||
private let service: SharedCardProviding
|
||||
|
||||
var isUploading = false
|
||||
var uploadResult: SharedCardUploadResult?
|
||||
var errorMessage: String?
|
||||
|
||||
/// Returns true if an App Clip URL has been generated.
|
||||
var hasAppClipURL: Bool { uploadResult != nil }
|
||||
|
||||
init(service: SharedCardProviding = SharedCardCloudKitService()) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
/// Uploads a card to CloudKit for App Clip sharing.
|
||||
/// - Parameter card: The business card to share.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the state for a new share operation.
|
||||
func reset() {
|
||||
uploadResult = nil
|
||||
errorMessage = nil
|
||||
}
|
||||
}
|
||||
@ -14,5 +14,10 @@ final class AppState {
|
||||
self.cardStore = CardStore(modelContext: modelContext)
|
||||
self.contactsStore = ContactsStore(modelContext: modelContext)
|
||||
self.shareLinkService = ShareLinkService()
|
||||
|
||||
// Clean up expired shared cards on launch (best-effort, non-blocking)
|
||||
Task {
|
||||
await SharedCardCloudKitService().cleanupExpiredCards()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,9 @@ struct ShareCardView: View {
|
||||
@State private var messageComposeURL: IdentifiableURL?
|
||||
@State private var mailComposeURL: IdentifiableURL?
|
||||
|
||||
// App Clip share state
|
||||
@State private var appClipState = AppClipShareState()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
@ -29,9 +32,12 @@ struct ShareCardView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: Design.Spacing.xLarge) {
|
||||
if let card = appState.cardStore.selectedCard {
|
||||
// QR Code section
|
||||
// QR Code section (vCard, no photo)
|
||||
QRCodeSection(card: card)
|
||||
|
||||
// App Clip section (includes photo)
|
||||
AppClipSection(card: card, appClipState: appClipState)
|
||||
|
||||
// Share options
|
||||
ShareOptionsSection(
|
||||
card: card,
|
||||
@ -110,6 +116,90 @@ private struct QRCodeSection: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App Clip Section
|
||||
|
||||
private struct AppClipSection: View {
|
||||
let card: BusinessCard
|
||||
@Bindable var appClipState: AppClipShareState
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
// Header
|
||||
HStack {
|
||||
Image(systemName: "app.gift")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.ShareSheet.text)
|
||||
Text("App Clip (includes photo)")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.ShareSheet.text)
|
||||
}
|
||||
|
||||
// Content based on state
|
||||
if appClipState.isUploading {
|
||||
ProgressView()
|
||||
.tint(Color.ShareSheet.text)
|
||||
.padding(Design.Spacing.xLarge)
|
||||
.accessibilityLabel(Text("Uploading card"))
|
||||
} else if let result = appClipState.uploadResult {
|
||||
// Show QR code for App Clip URL
|
||||
QRCodeView(payload: result.appClipURL.absoluteString)
|
||||
.frame(width: Design.CardSize.qrSizeLarge, height: Design.CardSize.qrSizeLarge)
|
||||
.padding(Design.Spacing.large)
|
||||
.background(Color.white)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
|
||||
// Expiration notice
|
||||
Text("Expires in 7 days")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.ShareSheet.secondaryText)
|
||||
|
||||
// Reset button
|
||||
Button {
|
||||
appClipState.reset()
|
||||
} label: {
|
||||
Text("Generate New Link")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.ShareSheet.text)
|
||||
}
|
||||
} else {
|
||||
// Generate button
|
||||
Button {
|
||||
Task { await appClipState.shareViaAppClip(card: card) }
|
||||
} label: {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: "qrcode")
|
||||
Text("Generate App Clip Link")
|
||||
}
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.ShareSheet.background)
|
||||
.padding(.horizontal, Design.Spacing.xLarge)
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
.background(Color.ShareSheet.text)
|
||||
.clipShape(.capsule)
|
||||
}
|
||||
|
||||
// Description
|
||||
Text("Creates a link that opens a mini-app for recipients to preview and save your card with photo.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.ShareSheet.secondaryText)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
// Error message
|
||||
if let error = appClipState.errorMessage {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Design.Spacing.xLarge)
|
||||
.background(Color.ShareSheet.cardBackground)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Share Options Section
|
||||
|
||||
private struct ShareOptionsSection: View {
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.950",
|
||||
"green" : "0.650",
|
||||
"red" : "0.350"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
BusinessCardClip/Assets.xcassets/Contents.json
Normal file
6
BusinessCardClip/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.150",
|
||||
"green" : "0.130",
|
||||
"red" : "0.120"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
22
BusinessCardClip/BusinessCardClip.entitlements
Normal file
22
BusinessCardClip/BusinessCardClip.entitlements
Normal file
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>appclips:$(APPCLIP_DOMAIN)</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
<key>com.apple.developer.parent-application-identifiers</key>
|
||||
<array>
|
||||
<string>$(TeamIdentifierPrefix)$(APP_BUNDLE_IDENTIFIER)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
37
BusinessCardClip/BusinessCardClipApp.swift
Normal file
37
BusinessCardClip/BusinessCardClipApp.swift
Normal file
@ -0,0 +1,37 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct BusinessCardClipApp: App {
|
||||
@State private var recordName: String?
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Group {
|
||||
if let recordName {
|
||||
ClipRootView(recordName: recordName)
|
||||
} else {
|
||||
ClipLoadingView()
|
||||
}
|
||||
}
|
||||
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
|
||||
handleUserActivity(activity)
|
||||
}
|
||||
.onOpenURL { url in
|
||||
handleURL(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleUserActivity(_ activity: NSUserActivity) {
|
||||
guard let url = activity.webpageURL else { return }
|
||||
handleURL(url)
|
||||
}
|
||||
|
||||
private func handleURL(_ url: URL) {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
||||
let id = components.queryItems?.first(where: { $0.name == "id" })?.value else {
|
||||
return
|
||||
}
|
||||
recordName = id
|
||||
}
|
||||
}
|
||||
8
BusinessCardClip/Configuration/Clip.xcconfig
Normal file
8
BusinessCardClip/Configuration/Clip.xcconfig
Normal file
@ -0,0 +1,8 @@
|
||||
// Clip.xcconfig
|
||||
// Configuration for App Clip target
|
||||
|
||||
#include "../BusinessCard/Configuration/Base.xcconfig"
|
||||
|
||||
// App Clip specific settings
|
||||
PRODUCT_BUNDLE_IDENTIFIER = $(APPCLIP_BUNDLE_IDENTIFIER)
|
||||
INFOPLIST_KEY_CFBundleDisplayName = BusinessCard
|
||||
37
BusinessCardClip/Configuration/ClipIdentifiers.swift
Normal file
37
BusinessCardClip/Configuration/ClipIdentifiers.swift
Normal file
@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
/// Centralized identifiers for the App Clip.
|
||||
/// Reads from Info.plist which gets values from xcconfig at build time.
|
||||
///
|
||||
/// The source of truth is `BusinessCard/Configuration/Base.xcconfig`.
|
||||
/// The App Clip inherits these values through its own xcconfig.
|
||||
enum ClipIdentifiers {
|
||||
|
||||
/// CloudKit container identifier.
|
||||
/// Must match the main app's container to access shared cards.
|
||||
static let cloudKitContainerIdentifier: String = {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CloudKitContainerIdentifier") as? String
|
||||
?? "iCloud.$(COMPANY_IDENTIFIER).$(APP_NAME)"
|
||||
}()
|
||||
|
||||
/// App Clip domain for URL handling.
|
||||
static let appClipDomain: String = {
|
||||
Bundle.main.object(forInfoDictionaryKey: "AppClipDomain") as? String
|
||||
?? "cards.example.com"
|
||||
}()
|
||||
|
||||
/// Bundle identifier of the App Clip.
|
||||
static var bundleIdentifier: String {
|
||||
Bundle.main.bundleIdentifier ?? "$(APPCLIP_BUNDLE_IDENTIFIER)"
|
||||
}
|
||||
|
||||
/// Parent app bundle identifier.
|
||||
static var parentBundleIdentifier: String {
|
||||
// Remove ".Clip" suffix to get parent bundle ID
|
||||
let clipSuffix = ".Clip"
|
||||
if bundleIdentifier.hasSuffix(clipSuffix) {
|
||||
return String(bundleIdentifier.dropLast(clipSuffix.count))
|
||||
}
|
||||
return bundleIdentifier
|
||||
}
|
||||
}
|
||||
57
BusinessCardClip/Design/ClipDesignConstants.swift
Normal file
57
BusinessCardClip/Design/ClipDesignConstants.swift
Normal file
@ -0,0 +1,57 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Design constants for the App Clip.
|
||||
/// Mirrors main app design but with minimal footprint for size constraints.
|
||||
enum ClipDesign {
|
||||
|
||||
// MARK: - Spacing
|
||||
|
||||
enum Spacing {
|
||||
static let xSmall: CGFloat = 4
|
||||
static let small: CGFloat = 8
|
||||
static let medium: CGFloat = 12
|
||||
static let large: CGFloat = 16
|
||||
static let xLarge: CGFloat = 24
|
||||
static let xxLarge: CGFloat = 32
|
||||
}
|
||||
|
||||
// MARK: - Corner Radius
|
||||
|
||||
enum CornerRadius {
|
||||
static let small: CGFloat = 8
|
||||
static let medium: CGFloat = 12
|
||||
static let large: CGFloat = 16
|
||||
static let xLarge: CGFloat = 24
|
||||
}
|
||||
|
||||
// MARK: - Sizes
|
||||
|
||||
enum Size {
|
||||
static let avatar: CGFloat = 80
|
||||
static let avatarLarge: CGFloat = 120
|
||||
static let buttonHeight: CGFloat = 50
|
||||
}
|
||||
|
||||
// MARK: - Opacity
|
||||
|
||||
enum Opacity {
|
||||
static let subtle: Double = 0.3
|
||||
static let medium: Double = 0.5
|
||||
static let strong: Double = 0.7
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Colors
|
||||
|
||||
extension Color {
|
||||
|
||||
enum Clip {
|
||||
static let background = Color(red: 0.12, green: 0.13, blue: 0.15)
|
||||
static let cardBackground = Color(red: 0.18, green: 0.19, blue: 0.22)
|
||||
static let text = Color(red: 0.96, green: 0.96, blue: 0.97)
|
||||
static let secondaryText = Color(red: 0.70, green: 0.72, blue: 0.75)
|
||||
static let accent = Color(red: 0.35, green: 0.65, blue: 0.95)
|
||||
static let success = Color(red: 0.30, green: 0.75, blue: 0.45)
|
||||
static let error = Color(red: 0.95, green: 0.35, blue: 0.35)
|
||||
}
|
||||
}
|
||||
17
BusinessCardClip/Info.plist
Normal file
17
BusinessCardClip/Info.plist
Normal file
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CloudKitContainerIdentifier</key>
|
||||
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
|
||||
<key>AppClipDomain</key>
|
||||
<string>$(APPCLIP_DOMAIN)</string>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>Save this contact to your address book.</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>LaunchBackground</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
40
BusinessCardClip/Models/SharedCardSnapshot.swift
Normal file
40
BusinessCardClip/Models/SharedCardSnapshot.swift
Normal file
@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
/// Represents a shared card fetched from CloudKit for display in the App Clip.
|
||||
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? {
|
||||
// Find line that starts with PHOTO; and contains base64 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)
|
||||
}
|
||||
}
|
||||
34
BusinessCardClip/Services/ClipCloudKitService.swift
Normal file
34
BusinessCardClip/Services/ClipCloudKitService.swift
Normal file
@ -0,0 +1,34 @@
|
||||
import CloudKit
|
||||
|
||||
/// Service for fetching shared cards from CloudKit public database.
|
||||
struct ClipCloudKitService: Sendable {
|
||||
private let database: CKDatabase
|
||||
|
||||
init(containerID: String = ClipIdentifiers.cloudKitContainerIdentifier) {
|
||||
self.database = CKContainer(identifier: containerID).publicCloudDatabase
|
||||
}
|
||||
|
||||
/// Fetches a shared card from CloudKit by record name.
|
||||
/// - Parameter recordName: The UUID string identifying the record.
|
||||
/// - Returns: A snapshot of the shared card data.
|
||||
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)
|
||||
}
|
||||
}
|
||||
25
BusinessCardClip/Services/ClipError.swift
Normal file
25
BusinessCardClip/Services/ClipError.swift
Normal file
@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
/// Errors that can occur in the App Clip.
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
31
BusinessCardClip/Services/ContactSaveService.swift
Normal file
31
BusinessCardClip/Services/ContactSaveService.swift
Normal file
@ -0,0 +1,31 @@
|
||||
import Contacts
|
||||
|
||||
/// Service for saving vCard data to the user's Contacts.
|
||||
struct ContactSaveService: Sendable {
|
||||
|
||||
/// Saves the vCard data as a new contact.
|
||||
/// - Parameter vCardData: The vCard string to parse and save.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
56
BusinessCardClip/State/ClipCardStore.swift
Normal file
56
BusinessCardClip/State/ClipCardStore.swift
Normal file
@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
/// State management for the App Clip card display and save flow.
|
||||
@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
|
||||
|
||||
/// The currently loaded card snapshot, if any.
|
||||
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
|
||||
}
|
||||
|
||||
/// Loads a shared card from CloudKit.
|
||||
/// - Parameter recordName: The record name (UUID) to fetch.
|
||||
func load(recordName: String) async {
|
||||
state = .loading
|
||||
do {
|
||||
let snapshot = try await cloudKit.fetchSharedCard(recordName: recordName)
|
||||
state = .loaded(snapshot)
|
||||
} catch {
|
||||
state = .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the currently loaded card to Contacts.
|
||||
func saveToContacts() async {
|
||||
guard let snapshot else { return }
|
||||
do {
|
||||
try await contactSave.saveContact(vCardData: snapshot.vCardData)
|
||||
state = .saved
|
||||
} catch {
|
||||
state = .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
34
BusinessCardClip/Views/ClipRootView.swift
Normal file
34
BusinessCardClip/Views/ClipRootView.swift
Normal file
@ -0,0 +1,34 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Root view for the App Clip that manages the card loading and display flow.
|
||||
struct ClipRootView: View {
|
||||
@State private var store = ClipCardStore()
|
||||
let recordName: String
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.Clip.background
|
||||
.ignoresSafeArea()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
120
BusinessCardClip/Views/Components/ClipCardPreview.swift
Normal file
120
BusinessCardClip/Views/Components/ClipCardPreview.swift
Normal file
@ -0,0 +1,120 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Displays a preview of the shared card with option to save to Contacts.
|
||||
struct ClipCardPreview: View {
|
||||
let snapshot: SharedCardSnapshot
|
||||
let onSave: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: ClipDesign.Spacing.xLarge) {
|
||||
Spacer()
|
||||
|
||||
// Card content
|
||||
VStack(spacing: ClipDesign.Spacing.large) {
|
||||
// Profile photo or placeholder
|
||||
if let photoData = snapshot.photoData,
|
||||
let uiImage = UIImage(data: photoData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: ClipDesign.Size.avatarLarge, height: ClipDesign.Size.avatarLarge)
|
||||
.clipShape(.circle)
|
||||
} else {
|
||||
Image(systemName: "person.crop.circle.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: ClipDesign.Size.avatarLarge, height: ClipDesign.Size.avatarLarge)
|
||||
.foregroundStyle(Color.Clip.secondaryText)
|
||||
}
|
||||
|
||||
// Name
|
||||
Text(snapshot.displayName)
|
||||
.font(.title)
|
||||
.bold()
|
||||
.foregroundStyle(Color.Clip.text)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Role and company
|
||||
if !snapshot.role.isEmpty || !snapshot.company.isEmpty {
|
||||
VStack(spacing: ClipDesign.Spacing.xSmall) {
|
||||
if !snapshot.role.isEmpty {
|
||||
Text(snapshot.role)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.Clip.secondaryText)
|
||||
}
|
||||
if !snapshot.company.isEmpty {
|
||||
Text(snapshot.company)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.Clip.secondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(ClipDesign.Spacing.xLarge)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.Clip.cardBackground)
|
||||
.clipShape(.rect(cornerRadius: ClipDesign.CornerRadius.xLarge))
|
||||
.padding(.horizontal, ClipDesign.Spacing.large)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Save button
|
||||
Button(action: onSave) {
|
||||
HStack(spacing: ClipDesign.Spacing.small) {
|
||||
Image(systemName: "person.crop.circle.badge.plus")
|
||||
Text("Save to Contacts")
|
||||
}
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.Clip.background)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: ClipDesign.Size.buttonHeight)
|
||||
.background(Color.Clip.accent)
|
||||
.clipShape(.capsule)
|
||||
}
|
||||
.padding(.horizontal, ClipDesign.Spacing.xLarge)
|
||||
.accessibilityLabel(Text("Save \(snapshot.displayName) to contacts"))
|
||||
|
||||
// Get full app prompt
|
||||
Button {
|
||||
openAppStore()
|
||||
} label: {
|
||||
Text("Get the full app")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.Clip.accent)
|
||||
}
|
||||
.padding(.bottom, ClipDesign.Spacing.large)
|
||||
}
|
||||
}
|
||||
|
||||
private func openAppStore() {
|
||||
// Open App Store page for the full app
|
||||
// Replace with actual App Store URL when available
|
||||
if let url = URL(string: "https://apps.apple.com/app/id1234567890") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ZStack {
|
||||
Color.Clip.background
|
||||
.ignoresSafeArea()
|
||||
|
||||
ClipCardPreview(
|
||||
snapshot: SharedCardSnapshot(
|
||||
recordName: "test",
|
||||
vCardData: """
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
N:Sullivan;Daniel;;;
|
||||
FN:Daniel Sullivan
|
||||
ORG:WR Construction
|
||||
TITLE:Property Developer
|
||||
END:VCARD
|
||||
"""
|
||||
)
|
||||
) {
|
||||
print("Save tapped")
|
||||
}
|
||||
}
|
||||
}
|
||||
54
BusinessCardClip/Views/Components/ClipErrorView.swift
Normal file
54
BusinessCardClip/Views/Components/ClipErrorView.swift
Normal file
@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Error state shown when card fetch or save fails.
|
||||
struct ClipErrorView: View {
|
||||
let message: String
|
||||
let onRetry: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: ClipDesign.Spacing.xLarge) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: ClipDesign.Size.avatar, height: ClipDesign.Size.avatar)
|
||||
.foregroundStyle(Color.Clip.error)
|
||||
|
||||
VStack(spacing: ClipDesign.Spacing.small) {
|
||||
Text("Something went wrong")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
.foregroundStyle(Color.Clip.text)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.Clip.secondaryText)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button(action: onRetry) {
|
||||
Text("Try Again")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.Clip.background)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: ClipDesign.Size.buttonHeight)
|
||||
.background(Color.Clip.accent)
|
||||
.clipShape(.capsule)
|
||||
}
|
||||
.padding(.horizontal, ClipDesign.Spacing.xLarge)
|
||||
.padding(.top, ClipDesign.Spacing.large)
|
||||
}
|
||||
.padding(ClipDesign.Spacing.xLarge)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(Text("Error: \(message)"))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ZStack {
|
||||
Color.Clip.background
|
||||
.ignoresSafeArea()
|
||||
ClipErrorView(message: "This card has expired") {
|
||||
print("Retry tapped")
|
||||
}
|
||||
}
|
||||
}
|
||||
26
BusinessCardClip/Views/Components/ClipLoadingView.swift
Normal file
26
BusinessCardClip/Views/Components/ClipLoadingView.swift
Normal file
@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Loading state view shown while fetching the card from CloudKit.
|
||||
struct ClipLoadingView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: ClipDesign.Spacing.large) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
.tint(Color.Clip.accent)
|
||||
|
||||
Text("Loading card...")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.Clip.text)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(Text("Loading business card"))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ZStack {
|
||||
Color.Clip.background
|
||||
.ignoresSafeArea()
|
||||
ClipLoadingView()
|
||||
}
|
||||
}
|
||||
67
BusinessCardClip/Views/Components/ClipSuccessView.swift
Normal file
67
BusinessCardClip/Views/Components/ClipSuccessView.swift
Normal file
@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Success state shown after the contact has been saved.
|
||||
struct ClipSuccessView: View {
|
||||
@State private var showCheckmark = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: ClipDesign.Spacing.xLarge) {
|
||||
// Animated checkmark
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: ClipDesign.Size.avatarLarge, height: ClipDesign.Size.avatarLarge)
|
||||
.foregroundStyle(Color.Clip.success)
|
||||
.scaleEffect(showCheckmark ? 1 : 0.5)
|
||||
.opacity(showCheckmark ? 1 : 0)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.6), value: showCheckmark)
|
||||
|
||||
VStack(spacing: ClipDesign.Spacing.small) {
|
||||
Text("Contact Saved!")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
.foregroundStyle(Color.Clip.text)
|
||||
|
||||
Text("You can find this contact in your Contacts app.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.Clip.secondaryText)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
// Open Contacts button
|
||||
Button {
|
||||
openContacts()
|
||||
} label: {
|
||||
Text("Open Contacts")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.Clip.background)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: ClipDesign.Size.buttonHeight)
|
||||
.background(Color.Clip.success)
|
||||
.clipShape(.capsule)
|
||||
}
|
||||
.padding(.horizontal, ClipDesign.Spacing.xLarge)
|
||||
.padding(.top, ClipDesign.Spacing.large)
|
||||
}
|
||||
.padding(ClipDesign.Spacing.xLarge)
|
||||
.onAppear {
|
||||
showCheckmark = true
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(Text("Contact saved successfully"))
|
||||
}
|
||||
|
||||
private func openContacts() {
|
||||
if let url = URL(string: "contacts://") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ZStack {
|
||||
Color.Clip.background
|
||||
.ignoresSafeArea()
|
||||
ClipSuccessView()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user