Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-10 16:11:58 -06:00
parent 0abf25c876
commit 44724914b4
31 changed files with 1881 additions and 8 deletions

654
APPCLIP-DEPLOYMENT.md Normal file
View 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*

View File

@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
EA837E672F107D6800077F87 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA837E662F107D6800077F87 /* Bedrock */; }; 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, ); }; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -33,6 +34,13 @@
remoteGlobalIDString = EA837F972F11B16400077F87; remoteGlobalIDString = EA837F972F11B16400077F87;
remoteInfo = "BusinessCardWatch Watch App"; remoteInfo = "BusinessCardWatch Watch App";
}; };
EACLIP0012F200000000003 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = EA83791B2F105F2600077F87 /* Project object */;
proxyType = 1;
remoteGlobalIDString = EACLIP0012F200000000004;
remoteInfo = BusinessCardClip;
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
@ -47,6 +55,17 @@
name = "Embed Watch Content"; name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0; 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 */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference 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; }; 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; }; 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; }; 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; }; EACLIP0012F200000000002 /* BusinessCardClip.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BusinessCardClip.app; sourceTree = BUILT_PRODUCTS_DIR; };
EACONFIG0012F200000000002 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BusinessCard/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; }; EACONFIG0012F200000000002 /* BusinessCard/Configuration/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; }; EACONFIG0012F200000000003 /* BusinessCard/Configuration/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; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -68,6 +86,13 @@
); );
target = EA8379222F105F2600077F87 /* BusinessCard */; target = EA8379222F105F2600077F87 /* BusinessCard */;
}; };
EACLIP0012F20000000000E /* Exceptions for "BusinessCardClip" folder in "BusinessCardClip" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = EACLIP0012F200000000004 /* BusinessCardClip */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
@ -94,6 +119,14 @@
path = "BusinessCardWatch Watch App"; path = "BusinessCardWatch Watch App";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
EACLIP0012F200000000006 /* BusinessCardClip */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EACLIP0012F20000000000E /* Exceptions for "BusinessCardClip" folder in "BusinessCardClip" target */,
);
path = BusinessCardClip;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -126,6 +159,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
EACLIP0012F200000000007 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@ -136,8 +176,10 @@
EA8379332F105F2800077F87 /* BusinessCardTests */, EA8379332F105F2800077F87 /* BusinessCardTests */,
EA83793D2F105F2800077F87 /* BusinessCardUITests */, EA83793D2F105F2800077F87 /* BusinessCardUITests */,
EA837F992F11B16400077F87 /* BusinessCardWatch Watch App */, EA837F992F11B16400077F87 /* BusinessCardWatch Watch App */,
EACLIP0012F200000000006 /* BusinessCardClip */,
EAAE89292F12DE110075BC8A /* Frameworks */, EAAE89292F12DE110075BC8A /* Frameworks */,
EA8379242F105F2600077F87 /* Products */, EA8379242F105F2600077F87 /* Products */,
EADCDC1C2F12F7EA007991B3 /* Recovered References */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -148,6 +190,7 @@
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */, EA8379302F105F2800077F87 /* BusinessCardTests.xctest */,
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */, EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */,
EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */, EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */,
EACLIP0012F200000000002 /* BusinessCardClip.app */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@ -159,6 +202,15 @@
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; 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 */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -170,11 +222,13 @@
EA8379202F105F2600077F87 /* Frameworks */, EA8379202F105F2600077F87 /* Frameworks */,
EA8379212F105F2600077F87 /* Resources */, EA8379212F105F2600077F87 /* Resources */,
EAAE892D2F12DE110075BC8A /* Embed Watch Content */, EAAE892D2F12DE110075BC8A /* Embed Watch Content */,
EACLIP0012F200000000005 /* Embed App Clips */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
EAAE892C2F12DE110075BC8A /* PBXTargetDependency */, EAAE892C2F12DE110075BC8A /* PBXTargetDependency */,
EACLIP0012F200000000008 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
EA8379252F105F2600077F87 /* BusinessCard */, EA8379252F105F2600077F87 /* BusinessCard */,
@ -255,6 +309,28 @@
productReference = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */; productReference = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */;
productType = "com.apple.product-type.application"; 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 */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@ -279,6 +355,9 @@
EA837F972F11B16400077F87 = { EA837F972F11B16400077F87 = {
CreatedOnToolsVersion = 26.0; CreatedOnToolsVersion = 26.0;
}; };
EACLIP0012F200000000004 = {
CreatedOnToolsVersion = 26.0;
};
}; };
}; };
buildConfigurationList = EA83791E2F105F2600077F87 /* Build configuration list for PBXProject "BusinessCard" */; buildConfigurationList = EA83791E2F105F2600077F87 /* Build configuration list for PBXProject "BusinessCard" */;
@ -304,6 +383,7 @@
EA83792F2F105F2800077F87 /* BusinessCardTests */, EA83792F2F105F2800077F87 /* BusinessCardTests */,
EA8379392F105F2800077F87 /* BusinessCardUITests */, EA8379392F105F2800077F87 /* BusinessCardUITests */,
EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */, EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */,
EACLIP0012F200000000004 /* BusinessCardClip */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@ -337,6 +417,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
EACLIP0012F20000000000B /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@ -368,6 +455,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
EACLIP0012F20000000000A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
@ -386,12 +480,17 @@
target = EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */; target = EA837F972F11B16400077F87 /* BusinessCardWatch Watch App */;
targetProxy = EAAE892B2F12DE110075BC8A /* PBXContainerItemProxy */; targetProxy = EAAE892B2F12DE110075BC8A /* PBXContainerItemProxy */;
}; };
EACLIP0012F200000000008 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = EACLIP0012F200000000004 /* BusinessCardClip */;
targetProxy = EACLIP0012F200000000003 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
EA8379422F105F2800077F87 /* Debug */ = { EA8379422F105F2800077F87 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = EACONFIG0012F200000000002 /* Debug.xcconfig */; baseConfigurationReference = EACONFIG0012F200000000002 /* BusinessCard/Configuration/Debug.xcconfig */;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@ -456,7 +555,7 @@
}; };
EA8379432F105F2800077F87 /* Release */ = { EA8379432F105F2800077F87 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = EACONFIG0012F200000000003 /* Release.xcconfig */; baseConfigurationReference = EACONFIG0012F200000000003 /* BusinessCard/Configuration/Release.xcconfig */;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@ -736,6 +835,80 @@
}; };
name = Release; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@ -784,6 +957,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
EACLIP0012F200000000009 /* Build configuration list for PBXNativeTarget "BusinessCardClip" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EACLIP0012F20000000000C /* Debug */,
EACLIP0012F20000000000D /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */

