655 lines
19 KiB
Markdown
655 lines
19 KiB
Markdown
# 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*
|