
App Clip
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates App Clip targets with NFC/QR/Safari invocation URL handling, experience routing, location confirmation, and a full-app upgrade prompt.
About
Generates production App Clip infrastructure for lightweight app experiences launched from NFC, QR codes, Safari banners, or Messages. A developer uses it to set up an App Clip target, invocation handling, and the upgrade-to-full-app flow.
- Handles invocation URLs, experience routing, and location confirmation
- Includes App Clip Card metadata and SKOverlay upgrade prompt
App Clip by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #885 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill app-clipAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates App Clip targets with NFC/QR/Safari invocation URL handling, experience routing, location confirmation, and a full-app upgrade prompt.
Files
App Clip Generator
Generate production App Clip infrastructure — a lightweight version of your app invoked from NFC tags, QR codes, Safari banners, or Messages. Includes App Clip target setup, invocation URL handling, experience routing, location confirmation, and full app upgrade flow.
When This Skill Activates
Use this skill when the user:
- Asks to "add an app clip" or "create an app clip target"
- Mentions "instant app" or "lightweight app experience"
- Wants to set up "App Clip Card" metadata
- Mentions "NFC tag" invocation or "QR code" launching an app
- Asks about "app clip invocation" or "invocation URL handling"
- Wants a "lightweight app experience" for a physical location
Pre-Generation Checks
1. Project Context Detection
- [ ] Check deployment target (iOS 14+ required for App Clips, iOS 16+ recommended)
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Check for @Observable support (iOS 17+ / macOS 14+)
- [ ] Identify Xcode project structure (.xcodeproj or .xcworkspace)
2. Conflict Detection
Search for existing App Clip targets:
Glob: **/*AppClip*/*.swift, **/*Clip*/*.swift
Grep: "NSUserActivityTypeBrowsingWeb" or "AppClipExperience" or "SKOverlay"If existing App Clip target found:
- Ask if user wants to replace or extend it
- If extending, identify which components are missing
3. Project Structure
Identify where the main app target lives and where to place the App Clip target alongside it.
Configuration Questions
Ask user via AskUserQuestion:
1. Invocation method?
- NFC tag only
- QR code only
- Safari banner (Smart App Banner)
- Messages
- All of the above — recommended
2. Primary experience?
- Order food (restaurant/cafe)
- Reserve (booking/reservation)
- Check in (event/location)
- Preview content (article/product)
3. Include location confirmation?
- Yes — verifies user is physically at the expected location (recommended for physical-world invocations)
- No — skip location verification
4. Include full app upgrade prompt?
- Yes — show SKOverlay banner to download full app (recommended)
- No — App Clip only, no upgrade path
Generation Process
Step 1: Read Templates
Read templates.md for production Swift code. Read patterns.md for constraints, testing, and best practices.
Step 2: Create Core Files
Generate these files: 1. AppClipApp.swift — @main App struct handling invocation via .onContinueUserActivity 2. InvocationHandler.swift — Parses invocation URL, extracts parameters, validates against registered experiences 3. AppClipExperience.swift — Protocol and concrete experience implementations
Step 3: Create Location Files (if selected)
4. LocationConfirmationView.swift — CLLocationManager-based location verification for physical invocations
Step 4: Create Upgrade Files (if selected)
5. FullAppUpgradeView.swift — SKOverlay-based banner prompting full app download 6. SharedDataManager.swift — App Group data sharing between App Clip and full app
Step 5: Determine File Location
Check project structure:
- If
Sources/exists ->Sources/AppClip/ - If main app target folder exists ->
AppClip/at the same level - Otherwise ->
AppClip/
Output Format
After generation, provide:
Files Created
AppClip/
├── AppClipApp.swift # @main entry point with invocation handling
├── InvocationHandler.swift # URL parsing and parameter extraction
├── AppClipExperience.swift # Experience protocol and implementations
├── LocationConfirmationView.swift # Location verification (optional)
├── FullAppUpgradeView.swift # SKOverlay upgrade prompt (optional)
└── SharedDataManager.swift # App Group data sharing (optional)Xcode Target Setup Instructions
1. Add App Clip Target:
- File > New > Target > App Clip
- Set bundle ID to
{main-app-bundle-id}.Clip - Set deployment target to iOS 16.0
2. Configure Associated Domains:
- Add
appclips:{your-domain.com}to both main app and App Clip entitlements
3. Set Up App Group:
- Add
group.{your-bundle-id}to both targets for shared data
4. Apple-App-Site-Association (AASA) file:
- Host at
https://{your-domain.com}/.well-known/apple-app-site-association
Integration
Handle invocation in the App Clip:
@main
struct MyAppClip: App {
@State private var handler = InvocationHandler()
var body: some Scene {
WindowGroup {
ContentView(experience: handler.currentExperience)
.onContinueUserActivity(
NSUserActivityTypeBrowsingWeb
) { activity in
handler.handle(activity)
}
}
}
}Route to the correct experience:
struct ContentView: View {
let experience: (any AppClipExperience)?
var body: some View {
if let experience {
AnyView(experience.makeView())
} else {
DefaultExperienceView()
}
}
}Share data with the full app:
// In App Clip — save order before user upgrades
SharedDataManager.shared.save(order, forKey: "pendingOrder")
// In Full App — restore after install
if let order: Order = SharedDataManager.shared.load(forKey: "pendingOrder") {
showOrder(order)
}Prompt full app download:
FullAppUpgradeView(
appStoreID: "123456789",
benefits: [
"Order history and favorites",
"Loyalty rewards program",
"Push notification for order updates"
]
)Testing
@Test
func invocationHandlerParsesProductURL() {
let handler = InvocationHandler()
let url = URL(string: "https://example.com/clip/product/abc123")!
let experience = handler.parseURL(url)
#expect(experience != nil)
#expect(experience?.experienceType == .previewContent)
#expect(experience?.parameters["productID"] == "abc123")
}
@Test
func invocationHandlerRejectsInvalidURL() {
let handler = InvocationHandler()
let url = URL(string: "https://other-domain.com/something")!
let experience = handler.parseURL(url)
#expect(experience == nil)
}
@Test
func sharedDataManagerRoundTrips() {
let manager = SharedDataManager(suiteName: "group.test")
let order = Order(id: "order-1", items: ["Latte", "Muffin"])
manager.save(order, forKey: "testOrder")
let loaded: Order? = manager.load(forKey: "testOrder")
#expect(loaded?.id == "order-1")
#expect(loaded?.items.count == 2)
}Common Patterns
Handle Invocation URL
Every App Clip starts from a URL. Parse it to determine what experience to show:
// URL: https://example.com/clip/order?location=store-42
// -> Route to OrderExperience with locationID = "store-42"Present Experience Immediately
Users expect instant value. Show the relevant experience within 1-2 seconds, no sign-in required.
Prompt Full App Install
After the user completes the primary task, show an SKOverlay banner with clear benefits of the full app.
Gotchas
- 10 MB size limit — App Clip binary must be under 10 MB. Use SF Symbols, avoid large assets, lazy-load images from network.
- 8-hour data retention — App Clip data is deleted after 8 hours of inactivity. Use App Group to persist data accessible to the full app.
- Limited frameworks — No CallKit, no HealthKit, no CareKit. Limited background processing. Check Apple's framework availability list.
- No background processing — App Clips cannot run background tasks, background fetch, or silent push notifications.
- Must work without sign-in — App Clips should provide value immediately. Defer sign-in until the full app upgrade.
- App Clip Card metadata — Configure in App Store Connect: card image (3000x2000 px), title, subtitle, call-to-action button text.
- Associated Domains required — Both the main app and App Clip must have the
appclips:associated domain configured, and the AASA file must be hosted on the domain. - Size budgeting — Regularly check App Clip size during development with
xcodebuild -exportArchiveor the App Thinning Size Report.
References
- templates.md — All production Swift templates for App Clip infrastructure
- patterns.md — Constraints, data lifecycle, testing, and best practices
- Related:
generators/deep-linking— Universal link and deep link handling - Related:
generators/onboarding-generator— Onboarding flow for full app upgrade
App Clip Patterns & Constraints
App Clip Size Budget
The 10 MB Limit
App Clips must be under 10 MB after App Thinning. This is a hard limit enforced by the system — if exceeded, the App Clip will not launch.
Strategies to Stay Under 10 MB
| Strategy | Savings | How |
|---|---|---|
| Use SF Symbols | ~2-5 MB | Replace custom icons with system symbols |
| Lazy-load images | ~3-10 MB | Download images from network instead of bundling |
| Minimize dependencies | ~1-5 MB | No SPM packages if possible; inline small utilities |
| Asset catalog optimization | ~1-3 MB | Use vector PDFs, remove unused assets |
| Share code via framework | Varies | Reference shared framework instead of duplicating |
| Remove unused localizations | ~0.5-2 MB | Include only essential languages |
Checking App Clip Size
# Build and export archive to check thinned size
xcodebuild archive \
-scheme "MyAppClip" \
-archivePath ./build/MyAppClip.xcarchive
xcodebuild -exportArchive \
-archivePath ./build/MyAppClip.xcarchive \
-exportPath ./build/export \
-exportOptionsPlist ExportOptions.plist
# Check the App Thinning Size Report
cat ./build/export/App\ Thinning\ Size\ Report.txtXcode Size Monitoring
Add a Run Script build phase to warn when approaching the limit:
# Warn if App Clip exceeds 8 MB (leaving 2 MB buffer)
APP_CLIP_SIZE=$(stat -f%z "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}")
LIMIT=$((8 * 1024 * 1024))
if [ "$APP_CLIP_SIZE" -gt "$LIMIT" ]; then
echo "warning: App Clip binary is $(($APP_CLIP_SIZE / 1024 / 1024)) MB — approaching 10 MB limit"
fiAvailable vs Unavailable Frameworks
Available in App Clips
| Framework | Notes |
|---|---|
| SwiftUI | Full support |
| UIKit | Full support |
| CoreLocation | Location confirmation only (no continuous tracking) |
| MapKit | Display maps |
| StoreKit | SKOverlay for full app promotion |
| WebKit | Limited web views |
| AVFoundation | Media playback |
| CoreImage | Image processing |
| CoreML | On-device ML (watch binary size) |
| AuthenticationServices | Sign in with Apple |
| PassKit | Apple Pay |
NOT Available in App Clips
| Framework | Alternative |
|---|---|
| CallKit | Not available — prompt full app download |
| HealthKit | Not available — prompt full app download |
| CareKit | Not available — prompt full app download |
| HomeKit | Not available — prompt full app download |
| ResearchKit | Not available — prompt full app download |
| SensorKit | Not available — prompt full app download |
Limited in App Clips
| Capability | Limitation |
|---|---|
| Background modes | No background fetch, no silent push |
| Push notifications | Ephemeral notifications only (8-hour window) |
| Keychain | Data cleared when App Clip data is deleted |
| File system | Sandbox is temporary — data deleted after inactivity |
Data Lifecycle
The 8-Hour Rule
User invokes App Clip
↓
App Clip launches, creates data
↓
User uses App Clip, then leaves
↓
8 hours of inactivity
↓
System deletes ALL App Clip data:
- UserDefaults
- Documents directory
- Caches directory
- Keychain items
↓
Only App Group data persists (for full app migration)Data Persistence Strategy
// ❌ Wrong — data will be lost after 8 hours
UserDefaults.standard.set(orderID, forKey: "lastOrder")
// ✅ Right — persist in App Group for full app to access
let shared = UserDefaults(suiteName: "group.com.yourapp")
shared?.set(orderID, forKey: "lastOrder")Ephemeral-to-Persistent Migration
When the user installs the full app, migrate data from the App Group:
// In full app's AppDelegate or root view
func migrateAppClipData() {
let shared = UserDefaults(suiteName: "group.com.yourapp")
if let pendingOrderData = shared?.data(forKey: "pendingOrder") {
let order = try? JSONDecoder().decode(Order.self, from: pendingOrderData)
// Import into full app's persistent store (Core Data, SwiftData, etc.)
if let order {
persistentStore.save(order)
}
// Clean up shared data
shared?.removeObject(forKey: "pendingOrder")
}
}Invocation URL Configuration
App Store Connect Setup
1. Navigate to your app in App Store Connect 2. Go to App Clip section 3. Add App Clip Experiences:
- URL: The invocation URL prefix (e.g.,
https://example.com/clip/) - Card Image: 3000 x 2000 px (1.5:1 ratio)
- Title: Up to 30 characters
- Subtitle: Brief description of the experience
- Call-to-Action: Button text (Open, View, Play, etc.)
Associated Domains Entitlement
Both the main app and the App Clip must include:
<!-- Main App and App Clip .entitlements -->
<key>com.apple.developer.associated-domains</key>
<array>
<string>appclips:example.com</string>
</array>Apple-App-Site-Association (AASA) File
Host at https://example.com/.well-known/apple-app-site-association:
{
"appclips": {
"apps": [
"TEAM_ID.com.yourapp.Clip"
]
},
"applinks": {
"apps": [],
"details": [
{
"appIDs": [
"TEAM_ID.com.yourapp",
"TEAM_ID.com.yourapp.Clip"
],
"components": [
{
"/": "/clip/*",
"comment": "App Clip invocation URLs"
}
]
}
]
}
}URL Pattern Registration
Register specific URL patterns for different experiences:
https://example.com/clip/order?location=* → Order experience
https://example.com/clip/reserve?venue=* → Reserve experience
https://example.com/clip/checkin?event=* → Check-in experience
https://example.com/clip/product/* → Product previewPhysical Invocation
NFC Tag Programming
Program NFC tags with your App Clip URL:
import CoreNFC
func writeAppClipURL(to tag: NFCNDEFTag, locationID: String) async throws {
let urlString = "https://example.com/clip/order?location=\(locationID)"
guard let url = URL(string: urlString) else { return }
let payload = NFCNDEFPayload.wellKnownTypeURIPayload(url: url)!
let message = NFCNDEFMessage(records: [payload])
try await tag.writeNDEF(message)
}QR Code Generation
Generate QR codes that invoke the App Clip:
import CoreImage
func generateAppClipQRCode(for locationID: String, size: CGFloat = 200) -> UIImage? {
let urlString = "https://example.com/clip/order?location=\(locationID)"
guard let data = urlString.data(using: .utf8),
let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil }
filter.setValue(data, forKey: "inputMessage")
filter.setValue("H", forKey: "inputCorrectionLevel") // High error correction
guard let ciImage = filter.outputImage else { return nil }
let scale = size / ciImage.extent.size.width
let scaledImage = ciImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
return UIImage(ciImage: scaledImage)
}App Clip Code Design
App Clip Codes are Apple-designed visual codes (similar to QR codes but with the App Clip logo). Generate them in App Store Connect:
1. Go to your App Clip experience in App Store Connect 2. Select Create App Clip Code 3. Choose style: NFC-integrated (NFC + visual) or Scan-only (visual only) 4. Download SVG for print
Testing
Local Testing with Xcode
Set the _XCAppClipURL environment variable in the App Clip scheme:
1. Edit Scheme > Run > Arguments > Environment Variables 2. Add: _XCAppClipURL = https://example.com/clip/order?location=store-42 3. Run the App Clip target — it will receive the URL on launch
TestFlight Testing
1. Archive and upload the main app (which includes the App Clip) 2. In TestFlight, add testers 3. Provide test invocation URLs to testers 4. Testers can invoke the App Clip from Safari, QR code, or NFC
Unit Testing the Invocation Handler
import Testing
@Suite("InvocationHandler")
struct InvocationHandlerTests {
@Test("Parses order URL with location parameter")
func parseOrderURL() {
let handler = InvocationHandler()
let url = URL(string: "https://example.com/clip/order?location=store-42")!
let experience = handler.parseURL(url)
#expect(experience != nil)
#expect(experience?.experienceType == .orderFood)
#expect(experience?.parameters["locationID"] == "store-42")
}
@Test("Parses reservation URL with venue parameter")
func parseReserveURL() {
let handler = InvocationHandler()
let url = URL(string: "https://example.com/clip/reserve?venue=restaurant-7")!
let experience = handler.parseURL(url)
#expect(experience != nil)
#expect(experience?.experienceType == .reserve)
#expect(experience?.parameters["venueID"] == "restaurant-7")
}
@Test("Parses check-in URL with event parameter")
func parseCheckInURL() {
let handler = InvocationHandler()
let url = URL(string: "https://example.com/clip/checkin?event=concert-123")!
let experience = handler.parseURL(url)
#expect(experience != nil)
#expect(experience?.experienceType == .checkIn)
#expect(experience?.parameters["eventID"] == "concert-123")
}
@Test("Parses product URL with path parameter")
func parseProductURL() {
let handler = InvocationHandler()
let url = URL(string: "https://example.com/clip/product/abc123")!
let experience = handler.parseURL(url)
#expect(experience != nil)
#expect(experience?.experienceType == .previewContent)
#expect(experience?.parameters["productID"] == "abc123")
}
@Test("Rejects URL from unregistered domain")
func rejectUnregisteredDomain() {
let handler = InvocationHandler()
let url = URL(string: "https://other-domain.com/clip/order?location=store-1")!
let experience = handler.parseURL(url)
#expect(experience == nil)
}
@Test("Rejects URL without clip path prefix")
func rejectMissingClipPrefix() {
let handler = InvocationHandler()
let url = URL(string: "https://example.com/order?location=store-1")!
let experience = handler.parseURL(url)
#expect(experience == nil)
}
@Test("Rejects URL with unknown action")
func rejectUnknownAction() {
let handler = InvocationHandler()
let url = URL(string: "https://example.com/clip/unknown?param=value")!
let experience = handler.parseURL(url)
#expect(experience == nil)
}
@Test("Rejects order URL missing required location parameter")
func rejectMissingLocationParam() {
let handler = InvocationHandler()
let url = URL(string: "https://example.com/clip/order")!
let experience = handler.parseURL(url)
#expect(experience == nil)
}
}Testing Shared Data Manager
@Suite("SharedDataManager")
struct SharedDataManagerTests {
struct TestOrder: Codable, Equatable {
let id: String
let items: [String]
let total: Double
}
@Test("Round-trips Codable data")
func roundTrip() {
let manager = SharedDataManager(suiteName: "group.test.appclip")
let order = TestOrder(id: "order-1", items: ["Latte", "Muffin"], total: 9.48)
manager.save(order, forKey: "testOrder")
let loaded: TestOrder? = manager.load(forKey: "testOrder")
#expect(loaded == order)
// Cleanup
manager.remove(forKey: "testOrder")
}
@Test("Returns nil for missing key")
func missingKey() {
let manager = SharedDataManager(suiteName: "group.test.appclip")
let result: TestOrder? = manager.load(forKey: "nonexistent")
#expect(result == nil)
}
@Test("Stores timestamp alongside data")
func timestampStorage() {
let manager = SharedDataManager(suiteName: "group.test.appclip")
let order = TestOrder(id: "order-2", items: ["Espresso"], total: 3.50)
let before = Date()
manager.save(order, forKey: "timestampTest")
let after = Date()
let timestamp = manager.timestamp(forKey: "timestampTest")
#expect(timestamp != nil)
#expect(timestamp! >= before)
#expect(timestamp! <= after)
// Cleanup
manager.remove(forKey: "timestampTest")
}
@Test("Detects pending migration keys")
func pendingMigration() {
let manager = SharedDataManager(suiteName: "group.test.appclip")
let order = TestOrder(id: "order-3", items: ["Tea"], total: 2.50)
manager.save(order, forKey: "pendingOrder")
let pending = manager.pendingMigrationKeys(from: ["pendingOrder", "otherKey"])
#expect(pending == ["pendingOrder"])
// Cleanup
manager.remove(forKey: "pendingOrder")
}
@Test("Clears migrated data")
func clearMigrated() {
let manager = SharedDataManager(suiteName: "group.test.appclip")
let order = TestOrder(id: "order-4", items: ["Cookie"], total: 1.99)
manager.save(order, forKey: "migrateTest")
manager.clearMigratedData(keys: ["migrateTest"])
let loaded: TestOrder? = manager.load(forKey: "migrateTest")
#expect(loaded == nil)
#expect(manager.timestamp(forKey: "migrateTest") == nil)
}
}Best Practices
Instant Value
The user tapped an NFC tag or scanned a QR code — they expect immediate results. Every second of delay increases abandonment.
// ✅ Show UI immediately, load data in background
struct OrderExperienceView: View {
@State private var isLoading = true
var body: some View {
// Skeleton UI appears instantly
if isLoading {
OrderSkeletonView()
} else {
OrderContentView()
}
}
}
// ❌ Blank screen while loading
struct OrderExperienceView: View {
var body: some View {
ProgressView() // User sees spinner, no context
}
}No Sign-In Required
App Clips must provide value without authentication. Defer sign-in to the full app.
// ✅ Allow anonymous ordering, collect identity later
struct OrderFlow {
func placeOrder(items: [MenuItem]) async {
// Create order without requiring account
let order = Order(items: items, guestID: UUID().uuidString)
await submitOrder(order)
}
}
// ❌ Block the experience with a login screen
struct OrderFlow {
func placeOrder() {
showLoginSheet() // User leaves immediately
}
}Clear Upgrade Path
After the user completes their task, show the value of the full app:
// ✅ Show upgrade after task completion
struct OrderConfirmationView: View {
var body: some View {
VStack {
// Order confirmation content
OrderReceiptView(order: order)
Spacer()
// Contextual upgrade prompt
UpgradeBanner(appStoreID: "123456789")
}
}
}Minimal Permissions
Request only what is absolutely necessary. Every permission prompt is friction.
// ✅ Only request location for physical-location experiences
// ✅ Use Sign in with Apple (minimal friction) if auth is needed
// ✅ Use Apple Pay (no form filling)
// ❌ Don't request notification permission in App Clip
// ❌ Don't request camera unless core to the experience
// ❌ Don't request contacts, calendar, etc.Anti-Patterns to Avoid
Don't Bundle Large Assets
// ❌ 5 MB image bundled in asset catalog
Image("hero-background") // Eats half the size budget
// ✅ Use SF Symbols or load from network
Image(systemName: "fork.knife.circle.fill")
.font(.system(size: 60))Don't Use Heavy Dependencies
// ❌ Adding Alamofire, SDWebImage, etc. bloats the binary
// Each SPM dependency can add 0.5-2 MB
// ✅ Use URLSession directly — it's already available
let (data, response) = try await URLSession.shared.data(from: url)Don't Persist Sensitive Data in App Clip Sandbox
// ❌ Keychain data is deleted with App Clip data
try keychain.set(token, forKey: "authToken")
// ✅ Store in App Group if full app needs it
SharedDataManager.shared.save(token, forKey: "authToken")Don't Ignore the Size Budget During Development
// ❌ "We'll optimize later" — then you're 15 MB at submission
// ✅ Check size on every PR
// Add a CI step that builds the App Clip and checks the thinned sizeApp Clip Code Templates
Production-ready Swift templates for App Clip infrastructure. All code targets iOS 16+ and uses modern Swift concurrency. iOS 17+ required for @Observable.
AppClipApp.swift
import SwiftUI
/// @main entry point for the App Clip target.
///
/// Handles invocation from NFC tags, QR codes, Safari banners,
/// and Messages by listening for `NSUserActivityTypeBrowsingWeb`.
@main
struct AppClipApp: App {
@State private var invocationHandler = InvocationHandler()
var body: some Scene {
WindowGroup {
AppClipRootView(handler: invocationHandler)
.onContinueUserActivity(
NSUserActivityTypeBrowsingWeb
) { userActivity in
guard let url = userActivity.webpageURL else { return }
invocationHandler.handleInvocation(url: url)
}
}
}
}
/// Root view that routes to the appropriate experience based on invocation.
struct AppClipRootView: View {
let handler: InvocationHandler
var body: some View {
Group {
if let experience = handler.currentExperience {
AnyView(experience.makeView())
} else {
DefaultAppClipView()
}
}
.animation(.default, value: handler.currentExperience != nil)
}
}
/// Default view shown when no specific invocation URL is provided.
struct DefaultAppClipView: View {
var body: some View {
ContentUnavailableView(
"Welcome",
systemImage: "app.badge",
description: Text("Scan an NFC tag or QR code to get started.")
)
}
}InvocationHandler.swift
import Foundation
import Observation
/// Parses App Clip invocation URLs and routes to the appropriate experience.
///
/// Registered URL patterns:
/// - `https://example.com/clip/order?location={id}` -> OrderExperience
/// - `https://example.com/clip/reserve?venue={id}` -> ReserveExperience
/// - `https://example.com/clip/checkin?event={id}` -> CheckInExperience
/// - `https://example.com/clip/product/{id}` -> PreviewContentExperience
@Observable
final class InvocationHandler {
/// The currently active experience parsed from the invocation URL.
private(set) var currentExperience: (any AppClipExperience)?
/// The raw invocation URL, if any.
private(set) var invocationURL: URL?
/// Registered domain for App Clip invocations.
/// Update this to match your associated domain.
private let registeredDomain = "example.com"
/// Handle an invocation URL from `onContinueUserActivity`.
func handleInvocation(url: URL) {
invocationURL = url
currentExperience = parseURL(url)
}
/// Parse a URL into an App Clip experience.
///
/// Returns `nil` if the URL doesn't match any registered pattern.
func parseURL(_ url: URL) -> (any AppClipExperience)? {
guard let host = url.host,
host.contains(registeredDomain) else {
return nil
}
let pathComponents = url.pathComponents.filter { $0 != "/" }
let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems ?? []
guard pathComponents.first == "clip",
pathComponents.count >= 2 else {
return nil
}
let action = pathComponents[1]
let parameters = Dictionary(
uniqueKeysWithValues: queryItems.compactMap { item in
item.value.map { (item.name, $0) }
}
)
switch action {
case "order":
guard let locationID = parameters["location"] else { return nil }
return OrderExperience(locationID: locationID)
case "reserve":
guard let venueID = parameters["venue"] else { return nil }
return ReserveExperience(venueID: venueID)
case "checkin":
guard let eventID = parameters["event"] else { return nil }
return CheckInExperience(eventID: eventID)
case "product":
guard pathComponents.count >= 3 else { return nil }
let productID = pathComponents[2]
return PreviewContentExperience(productID: productID)
default:
return nil
}
}
}AppClipExperience.swift
import SwiftUI
/// Defines a single App Clip experience.
///
/// Each experience represents one user flow triggered by an invocation URL.
/// Implementations must provide a lightweight view that loads instantly
/// and delivers value without sign-in.
protocol AppClipExperience: Sendable {
/// The type of experience for analytics and routing.
var experienceType: AppClipExperienceType { get }
/// Parameters extracted from the invocation URL.
var parameters: [String: String] { get }
/// Create the SwiftUI view for this experience.
@MainActor func makeView() -> any View
}
/// Types of App Clip experiences.
enum AppClipExperienceType: String, Sendable {
case orderFood
case reserve
case checkIn
case previewContent
}
// MARK: - Order Experience
/// Handles food/drink ordering from a physical location.
///
/// Invocation: `https://example.com/clip/order?location={locationID}`
struct OrderExperience: AppClipExperience {
let locationID: String
var experienceType: AppClipExperienceType { .orderFood }
var parameters: [String: String] {
["locationID": locationID]
}
@MainActor func makeView() -> any View {
OrderExperienceView(locationID: locationID)
}
}
struct OrderExperienceView: View {
let locationID: String
@State private var menuItems: [MenuItem] = []
@State private var isLoading = true
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Loading menu...")
} else {
List(menuItems) { item in
MenuItemRow(item: item)
}
}
}
.navigationTitle("Order")
.task {
await loadMenu()
}
}
}
private func loadMenu() async {
// TODO: Replace with actual API call
// Keep network requests minimal for App Clip size budget
try? await Task.sleep(for: .milliseconds(500))
menuItems = [
MenuItem(id: "1", name: "Sample Item", price: 4.99)
]
isLoading = false
}
}
// MARK: - Reserve Experience
/// Handles reservation/booking at a venue.
///
/// Invocation: `https://example.com/clip/reserve?venue={venueID}`
struct ReserveExperience: AppClipExperience {
let venueID: String
var experienceType: AppClipExperienceType { .reserve }
var parameters: [String: String] {
["venueID": venueID]
}
@MainActor func makeView() -> any View {
ReserveExperienceView(venueID: venueID)
}
}
struct ReserveExperienceView: View {
let venueID: String
@State private var selectedDate = Date()
@State private var partySize = 2
var body: some View {
NavigationStack {
Form {
Section("Reservation Details") {
DatePicker("Date & Time", selection: $selectedDate,
in: Date()...,
displayedComponents: [.date, .hourAndMinute])
Stepper("Party size: \(partySize)", value: $partySize, in: 1...20)
}
Section {
Button("Reserve Now") {
// TODO: Submit reservation
}
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
}
}
.navigationTitle("Reserve")
}
}
}
// MARK: - Check-In Experience
/// Handles event or location check-in.
///
/// Invocation: `https://example.com/clip/checkin?event={eventID}`
struct CheckInExperience: AppClipExperience {
let eventID: String
var experienceType: AppClipExperienceType { .checkIn }
var parameters: [String: String] {
["eventID": eventID]
}
@MainActor func makeView() -> any View {
CheckInExperienceView(eventID: eventID)
}
}
struct CheckInExperienceView: View {
let eventID: String
@State private var isCheckedIn = false
var body: some View {
NavigationStack {
VStack(spacing: 24) {
if isCheckedIn {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 80))
.foregroundStyle(.green)
Text("You're checked in!")
.font(.title)
} else {
Image(systemName: "qrcode.viewfinder")
.font(.system(size: 80))
.foregroundStyle(.secondary)
Text("Ready to check in")
.font(.title)
Button("Check In Now") {
withAnimation {
isCheckedIn = true
}
// TODO: Submit check-in to server
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
}
.padding()
.navigationTitle("Check In")
}
}
}
// MARK: - Preview Content Experience
/// Handles product or content preview.
///
/// Invocation: `https://example.com/clip/product/{productID}`
struct PreviewContentExperience: AppClipExperience {
let productID: String
var experienceType: AppClipExperienceType { .previewContent }
var parameters: [String: String] {
["productID": productID]
}
@MainActor func makeView() -> any View {
PreviewContentExperienceView(productID: productID)
}
}
struct PreviewContentExperienceView: View {
let productID: String
@State private var product: ProductInfo?
@State private var isLoading = true
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Loading...")
} else if let product {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Product image placeholder
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.2))
.aspectRatio(16 / 9, contentMode: .fit)
.overlay {
Image(systemName: "photo")
.font(.largeTitle)
.foregroundStyle(.secondary)
}
Text(product.name)
.font(.title.bold())
Text(product.formattedPrice)
.font(.title2)
.foregroundStyle(.secondary)
Text(product.description)
.font(.body)
Button("Add to Cart") {
// TODO: Add to cart
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.frame(maxWidth: .infinity)
}
.padding()
}
} else {
ContentUnavailableView(
"Product Not Found",
systemImage: "exclamationmark.triangle",
description: Text("This product is no longer available.")
)
}
}
.navigationTitle("Product")
.task {
await loadProduct()
}
}
}
private func loadProduct() async {
// TODO: Replace with actual API call
try? await Task.sleep(for: .milliseconds(500))
product = ProductInfo(
id: productID,
name: "Sample Product",
price: 29.99,
description: "Product description loaded from the server."
)
isLoading = false
}
}
// MARK: - Supporting Models
struct MenuItem: Identifiable {
let id: String
let name: String
let price: Double
}
struct MenuItemRow: View {
let item: MenuItem
var body: some View {
HStack {
Text(item.name)
Spacer()
Text(item.price, format: .currency(code: "USD"))
.foregroundStyle(.secondary)
}
}
}
struct ProductInfo {
let id: String
let name: String
let price: Double
let description: String
var formattedPrice: String {
price.formatted(.currency(code: "USD"))
}
}LocationConfirmationView.swift
import SwiftUI
import CoreLocation
/// Verifies the user is physically at the expected location before
/// proceeding with the App Clip experience.
///
/// Uses the App Clip location confirmation API. The system shows a
/// confirmation dialog — the app never receives the exact location,
/// only a boolean result indicating if the user is within the expected region.
///
/// Usage:
/// ```swift
/// LocationConfirmationView(
/// region: CLCircularRegion(
/// center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
/// radius: 100,
/// identifier: "store-42"
/// )
/// ) {
/// // Proceed to experience
/// OrderExperienceView(locationID: "store-42")
/// }
/// ```
struct LocationConfirmationView<Content: View>: View {
let region: CLCircularRegion
@ViewBuilder let confirmedContent: () -> Content
@State private var confirmationStatus: ConfirmationStatus = .pending
@State private var locationManager = AppClipLocationManager()
var body: some View {
Group {
switch confirmationStatus {
case .pending:
VStack(spacing: 20) {
ProgressView()
.controlSize(.large)
Text("Confirming your location...")
.font(.headline)
Text("This helps ensure you're at the right place.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding()
case .confirmed:
confirmedContent()
case .denied:
ContentUnavailableView(
"Location Not Confirmed",
systemImage: "location.slash",
description: Text(
"We couldn't confirm you're at the expected location. "
+ "Please make sure you're at the right place and try again."
)
)
case .failed(let message):
ContentUnavailableView(
"Location Error",
systemImage: "exclamationmark.triangle",
description: Text(message)
)
}
}
.task {
await confirmLocation()
}
}
private func confirmLocation() async {
do {
let confirmed = try await locationManager.confirmLocation(in: region)
confirmationStatus = confirmed ? .confirmed : .denied
} catch {
confirmationStatus = .failed(error.localizedDescription)
}
}
}
// MARK: - Confirmation Status
enum ConfirmationStatus {
case pending
case confirmed
case denied
case failed(String)
}
// MARK: - Location Manager
/// Wraps CLLocationManager for App Clip location confirmation.
///
/// App Clips use a special confirmation flow where the system
/// shows a dialog to the user. The app receives only a boolean
/// result, never the precise location.
@Observable
final class AppClipLocationManager: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var continuation: CheckedContinuation<Bool, Error>?
override init() {
super.init()
manager.delegate = self
}
/// Confirm the user is within the specified region.
///
/// - Parameter region: The expected location region.
/// - Returns: `true` if the user confirmed they are at the location.
func confirmLocation(in region: CLCircularRegion) async throws -> Bool {
return try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
// Request location confirmation — system shows a dialog
manager.requestWhenInUseAuthorization()
manager.startMonitoring(for: region)
manager.requestState(for: region)
}
}
// MARK: - CLLocationManagerDelegate
func locationManager(
_ manager: CLLocationManager,
didDetermineState state: CLRegionState,
for region: CLRegion
) {
manager.stopMonitoring(for: region)
switch state {
case .inside:
continuation?.resume(returning: true)
case .outside, .unknown:
continuation?.resume(returning: false)
@unknown default:
continuation?.resume(returning: false)
}
continuation = nil
}
func locationManager(
_ manager: CLLocationManager,
monitoringDidFailFor region: CLRegion?,
withError error: Error
) {
if let region {
manager.stopMonitoring(for: region)
}
continuation?.resume(throwing: error)
continuation = nil
}
}FullAppUpgradeView.swift
import SwiftUI
import StoreKit
/// Presents an SKOverlay banner prompting the user to download the full app.
///
/// Shows a list of benefits the user will get by upgrading,
/// and displays the system App Store overlay for one-tap install.
///
/// Usage:
/// ```swift
/// FullAppUpgradeView(
/// appStoreID: "123456789",
/// benefits: [
/// "Order history and favorites",
/// "Loyalty rewards program",
/// "Push notifications for order updates"
/// ]
/// )
/// ```
struct FullAppUpgradeView: View {
let appStoreID: String
let benefits: [String]
@State private var showOverlay = false
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 24) {
// Header
VStack(spacing: 8) {
Image(systemName: "arrow.down.app.fill")
.font(.system(size: 56))
.foregroundStyle(.tint)
Text("Get the Full App")
.font(.title.bold())
Text("Unlock all features with the full app.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
// Benefits list
VStack(alignment: .leading, spacing: 12) {
ForEach(benefits, id: \.self) { benefit in
HStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(benefit)
.font(.body)
}
}
}
.padding()
.background {
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.1))
}
Spacer()
// Install button
Button {
showOverlay = true
} label: {
Text("Download Full App")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
// Skip button
Button("Not Now") {
dismiss()
}
.foregroundStyle(.secondary)
}
.padding()
.appStoreOverlay(isPresented: $showOverlay) {
SKOverlay.AppClipConfiguration(position: .bottom)
}
}
}
// MARK: - Inline Upgrade Banner
/// A compact banner view that can be placed at the bottom of any experience
/// to suggest upgrading to the full app.
///
/// Usage:
/// ```swift
/// VStack {
/// // Main experience content
/// OrderExperienceView(locationID: locationID)
///
/// UpgradeBanner(appStoreID: "123456789")
/// }
/// ```
struct UpgradeBanner: View {
let appStoreID: String
@State private var showOverlay = false
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Get the full experience")
.font(.subheadline.bold())
Text("Download the app for all features")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Get") {
showOverlay = true
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
.padding()
.background {
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.1))
}
.padding(.horizontal)
.appStoreOverlay(isPresented: $showOverlay) {
SKOverlay.AppClipConfiguration(position: .bottom)
}
}
}SharedDataManager.swift
import Foundation
/// Manages shared data between the App Clip and the full app via App Group.
///
/// Data stored by the App Clip can be read by the full app after install,
/// enabling seamless transfer of user activity (orders, preferences, etc.).
///
/// **Important:** App Clip data is deleted after 8 hours of inactivity.
/// Use this manager to persist critical data in the shared App Group container
/// so the full app can access it after install.
///
/// Setup:
/// 1. Add App Group capability to both targets
/// 2. Use the same group identifier (e.g., `group.com.yourapp`)
///
/// Usage:
/// ```swift
/// // App Clip: Save data
/// SharedDataManager.shared.save(order, forKey: "pendingOrder")
///
/// // Full App: Load data
/// if let order: Order = SharedDataManager.shared.load(forKey: "pendingOrder") {
/// showPendingOrder(order)
/// }
/// ```
final class SharedDataManager: Sendable {
/// Shared instance using the default App Group.
/// Update the suite name to match your App Group identifier.
static let shared = SharedDataManager(suiteName: "group.com.yourapp")
private let defaults: UserDefaults?
/// Initialize with an App Group suite name.
///
/// - Parameter suiteName: The App Group identifier (e.g., `group.com.yourapp`).
init(suiteName: String) {
self.defaults = UserDefaults(suiteName: suiteName)
}
// MARK: - Save
/// Save a Codable value to the shared container.
///
/// - Parameters:
/// - value: The value to save.
/// - key: The key to store the value under.
func save<T: Codable>(_ value: T, forKey key: String) {
guard let data = try? JSONEncoder().encode(value) else { return }
defaults?.set(data, forKey: key)
// Also store a timestamp for migration awareness
defaults?.set(Date(), forKey: "\(key)_timestamp")
}
// MARK: - Load
/// Load a Codable value from the shared container.
///
/// - Parameter key: The key the value was stored under.
/// - Returns: The decoded value, or `nil` if not found or decoding fails.
func load<T: Codable>(forKey key: String) -> T? {
guard let data = defaults?.data(forKey: key) else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
// MARK: - Timestamp
/// Get the timestamp when a value was saved.
///
/// Useful to check if shared data is stale or fresh.
func timestamp(forKey key: String) -> Date? {
defaults?.object(forKey: "\(key)_timestamp") as? Date
}
// MARK: - Remove
/// Remove a value from the shared container.
func remove(forKey key: String) {
defaults?.removeObject(forKey: key)
defaults?.removeObject(forKey: "\(key)_timestamp")
}
// MARK: - Migration
/// Check if there is pending data from the App Clip to migrate.
///
/// Call this in the full app's launch sequence to detect
/// and import App Clip data.
///
/// - Parameter keys: Keys to check for pending data.
/// - Returns: Keys that have data available.
func pendingMigrationKeys(from keys: [String]) -> [String] {
keys.filter { defaults?.data(forKey: $0) != nil }
}
/// Remove all migrated data after successful import.
///
/// Call this after the full app has imported all App Clip data.
func clearMigratedData(keys: [String]) {
for key in keys {
remove(forKey: key)
}
}
}
// MARK: - Migration Helper
/// Handles one-time data migration from App Clip to full app.
///
/// Usage in the full app's root view or App struct:
/// ```swift
/// .task {
/// AppClipMigrator.migrateIfNeeded { migrated in
/// if migrated.contains("pendingOrder") {
/// // Show the user their pending order from the App Clip
/// }
/// }
/// }
/// ```
enum AppClipMigrator {
private static let migrationCompleteKey = "appClipMigrationComplete"
/// Known keys that the App Clip may have stored.
/// Update this list to match your App Clip's stored data keys.
static let knownKeys = [
"pendingOrder",
"userPreferences",
"recentActivity"
]
/// Check for and migrate App Clip data.
///
/// This is idempotent — it only runs once per install.
///
/// - Parameter handler: Closure called with the list of keys that had data.
static func migrateIfNeeded(handler: ([String]) -> Void) {
let manager = SharedDataManager.shared
// Skip if already migrated
guard !(UserDefaults.standard.bool(forKey: migrationCompleteKey)) else {
return
}
let pendingKeys = manager.pendingMigrationKeys(from: knownKeys)
if !pendingKeys.isEmpty {
handler(pendingKeys)
// Clean up after migration
manager.clearMigratedData(keys: pendingKeys)
}
UserDefaults.standard.set(true, forKey: migrationCompleteKey)
}
}