View File

@ -5,6 +5,11 @@
<key>SchemeUserState</key> <key>SchemeUserState</key>
<dict> <dict>
<key>BusinessCard.xcscheme_^#shared#^_</key> <key>BusinessCard.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>BusinessCardClip.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>1</integer>
@ -12,7 +17,7 @@
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key> <key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>2</integer> <integer>3</integer>
</dict> </dict>
</dict> </dict>
</dict> </dict>

View File

@ -4,6 +4,10 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <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> <key>com.apple.developer.icloud-container-identifiers</key>
<array> <array>
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string> <string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>

View 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
}
}

View 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
}

View 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
}

View 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")
}
}
}

View 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
}
}

View File

@ -14,5 +14,10 @@ final class AppState {
self.cardStore = CardStore(modelContext: modelContext) self.cardStore = CardStore(modelContext: modelContext)
self.contactsStore = ContactsStore(modelContext: modelContext) self.contactsStore = ContactsStore(modelContext: modelContext)
self.shareLinkService = ShareLinkService() self.shareLinkService = ShareLinkService()
// Clean up expired shared cards on launch (best-effort, non-blocking)
Task {
await SharedCardCloudKitService().cleanupExpiredCards()
}
} }
} }

View File

@ -19,6 +19,9 @@ struct ShareCardView: View {
@State private var messageComposeURL: IdentifiableURL? @State private var messageComposeURL: IdentifiableURL?
@State private var mailComposeURL: IdentifiableURL? @State private var mailComposeURL: IdentifiableURL?
// App Clip share state
@State private var appClipState = AppClipShareState()
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
@ -29,9 +32,12 @@ struct ShareCardView: View {
ScrollView { ScrollView {
VStack(spacing: Design.Spacing.xLarge) { VStack(spacing: Design.Spacing.xLarge) {
if let card = appState.cardStore.selectedCard { if let card = appState.cardStore.selectedCard {
// QR Code section // QR Code section (vCard, no photo)
QRCodeSection(card: card) QRCodeSection(card: card)
// App Clip section (includes photo)
AppClipSection(card: card, appClipState: appClipState)
// Share options // Share options
ShareOptionsSection( ShareOptionsSection(
card: card, 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 // MARK: - Share Options Section
private struct ShareOptionsSection: View { private struct ShareOptionsSection: View {

View File

@ -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
}
}

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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
}
}

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

View 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
}
}

View 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

View 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
}
}

View 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)
}
}

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

View 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)
}
}

View 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)
}
}

View 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")
}
}
}

View 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
}
}
}

View 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)
}
}
}

View 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)
}
}
}

View 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")
}
}
}

View 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")
}
}
}

View 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()
}
}

View 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()
}
}