_template.md
# Skill Document Template
Reference guide for writing skill documents in this repo. Each skill doc covers one Apple extension target type and should give an AI agent everything it needs to build a production implementation.
## Frontmatter (required)
```yaml
---
title: Human-Readable Extension Name
description: One sentence. What the extension does and why you'd use it. No jargon in the first few words.
version: iOS X.0+, macOS Y.0+ (minimum OS where the extension type is available)
---
```
## Structure
### 1. Title + One-Liner
`# Human-Readable Name (\`target-type-slug\`)`
One paragraph explaining what this extension does in plain language. Focus on the user-facing behavior, not the API surface.
### 2. Apple Documentation
Bulleted list of links to Apple's developer docs. Prioritize:
- The framework overview page
- The key class/protocol the extension subclasses
- Any Apple guide/article specifically about this extension type
- Related sample code if it exists
### 3. WWDC History
Chronological list of WWDC sessions where this extension type was introduced or significantly updated. Format:
```
- **[WWDC YEAR, Session XXXXX -- Title](url)** -- One sentence on what was covered.
```
Include the year the extension was introduced and any major API changes in subsequent years.
### 4. What It Does
Explain the runtime behavior. What triggers the extension? What does the system pass to it? What does it return? Use a numbered flow when the interaction has clear steps.
### 5. Use Cases
2-4 subsections with `###` headings. Each describes a real-world scenario where you'd build this extension. Name specific app categories or industries. This is the section that helps an agent decide whether this is the right target type.
### 6. Key Classes (table)
| Class | Role |
|-------|------|
| `ClassName` | One sentence describing what to subclass/implement and its core responsibility. |
### 7. Implementation
The most important section. Include:
- **The most common implementation pattern** as a complete, copy-pasteable Swift code block with inline comments explaining each step.
- **Secondary patterns** if the extension supports multiple modes (e.g., with/without UI).
- Do NOT show the bare template -- show a realistic implementation that an agent can adapt.
- Use numbered inline comments (`// 1.`, `// 2.`) to walk through the flow.
### 8. Using with @bacons/apple-targets
Brief -- just the three commands with nuanced prebuild guidance:
```sh
bunx create-target <type>
```
```js
// app.json -- show the plugin entry
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are automatically linked - no prebuild needed for code changes
# Only re-run prebuild when config changes or to preserve manual Xcode modifications (omit --clean)
```
### 9. Platform Availability (table)
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
### 10. Gotchas
Bulleted list of **bold lead-in** + explanation. Include:
- Common mistakes from Apple Developer Forums
- Privacy/sandbox restrictions
- Known iOS version bugs
- Required entitlements or capabilities that aren't obvious
- Anything that would cause silent failures
## Quality Checklist
- [ ] Frontmatter has title, description, version
- [ ] Every Apple doc link uses `https://developer.apple.com/...` format
- [ ] WWDC links use `https://developer.apple.com/videos/play/wwdcYYYY/XXXXX/`
- [ ] At least one complete, realistic Swift implementation (not just the template stub)
- [ ] Use cases name specific app categories, not abstract descriptions
- [ ] Gotchas include at least one forum-sourced issue
- [ ] No internal file paths from this repo (no `packages/apple-targets/src/...`)
- [ ] The "Using with @bacons/apple-targets" section is concise (3 code blocks max)
- [ ] Platform table covers iOS, iPadOS, macOS, watchOS, tvOS, visionOS
account-auth.md
---
title: Account Authentication Modification Extension
description: Lets password managers and apps offer one-tap upgrades from weak passwords to Sign in with Apple or strong auto-generated passwords, triggered from iCloud Keychain security recommendations and AutoFill prompts.
version: iOS 14.0+, macOS 11.0+
---
# Account Authentication Modification Extension (`account-auth`)
An extension that integrates with iCloud Keychain to offer users one-tap account security upgrades. When the system detects a weak, reused, or breached password for your app, it can present your extension to silently upgrade the credential to **Sign in with Apple** or a **strong auto-generated password** -- no manual password reset flow required.
## Apple Documentation
- [Upgrading Account Security With an Account Authentication Modification Extension](https://developer.apple.com/documentation/authenticationservices/upgrading-account-security-with-an-account-authentication-modification-extension)
- [ASAccountAuthenticationModificationViewController](https://developer.apple.com/documentation/authenticationservices/asaccountauthenticationmodificationviewcontroller)
- [ASAccountAuthenticationModificationExtensionContext](https://developer.apple.com/documentation/authenticationservices/asaccountauthenticationmodificationextensioncontext)
- [convertAccountToSignInWithAppleWithoutUserInteraction](https://developer.apple.com/documentation/authenticationservices/asaccountauthenticationmodificationviewcontroller/3650342-convertaccounttosigninwithapplew)
- [changePasswordWithoutUserInteraction](https://developer.apple.com/documentation/authenticationservices/asaccountauthenticationmodificationviewcontroller/changepasswordwithoutuserinteraction(for:existingcredential:newpassword:userinfo:))
## WWDC History
- **[WWDC 2020, Session 10666 -- One-Tap Account Security Upgrades](https://developer.apple.com/videos/play/wwdc2020/10666/)** -- Introduced the extension. The primary reference for implementation. Covers both upgrade paths, the non-interactive and interactive flows, and associated domain setup.
- **[WWDC 2020, Session 10173 -- Get the Most Out of Sign in with Apple](https://developer.apple.com/videos/play/wwdc2020/10173/)** -- Covers the Sign in with Apple upgrade path in the context of broader SIWA adoption.
- **[WWDC 2022, Session 10122 -- Enhance Your Sign in with Apple Experience](https://developer.apple.com/videos/play/wwdc2022/10122/)** -- Follow-up with refined guidance on offering upgrades to existing users.
## What It Does
The extension provides two upgrade paths:
1. **Upgrade to Sign in with Apple** -- Converts a password-based account to use Sign in with Apple. The system handles the SIWA authorization, your extension calls your backend to swap the credential, and the old password is deleted from iCloud Keychain.
2. **Upgrade to a strong password** -- Replaces a weak/reused password with a system-generated strong password. The system generates the password (respecting your app's password rules), your extension calls your backend to change it, and iCloud Keychain stores the new credential.
Users encounter these upgrades in three places:
- **Security Recommendations** in Settings > Passwords (flagged weak/breached passwords)
- **Password detail view** in the iCloud Keychain password manager
- **Automatic system prompts** when signing in with a weak password via AutoFill
## Use Cases
### Apps with password-based accounts
Any app that stores user credentials and wants to proactively upgrade users to stronger authentication. The system surfaces your extension when it detects the user's saved password is weak, reused, or appeared in a known data breach.
### Sign in with Apple migration
Apps adopting Sign in with Apple for existing users. Instead of building a manual migration flow, the extension lets users upgrade with a single tap from the password manager.
### Password manager integration
Third-party password managers can use this extension to offer automated credential upgrades when the system flags security issues.
## Prerequisites
Before the extension works, your app must be associated with your web domain:
1. Add an `apple-app-site-association` file to your server with a `webcredentials` entry:
```json
{
"webcredentials": {
"apps": ["TEAMID.com.example.myapp"]
}
}
```
2. Add the Associated Domains capability to your app with `webcredentials:example.com`.
This association is how the system knows which saved passwords belong to your app.
## Key Classes
| Class | Role |
|-------|------|
| `ASAccountAuthenticationModificationViewController` | Principal view controller. Subclass this. The system calls your override methods to perform upgrades. Shows UI only when additional authentication is needed. |
| `ASAccountAuthenticationModificationExtensionContext` | The extension context. Use it to complete upgrades, request SIWA authorization, or cancel with errors. |
| `ASCredentialServiceIdentifier` | Identifies the service (domain) the credential belongs to. |
| `ASPasswordCredential` | The existing username + password being upgraded. |
## Implementation
### Sign in with Apple Upgrade (Non-Interactive)
The most common flow. The system calls this when it believes the upgrade can happen silently (user is already authenticated via biometrics to access the password manager).
```swift
import AuthenticationServices
class AccountAuthViewController: ASAccountAuthenticationModificationViewController {
override func convertAccountToSignInWithAppleWithoutUserInteraction(
for serviceIdentifier: ASCredentialServiceIdentifier,
existingCredential: ASPasswordCredential,
userInfo: [AnyHashable: Any]?
) {
// 1. Validate the existing credential with your backend
guard validateCredential(existingCredential) else {
// If you need to show UI for 2FA or re-auth, request it:
self.extensionContext.cancelRequest(
withError: ASExtensionError(.userInteractionRequired)
)
return
}
// 2. Request Sign in with Apple authorization
self.extensionContext.getSignInWithAppleUpgradeAuthorization(
state: generateState(),
nonce: generateNonce()
) { authorization, error in
guard let authorization = authorization else {
self.extensionContext.cancelRequest(
withError: ASExtensionError(.failed)
)
return
}
// 3. Send the SIWA credential to your backend to link accounts
let appleIDCredential = authorization.credential
as! ASAuthorizationAppleIDCredential
self.upgradeAccountOnServer(
existingCredential: existingCredential,
appleIDCredential: appleIDCredential
) { success in
if success {
// 4. Tell the system the upgrade succeeded.
// The old password is deleted from iCloud Keychain.
self.extensionContext.completeUpgradeToSignInWithApple()
} else {
self.extensionContext.cancelRequest(
withError: ASExtensionError(.failed)
)
}
}
}
}
}
```
### Strong Password Upgrade (Non-Interactive)
```swift
override func changePasswordWithoutUserInteraction(
for serviceIdentifier: ASCredentialServiceIdentifier,
existingCredential: ASPasswordCredential,
newPassword: String,
userInfo: [AnyHashable: Any]?
) {
// 1. Validate existing credential
guard validateCredential(existingCredential) else {
self.extensionContext.cancelRequest(
withError: ASExtensionError(.userInteractionRequired)
)
return
}
// 2. Change the password on your backend
changePasswordOnServer(
username: existingCredential.user,
oldPassword: existingCredential.password,
newPassword: newPassword
) { success in
if success {
// 3. Complete with the updated credential.
// iCloud Keychain stores the new strong password.
let newCredential = ASPasswordCredential(
user: existingCredential.user,
password: newPassword
)
self.extensionContext.completeChangePasswordRequest(
updatedCredential: newCredential
)
} else {
self.extensionContext.cancelRequest(
withError: ASExtensionError(.failed)
)
}
}
}
```
### Interactive Flow (When Additional Auth Is Needed)
If you cancel with `.userInteractionRequired`, the system presents your view controller's UI. Override the `prepareInterface` methods to show a step-up authentication screen (e.g., SMS 2FA code entry):
```swift
override func prepareInterfaceToConvertAccountToSignInWithApple(
for serviceIdentifier: ASCredentialServiceIdentifier,
existingCredential: ASPasswordCredential,
userInfo: [AnyHashable: Any]? = nil
) {
// Show your 2FA or re-authentication UI here.
// When the user completes it, call the same
// getSignInWithAppleUpgradeAuthorization / completeUpgradeToSignInWithApple
// flow from your action handler.
}
override func prepareInterfaceToChangePassword(
for serviceIdentifier: ASCredentialServiceIdentifier,
existingCredential: ASPasswordCredential,
newPassword: String,
userInfo: [AnyHashable: Any]? = nil
) {
// Show your re-authentication UI here.
// When complete, call completeChangePasswordRequest.
}
```
### In-App Upgrade Trigger
You can also trigger upgrades from within your app (not just from the password manager):
```swift
import AuthenticationServices
let controller = ASAccountAuthenticationModificationController()
controller.delegate = self
controller.presentationContextProvider = self
// Request a Sign in with Apple upgrade
let request = ASAccountAuthenticationModificationUpgradePasswordToStrongPasswordRequest(
user: "username",
serviceIdentifier: ASCredentialServiceIdentifier(
identifier: "example.com",
type: .domain
)
)
controller.perform(request)
```
## Info.plist Configuration
The extension declares which upgrade types it supports:
| Key | Type | Description |
|-----|------|-------------|
| `ASAccountAuthenticationModificationSupportsUpgradeToSignInWithApple` | Boolean | Set `true` to offer Sign in with Apple upgrades. |
| `ASAccountAuthenticationModificationSupportsStrongPasswordChange` | Boolean | Set `true` to offer strong password upgrades. |
Both are enabled by default in the `@bacons/apple-targets` config plugin.
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target account-auth
```
```js
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 14.0+ | Full support. |
| iPadOS | 14.0+ | Full support. |
| macOS | 11.0+ | Full support. |
| visionOS | 1.0+ | Supported. |
| watchOS | -- | Not supported. |
| tvOS | -- | Not supported. |
## Gotchas
- **Associated Domains are required.** The extension only activates for passwords saved against domains you own. Without a valid `apple-app-site-association` file and the `webcredentials` associated domain, the system will never surface your extension.
- **Prefer non-interactive flows.** Apple recommends completing upgrades without UI whenever possible. Only request `.userInteractionRequired` when you genuinely need a step-up authentication (2FA, re-auth). Unnecessary UI friction defeats the purpose of one-tap upgrades.
- **iOS 18 Passwords app limitation.** As of iOS 18, these extensions are not invoked from the new standalone Passwords app. They still work from AutoFill prompts and from within your own app. Apple has acknowledged this gap.
- **The old password is deleted on SIWA upgrade.** After `completeUpgradeToSignInWithApple()`, the system removes the password from iCloud Keychain. Make sure your backend has fully linked the Apple ID before completing.
- **No third-party tutorials exist.** This is a niche API. The WWDC 2020 session and Apple's documentation are the only substantive references available.
action.md
---
title: Action Extension
description: Transforms or processes content in-place from the system share sheet, optionally with a UI or as a headless background operation.
version: iOS 8.0+
---
# Action Extension (`action`)
An Action Extension appears alongside Share Extensions in the system share sheet but serves a different purpose: instead of sending content to another app, it transforms or processes content and optionally returns modified data back to the host app. Action Extensions come in two flavors -- UI-based (presents a view controller for user interaction) and headless (no UI, runs entirely in the background via `NSExtensionRequestHandling`). A powerful pattern pairs the headless Swift handler with a JavaScript preprocessing file that can read and modify the current webpage in Safari, creating a bridge between native code and web content. Content flows through `NSExtensionContext` using `NSExtensionItem` and `NSItemProvider`, the same mechanism as Share Extensions, but Action Extensions can return results via `completeRequest(returningItems:)`.
## Apple Documentation
- [App Extension Programming Guide: Action](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/Action.html)
- [NSExtensionContext](https://developer.apple.com/documentation/foundation/nsextensioncontext)
- [NSItemProvider](https://developer.apple.com/documentation/foundation/nsitemprovider)
- [NSExtensionItem](https://developer.apple.com/documentation/foundation/nsextensionitem)
- [NSExtensionActivationRule](https://developer.apple.com/documentation/bundleresources/information-property-list/nsextension/nsextensionattributes/nsextensionactivationrule)
- [NSExtensionRequestHandling](https://developer.apple.com/documentation/foundation/nsextensionrequesthandling)
- [App Extension Keys Reference](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/AppExtensionKeys.html)
## WWDC History
- **[WWDC 2014, Session 205 -- Creating Extensions for iOS and OS X, Part 1](https://developer.apple.com/videos/play/wwdc2014/205/)** -- Introduced the app extension model including Action Extensions. Covered the extension lifecycle, `NSExtensionContext`, and data flow between host app and extension.
- **[WWDC 2014, Session 217 -- Creating Extensions for iOS and OS X, Part 2](https://developer.apple.com/videos/play/wwdc2014/217/)** -- Deep dive into Action Extensions specifically. Demonstrated returning modified data back to the host app, Safari JavaScript preprocessing, and the `run()`/`finalize()` pattern for web page manipulation.
- **[WWDC 2015, Session 224 -- App Extension Best Practices](https://developer.apple.com/videos/play/wwdc2015/224/)** -- Covered activation rules, memory management, and the distinction between Action Extensions ("change content in place") and Share Extensions ("move content to another app").
## What It Does
1. **User selects content and opens the share sheet.** The system evaluates your `NSExtensionActivationRule` against the available content. If it matches, your Action Extension appears in the action row of the activity view controller.
2. **For headless extensions:** The system calls `beginRequest(with:)` on your `NSExtensionRequestHandling` conforming class. No UI is presented.
3. **For UI-based extensions:** The system instantiates your principal view controller and presents it modally.
4. **Content arrives via `NSExtensionContext`.** Input items are `NSExtensionItem` objects with `NSItemProvider` attachments, identical to Share Extensions.
5. **JavaScript preprocessing (Safari).** If the extension declares an `NSExtensionJavaScriptPreprocessingFile`, Safari runs the file's `run()` function before launching the extension. The JavaScript can extract page data (title, URL, DOM content) and pass it to the native side as a property list dictionary via the `completionFunction` callback.
6. **Extension processes content.** Your native code receives the preprocessed data, performs transformations (translate text, apply image filter, extract metadata), and prepares results.
7. **Results flow back.** The extension creates a new `NSExtensionItem` with `NSItemProvider` attachments containing the modified data and calls `extensionContext.completeRequest(returningItems:)`. For Safari extensions, the results are passed to the JavaScript file's `finalize()` function, which can apply changes to the webpage DOM.
## Use Cases
### Webpage background color changer
A headless Action Extension paired with a JavaScript file. The JS `run()` reads the current `document.body.style.backgroundColor` and passes it to Swift. The Swift handler decides on a new color and returns it. The JS `finalize()` applies the new background color to the page. No UI required.
### Text translation
A UI-based Action Extension that receives selected text from any app. The extension presents a view showing the original text and a translated version using a local ML model or API call. The user confirms, and the translated text is returned to the host app to replace the selection.
### Image markup and annotation
A UI-based extension that receives an image attachment. The user draws annotations, adds text overlays, or applies filters. The modified image is returned to the host app via `completeRequest(returningItems:)`.
### Save to reading list
A headless Action Extension that extracts the current page URL from Safari via JavaScript preprocessing and saves it to the app's shared data store. A brief system notification confirms the save without interrupting the user's browsing.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `NSExtensionRequestHandling` | Protocol for headless (no-UI) Action Extensions. Implement `beginRequest(with:)` to receive the extension context. |
| `NSExtensionContext` | Provides `inputItems` from the host app and the `completeRequest(returningItems:completionHandler:)` method to return results. |
| `NSExtensionItem` | Wraps one logical item from the host. Contains `attachments` (array of `NSItemProvider`), plus optional `attributedTitle` and `attributedContentText`. |
| `NSItemProvider` | Represents a single attachment. Use `hasItemConformingToTypeIdentifier(_:)` to probe, then `loadItem(forTypeIdentifier:options:)` to retrieve data asynchronously. |
| `UIViewController` | For UI-based Action Extensions. Set as `NSExtensionPrincipalClass` and present your custom interface. Access input via `extensionContext`. |
| `NSExtensionJavaScriptPreprocessingResultsKey` | Dictionary key used to extract the results from the JavaScript `run()` function in the loaded property list. |
| `NSExtensionJavaScriptFinalizeArgumentKey` | Dictionary key for the data you pass back to the JavaScript `finalize()` function from your native handler. |
## Implementation
```swift
import UIKit
import UniformTypeIdentifiers
// 1. Conform to NSExtensionRequestHandling for a headless (no-UI) Action Extension.
class ActionRequestHandler: NSObject, NSExtensionRequestHandling {
var extensionContext: NSExtensionContext?
func beginRequest(with context: NSExtensionContext) {
// 2. Store the context; do not call super for headless extensions.
self.extensionContext = context
var found = false
// 3. Iterate input items to find JavaScript preprocessing results.
outer:
for item in context.inputItems as! [NSExtensionItem] {
if let attachments = item.attachments {
for provider in attachments {
if provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) {
provider.loadItem(
forTypeIdentifier: UTType.propertyList.identifier,
options: nil
) { (item, error) in
let dictionary = item as! [String: Any]
let results = dictionary[
NSExtensionJavaScriptPreprocessingResultsKey
] as? [String: Any] ?? [:]
// 4. Process the JavaScript results on the main queue.
OperationQueue.main.addOperation {
self.processResults(results)
}
}
found = true
break outer
}
}
}
}
if !found {
// 5. No JavaScript results found; complete immediately.
done(with: nil)
}
}
func processResults(_ jsResults: [String: Any]) {
// 6. Read the page title extracted by the JavaScript run() function.
let pageTitle = jsResults["pageTitle"] as? String ?? "Untitled"
// 7. Prepare data to send back to the JavaScript finalize() function.
let responseData: [String: Any] = [
"processedTitle": pageTitle.uppercased(),
"timestamp": ISO8601DateFormatter().string(from: Date())
]
done(with: responseData)
}
func done(with resultsForJS: [String: Any]?) {
if let results = resultsForJS {
// 8. Wrap the results in an NSExtensionItem for the JS finalize() callback.
let resultsDictionary = [NSExtensionJavaScriptFinalizeArgumentKey: results]
let provider = NSItemProvider(
item: resultsDictionary as NSDictionary,
typeIdentifier: UTType.propertyList.identifier
)
let outputItem = NSExtensionItem()
outputItem.attachments = [provider]
extensionContext?.completeRequest(returningItems: [outputItem], completionHandler: nil)
} else {
// 9. Nothing to return; still must signal completion.
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
extensionContext = nil
}
}
```
The companion JavaScript file (`assets/index.js`) bridges the native handler and the webpage:
```javascript
// assets/index.js -- loaded by Safari before the native extension runs.
class Action {
// 10. run() executes in the webpage context before the native handler launches.
run({ extensionName, completionFunction }) {
completionFunction({
pageTitle: document.title,
currentURL: window.location.href,
selectedText: window.getSelection()?.toString() || "",
});
}
// 11. finalize() executes after the native handler returns results.
finalize(args) {
if (args.processedTitle) {
document.title = args.processedTitle;
}
}
}
// 12. Must assign to window.ExtensionPreprocessingJS for Safari to find it.
window.ExtensionPreprocessingJS = new Action();
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target action
```
```json
// targets/action/expo-target.config.json
{
"type": "action"
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 8.0+ | Full support. Appears in the share sheet action row. |
| iPadOS | 8.0+ | Full support. Share sheet appears as a popover. |
| macOS | 10.10+ | Supported via `NSViewController`. Finder integration via `NSExtensionServiceAllowsFinderPreviewItem`. |
| Mac Catalyst | 13.1+ | Uses iOS extension APIs. |
| watchOS | -- | Not supported. |
| tvOS | -- | Not supported. |
| visionOS | 1.0+ | Share sheet supported. |
## Gotchas
- **`TRUEPREDICATE` will get your app rejected.** The default `NSExtensionActivationRule` is `TRUEPREDICATE`, which makes the extension appear for all content. Apple rejects App Store submissions containing this value. Replace it with specific keys (`NSExtensionActivationSupportsWebURLWithMaxCount`, `NSExtensionActivationSupportsText`, etc.) or a custom predicate string.
- **Activation rule dictionary version matters.** `NSExtensionActivationDictionaryVersion` defaults to 1, which requires the extension to handle ALL offered content types. Set it to 2 if you want "at least one matching type" semantics. Getting this wrong means your extension either never appears or appears when it should not.
- **Headless extensions have no UI lifecycle.** There is no `viewDidLoad` or `viewDidAppear`. All work happens in `beginRequest(with:)`. You must call `completeRequest(returningItems:)` when finished or the system will eventually kill the extension.
- **JavaScript file must use `window.ExtensionPreprocessingJS`.** Safari looks for this specific global variable. If the class is not assigned to `window.ExtensionPreprocessingJS`, the preprocessing step silently fails and the native handler receives no data.
- **Same 120 MB memory limit as other extensions.** Action Extensions are subject to the same ~120 MB memory cap. Image processing or heavy computation should be done incrementally and with downsampled inputs.
- **Return data type must match what the host expects.** Not all host apps handle returned items. Safari expects property list data keyed with `NSExtensionJavaScriptFinalizeArgumentKey`. Other host apps may ignore returned items entirely. Test with multiple host apps.
- **`NSExtensionServiceAllowsFinderPreviewItem` is macOS-only.** The Finder-specific and Touch Bar-specific keys in the default template Info.plist have no effect on iOS. They are included for cross-platform extensions that also target macOS.
- **Action and Share share the same sheet but different rows.** Action Extensions appear in the bottom row of the share sheet (the "action" row), while Share Extensions appear in the top row. Users can reorder and hide individual extensions, so do not assume your extension is visible by default.
- **No background execution after completion.** Unlike Share Extensions which can use background URL sessions for uploads, headless Action Extensions have no mechanism for continued background work. All processing must complete before calling `completeRequest`.
app-intent.md
---
title: App Intents Extension
description: Provides app actions to Siri, Shortcuts, Spotlight, interactive widgets, Focus filters, and Control Center controls using the modern pure-Swift App Intents framework via an ExtensionKit-based extension.
version: iOS 16.0+
---
# App Intents Extension (`app-intent`)
An App Intents extension is a lightweight ExtensionKit-based process that exposes your app's actions and entities to the system without launching your main app. Unlike legacy SiriKit Intents that required IntentDefinition files and Objective-C code generation, App Intents are declared entirely in Swift using structs conforming to the `AppIntent` protocol with `@Parameter` property wrappers. The framework powers Siri voice commands, Shortcuts automations, Spotlight suggestions, interactive widget buttons, Focus filters, the Action Button, Apple Pencil Pro squeeze, and -- starting with iOS 18 -- Control Center controls. Running intents in a dedicated extension keeps your app's startup cost out of the critical path, so Siri and Shortcuts respond faster.
## Apple Documentation
- [App Intents Framework](https://developer.apple.com/documentation/appintents)
- [AppIntent Protocol](https://developer.apple.com/documentation/appintents/appintent)
- [Creating Your First App Intent](https://developer.apple.com/documentation/appintents/creating-your-first-app-intent)
- [AppShortcutsProvider](https://developer.apple.com/documentation/appintents/appshortcutsprovider)
- [EntityQuery](https://developer.apple.com/documentation/appintents/entityquery)
- [AppIntentsExtension](https://developer.apple.com/documentation/appintents/appintentsextension)
- [Accelerating App Interactions with App Intents](https://developer.apple.com/documentation/appintents/acceleratingappinteractionswithappintents)
- [ExtensionKit Framework](https://developer.apple.com/documentation/extensionkit)
## WWDC History
- **[WWDC 2022, Session 10032 -- Dive into App Intents](https://developer.apple.com/videos/play/wwdc2022/10032/)** -- Introduced the App Intents framework as a pure-Swift replacement for SiriKit custom intents. Covered `AppIntent`, `AppEntity`, `EntityQuery`, and `AppShortcutsProvider` for zero-setup Siri phrases.
- **[WWDC 2022, Session 10170 -- Implement App Shortcuts with App Intents](https://developer.apple.com/videos/play/wwdc2022/10170/)** -- Detailed walkthrough of building App Shortcuts with parameterized phrases, including entity resolution and Siri dialog.
- **[WWDC 2022, Session 10169 -- Design App Shortcuts](https://developer.apple.com/videos/play/wwdc2022/10169/)** -- Design guidance for creating discoverable, natural-sounding App Shortcuts.
- **[WWDC 2023, Session 10103 -- Explore Enhancements to App Intents](https://developer.apple.com/videos/play/wwdc2023/10103/)** -- iOS 17 updates: App Intents in frameworks via `AppIntentsPackage`, Shortcuts integration improvements, and Spotlight entity indexing.
- **[WWDC 2024, Session 10134 -- What's New in App Intents](https://developer.apple.com/videos/play/wwdc2024/10134/)** -- Universal Links for entities, Transferable support, IntentFile APIs, union values, and the guidance shift to "every feature should be an App Intent."
- **[WWDC 2024, Session 10210 -- Bring Your App's Core Features to Users with App Intents](https://developer.apple.com/videos/play/wwdc2024/10210/)** -- End-to-end guide for surfacing app features through Siri, Spotlight, widgets, the Action Button, and Control Center controls in iOS 18.
- **[WWDC 2025, Session 244 -- Get to Know App Intents](https://developer.apple.com/videos/play/wwdc2025/244/)** -- The new `@AppIntent` macro, `ComputedProperty` and `DeferredProperty` macros, `UnionValue` for mixed entity queries, and Swift Package support for App Intents code.
## What It Does
1. You declare Swift structs conforming to `AppIntent` with a `title`, `@Parameter` properties, and a `perform()` method.
2. The system's metadata extractor discovers your intents at build time (no runtime registration needed).
3. When a user triggers the intent -- through Siri, Shortcuts, Spotlight, a widget button, or Control Center -- the system launches your App Intents extension process.
4. The extension calls your `perform() async throws -> some IntentResult` method.
5. The method executes the action, optionally returning a dialog string, a SwiftUI snippet view, or an `IntentResultValue` for downstream Shortcuts actions.
6. For Siri voice activation without user setup, you register phrases in an `AppShortcutsProvider` so the system knows which spoken commands map to which intents.
## Use Cases
### Voice-activated app actions
A task manager lets users say "Add a task in TaskApp" to create a new item. The `AppShortcutsProvider` registers the phrase, and the intent's `perform()` method creates the task in the shared data store and returns a confirmation dialog.
### Shortcuts automations
A home automation app exposes "Set thermostat to X degrees" as a parameterized intent. Users build Shortcuts automations that trigger the intent at specific times or locations without opening the app.
### Spotlight quick actions
A recipe app conforms its recipes to `AppEntity` with a `StringSearchableEntityQuery`. When the user searches in Spotlight, matching recipes appear as actionable results that open directly to the recipe detail view.
### Interactive widget buttons
A media player widget includes play/pause buttons backed by App Intents. Tapping the button executes the intent in the extension, toggling playback without launching the full app.
### Control Center controls (iOS 18+)
A smart-home app registers a `ControlWidget` backed by an App Intent to toggle lights. The control appears in the customizable Control Center grid and executes entirely in the extension.
### Focus filters
A communication app uses `SetFocusFilterIntent` to configure which accounts are visible during a Focus mode, filtering notifications and content to match the user's current context.
## Key Classes
| Class / Protocol | Role |
|-----------------|------|
| `AppIntent` | Core protocol. Declare a `static var title`, `@Parameter` properties, and `perform() async throws -> some IntentResult`. |
| `AppIntentsExtension` | Marker protocol for the `@main` entry point struct of an App Intents extension. |
| `IntentResult` | Return type from `perform()`. Compose with `ProvidesDialog`, `ShowsSnippetView`, or `ReturnsValue` for richer results. |
| `AppEntity` | Protocol for exposing your data model to the system. Requires `id`, `displayRepresentation`, and a `defaultQuery`. |
| `EntityQuery` | Retrieves entities by identifier (`entities(for:)`) and provides suggestions (`suggestedEntities()`). Subtypes include `EnumerableEntityQuery` and `EntityStringQuery`. |
| `AppShortcutsProvider` | Declares `appShortcuts` array of `AppShortcut` with trigger phrases containing `.applicationName`. Enables Siri voice activation with zero user setup. |
| `@Parameter` | Property wrapper that declares a typed intent parameter. Supports default values, `title`, `description`, and `requestValueDialog`. |
| `AppIntentsPackage` | Protocol for re-exporting intents from frameworks and Swift packages. Use `includedPackages` to compose modular intent libraries. |
| `AppEnum` | Protocol for exposing a Swift enum as an intent parameter with localized display names for each case. |
## Implementation
```swift
import AppIntents
// 1. Mark the entry point with @main. AppIntentsExtension is the
// required conformance for an App Intents extension target.
@main
struct MyAppIntentsExtension: AppIntentsExtension {
}
// 2. Define an entity that represents your app's data model.
// The system uses the defaultQuery to look up entities by ID.
struct TaskEntity: AppEntity {
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Task"
static var defaultQuery = TaskEntityQuery()
var id: UUID
var name: String
var isComplete: Bool
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}
}
// 3. Implement an EntityQuery so Siri and Shortcuts can resolve
// task parameters by identifier or by search string.
struct TaskEntityQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [TaskEntity] {
TaskStore.shared.tasks.filter { identifiers.contains($0.id) }
}
func suggestedEntities() async throws -> [TaskEntity] {
TaskStore.shared.tasks.filter { !$0.isComplete }
}
}
// 4. Declare the intent with @Parameter properties and a perform method.
// The title appears in Shortcuts and Spotlight as the action name.
struct CompleteTaskIntent: AppIntent {
static var title: LocalizedStringResource = "Complete Task"
static var description: IntentDescription = "Marks a task as done."
// 5. @Parameter exposes typed inputs. The system uses the entity's
// defaultQuery to present a picker when the user taps this parameter.
@Parameter(title: "Task")
var task: TaskEntity
// 6. perform() is called when the intent executes. Return a dialog
// string so Siri speaks confirmation, and a value for Shortcuts chaining.
func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue<Bool> {
TaskStore.shared.markComplete(task.id)
return .result(value: true, dialog: "Done! \(task.name) is complete.")
}
}
// 7. Register App Shortcuts for voice activation. Each phrase must
// include .applicationName so Siri knows which app to route to.
struct MyAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: CompleteTaskIntent(),
phrases: [
"Mark a task done in \(.applicationName)",
"Complete a task in \(.applicationName)"
],
shortTitle: "Complete Task",
systemImageName: "checkmark.circle"
)
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target app-intent
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 16.0+ | Full support. Control Center controls require iOS 18+. |
| iPadOS | 16.0+ | Full support. |
| macOS | 13.0+ | Full support. App Intents power Shortcuts and Spotlight on Mac. |
| watchOS | 9.0+ | Supported. Intents can run in a watchOS App Intents extension. |
| tvOS | -- | Not supported. |
| visionOS | 1.0+ | Full support via compatible iOS intents. |
## Gotchas
- **Extension uses ExtensionKit, not NSExtension.** The App Intents extension uses the `EXAppExtensionAttributes` Info.plist key with `EXExtensionPointIdentifier` set to `com.apple.appintents-extension`, and its product type is `com.apple.product-type.extensionkit-extension`. It installs into the `Extensions/` directory, not `PlugIns/`. Mixing these up causes silent validation failures.
- **Metadata is extracted at build time.** The system discovers your intents by scanning compiled metadata, not by runtime registration. If your intent struct is not compiled into the extension target (or a framework linked to it), it will not appear in Shortcuts or Siri. There is no runtime error -- the intent simply does not exist.
- **AppShortcutsProvider phrases must include `.applicationName`.** Every phrase string in your `AppShortcut` must contain the `.applicationName` interpolation token. Without it, the phrase will not compile, and Siri cannot route the voice command to your app.
- **Extension and main app share intents via frameworks.** If you need the same intents available in both the extension and the main app, compile them into a shared framework and use `AppIntentsPackage` with `includedPackages` to re-export the metadata. Duplicating intent code across targets causes conflicts.
- **No UI by default.** Unlike legacy Intents UI extensions, App Intents do not show custom UI unless you explicitly return `ShowsSnippetView` from `perform()` with an attached SwiftUI view. The default result is a text dialog.
- **perform() runs on an arbitrary queue.** If your intent needs main-thread access (e.g., UIKit operations), annotate `perform()` with `@MainActor`. Accessing main-thread-only APIs without this annotation causes undefined behavior.
- **Entity IDs must be stable.** The system persists entity identifiers across Shortcuts runs and Siri sessions. If your entity IDs change between app launches (e.g., using array indices), saved Shortcuts will break silently with "entity not found" errors.
- **Older OS versions ignore newer intent features.** Features like Control Center controls (iOS 18), `@AppIntent` macro (iOS 26), and `UnionValue` (iOS 26) are not available on earlier OS versions. Use `@available` checks and provide fallback intents for broader compatibility.
- **App Intents extensions cannot show UI on launch.** The extension runs headlessly. If your action requires user interaction (login, confirmation), return `.result()` with a `needsContinueInForegroundError` or use `openAppWhenRun = true` to hand off to the main app.
authentication-services.md
---
title: Single Sign-On Extension (Authentication Services)
description: Enterprise SSO extension that intercepts web authentication requests to your identity provider and handles them natively, eliminating browser-based login flows on managed devices.
version: iOS 13.0+, macOS 10.15+
---
# Single Sign-On Extension (`authentication-services`)
An Authentication Services SSO extension intercepts HTTP authentication requests destined for your identity provider (IdP) and handles them directly in the extension process rather than loading a web login page. When any app or Safari on a managed device makes a request to a URL matching your IdP's domain, the system routes the authentication challenge to your extension. The extension can authenticate silently using device credentials, present a native login UI, use Secure Enclave keys, or perform multi-factor authentication -- all without a browser redirect. This is the foundation of enterprise SSO on Apple platforms, used by Okta, Microsoft Entra ID (Azure AD), Jamf Connect, and Apple's own internal SSO infrastructure.
## Apple Documentation
- [ASAuthorizationProviderExtensionAuthorizationRequestHandler](https://developer.apple.com/documentation/authenticationservices/asauthorizationproviderextensionauthorizationrequesthandler) -- The protocol your extension implements to handle authorization requests.
- [ASAuthorizationProviderExtensionAuthorizationRequest](https://developer.apple.com/documentation/authenticationservices/asauthorizationproviderextensionauthorizationrequest) -- The request object passed to your handler, containing the URL, headers, and body.
- [Creating Extensions That Support Platform SSO](https://developer.apple.com/documentation/authenticationservices/creating-extensions-that-support-platform-sso) -- Guide for building extensions that integrate with macOS login.
- [ASAuthorizationProviderExtensionLoginManager](https://developer.apple.com/documentation/authenticationservices/asauthorizationproviderextensionloginmanager) -- Platform SSO login manager for macOS (macOS 13+).
- [AuthenticationServices Framework](https://developer.apple.com/documentation/authenticationservices)
- [Platform Single Sign-On for macOS (Deployment Guide)](https://support.apple.com/guide/deployment/platform-sso-for-macos-dep7bbb05313/web)
## WWDC History
- **[WWDC 2019 -- What's New in Managing Apple Devices](https://developer.apple.com/videos/play/wwdc2019/303/)** -- Introduced Extensible Enterprise SSO. Announced the `ASAuthorizationProviderExtensionAuthorizationRequestHandler` protocol and the requirement for MDM-managed configuration.
- **[Tech Talk 301 -- Introducing Extensible Enterprise SSO](https://developer.apple.com/videos/play/tech-talks/301/)** -- Dedicated deep-dive into the SSO extension architecture, including how the system intercepts HTTP requests, the difference between Redirect and Credential extension types, and the MDM profile keys.
- **[WWDC 2020 -- What's New in Managing Apple Devices](https://developer.apple.com/videos/play/wwdc2020/10639/)** -- Updates to SSO extension capabilities, including associated domains for extension discovery.
- **[WWDC 2022, Session 10045 -- What's New in Managing Apple Devices](https://developer.apple.com/videos/play/wwdc2022/10045/)** -- Introduced **Platform SSO** for macOS 13 Ventura. Ties the local user account to the IdP, enabling sign-in at the macOS login window with IdP credentials or Secure Enclave keys. Replaces Active Directory binding.
- **[WWDC 2023 -- Manage Devices with Apple Frameworks](https://developer.apple.com/videos/play/wwdc2023/10040/)** -- Platform SSO enhancements: device compliance integration, improved token handling.
## What It Does
1. **MDM deploys a configuration profile.** The enterprise MDM server pushes an Extensible SSO profile to the device. This profile specifies your extension's bundle identifier, the IdP URLs it handles, and whether it operates in **Redirect** mode (intercepts HTTP redirects to your IdP) or **Credential** mode (intercepts HTTP 401 challenges).
2. **App or Safari makes an HTTP request.** When any app on the device makes a request that matches the configured IdP URLs, the system intercepts it before the response reaches the app.
3. **System loads your extension.** The system calls `beginAuthorization(with:)` on your `ASAuthorizationProviderExtensionAuthorizationRequestHandler`, passing an `ASAuthorizationProviderExtensionAuthorizationRequest` containing the original URL, HTTP headers, and body.
4. **Extension authenticates.** Your extension can:
- Complete silently by returning HTTP authorization headers (e.g., a cached token).
- Present a native login UI via `request.presentAuthorizationViewController(completion:)`.
- Perform device-bound authentication using Secure Enclave keys.
- Call your IdP's token endpoint and return the result.
5. **Extension completes the request.** Call one of:
- `request.complete(httpAuthorizationHeaders:)` -- inject auth headers into the original request.
- `request.complete(httpResponse:httpBody:)` -- return a full HTTP response.
- `request.complete(error:)` -- fail the authentication.
- `request.doNotHandle()` -- pass through to the default web flow.
6. **App receives authenticated response.** The requesting app is unaware that an extension handled the auth. It receives the response as if the server returned it directly.
### Platform SSO (macOS 13+)
Platform SSO extends this mechanism to the macOS login window:
1. At the login screen, the user enters their IdP password (or uses Touch ID backed by a Secure Enclave key).
2. The system calls your extension's `ASAuthorizationProviderExtensionLoginManager` to authenticate with the IdP.
3. On success, the local macOS account is unlocked and the user is simultaneously authenticated to the IdP for all subsequent SSO requests.
4. This replaces Active Directory binding and mobile accounts.
## Use Cases
### Enterprise Identity Providers
Okta, Microsoft Entra ID (formerly Azure AD), Ping Identity, and OneLogin build SSO extensions so their customers' managed devices get native, seamless authentication across all apps and Safari. Employees sign in once and are silently authenticated everywhere.
### Government and Regulated Industries
Organizations in healthcare, finance, and government use SSO extensions combined with certificate-based authentication or smart cards. The extension can enforce compliance policies (device attestation, OS version checks) before issuing tokens.
### Education (Higher Ed)
Universities deploy SSO extensions via MDM so students and staff get seamless SAML/OIDC authentication across learning management systems, email, and internal portals without repeated browser-based login prompts.
### macOS Login with Cloud Identity (Platform SSO)
IT departments use Platform SSO to eliminate Active Directory binding. Employees log in to their Mac at the login window with their cloud IdP credentials. Jamf Connect and Microsoft Entra ID are the primary Platform SSO providers as of 2025.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `ASAuthorizationProviderExtensionAuthorizationRequestHandler` | Protocol your extension's principal class conforms to. Implement `beginAuthorization(with:)` to handle incoming auth requests. |
| `ASAuthorizationProviderExtensionAuthorizationRequest` | The request object. Contains `url`, `httpHeaders`, `httpBody`, `realm`, `requestedOperation`, `callerBundleIdentifier`. Call `complete(...)` or `doNotHandle()` when finished. |
| `ASAuthorizationProviderExtensionLoginManager` | macOS 13+ Platform SSO manager. Handles login window authentication, user registration, device registration, and token management. |
| `ASAuthorizationSingleSignOnProvider` | Used by client apps to explicitly trigger the SSO extension (less common -- most usage is automatic via HTTP interception). |
| `ASAuthorizationSingleSignOnCredential` | The credential returned to client apps after successful SSO authentication. |
## Implementation
### Redirect-Type SSO Extension
```swift
import AuthenticationServices
// 1. Conform to the SSO request handler protocol.
// This class is declared as NSExtensionPrincipalClass in Info.plist.
class AuthenticationExtension: NSObject,
ASAuthorizationProviderExtensionAuthorizationRequestHandler {
private var currentRequest: ASAuthorizationProviderExtensionAuthorizationRequest?
// 2. Called when the system intercepts an HTTP request matching
// your IdP's configured URLs.
func beginAuthorization(
with request: ASAuthorizationProviderExtensionAuthorizationRequest
) {
self.currentRequest = request
// 3. Check if we have a cached token that is still valid.
if let cachedToken = TokenCache.shared.validToken(for: request.url) {
// 4. Complete silently by injecting the auth header.
// The requesting app never sees a login prompt.
request.complete(
httpAuthorizationHeaders: [
"Authorization": "Bearer \(cachedToken)"
]
)
return
}
// 5. No valid cached token -- authenticate with the IdP.
// For a Redirect-type extension, exchange the intercepted
// URL for tokens using your IdP's token endpoint.
authenticateWithIdP(request: request)
}
private func authenticateWithIdP(
request: ASAuthorizationProviderExtensionAuthorizationRequest
) {
// 6. Build a token request to your IdP.
let tokenURL = URL(string: "https://idp.example.com/oauth2/token")!
var tokenRequest = URLRequest(url: tokenURL)
tokenRequest.httpMethod = "POST"
tokenRequest.setValue(
"application/x-www-form-urlencoded",
forHTTPHeaderField: "Content-Type"
)
// 7. Include device attestation or client certificate for
// strong device identity.
let body = "grant_type=device_code&client_id=myapp&device_id=\(deviceId())"
tokenRequest.httpBody = body.data(using: .utf8)
URLSession.shared.dataTask(with: tokenRequest) { data, response, error in
guard let data = data,
let httpResponse = response as? HTTPURLResponse,
error == nil else {
// 8. If IdP is unreachable, fall back to web login.
request.doNotHandle()
return
}
if httpResponse.statusCode == 200 {
// 9. Parse the token and cache it.
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let accessToken = json["access_token"] as? String {
TokenCache.shared.store(token: accessToken, for: request.url)
// 10. Complete the request with the authorization header.
request.complete(
httpAuthorizationHeaders: [
"Authorization": "Bearer \(accessToken)"
]
)
} else {
request.complete(error: SSOError.invalidResponse)
}
} else {
// 11. Token request failed -- present native login UI.
request.presentAuthorizationViewController(completion: { success, error in
if !success {
request.complete(
error: error ?? SSOError.userCancelled
)
}
// The view controller handles completion via
// request.complete(httpAuthorizationHeaders:).
})
}
}.resume()
}
private func deviceId() -> String {
// Return a stable device identifier for your IdP.
return UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
}
}
enum SSOError: Error {
case invalidResponse
case userCancelled
}
```
### Minimal Extension (Template)
The `create-target` scaffold produces this minimal starting point:
```swift
import AuthenticationServices
class AuthenticationExtension: NSObject,
ASAuthorizationProviderExtensionAuthorizationRequestHandler {
func beginAuthorization(
with request: ASAuthorizationProviderExtensionAuthorizationRequest
) {
// Pass through to default web login until implementation is ready.
request.doNotHandle()
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target authentication-services
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 13.0+ | Redirect and Credential SSO extension types. Requires MDM configuration profile. |
| iPadOS | 13.0+ | Same as iOS. Shared iPad also supported. |
| macOS | 10.15+ | SSO extensions. Platform SSO (login window integration) requires macOS 13.0+. Simplified Setup in macOS 26+. |
| watchOS | -- | Not supported. |
| tvOS | -- | Not supported. tvOS has no SSO extension mechanism. |
| visionOS | 1.0+ | SSO extensions are supported on managed devices. |
## Gotchas
- **MDM is absolutely required.** The SSO extension does nothing without a configuration profile deployed via MDM (Jamf, Intune, Kandji, Mosyle, etc.). The profile maps your IdP's URLs to your extension's bundle identifier. Without it, the system never loads your extension. There is no way for a user to manually enable an SSO extension.
- **Two extension types: Redirect vs. Credential.** A **Redirect** extension intercepts HTTP 302 redirects to your IdP and handles the login flow. A **Credential** extension intercepts HTTP 401 challenges and provides credentials directly. Choose based on your IdP's flow. If you need to provide headers, use a Credential extension -- Redirect extensions are for handling full request/response flows.
- **Associated Domains are required for URL matching.** Your app must declare associated domains (`authsrv:` prefix) matching your IdP's URLs, or the URL list must be provided via the MDM profile. Without this, the system cannot determine which requests to route to your extension.
- **Testing requires a managed device.** You cannot test SSO extensions on the iOS Simulator or on unmanaged devices. You need either a real device enrolled in MDM or Apple Configurator 2 to push a configuration profile.
- **`doNotHandle()` is your escape hatch.** If your extension encounters a request it cannot handle, call `request.doNotHandle()` to let the system fall back to the default web-based login. Never leave a request hanging without calling a completion method.
- **Platform SSO requires macOS 13+ and IdP support.** Platform SSO is not a drop-in feature. Your IdP must implement the protocol, including device registration, token exchange, and key attestation endpoints. As of 2025, only Microsoft Entra ID, Okta, and Jamf Connect have production Platform SSO implementations.
- **Extension runs in a separate process with limited memory.** Keep your extension lightweight. Heavy operations (large token caches, complex UI) can cause the system to terminate your extension.
- **Apple uses this internally.** Apple has its own SSO extension for corporate authentication, which is how Apple employees get single sign-on across internal tools. This is evidence the API is production-hardened but also means Apple prioritizes enterprise use cases.
- **The Kerberos SSO extension is built in.** Apple ships a built-in Kerberos SSO extension (`com.apple.AppSSOKerberos.KerberosExtension`) that handles Active Directory/Kerberos authentication automatically when configured via MDM. You only need a custom extension for SAML, OIDC, or proprietary auth protocols.
bg-download.md
---
title: Background Asset Download Extension
description: Schedules large asset downloads that run in the background, including immediately after App Store install before the user first launches the app.
version: iOS 16.1+, macOS 13.0+
---
# Background Asset Download Extension (`bg-download`)
A background asset download extension lets your app schedule large file downloads that execute outside the app's lifecycle -- during installation, after updates, or periodically in the background. The system invokes your extension to determine what needs downloading, then manages the transfers automatically. Starting with iOS 16.4, downloads marked as "essential" block the app's first launch until they complete, integrating into the App Store installation progress bar. The extension communicates with the main app through App Groups shared storage.
## Apple Documentation
- [BackgroundAssets Framework Overview](https://developer.apple.com/documentation/backgroundassets)
- [BADownloaderExtension Protocol](https://developer.apple.com/documentation/backgroundassets/badownloaderextension-qwaw)
- [Downloading Essential Assets in the Background](https://developer.apple.com/documentation/backgroundassets/downloading-essential-assets-in-the-background)
- [BADownloadManager](https://developer.apple.com/documentation/backgroundassets/badownloadmanager)
- [BAURLDownload](https://developer.apple.com/documentation/backgroundassets/baurldownload)
- [BAContentRequest](https://developer.apple.com/documentation/backgroundassets/bacontentrequest)
## WWDC History
- **[WWDC 2022, Session 110403 -- Meet Background Assets](https://developer.apple.com/videos/play/wwdc2022/110403/)** -- Introduced the BackgroundAssets framework for scheduling large downloads outside the app lifecycle, covering the extension architecture, BADownloadManager, essential vs. non-essential downloads, and App Group requirements.
- **[WWDC 2023, Session 10108 -- What's New in Background Assets](https://developer.apple.com/videos/play/wwdc2023/10108/)** -- Added essential downloads that block app launch during installation, manifest URL pre-fetching, and improved extension lifecycle management.
- **[WWDC 2025, Session 325 -- Discover Apple-Hosted Background Assets](https://developer.apple.com/videos/play/wwdc2025/325/)** -- Introduced managed background assets with Apple-hosted CDN support, automatic compression, and versioning via App Store Connect.
## What It Does
1. **System triggers the extension.** After an app install, update, or on a periodic schedule, the system launches your extension and calls `downloads(for:manifestURL:extensionInfo:)`.
2. **Extension reads the manifest.** The system pre-downloads a manifest file (specified by `BAManifestURL` in your app's Info.plist) and passes its local path to the extension. Your code parses it to determine which assets are needed.
3. **Extension returns download descriptors.** You create `BAURLDownload` objects specifying the URL, file size, whether the download is essential, and the App Group identifier for storage. Return them as a `Set<BADownload>`.
4. **System manages transfers.** The framework downloads files in the background, handling retries, cellular vs. Wi-Fi policies, and power management.
5. **Extension receives completion callbacks.** When a download finishes, `backgroundDownload(_:finishedWithFileURL:)` is called. Move the file to a permanent location in the App Group container. On failure, `backgroundDownload(_:failedWithError:)` fires.
6. **Main app reads downloaded assets.** On launch, the app checks the shared App Group container for downloaded files and uses them directly.
## Use Cases
### Games with large asset packs
A mobile game ships a small binary through the App Store and uses essential background downloads to fetch texture packs, level data, and audio assets during installation. The player sees a single progress bar and can start playing immediately after install completes.
### Machine learning model delivery
An ML-powered app downloads updated CoreML models periodically in the background. When the extension fires on a periodic schedule, it checks the manifest for newer model versions and schedules non-essential downloads that complete while the device is idle.
### Media and podcast pre-caching
A streaming app schedules background downloads of new episodes or content catalogs. The extension checks the manifest during periodic runs and queues downloads for content the user is likely to want, reducing wait times on next launch.
### Map and navigation data
A navigation app downloads regional map tiles and routing databases as essential assets during install, ensuring the app is functional for offline use from the very first launch.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `BADownloaderExtension` | Protocol your extension struct conforms to. Defines entry points for download scheduling and completion. |
| `BAContentRequest` | Enum indicating why the extension was invoked: `.install`, `.update`, or `.periodic`. |
| `BAAppExtensionInfo` | Metadata about the app including `applicationIdentifier`, `lastPeriodicCheckTime`, and `downloadSizeRestricted`. |
| `BAURLDownload` | A download descriptor specifying URL, file size, essential flag, App Group ID, and priority. |
| `BADownload` | Base class for download objects. `BAURLDownload` is the concrete subclass for URL-based downloads. |
| `BADownloadManager` | Singleton used by the main app (or extension) to schedule, cancel, and promote downloads. Foreground promotion is app-only. |
## Implementation
```swift
import BackgroundAssets
// 1. Declare the extension entry point with @main.
@main
struct DownloadHandler: BADownloaderExtension {
// 2. System calls this to ask what downloads are needed.
func downloads(
for request: BAContentRequest,
manifestURL: URL,
extensionInfo: BAAppExtensionInfo
) -> Set<BADownload> {
let appGroup = "group.com.example.myapp"
// 3. Parse the pre-downloaded manifest to determine available assets.
guard let manifestData = try? Data(contentsOf: manifestURL),
let manifest = try? JSONDecoder().decode(AssetManifest.self, from: manifestData)
else {
return []
}
// 4. Compare manifest against already-downloaded files in the App Group.
let downloaded = AlreadyDownloaded.load(appGroup: appGroup)
let needed = manifest.assets.filter { !downloaded.contains($0.identifier) }
var downloads: Set<BADownload> = []
for asset in needed {
let isEssential = (request == .install || request == .update) && asset.required
// 5. Create BAURLDownload for each needed asset.
let download = BAURLDownload(
identifier: asset.identifier,
request: URLRequest(url: asset.url),
essential: isEssential,
fileSize: asset.fileSize,
applicationGroupIdentifier: appGroup,
priority: isEssential ? .max : .default
)
downloads.insert(download)
}
return downloads
}
// 6. Called when a download completes successfully.
func backgroundDownload(
_ finishedDownload: BADownload,
finishedWithFileURL fileURL: URL
) {
// Move the file to Library/Caches in the App Group container
// so the system can reclaim space if storage is critically low.
guard let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.example.myapp"
) else { return }
let destination = container
.appendingPathComponent("Library/Caches", isDirectory: true)
.appendingPathComponent(finishedDownload.identifier)
try? FileManager.default.createDirectory(
at: destination.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try? FileManager.default.moveItem(at: fileURL, to: destination)
}
// 7. Called when a download fails. Optionally reschedule.
func backgroundDownload(
_ failedDownload: BADownload,
failedWithError error: Error
) {
// Log the failure. The system may retry automatically depending
// on the error type. You can also reschedule via BADownloadManager.
}
// 8. Called shortly before the extension is terminated.
func extensionWillTerminate() {
// Serialize any in-memory state to disk immediately.
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target bg-download
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 16.1+ | Full support. Essential downloads added in 16.4. |
| iPadOS | 16.1+ | Full support. Essential downloads added in 16.4. |
| macOS | 13.0+ (Ventura) | Extension runs even when the app is terminated. |
| watchOS | -- | Not supported. |
| tvOS | -- | Not supported (managed assets coming in tvOS 26). |
| visionOS | -- | Not supported (managed assets coming in visionOS 26). |
## Gotchas
- **App Groups are mandatory.** The extension runs in a separate process from your app. All downloaded files must be stored in a shared App Group container. Without a correctly configured App Group, the extension cannot write files the app can read.
- **Downloaded files are marked purgeable.** The system may delete files downloaded by the framework when the device is critically low on storage. Store files in `Library/Caches` and always verify their existence before use.
- **Low Power Mode and Background App Refresh disable the extension.** If the user has enabled Low Power Mode or disabled Background App Refresh (globally or per-app), the extension will never run for periodic requests. Essential downloads during install/update are not affected.
- **On iOS, the user must not have force-quit the app.** If the user swipes the app away from the app switcher, the extension will not receive periodic runtime. On macOS, the extension runs regardless of app termination state.
- **Extension runtime is strictly limited.** The system measures runtime from when a function is invoked to when it returns. Once your function exits scope, the extension may be suspended and terminated immediately. Do not rely on instance variables persisting between calls.
- **Essential downloads block app launch.** If a download marked `essential: true` fails or takes too long, the user cannot launch your app until the system times out. Keep essential downloads small and ensure your CDN is reliable.
- **The `BAManifestURL` Info.plist key is required.** The system downloads this URL before invoking your extension. If the key is missing or the URL is unreachable, the `manifestURL` parameter will point to an empty or missing file.
- **`BADownloadManager.delegate` in the app overrides the extension.** If your main app registers a delegate on `BADownloadManager`, the app receives download callbacks instead of the extension while the app is running. Design your code to handle both paths.
broadcast-setup-ui.md
---
title: Broadcast Setup UI Extension
description: Presents a configuration interface before a ReplayKit live broadcast begins, allowing users to log in, select a channel, and configure stream settings.
version: iOS 10.0+, tvOS 10.0+, macOS 11.0+
---
# Broadcast Setup UI Extension (`broadcast-setup-ui`)
An extension that displays a modal view controller when the user starts a live broadcast through ReplayKit. The setup UI appears after the user picks your broadcast service from `RPBroadcastActivityViewController` or the system broadcast picker, and before any audio or video data starts flowing. Your view controller collects whatever configuration is needed -- account credentials, stream title, channel selection, quality preset -- then calls `extensionContext?.completeRequest(withBroadcast:setupInfo:)` to hand a `setupInfo` dictionary to the paired broadcast-upload extension and begin the broadcast. This is the companion to the `broadcast-upload` extension type.
## Apple Documentation
- [ReplayKit Framework](https://developer.apple.com/documentation/replaykit)
- [RPBroadcastActivityViewController](https://developer.apple.com/documentation/replaykit/rpbroadcastactivityviewcontroller)
- [RPSystemBroadcastPickerView](https://developer.apple.com/documentation/replaykit/rpsystembroadcastpickerview)
- [NSExtensionContext.completeRequest(withBroadcast:setupInfo:)](https://developer.apple.com/documentation/replaykit/nsextensioncontext)
- [App Extension Programming Guide -- Broadcast](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionOverview.html)
## WWDC History
- **[WWDC 2016, Session 601 -- Go Live with ReplayKit](https://developer.apple.com/videos/play/wwdc2016/601/)** -- Introduced broadcast extensions as a pair: a setup UI extension to collect user preferences and an upload extension to stream the data. Demonstrated live game streaming through a Mobcrush extension.
- **[WWDC 2017, Session 606 -- What's New with Screen Recording and Live Broadcast](https://developer.apple.com/videos/play/wwdc2017/606/)** -- Introduced ReplayKit 2 with system-wide broadcast from Control Center. Noted that Control Center broadcasts bypass the setup UI extension entirely, calling `broadcastStarted(withSetupInfo: nil)` on the upload extension.
- **[WWDC 2018, Session 601 -- Live Screen Broadcast with ReplayKit](https://developer.apple.com/videos/play/wwdc2018/601/)** -- Introduced `RPSystemBroadcastPickerView` (iOS 12+), which lets apps embed a broadcast button that can target a specific upload extension by bundle ID, potentially bypassing the setup UI for simpler flows. Covered best practices for handling sign-in inside broadcast extensions.
- **[WWDC 2020, Session 10633 -- Capture and Stream Apps on the Mac with ReplayKit](https://developer.apple.com/videos/play/wwdc2020/10633/)** -- Brought ReplayKit broadcast extensions to macOS, including Mac Catalyst support.
## What It Does
1. The user taps a broadcast button in an app (via `RPBroadcastActivityViewController` or `RPSystemBroadcastPickerView`) or selects a broadcast service from Control Center.
2. If the broadcast is started from within an app using `RPBroadcastActivityViewController`, the system presents a list of available broadcast services. The user picks yours.
3. The system launches your setup UI extension and displays your `BroadcastSetupViewController` as a modal sheet.
4. Your view controller presents login fields, channel pickers, quality selectors, or any other configuration UI.
5. When the user confirms, you call `extensionContext?.completeRequest(withBroadcast: broadcastURL, setupInfo: setupInfo)`. The `broadcastURL` is a URL where viewers can watch the stream. The `setupInfo` dictionary is forwarded to the paired broadcast-upload extension's `broadcastStarted(withSetupInfo:)` method.
6. If the user cancels, you call `extensionContext?.cancelRequest(withError:)` and the broadcast is aborted.
7. After the setup completes successfully, the system launches the broadcast-upload extension and begins delivering `CMSampleBuffer` objects.
## Use Cases
### Live Game Streaming Platforms
A game streaming service (Twitch, YouTube Gaming) presents a setup UI where the user authenticates, selects a stream title, and picks a quality preset before going live. The `setupInfo` dictionary passes the server URL and stream key to the upload extension.
### Video Conferencing Screen Sharing
An enterprise conferencing app shows a setup screen to select which meeting room to broadcast into, confirm permissions, and choose whether to include microphone audio. The setup info includes the room ID and auth token.
### Educational Broadcasting
An instructor selects which class section to stream to and whether to record a local copy. The setup UI reads class rosters from shared `UserDefaults` (via App Groups) and passes the selected section ID to the upload extension.
## Key Classes
| Class | Role |
|-------|------|
| `UIViewController` | Subclass this as your `BroadcastSetupViewController`. Presents the configuration interface to the user. |
| `NSExtensionContext` | Provides `completeRequest(withBroadcast:setupInfo:)` to start the broadcast and `cancelRequest(withError:)` to abort it. |
| `RPBroadcastActivityViewController` | System-provided view controller (used by the host app) that lists available broadcast services and launches your setup UI. |
| `RPSystemBroadcastPickerView` | UIView (iOS 12+) that displays a system picker button. Can set `preferredExtension` to target a specific upload extension, bypassing setup UI for simpler flows. |
## Implementation
### Stream Configuration Setup UI
A realistic broadcast setup UI extension that presents a login form and channel picker, then passes stream configuration to the paired upload extension:
```swift
import ReplayKit
import UIKit
class BroadcastSetupViewController: UIViewController {
private let serverURLField = UITextField()
private let streamKeyField = UITextField()
private let channelPicker = UISegmentedControl(items: ["Main", "Gaming", "IRL"])
private let startButton = UIButton(type: .system)
private let cancelButton = UIButton(type: .system)
// 1. Build the setup UI programmatically. Xcode's template provides no
// default UI -- init(nibName:bundle:) is called with nil for both.
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
serverURLField.placeholder = "rtmp://live.example.com/stream"
serverURLField.borderStyle = .roundedRect
serverURLField.autocapitalizationType = .none
streamKeyField.placeholder = "Stream key"
streamKeyField.borderStyle = .roundedRect
streamKeyField.isSecureTextEntry = true
channelPicker.selectedSegmentIndex = 0
startButton.setTitle("Go Live", for: .normal)
startButton.addTarget(self, action: #selector(startTapped), for: .touchUpInside)
cancelButton.setTitle("Cancel", for: .normal)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
// 2. Lay out controls in a vertical stack
let stack = UIStackView(arrangedSubviews: [
serverURLField, streamKeyField, channelPicker, startButton, cancelButton
])
stack.axis = .vertical
stack.spacing = 16
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
])
// 3. Pre-fill from shared UserDefaults if the user has broadcast before.
// Requires the App Groups entitlement.
if let defaults = UserDefaults(suiteName: "group.com.example.broadcast") {
serverURLField.text = defaults.string(forKey: "lastServerURL")
streamKeyField.text = defaults.string(forKey: "lastStreamKey")
}
}
// 4. User confirmed -- build the setupInfo dictionary and hand it off.
@objc private func startTapped() {
let serverURL = serverURLField.text ?? "rtmp://live.example.com/stream"
let streamKey = streamKeyField.text ?? ""
let channel = channelPicker.titleForSegment(at: channelPicker.selectedSegmentIndex) ?? "Main"
// 5. Persist the configuration for next time
if let defaults = UserDefaults(suiteName: "group.com.example.broadcast") {
defaults.set(serverURL, forKey: "lastServerURL")
defaults.set(streamKey, forKey: "lastStreamKey")
}
// 6. The broadcastURL is where viewers can watch the stream.
// This URL is surfaced by RPBroadcastActivityViewController in the host app.
let broadcastURL = URL(string: "https://example.com/live/\(channel.lowercased())")!
// 7. setupInfo is forwarded to the upload extension's broadcastStarted(withSetupInfo:).
// Keys and values must be NSCoding & NSObjectProtocol.
let setupInfo: [String: NSCoding & NSObjectProtocol] = [
"serverURL": serverURL as NSString,
"streamKey": streamKey as NSString,
"channel": channel as NSString,
]
// 8. Tell ReplayKit the setup is complete. This dismisses the setup UI
// and launches the broadcast-upload extension.
extensionContext?.completeRequest(withBroadcast: broadcastURL, setupInfo: setupInfo)
}
// 9. User cancelled -- dismiss without starting a broadcast.
@objc private func cancelTapped() {
let error = NSError(
domain: "com.example.broadcast",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "User cancelled broadcast setup."]
)
extensionContext?.cancelRequest(withError: error)
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target broadcast-setup-ui
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 10.0+ | Full support. System-wide broadcast from Control Center added in iOS 11, but Control Center bypasses the setup UI. |
| iPadOS | 10.0+ | Full support. |
| macOS | 11.0+ | Supported via Mac Catalyst. |
| tvOS | 10.0+ | Supported for in-app broadcasts only. |
| visionOS | -- | Not supported. |
| watchOS | -- | Not supported. |
## Gotchas
- **Control Center broadcasts bypass the setup UI entirely.** When a user starts a broadcast from Control Center (iOS 11+), the system launches only the upload extension and calls `broadcastStarted(withSetupInfo: nil)`. Your upload extension must handle a `nil` `setupInfo` gracefully -- fall back to configuration stored in shared `UserDefaults` via App Groups.
- **`RPSystemBroadcastPickerView` (iOS 12+) can also bypass setup UI.** When the host app sets `preferredExtension` to a specific upload extension bundle ID, the system may skip your setup UI and go straight to broadcasting. For simpler flows where no login is needed, this is intentional.
- **Xcode's template provides no visible UI.** The generated `BroadcastSetupViewController` stub has an empty `viewDidLoad`. You must build the entire view hierarchy programmatically or via a nib. The `init(nibName:bundle:)` initializer is called with `nil` for both parameters by default, so if you use Interface Builder, override `init` to provide your nib name explicitly.
- **The setupInfo dictionary values must conform to `NSCoding & NSObjectProtocol`.** You cannot pass arbitrary Swift types. Stick to `NSString`, `NSNumber`, `NSData`, `NSURL`, and `NSArray`/`NSDictionary` containing those types.
- **50MB memory limit applies to the setup UI extension process too.** Although the setup UI typically uses far less memory than the upload extension, loading large images or complex storyboards can push you close to the limit on older devices.
- **The extension cannot present additional view controllers.** Your `BroadcastSetupViewController` is presented modally by the system. Pushing or presenting additional view controllers on top of it may not work reliably. Keep your entire setup flow in a single view controller, or use child view controllers and container views.
- **iOS 13.0 had a bug with `RPSystemBroadcastPickerView`.** Tapping the picker button could throw an exception. This was fixed in iOS 13.1. If you support iOS 13.0, guard against this or use `RPBroadcastActivityViewController` as a fallback.
broadcast-upload.md
---
title: Broadcast Upload Extension
description: Receives screen recording video and audio sample buffers from ReplayKit and uploads them to a live streaming service.
version: iOS 10.0+, tvOS 10.0+, macOS 11.0+
---
# Broadcast Upload Extension (`broadcast-upload`)
An extension that receives live screen capture data -- video frames, app audio, and microphone audio -- from ReplayKit and streams it to a remote broadcast service. The system delivers `CMSampleBuffer` objects to your extension process in real time, and your code encodes and uploads them. This is the engine behind live streaming features in apps like Twitch, YouTube Live, and game streaming platforms.
## Apple Documentation
- [ReplayKit Framework](https://developer.apple.com/documentation/replaykit)
- [RPBroadcastSampleHandler](https://developer.apple.com/documentation/replaykit/rpbroadcastsamplehandler)
- [processSampleBuffer(_:with:)](https://developer.apple.com/documentation/replaykit/rpbroadcastsamplehandler/processsamplebuffer(_:with:))
- [broadcastStarted(withSetupInfo:)](https://developer.apple.com/documentation/replaykit/rpbroadcastsamplehandler/broadcaststarted(withsetupinfo:))
- [broadcastFinished()](https://developer.apple.com/documentation/replaykit/rpbroadcastsamplehandler/broadcastfinished())
- [finishBroadcastWithError(_:)](https://developer.apple.com/documentation/replaykit/rpbroadcastsamplehandler/finishbroadcastwitherror(_:))
- [RPSampleBufferType](https://developer.apple.com/documentation/replaykit/rpsamplebuffertype)
## WWDC History
- **[WWDC 2016, Session 601 -- Go Live with ReplayKit](https://developer.apple.com/videos/play/wwdc2016/601/)** -- Introduced broadcast extensions (setup UI + upload). Demonstrated live game streaming with a Mobcrush extension.
- **[WWDC 2017, Session 606 -- What's New with Screen Recording and Live Broadcast](https://developer.apple.com/videos/play/wwdc2017/606/)** -- Added in-app camera overlay support and refined the broadcast upload API.
- **[WWDC 2018, Session 601 -- Live Screen Broadcast with ReplayKit](https://developer.apple.com/videos/play/wwdc2018/601/)** -- Introduced system-wide broadcast from Control Center (ReplayKit 2), `RPSystemBroadcastPickerView`, and best practices for handling account sign-in in broadcast extensions.
- **[WWDC 2020, Session 10633 -- Capture and Stream Apps on the Mac with ReplayKit](https://developer.apple.com/videos/play/wwdc2020/10633/)** -- Brought ReplayKit broadcast extensions to macOS, including Mac Catalyst support.
## What It Does
1. The user initiates a broadcast via Control Center, `RPSystemBroadcastPickerView`, or `RPBroadcastActivityViewController`.
2. The system launches the broadcast upload extension process and calls `broadcastStarted(withSetupInfo:)` with any configuration from the paired setup UI extension.
3. ReplayKit continuously delivers `CMSampleBuffer` objects to `processSampleBuffer(_:with:)` with one of three types: `.video` (screen frames), `.audioApp` (system/app audio), and `.audioMic` (microphone input).
4. Your extension encodes the buffers and uploads them to your streaming backend over a socket or HTTP connection.
5. When the user stops the broadcast, the system calls `broadcastFinished()`. Your extension flushes remaining data and tears down the connection.
6. If a fatal error occurs, your extension calls `finishBroadcastWithError(_:)` to surface a message to the user and terminate the session.
## Use Cases
### Game Live Streaming
A gaming platform (Twitch, YouTube Gaming, Facebook Gaming) provides a broadcast upload extension so players can stream gameplay directly from any iOS game without third-party capture hardware.
### Video Conferencing Screen Sharing
Apps like Zoom, Teams, or WebEx use a broadcast upload extension to share the entire screen during a call. The extension captures screen content and sends it to the conferencing server via WebRTC or a proprietary protocol.
### Remote Support and Collaboration
Enterprise remote-assistance apps stream the user's screen to a support agent. The broadcast upload extension captures the display while the main app handles the bidirectional communication channel.
### Educational Broadcasting
An instructor streams their iPad screen to students via a custom LMS. The extension uploads the screen capture to a media server, and students view it through a companion app or web player.
## Key Classes
| Class | Role |
|-------|------|
| `RPBroadcastSampleHandler` | Subclass this. Receives lifecycle callbacks and sample buffers for the broadcast session. |
| `CMSampleBuffer` | Core Media buffer containing a video frame or audio samples delivered by ReplayKit. |
| `RPSampleBufferType` | Enum distinguishing `.video`, `.audioApp`, and `.audioMic` buffers. |
| `RPSystemBroadcastPickerView` | UIView (iOS 12+) that displays a system picker button to start a broadcast from within your app. |
| `RPBroadcastActivityViewController` | View controller that presents a list of available broadcast services (pre-iOS 12 flow). |
## Implementation
### RTMP-Style Streaming Handler
A realistic broadcast upload extension that connects to an RTMP-style streaming server, encodes video frames, mixes audio, and handles lifecycle events:
```swift
import ReplayKit
import VideoToolbox
class SampleHandler: RPBroadcastSampleHandler {
private var socketConnection: StreamSocketConnection?
private var videoEncoder: H264Encoder?
private var audioMixer: AudioSampleMixer?
private var isBroadcasting = false
// 1. Called when the user starts the broadcast. Set up your upload pipeline.
override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) {
// Extract server URL and stream key from setup info (provided by the setup UI extension)
let serverURL = setupInfo?["serverURL"] as? String ?? "rtmp://live.example.com/stream"
let streamKey = setupInfo?["streamKey"] as? String ?? ""
// 2. Initialize the socket connection to the streaming server
socketConnection = StreamSocketConnection(url: serverURL, key: streamKey)
socketConnection?.onError = { [weak self] error in
// Surface connection errors to the user
self?.finishBroadcastWithError(NSError(
domain: "com.example.broadcast",
code: -1,
userInfo: [NSLocalizedFailureReasonErrorKey: "Lost connection to server: \(error.localizedDescription)"]
))
}
socketConnection?.connect()
// 3. Set up hardware-accelerated H.264 encoding.
// Downscale to 720p to stay within the 50MB memory limit.
videoEncoder = H264Encoder(
width: 1280,
height: 720,
bitrate: 3_000_000,
keyFrameInterval: 2
)
videoEncoder?.onEncodedFrame = { [weak self] nalUnits, presentationTime in
self?.socketConnection?.sendVideo(nalUnits, timestamp: presentationTime)
}
// 4. Set up audio mixing (app audio + mic audio into a single AAC stream)
audioMixer = AudioSampleMixer(sampleRate: 44100, channels: 2)
audioMixer?.onMixedSamples = { [weak self] aacData, presentationTime in
self?.socketConnection?.sendAudio(aacData, timestamp: presentationTime)
}
isBroadcasting = true
}
// 5. Called for every video frame and audio chunk. This is the hot path.
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) {
guard isBroadcasting else { return }
switch sampleBufferType {
case .video:
// 6. Encode the video frame. The encoder downscales and compresses
// using VideoToolbox hardware acceleration.
videoEncoder?.encode(sampleBuffer)
case .audioApp:
// 7. Feed app audio into the mixer
audioMixer?.appendAppAudio(sampleBuffer)
case .audioMic:
// 8. Feed microphone audio into the mixer
audioMixer?.appendMicAudio(sampleBuffer)
@unknown default:
break
}
}
// 9. Called when the broadcast is paused (e.g., phone call interruption)
override func broadcastPaused() {
audioMixer?.pause()
videoEncoder?.pause()
}
// 10. Called when the broadcast resumes after a pause
override func broadcastResumed() {
audioMixer?.resume()
videoEncoder?.resume()
}
// 11. Called when the user stops the broadcast. Flush buffers and disconnect.
override func broadcastFinished() {
isBroadcasting = false
videoEncoder?.flush()
audioMixer?.flush()
socketConnection?.disconnect()
videoEncoder = nil
audioMixer = nil
socketConnection = nil
}
}
```
### Minimal Sample Buffer Logger (Debugging)
Useful for verifying your extension receives data before building the full upload pipeline:
```swift
import ReplayKit
class SampleHandler: RPBroadcastSampleHandler {
private var frameCount = 0
override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) {
frameCount = 0
NSLog("[BroadcastUpload] Broadcast started. Setup info: \(setupInfo ?? [:])")
}
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) {
switch sampleBufferType {
case .video:
frameCount += 1
if frameCount % 60 == 0 {
// 1. Log every 60th frame to avoid flooding the console
let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
NSLog("[BroadcastUpload] Video frame #\(frameCount), PTS: \(timestamp.seconds)")
}
case .audioApp:
break // App audio received
case .audioMic:
break // Mic audio received
@unknown default:
break
}
}
override func broadcastFinished() {
NSLog("[BroadcastUpload] Broadcast finished. Total video frames: \(frameCount)")
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target broadcast-upload
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 10.0+ | Full support. System-wide broadcast added in iOS 11. |
| iPadOS | 10.0+ | Full support. Larger screen sizes increase memory pressure. |
| macOS | 11.0+ | Supported via Mac Catalyst and native macOS apps. |
| tvOS | 10.0+ | Supported for in-app broadcasts only (no Control Center picker). |
| visionOS | -- | Not supported. |
| watchOS | -- | Not supported. |
## Gotchas
- **50MB memory limit.** The extension process is killed if it exceeds 50MB of RAM. ReplayKit itself can consume 20-25MB of that budget for internal buffers, leaving your code with roughly 25MB. Downscale video frames and use hardware-accelerated H.264 (not VP8) to stay under the limit.
- **iPad screen sizes spike memory.** ReplayKit delivers pixel buffers at the native screen resolution. On iPad Pro (2732x2048), uncompressed frames are significantly larger than iPhone frames. Always downscale before encoding.
- **App switching causes memory spikes.** When the user presses the home button or switches apps during a system broadcast, ReplayKit may queue up to 6-8 unprocessed buffers simultaneously, causing a temporary memory spike that can push you over the 50MB limit even if your steady-state usage is well under it.
- **No direct access to the broadcast state.** The extension process is separate from your main app. Use App Groups and shared `UserDefaults` or a shared file to communicate state (stream key, authentication tokens) between your app and the extension.
- **`finishBroadcastWithError` is your only error UI.** The extension has no visible interface. The only way to communicate errors to the user is through the localized failure reason in the `NSError` you pass to `finishBroadcastWithError(_:)`.
- **Audio buffers arrive on a different cadence than video.** Do not assume audio and video buffers are interleaved 1:1. Audio buffers arrive more frequently and in smaller chunks. Your encoder/muxer must handle independent timing.
- **`RPBroadcastProcessMode` must be set in Info.plist.** The extension's Info.plist must include `RPBroadcastProcessMode` set to `RPBroadcastProcessModeSampleBuffer`. Without this, the system may not deliver sample buffers to your handler. The `@bacons/apple-targets` plugin sets this automatically.
call-directory.md
---
title: CallKit Call Directory Extension
description: Provides caller identification labels and call-blocking entries to the native Phone app via a database of phone numbers.
version: iOS 10.0+
---
# CallKit Call Directory Extension (`call-directory`)
A Call Directory extension lets your app supply the system with a database of phone numbers to block or identify. When an incoming call arrives, iOS looks up the number against all enabled Call Directory extensions and displays your label (e.g. "Likely Spam") on the incoming call screen or silently blocks the call. The extension itself never sees live call data -- it simply pre-populates a system-managed database. Users must explicitly enable the extension in Settings > Phone > Call Blocking & Identification before it takes effect.
## Apple Documentation
- [CXCallDirectoryProvider](https://developer.apple.com/documentation/callkit/cxcalldirectoryprovider)
- [CXCallDirectoryExtensionContext](https://developer.apple.com/documentation/callkit/cxcalldirectoryextensioncontext)
- [CXCallDirectoryManager](https://developer.apple.com/documentation/callkit/cxcalldirectorymanager)
- [beginRequest(with:)](https://developer.apple.com/documentation/callkit/cxcalldirectoryprovider/1779582-beginrequest)
- [CallKit Framework Overview](https://developer.apple.com/documentation/callkit)
## WWDC History
- **[WWDC 2016, Session 230 -- Enhancing VoIP Apps with CallKit](https://developer.apple.com/videos/play/wwdc2016/230/)** -- Introduced CallKit, including the Call Directory extension for caller identification and call blocking.
## What It Does
1. **System triggers a reload.** When the user enables the extension, or your app calls `CXCallDirectoryManager.sharedInstance.reloadExtension(withIdentifier:)`, the system launches the extension in the background.
2. **Extension provides phone numbers.** Your `CXCallDirectoryProvider` subclass receives a `beginRequest(with:)` callback. You add blocking entries via `context.addBlockingEntry(withNextSequentialPhoneNumber:)` and identification entries via `context.addIdentificationEntry(withNextSequentialPhoneNumber:label:)`.
3. **Numbers must be strictly ascending.** Every phone number is an `Int64` in E.164 format (country code + number, no punctuation). Each call to `addBlockingEntry` or `addIdentificationEntry` must supply a number greater than the previous one. Violating this causes the entire load to fail silently.
4. **Database is consulted at call time.** When a call arrives from an unknown number, iOS checks the blocking database first (blocking takes priority) and then the identification database. If a match is found, the label is shown on the call screen.
5. **Incremental loading (iOS 11+).** On subsequent reloads, `context.isIncremental` may be `true`. In that case, provide only additions and removals since the last full load, dramatically reducing reload time for large databases.
## Use Cases
### Spam and robocall blocking
A crowd-sourced spam database app downloads known spam numbers from a server and loads them as blocking entries. The main app periodically triggers `reloadExtension` after syncing new data via App Groups shared storage.
### Business caller identification
An enterprise communication app labels incoming calls from known company contacts (e.g. "IT Help Desk", "HR Department") so employees can see who is calling before answering, even if the number is not in their personal contacts.
### Regional telemarketer identification
A consumer protection app provides caller ID labels for known telemarketing companies in the user's region, showing labels like "Telemarketer" or "Survey" on the incoming call screen.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `CXCallDirectoryProvider` | Abstract base class for the extension. Subclass this and override `beginRequest(with:)`. |
| `CXCallDirectoryExtensionContext` | Passed to `beginRequest`. Use it to add blocking and identification entries, check `isIncremental`, and call `completeRequest()`. |
| `CXCallDirectoryExtensionContextDelegate` | Delegate protocol for handling errors during the request (e.g. out-of-order numbers). |
| `CXCallDirectoryManager` | Used from the main app to reload the extension, check its enabled status, and open Settings. |
| `CXCallDirectoryPhoneNumber` | Type alias for `Int64`. Represents a phone number in E.164 format. |
## Implementation
```swift
import Foundation
import CallKit
// 1. Subclass CXCallDirectoryProvider -- the system instantiates this class
// when a reload is triggered.
class CallDirectoryHandler: CXCallDirectoryProvider {
override func beginRequest(with context: CXCallDirectoryExtensionContext) {
context.delegate = self
// 2. Check whether the system is asking for a full load or an
// incremental update (iOS 11+).
if context.isIncremental {
addOrRemoveIncrementalBlockingPhoneNumbers(to: context)
addOrRemoveIncrementalIdentificationPhoneNumbers(to: context)
} else {
addAllBlockingPhoneNumbers(to: context)
addAllIdentificationPhoneNumbers(to: context)
}
// 3. Always call completeRequest() when finished. Failing to call
// this will cause the extension to be terminated.
context.completeRequest()
}
private func addAllBlockingPhoneNumbers(to context: CXCallDirectoryExtensionContext) {
// 4. Load phone numbers from your shared data store (App Group).
// Numbers MUST be sorted in strictly ascending order (E.164 Int64).
let blockedNumbers: [CXCallDirectoryPhoneNumber] = [
1_408_555_1234,
1_800_555_5678,
1_888_555_9012,
]
// 5. For large datasets, use autoreleasepool batches to keep memory
// within the extension's ~6 MB limit.
for number in blockedNumbers {
context.addBlockingEntry(withNextSequentialPhoneNumber: number)
}
}
private func addAllIdentificationPhoneNumbers(to context: CXCallDirectoryExtensionContext) {
// 6. Identification entries associate a label with a phone number.
// Again, numbers must be in ascending order.
let phoneNumbers: [CXCallDirectoryPhoneNumber] = [
1_877_555_1111,
1_888_555_2222,
]
let labels = ["Known Telemarketer", "Local Business"]
for (number, label) in zip(phoneNumbers, labels) {
context.addIdentificationEntry(
withNextSequentialPhoneNumber: number,
label: label
)
}
}
private func addOrRemoveIncrementalBlockingPhoneNumbers(to context: CXCallDirectoryExtensionContext) {
// 7. For incremental updates, add new entries and remove stale ones.
// Removals: context.removeBlockingEntry(withPhoneNumber:)
// Additions: context.addBlockingEntry(withNextSequentialPhoneNumber:)
// Track changes using a timestamp in UserDefaults (App Group).
}
private func addOrRemoveIncrementalIdentificationPhoneNumbers(to context: CXCallDirectoryExtensionContext) {
// 8. Same pattern for identification entries.
// context.removeIdentificationEntry(withPhoneNumber:)
// context.addIdentificationEntry(withNextSequentialPhoneNumber:label:)
}
}
// 9. Implement the delegate to handle errors during loading.
extension CallDirectoryHandler: CXCallDirectoryExtensionContextDelegate {
func requestFailed(for extensionContext: CXCallDirectoryExtensionContext, withError error: Error) {
// 10. Check CXErrorCodeCallDirectoryManagerError for specific codes:
// .entriesOutOfOrder, .duplicateEntries, .unexpectedIncrementalRemoval, etc.
if let cxError = error as? CXErrorCodeCallDirectoryManagerError {
print("Call Directory request failed: \(cxError.code)")
}
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target call-directory
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 10.0+ | Full support. Incremental loading added in iOS 11. |
| iPadOS | -- | Not applicable (no Phone app on iPad). |
| macOS | -- | Not supported. |
| watchOS | -- | Not supported. |
| visionOS | -- | Not supported. |
| tvOS | -- | Not supported. |
## Gotchas
- **Phone numbers must be in strictly ascending order.** Every call to `addBlockingEntry` or `addIdentificationEntry` must supply a number numerically greater than the previous one. Out-of-order entries cause the entire reload to fail with `CXErrorCodeCallDirectoryManagerError.entriesOutOfOrder`, and no error UI is shown to the user.
- **Extension memory limit is approximately 6 MB.** For large databases (hundreds of thousands of numbers), load entries in batches wrapped in `autoreleasepool` blocks. Memory-mapped files are recommended over loading everything into an array.
- **User must manually enable the extension.** Installing the app does not activate the Call Directory extension. The user must go to Settings > Phone > Call Blocking & Identification and toggle your extension on. Use `CXCallDirectoryManager.sharedInstance.getEnabledStatusForExtension` to check and prompt the user.
- **Contacts always take priority over your labels.** If the incoming number matches a contact in the user's address book, the contact name is displayed instead of your identification label. Your blocking entries still apply even if the number is in contacts.
- **Killing the app during reload can cause a stuck state.** If the containing app is terminated while the extension is reloading, the extension can get stuck in a perpetual "loading" state. Subsequent calls to `reloadExtension` return `CXErrorCodeCallDirectoryManagerError.currentlyLoading`. The only recovery is waiting or, in severe cases, the user resetting all settings.
- **Incremental removal requires `isIncremental` check.** Calling `removeBlockingEntry(withPhoneNumber:)` or `removeIdentificationEntry(withPhoneNumber:)` when `context.isIncremental` is `false` causes an `unexpectedIncrementalRemoval` error. Always check the flag before removing entries.
- **App Group is required for data sharing.** The extension runs in a separate process and cannot access the main app's sandbox. Use an App Group container (`UserDefaults(suiteName:)` or shared file container) to pass phone number data from the app to the extension.
- **No real-time call interception.** The extension never receives the actual incoming phone number at call time. It pre-populates a database that the system consults. You cannot implement dynamic, per-call logic.
classkit-context.md
---
title: ClassKit Context Provider Extension
description: Provides your educational app's activity hierarchy to Apple's Schoolwork app so teachers can assign activities and track student progress.
version: iOS 12.2+
---
# ClassKit Context Provider Extension (`classkit-context`)
A ClassKit Context Provider extension tells the Schoolwork app about the educational activities inside your app without requiring your app to be launched. Teachers browse your activity tree in Schoolwork, assign specific activities to students, and then students open your app to complete them. Progress (time spent, scores, completion) is automatically reported back through ClassKit's `CLSDataStore`. This extension is used exclusively in Apple's K-12 education ecosystem.
## Apple Documentation
- [ClassKit Framework Overview](https://developer.apple.com/documentation/classkit)
- [CLSContextProvider Protocol](https://developer.apple.com/documentation/classkit/clscontextprovider)
- [CLSContext](https://developer.apple.com/documentation/classkit/clscontext)
- [CLSDataStore](https://developer.apple.com/documentation/classkit/clsdatastore)
- [ClassKit Environment Entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.classkit-environment)
- [About ClassKit and User Roles](https://developer.apple.com/documentation/classkit/enabling_classkit_in_your_app/about_classkit_and_user_roles)
## WWDC History
- **[WWDC 2018, Session 215 -- Introducing ClassKit](https://developer.apple.com/videos/play/wwdc2018/215/)** -- ClassKit framework introduced with iOS 11.4. Covered `CLSContext` trees, `CLSActivity`, and `CLSDataStore` for reporting student progress. The Context Provider extension did not exist yet -- apps had to be launched to declare their context trees.
- **[WWDC 2019, Session 247 -- What's New in ClassKit](https://developer.apple.com/videos/play/wwdc2019/247/)** -- Introduced the **ClassKit Context Provider extension** (iOS 12.2). This session walked through creating the extension target in Xcode, implementing `updateDescendants(of:completion:)`, and marking activities as complete.
- **[WWDC 2020, Session 10672 -- What's New in ClassKit](https://developer.apple.com/videos/play/wwdc2020/10672/)** -- Added `identifierPath` read-only property, new context types (`course`, `custom`), and the ability to mark contexts as non-assignable.
- **[WWDC 2021, Session 10257 -- Meet ClassKit for File-Based Apps](https://developer.apple.com/videos/play/wwdc2021/10257/)** -- Extended ClassKit to document-based apps (Pages, Keynote, etc.) with file-based progress reporting. Also covered testing in developer mode.
## What It Does
1. **Teacher opens Schoolwork.** Schoolwork queries installed educational apps for their activity hierarchy.
2. **System loads your extension.** The system calls `updateDescendants(of:completion:)` on your `CLSContextProvider` subclass, passing in a `CLSContext`. The first call passes the root (main app) context, which has no parent.
3. **Extension populates children.** Your extension creates or updates `CLSContext` objects representing content tiers (courses, chapters, quizzes, etc.) and adds them as descendants of the passed-in context.
4. **Teacher assigns an activity.** In Schoolwork, the teacher selects one of your contexts and assigns it to students as a "handout."
5. **Student launches your app.** When the student taps the assignment, your app opens via a deep link. Your app navigates to the matching `CLSContext` using its identifier path.
6. **App reports progress.** As the student works, your app creates a `CLSActivity`, starts it, adds `CLSProgressReportItem` or `CLSScoreItem` objects, stops the activity, and saves via `CLSDataStore.shared.save()`.
7. **Teacher sees results.** Schoolwork aggregates progress data across students and displays completion status, time spent, and scores.
## Use Cases
### K-12 Math and Reading Apps
A math practice app exposes a hierarchy like "Grade 5 > Fractions > Adding Fractions > Quiz 1". Teachers assign specific quizzes, and Schoolwork shows each student's score and time spent.
### Language Learning Apps
A language app publishes lessons organized by level and topic ("Spanish > Beginner > Greetings > Lesson 3"). Teachers can assign specific lessons to different groups of students and track who has completed them.
### Science and STEM Interactive Labs
A virtual lab app exposes experiments as assignable contexts. Students complete the lab, and the app reports results (pass/fail, completion percentage) back to Schoolwork.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `CLSContextProvider` | Conform to this protocol (via `NSObject` subclass) in your extension. Override `updateDescendants(of:completion:)` to populate the context tree. |
| `CLSContext` | Represents a single node in your content hierarchy (a course, chapter, quiz, etc.). Has a `title`, `type`, `identifier`, and optional `topic`. |
| `CLSDataStore` | Singleton that manages saving and fetching contexts and activities. Use `CLSDataStore.shared` in both your app and extension. |
| `CLSActivity` | Represents a student's engagement with a context. Start/stop it to track duration. Attach progress or score items. |
| `CLSProgressReportItem` | Reports a 0.0-1.0 progress value for a context. |
| `CLSScoreItem` | Reports a numeric score (e.g., 8 out of 10) for a context. |
| `CLSBinaryItem` | Reports a true/false outcome (e.g., pass/fail). |
## Implementation
```swift
import ClassKit
// 1. Conform to CLSContextProvider. This is the only class in the extension.
class ContextProvider: NSObject, CLSContextProvider {
// 2. Called by the system when Schoolwork needs your content tree.
// `context` is the parent whose children you should populate.
func updateDescendants(
of context: CLSContext,
completion: @escaping (Error?) -> Void
) {
// 3. Check if this is the root (main app) context.
// The root context has no parent.
if context.parent == nil {
populateTopLevel(context: context, completion: completion)
} else {
populateChildren(of: context, completion: completion)
}
}
private func populateTopLevel(
context: CLSContext,
completion: @escaping (Error?) -> Void
) {
let dataStore = CLSDataStore.shared
// 4. Create top-level course contexts.
let courses = [
("algebra", "Algebra", CLSContextTopic.math),
("geometry", "Geometry", CLSContextTopic.math),
("reading", "Reading Comprehension", CLSContextTopic.literacyAndWriting),
]
for (id, title, topic) in courses {
// 5. Check if the context already exists to avoid duplicates.
dataStore.mainAppContext.descendant(
matchingIdentifierPath: [id]
) { existingContext in
if let existing = existingContext {
existing.title = title
existing.topic = topic
} else {
// 6. Create a new child context.
let child = CLSContext(
type: .course,
identifier: id,
title: title
)
child.topic = topic
context.addChildContext(child)
}
}
}
// 7. Save and signal completion.
dataStore.save { error in
completion(error)
}
}
private func populateChildren(
of context: CLSContext,
completion: @escaping (Error?) -> Void
) {
let dataStore = CLSDataStore.shared
// 8. Build children based on parent identifier.
// In a real app, fetch this structure from your content database.
let lessons: [(String, String)] = {
switch context.identifier {
case "algebra":
return [
("linear-equations", "Linear Equations"),
("quadratics", "Quadratic Equations"),
]
case "geometry":
return [
("triangles", "Triangles"),
("circles", "Circles"),
]
default:
return []
}
}()
for (id, title) in lessons {
// 9. Create assignable activity contexts.
let child = CLSContext(
type: .exercise,
identifier: id,
title: title
)
// 10. Mark as assignable so teachers can hand it out.
child.isAssignable = true
context.addChildContext(child)
}
dataStore.save { error in
completion(error)
}
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target classkit-context
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 12.2+ | Context Provider extension introduced in 12.2. ClassKit framework available since 11.4. |
| iPadOS | 12.2+ | Primary platform for Schoolwork. iPad is the dominant device in K-12 Apple deployments. |
| macOS | 11.0+ | ClassKit available via Catalyst. Schoolwork for Mac introduced with macOS 11. |
| watchOS | -- | Not supported. |
| tvOS | -- | Not supported. |
| visionOS | -- | Not supported. |
## Gotchas
- **ClassKit entitlement is required.** You must enable the ClassKit capability in Xcode, which adds the `com.apple.developer.ClassKit-environment` entitlement. Set it to `development` for testing and `production` for App Store builds. Without this entitlement, the extension will not load.
- **Testing requires Schoolwork in developer mode.** You need a managed Apple ID (Apple School Manager) and the Schoolwork app to test the full flow. For local development, set the entitlement value to `development` and use `CLSDataStore.shared.completeAllAssignedActivities(matching:)` to simulate completion.
- **The extension only provides the tree structure, not progress.** The context provider extension populates the hierarchy of assignable content. Actual progress reporting (`CLSActivity`, `CLSScoreItem`, etc.) must happen from your main app when the student interacts with the content.
- **`updateDescendants` may be called multiple times.** The system calls your extension whenever Schoolwork needs fresh data. Do not assume it is called only once. Guard against creating duplicate contexts by checking for existing descendants before adding new ones.
- **Context identifiers must be stable.** The `identifier` you give each `CLSContext` is used to match assignments to content. If you change identifiers between app updates, existing assignments will break and teachers will see errors in Schoolwork.
- **Schoolwork adoption is limited to managed education environments.** ClassKit only works with Apple School Manager-managed devices. Consumer users will never see your ClassKit integration. This limits the audience to institutional K-12 deployments.
- **The root context is created automatically.** You do not create the main app context yourself. It is provided by the system as the parentless `CLSContext` passed to `updateDescendants`. Attempting to create a root context manually will cause errors.
- **Context types affect Schoolwork UI.** Use appropriate `CLSContextType` values (`.course`, `.chapter`, `.exercise`, `.quiz`, etc.) because Schoolwork uses these to display different icons and group activities logically for teachers.
clip.md
---
title: App Clip
description: A lightweight, instantly launchable subset of your app triggered by NFC tags, QR codes, App Clip Codes, Safari banners, Maps, and Messages links.
version: iOS 14.0+
---
# App Clip (`clip`)
An App Clip is a small, focused part of your full app that users can discover and launch instantly without installing anything from the App Store. It uses the product type `com.apple.product-type.application.on-demand-install-capable` and is NOT an extension -- it is a fully functional application target with size constraints. App Clips are triggered by real-world invocations (NFC tags, QR codes, App Clip Codes) and digital invocations (Safari App Clip banners, Messages links, Maps place cards). The system downloads the App Clip on demand, runs it, and automatically removes it after a period of inactivity. App Clips share code and assets with the full app via shared targets, and transfer user data to the full app through App Groups and shared Keychain access groups.
## Apple Documentation
- [App Clips Framework Overview](https://developer.apple.com/documentation/appclip) -- top-level framework reference covering the App Clip lifecycle, invocation handling, and available APIs.
- [Creating an App Clip with Xcode](https://developer.apple.com/documentation/appclip/creating-an-app-clip-with-xcode) -- step-by-step guide for adding an App Clip target, sharing code with the full app, and configuring build settings.
- [Configuring App Clip Experiences](https://developer.apple.com/documentation/appclip/configuring-the-launch-experience-of-your-app-clip) -- how to register invocation URLs in App Store Connect, set up App Clip cards, and configure advanced and default experiences.
- [Choosing the Right Functionality for Your App Clip](https://developer.apple.com/documentation/appclip/choosing-the-right-functionality-for-your-app-clip) -- guidance on which frameworks are available, size budgets, and what makes a great App Clip experience.
- [Testing the Launch Experience of Your App Clip](https://developer.apple.com/documentation/appclip/testing-the-launch-experience-of-your-app-clip) -- how to use `_XCAppClipURL`, local experiences in Developer Settings, and TestFlight for testing invocations.
- [Enabling Notifications in App Clips](https://developer.apple.com/documentation/appclip/enabling-notifications-in-app-clips) -- ephemeral notification permissions and how to request them.
## WWDC History
- **[WWDC 2020, Session 10174 -- Explore App Clips](https://developer.apple.com/videos/play/wwdc2020/10174/)** -- introduced the App Clip concept, design principles for short and fast interactions, and discovery surfaces (NFC, QR, Safari banners, Maps).
- **[WWDC 2020, Session 10146 -- Configure and Link Your App Clips](https://developer.apple.com/videos/play/wwdc2020/10146/)** -- covered associated domains, invocation URL configuration in App Store Connect, App Clip Codes, and web banner integration.
- **[WWDC 2020, Session 10120 -- Streamline Your App Clip](https://developer.apple.com/videos/play/wwdc2020/10120/)** -- best practices for transaction flows, ephemeral notifications, one-time location confirmation, and migrating data to the full app.
- **[WWDC 2020, Session 10118 -- Create App Clips for Other Businesses](https://developer.apple.com/videos/play/wwdc2020/10118/)** -- advanced App Clip experiences on behalf of third-party businesses, multi-experience configuration, and per-business App Clip cards.
- **[WWDC 2021, Session 10012 -- What's New in App Clips](https://developer.apple.com/videos/play/wwdc2021/10012/)** -- App Clip Codes as AR anchors, local testing improvements, and Safari/SafariViewController App Clip card support.
- **[WWDC 2022, Session 10097 -- What's New in App Clips](https://developer.apple.com/videos/play/wwdc2022/10097/)** -- diagnostics tools, CloudKit public database read access, and size limit increase to 15 MB for physical invocations.
- **[WWDC 2023, Session 10178 -- What's New in App Clips](https://developer.apple.com/videos/play/wwdc2023/10178/)** -- default App Clip links (iOS 16.4+), 50 MB size limit for digital invocations (iOS 17+), and cross-app invocation.
## What It Does
1. **User encounters an invocation.** A person taps an NFC tag, scans a QR code or App Clip Code, taps a Safari App Clip banner, opens a Maps place card, or taps a link in Messages.
2. **System shows the App Clip card.** iOS displays a system-provided card with the App Clip name, icon, subtitle, and action button. The card is configured in App Store Connect under App Clip Experiences.
3. **App Clip downloads and launches.** The system downloads the App Clip binary (subject to size limits) and launches it, passing the invocation URL as an `NSUserActivity` with activity type `NSUserActivityTypeBrowsingWeb`.
4. **App Clip parses the URL.** The App Clip extracts path components and query parameters from the invocation URL to determine what experience to show (e.g., which restaurant, which parking meter).
5. **User completes a focused task.** The App Clip presents a streamlined flow -- ordering food, renting a scooter, paying for parking -- using Apple Pay, Sign in with Apple, and other quick-interaction APIs.
6. **Ephemeral notifications fire (optional).** If `NSAppClipRequestEphemeralUserNotification` is enabled and the user granted permission on the App Clip card, the App Clip can send push notifications for up to 8 hours after each launch without prompting.
7. **Location confirmation completes (optional).** If `NSAppClipRequestLocationConfirmation` is enabled, the system shows a one-time confirmation dialog verifying the user is at the expected physical location.
8. **Data migrates to the full app.** When the user installs the full app, shared App Group containers and shared Keychain items make all saved data (credentials, preferences, order history) immediately available.
9. **System removes the App Clip.** After a period of inactivity, iOS automatically deletes the App Clip and its non-shared data.
## Use Cases
### Restaurant Ordering
A diner scans an NFC tag embedded in the table. The App Clip launches showing that table's menu, allows the user to customize an order, and completes payment with Apple Pay -- all without installing the full app.
### Bike and Scooter Rental
A user scans a QR code on a rental scooter. The App Clip authenticates via Sign in with Apple, unlocks the scooter, and starts a ride timer. Ephemeral notifications alert the user about ride duration and cost.
### Parking Meter Payment
A driver taps an App Clip Code on a parking meter. The App Clip uses one-time location confirmation to verify the correct meter, then processes payment. A notification reminds the driver before time expires.
### Game Demo
A user taps a link in Messages. The App Clip downloads as a playable demo (up to 50 MB for digital invocations on iOS 17+). Progress and achievements transfer to the full game via shared App Group storage.
## Key Classes
| Class | Role |
|-------|------|
| `NSUserActivity` | Carries the invocation URL to the App Clip at launch. Activity type is `NSUserActivityTypeBrowsingWeb`. |
| `CLLocationManager` | Used for one-time location verification when `NSAppClipRequestLocationConfirmation` is enabled. |
| `ASAuthorizationController` | Provides Sign in with Apple for quick, privacy-preserving authentication. |
| `PKPaymentAuthorizationController` | Handles Apple Pay transactions for fast checkout flows. |
| `UNUserNotificationCenter` | Manages ephemeral and standard notification registration. |
| `SKOverlay` | Presents a system-provided banner recommending the full app for download. |
| `ASWebAuthenticationSession` | Handles OAuth/federated sign-in callbacks without registering custom URL schemes. |
## Implementation
### SwiftUI App Clip with Invocation URL Handling
```swift
import SwiftUI
import AppClip
import StoreKit
// 1. Define the main App Clip entry point using the SwiftUI App lifecycle.
@main
struct OrderingClipApp: App {
@StateObject private var orderManager = OrderManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(orderManager)
// 2. Handle the invocation URL when the App Clip launches or
// returns to foreground via a new invocation.
.onContinueUserActivity(
NSUserActivityTypeBrowsingWeb,
perform: handleUserActivity
)
}
}
func handleUserActivity(_ userActivity: NSUserActivity) {
// 3. Extract the invocation URL. In Xcode testing, this comes from
// the _XCAppClipURL environment variable.
guard let incomingURL = userActivity.webpageURL,
let components = URLComponents(url: incomingURL, resolvingAgainstBaseURL: true)
else { return }
// 4. Parse path and query parameters to determine the experience.
// e.g., https://example.com/order?restaurant=42&table=7
if let restaurantId = components.queryItems?.first(where: {
$0.name == "restaurant"
})?.value {
orderManager.loadRestaurant(id: restaurantId)
}
if let tableId = components.queryItems?.first(where: {
$0.name == "table"
})?.value {
orderManager.setTable(id: tableId)
}
// 5. Optionally verify the user's location against the expected
// business location (requires NSAppClipRequestLocationConfirmation).
verifyLocation(for: incomingURL)
}
func verifyLocation(for url: URL) {
// 6. The system handles the location confirmation dialog automatically
// when the key is set in Info.plist. You can check the result:
guard let payload = try? AppClipActivationPayload(url: url) else { return }
let region = CLCircularRegion(
center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
radius: 100,
identifier: "restaurant-location"
)
Task {
do {
try await payload.confirmAcquired(in: region)
orderManager.locationVerified = true
} catch {
orderManager.locationVerified = false
}
}
}
}
// 7. The order manager stores state in an App Group so the full app
// can access it after installation.
class OrderManager: ObservableObject {
@Published var restaurantName: String = ""
@Published var tableId: String = ""
@Published var locationVerified = false
@Published var items: [MenuItem] = []
private let defaults = UserDefaults(
suiteName: "group.com.example.ordering"
)
func loadRestaurant(id: String) {
// Fetch restaurant details from your API.
restaurantName = "Restaurant \(id)"
}
func setTable(id: String) {
tableId = id
// 8. Persist to shared App Group so the full app can read it.
defaults?.set(id, forKey: "lastTableId")
}
func saveOrderHistory(_ order: Order) {
// 9. Write order data to the shared container for migration.
guard let url = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.ordering")?
.appendingPathComponent("orders.json")
else { return }
let encoder = JSONEncoder()
if let data = try? encoder.encode(order) {
try? data.write(to: url, options: .atomic)
}
}
}
struct MenuItem: Identifiable, Codable {
let id: String
let name: String
let price: Decimal
}
struct Order: Codable {
let items: [MenuItem]
let total: Decimal
let date: Date
}
// 10. Main content view with menu, cart, and a banner prompting full app install.
struct ContentView: View {
@EnvironmentObject var orderManager: OrderManager
var body: some View {
NavigationStack {
VStack {
if orderManager.restaurantName.isEmpty {
ProgressView("Loading...")
} else {
Text(orderManager.restaurantName)
.font(.largeTitle.bold())
Text("Table \(orderManager.tableId)")
.font(.headline)
.foregroundStyle(.secondary)
List(orderManager.items) { item in
HStack {
Text(item.name)
Spacer()
Text(item.price, format: .currency(code: "USD"))
}
}
Button("Pay with Apple Pay") {
// 11. Integrate PKPaymentAuthorizationController here
// for a fast, focused checkout.
}
.buttonStyle(.borderedProminent)
.padding()
}
}
.toolbar {
ToolbarItem(placement: .bottomBar) {
// 12. Show the App Store overlay to encourage full app install.
// The overlay appears as a banner at the bottom of the screen.
AppStoreOverlayButton()
}
}
}
}
}
struct AppStoreOverlayButton: View {
@State private var showOverlay = false
var body: some View {
Button("Get the Full App") {
showOverlay = true
}
.appStoreOverlay(isPresented: $showOverlay) {
// 13. Use your full app's Apple ID from App Store Connect.
SKOverlay.AppClipConfiguration(position: .bottom)
}
}
}
// 14. Register for ephemeral notifications on launch.
// Call this from your App's init or onAppear.
func requestEphemeralNotifications() {
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
// 15. If ephemeral is already granted (from the App Clip card),
// skip the prompt entirely.
if settings.authorizationStatus == .ephemeral {
print("Ephemeral notifications granted for 8 hours.")
return
}
// 16. Otherwise, request standard authorization as a fallback.
center.requestAuthorization(options: [.alert, .sound]) { granted, error in
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target clip
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 14.0+ | Full App Clip support. Size limit: 10 MB (iOS 14-15), 15 MB for physical invocations (iOS 16+), 50 MB for digital invocations (iOS 17+). |
| iPadOS | 14.0+ | Supported. Same size limits as iOS. |
| macOS | -- | Not supported. App Clips are iOS/iPadOS only. |
| watchOS | -- | Not supported. |
| tvOS | -- | Not supported. |
| visionOS | -- | Not supported. |
## Gotchas
- **Size limits depend on invocation type and OS version.** Physical invocations (NFC, App Clip Codes) are capped at 15 MB (iOS 16+). Digital invocations (Safari, Messages, Maps) allow up to 50 MB but only on iOS 17+ with a deployment target of iOS 17. The size is measured as the uncompressed binary -- use `app-thinning-size-report.txt` from your App Store archive to verify.
- **App Clips cannot register URL schemes or Universal Links.** They receive invocation URLs through `NSUserActivity`, not through the standard `openURL` flow. Use `ASWebAuthenticationSession` for OAuth callbacks.
- **Ephemeral notifications expire after 8 hours per launch.** Each time the App Clip is opened, the 8-hour window resets. After that, notifications stop unless the user explicitly grants standard notification permission.
- **Data is deleted on inactivity.** The system removes the App Clip and its non-shared data after approximately 30 days of inactivity. Always store important data in a shared App Group container or Keychain access group so the full app can access it.
- **`_XCAppClipURL` does not work in UI tests.** The environment variable correctly passes the invocation URL when running the App Clip scheme normally, but `NSUserActivity` is not delivered during XCUITest execution. Use a separate test-only environment variable as a workaround.
- **Location confirmation is one-time per session.** The `confirmAcquired(in:)` call presents a system dialog once. If the user denies it or the location check fails, you cannot re-prompt in the same session.
- **CloudKit access is read-only.** Since iOS 16, App Clips can read from a CloudKit public database but cannot write to CloudKit or use cloud documents and key-value stores.
- **App Group container must match.** The App Clip's App Group identifier must exactly match the full app's identifier. Mismatches silently prevent data sharing. Configure this in the `expo-target.config.js` entitlements.
- **No background execution modes.** App Clips cannot register for most background modes (background fetch, background processing, etc.). They are designed for foreground-only, short-lived interactions.
- **Parent Application Identifiers entitlement is required.** The App Clip must include the `com.apple.developer.parent-application-identifiers` entitlement pointing to the full app's bundle identifier. The `@bacons/apple-targets` plugin configures this automatically.
content-blocker.md
---
title: Safari Content Blocker
description: Provides declarative JSON rules to Safari for blocking or hiding web content without runtime code execution.
version: iOS 9.0+
---
# Safari Content Blocker (`content-blocker`)
A content blocker extension supplies Safari with a JSON array of trigger/action rules that block network requests, hide page elements, or strip cookies -- all without executing any code at page-load time. Safari compiles the JSON rules into efficient bytecode at install time, so blocking decisions happen at native speed with zero information leaking back to the extension about the user's browsing activity. This privacy-by-design architecture means the extension has no knowledge of what sites the user visits. The containing app can update rules dynamically and call `SFContentBlockerManager.reloadContentBlocker(withIdentifier:)` to push changes to Safari.
## Apple Documentation
- [Creating a Content Blocker](https://developer.apple.com/documentation/safariservices/creating-a-content-blocker)
- [SFContentBlockerManager](https://developer.apple.com/documentation/safariservices/sfcontentblockermanager)
- [App Extension Programming Guide: Content Blocker](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ContentBlocker.html)
- [Safari Content-Blocking Rules Reference](https://developer.apple.com/documentation/safariservices/creating-a-content-blocker#overview)
- [Blocking Content with Your Safari Web Extension](https://developer.apple.com/documentation/safariservices/blocking-content-with-your-safari-web-extension)
## WWDC History
- **[WWDC 2015, Session 511 -- Safari Extensibility: Content Blocking and Shared Links](https://developer.apple.com/videos/play/wwdc2015/511/)** -- Introduced content blockers in iOS 9 and OS X El Capitan. Covered the JSON rule format, trigger/action model, and the privacy-first architecture where Safari compiles rules to bytecode with no runtime callbacks.
## What It Does
1. **Extension returns JSON rules.** The system calls your `NSExtensionRequestHandling` implementation (`beginRequest(with:)`), which returns one or more `NSItemProvider` attachments containing JSON rule arrays.
2. **Safari compiles rules to bytecode.** Safari parses the JSON and compiles it into an optimized bytecode representation. This compilation happens once at install time and after each `reloadContentBlocker` call.
3. **Rules are evaluated per resource load.** For every network request on a page, Safari checks the compiled rules against the URL and resource metadata. Matching happens entirely within the Safari/WebKit process -- no IPC to your extension occurs.
4. **Matched actions execute.** Depending on the action type, Safari blocks the network request, hides a DOM element via CSS, or ignores previously matched rules.
5. **No browsing data reaches the extension.** The extension never learns which URLs the user visits, which rules matched, or any page content. This is enforced architecturally, not by policy.
6. **App can update rules.** The containing app can regenerate the JSON (e.g., based on user preferences) and call `SFContentBlockerManager.reloadContentBlocker(withIdentifier:completionHandler:)` to tell Safari to re-fetch and recompile the rules.
## Use Cases
### Ad blocking
Block advertising network requests by matching known ad-serving domains in `url-filter` triggers. Use `resource-type: ["image", "script", "raw"]` to target ad-related resources. Combine with `css-display-none` actions to hide ad container elements that loaded before the block rule applied.
### Privacy and tracker blocking
Block third-party tracking scripts and pixels by using `load-type: ["third-party"]` triggers combined with a list of known tracker domains in `if-domain` or `url-filter`. Strip cookies from specific domains using the `block-cookies` action type.
### Parental controls and safe browsing
Block access to entire domains by matching broad URL patterns. Use `unless-domain` to allowlist specific sites while blocking everything else. The containing app can present a UI for parents to configure allowed/blocked domains and reload the rules.
### Reader-friendly experience
Hide distracting page elements (pop-ups, newsletter overlays, sticky headers, autoplay video containers) using `css-display-none` actions with CSS selectors targeting common annoyance patterns.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `NSExtensionRequestHandling` | Protocol your handler class conforms to. Implement `beginRequest(with:)` to return the JSON rules. |
| `NSExtensionContext` | The context object passed to `beginRequest(with:)`. Call `completeRequest(returningItems:completionHandler:)` with your JSON attachment. |
| `NSItemProvider` | Wraps the JSON file or data as an attachment on the `NSExtensionItem`. |
| `SFContentBlockerManager` | Class in SafariServices used by the containing app to reload rules. Call `reloadContentBlocker(withIdentifier:completionHandler:)`. |
## Implementation
```swift
import UIKit
import MobileCoreServices
// 1. The request handler conforms to NSExtensionRequestHandling.
// Safari calls beginRequest(with:) to fetch the blocking rules.
class ContentBlockerRequestHandler: NSObject, NSExtensionRequestHandling {
func beginRequest(with context: NSExtensionContext) {
// 2. Load a static JSON file bundled with the extension.
// You can also build the JSON dynamically based on user settings
// stored in a shared App Group container.
let attachment = NSItemProvider(
contentsOf: Bundle.main.url(forResource: "blockerList", withExtension: "json")!
)!
let item = NSExtensionItem()
item.attachments = [attachment]
// 3. Return the rules to Safari. Safari compiles them to bytecode.
context.completeRequest(returningItems: [item], completionHandler: nil)
}
}
// 4. Example: dynamically building rules from user preferences.
class DynamicContentBlockerRequestHandler: NSObject, NSExtensionRequestHandling {
func beginRequest(with context: NSExtensionContext) {
// 5. Read user preferences from a shared App Group.
let defaults = UserDefaults(suiteName: "group.com.example.myapp")
let blockedDomains = defaults?.stringArray(forKey: "blockedDomains") ?? []
let hideAds = defaults?.bool(forKey: "hideAds") ?? true
var rules: [[String: Any]] = []
// 6. Generate block rules for each user-specified domain.
for domain in blockedDomains {
rules.append([
"trigger": ["url-filter": ".*", "if-domain": [domain]],
"action": ["type": "block"]
])
}
// 7. Add a CSS-based rule to hide common ad containers.
if hideAds {
rules.append([
"trigger": [
"url-filter": ".*",
"resource-type": ["document"]
],
"action": [
"type": "css-display-none",
"selector": ".ad-banner, .ad-container, [id*='google_ads'], [class*='sponsored']"
]
])
}
// 8. Serialize to JSON and return via NSItemProvider.
guard let jsonData = try? JSONSerialization.data(withJSONObject: rules) else {
context.cancelRequest(withError: NSError(
domain: "ContentBlocker", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to serialize rules"]
))
return
}
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("rules.json")
try? jsonData.write(to: tempURL)
let attachment = NSItemProvider(contentsOf: tempURL)!
let item = NSExtensionItem()
item.attachments = [attachment]
context.completeRequest(returningItems: [item], completionHandler: nil)
}
}
```
### JSON Rule Format Reference
```json
[
{
"trigger": {
"url-filter": "://ads\\.example\\.com/",
"resource-type": ["script", "image", "raw"],
"load-type": ["third-party"]
},
"action": {
"type": "block"
}
},
{
"trigger": {
"url-filter": ".*",
"if-domain": ["news.example.com"]
},
"action": {
"type": "css-display-none",
"selector": ".newsletter-popup, .cookie-banner"
}
},
{
"trigger": {
"url-filter": "://tracker\\.example\\.com/"
},
"action": {
"type": "block-cookies"
}
},
{
"trigger": {
"url-filter": "://cdn\\.trusted\\.com/",
"load-type": ["first-party"]
},
"action": {
"type": "ignore-previous-rules"
}
}
]
```
### Reloading Rules from the Containing App
```swift
import SafariServices
// Call this from your containing app after updating the JSON rules.
func reloadBlockerRules() {
// The identifier must match the bundle identifier of the content blocker extension.
SFContentBlockerManager.reloadContentBlocker(
withIdentifier: "com.example.myapp.content-blocker"
) { error in
if let error = error {
print("Failed to reload content blocker: \(error.localizedDescription)")
} else {
print("Content blocker rules reloaded successfully.")
}
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target content-blocker
```
```json
// targets/content-blocker/expo-target.config.json
{
"type": "content-blocker"
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 9.0+ | Safari only. Content blockers apply to all Safari tabs and SFSafariViewController instances. |
| iPadOS | 9.0+ | Same as iOS. |
| macOS | 10.11+ (Safari 9+) | Works in Safari. Also applies to other WebKit-based browsers on macOS 10.12+. |
| watchOS | -- | Not supported. |
| visionOS | -- | Not supported. |
| tvOS | -- | Not supported. |
## Gotchas
- **Hard limit of 50,000 rules per extension.** WebKit enforces a maximum of 50,000 rules per content blocker extension. If your JSON array exceeds this count, Safari silently drops the excess rules. To support more rules, register multiple content blocker extensions in the same app, each with its own 50,000-rule budget.
- **Rules are compiled once, not evaluated per-request.** Safari compiles JSON rules to bytecode at install/reload time. Changes to the JSON file have no effect until `SFContentBlockerManager.reloadContentBlocker(withIdentifier:)` is called from the containing app, or the user toggles the extension off and on in Settings.
- **No runtime code execution.** Unlike Safari Web Extensions, content blocker extensions cannot run JavaScript, inspect page content, or make decisions at load time. The entire blocking logic must be expressible as static JSON trigger/action pairs.
- **The extension never sees browsing data.** By design, the extension process receives zero information about which sites the user visits or which rules matched. You cannot collect analytics about blocked content.
- **url-filter uses a subset of regular expressions.** The `url-filter` field supports a limited regex syntax (no backreferences, no lookahead/lookbehind). Patterns are matched case-insensitively by default. Use `url-filter-is-case-sensitive: true` to override.
- **css-display-none only hides elements, it does not block network requests.** Using `"type": "css-display-none"` sets `display: none` on matching DOM elements but does not prevent the associated resources from loading. Combine with `block` rules for complete blocking.
- **ignore-previous-rules is order-dependent.** The `ignore-previous-rules` action type causes Safari to discard all previously matched rules for the current resource. Rule order in the JSON array matters. Place allowlist rules after block rules.
- **Large rule sets can crash on iOS 17.** Invoking `reloadContentBlocker` with very large JSON payloads (20 MB+) can cause the content blocker XPC service to crash on iOS 17. Keep individual JSON files reasonably sized and split across multiple extensions if needed.
- **User must explicitly enable the extension.** Content blockers are disabled by default. The user must go to Settings > Safari > Content Blockers (iOS) or Safari > Preferences > Extensions (macOS) to enable your blocker. Your containing app should guide the user to this setting.
- **Simulator requires toggling the extension off and on.** In the iOS Simulator, installing a new build or updating rules often does not take effect until you toggle the content blocker off and back on in Settings > Safari > Extensions. This forces Safari to reload, recompile the rules, and pick up changes. On a physical device `reloadContentBlocker` is usually sufficient, but in the Simulator the toggle is the most reliable way to force a refresh during development.
- **No effect in WKWebView.** Content blocker extensions only apply to Safari and `SFSafariViewController`. Arbitrary `WKWebView` instances in other apps are not affected, though `WKWebView` has its own `WKContentRuleListStore` API for programmatic content rules.
credentials-provider.md
---
title: AutoFill Credential Provider Extension
description: Integrates a password manager with the system AutoFill UI to provide passwords, passkeys, and verification codes.
version: iOS 12.0+
---
# AutoFill Credential Provider Extension (`credentials-provider`)
A credential provider extension integrates your password manager or authentication app with the system-wide AutoFill UI, allowing users to fill passwords, passkeys, and one-time codes directly from the QuickType bar or the full credential list during sign-in flows in any app or Safari. The extension subclasses `ASCredentialProviderViewController` from the AuthenticationServices framework and implements methods to present a credential list, provide credentials without user interaction (for QuickType bar autofill), and handle user authentication before releasing sensitive data. Starting with iOS 17, extensions can also create and authenticate with passkeys and supply one-time verification codes, making this the primary integration point for full-featured credential managers on Apple platforms.
## Apple Documentation
- [ASCredentialProviderViewController](https://developer.apple.com/documentation/authenticationservices/ascredentialproviderviewcontroller)
- [AutoFill Credential Provider Entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.authentication-services.autofill-credential-provider)
- [ASCredentialIdentityStore](https://developer.apple.com/documentation/authenticationservices/ascredentialidentitystore)
- [ASCredentialProviderExtensionContext](https://developer.apple.com/documentation/authenticationservices/ascredentialproviderextensioncontext)
- [ASPasswordCredential](https://developer.apple.com/documentation/authenticationservices/aspasswordcredential)
- [ASPasskeyCredentialRequest](https://developer.apple.com/documentation/authenticationservices/aspasskeycredentialrequest)
- [ASCredentialProviderExtensionCapabilities (Info.plist)](https://developer.apple.com/documentation/bundleresources/information_property_list/nsextension/nsextensionattributes/ascredentialproviderextensioncapabilities)
- [Credential Provider Extensions -- Apple Platform Security](https://support.apple.com/guide/security/credential-provider-extensions-sec6319ac7b9/web)
## WWDC History
- **[WWDC 2018, Session 721 -- Implementing AutoFill Credential Provider Extensions](https://developer.apple.com/videos/play/wwdc2018/721/)** -- Introduced the credential provider extension point in iOS 12. Covered `ASCredentialProviderViewController`, the `ASCredentialIdentityStore` for QuickType bar population, and the three-method lifecycle (credential list, no-UI provision, interactive provision).
- **[WWDC 2018, Session 204 -- Automatic Strong Passwords and Security Code AutoFill](https://developer.apple.com/videos/play/wwdc2018/204/)** -- Broader session on Password AutoFill in iOS 12, including how third-party credential providers integrate alongside iCloud Keychain.
- **[WWDC 2021, Session 10106 -- Move Beyond Passwords](https://developer.apple.com/videos/play/wwdc2021/10106/)** -- Introduced passkeys as a concept, laying groundwork for credential provider passkey support.
- **[WWDC 2023, Session 10263 -- Deploy Passkeys at Work](https://developer.apple.com/videos/play/wwdc2023/10263/)** -- Expanded the credential provider API to support passkeys for third-party password managers in iOS 17. Covered `ProvidesPasskeys` capability, managed Apple IDs with iCloud Keychain, and enterprise attestation.
- **[WWDC 2024, Session 10125 -- Streamline Sign-In with Passkey Upgrades and Credential Managers](https://developer.apple.com/videos/play/wwdc2024/10125/)** -- Added automatic passkey upgrades, one-time code filling, and the ability to fill credentials into any text field.
## What It Does
1. **User selects your provider in Settings.** The user goes to Settings > Passwords > Password Options (iOS 18+) or Settings > Passwords & Accounts > AutoFill Passwords (earlier iOS) and enables your app as a credential provider alongside or instead of iCloud Keychain.
2. **QuickType bar shows suggestions.** When the user taps a username or password field, iOS checks the `ASCredentialIdentityStore` for identities your extension previously registered. Matching credentials appear on the QuickType bar.
3. **No-UI provision for QuickType.** If the user taps a QuickType suggestion, iOS calls `provideCredentialWithoutUserInteraction(for:)`. If your credential store is unlocked, return the credential immediately. If it requires authentication, throw `ASExtensionError.userInteractionRequired`.
4. **Interactive credential list.** If the user taps the key icon or your extension requires authentication, iOS presents your view controller. Override `prepareCredentialList(for:)` to show a searchable list of credentials filtered by the requesting service identifiers.
5. **User selects a credential.** After optional authentication (biometrics, master password), call `extensionContext.completeRequest(withSelectedCredential:completionHandler:)` with the `ASPasswordCredential`.
6. **Passkey flows (iOS 17+).** For passkey authentication, override `provideCredentialWithoutUserInteraction(for:)` accepting `ASPasskeyCredentialRequest`, perform the WebAuthn assertion, and return an `ASPasskeyAssertionCredential`. For passkey registration, override `prepareInterface(forPasskeyRegistration:)`.
7. **Identity store keeps QuickType current.** Call `ASCredentialIdentityStore.shared.saveCredentialIdentities(_:)` whenever your credential database changes, so the QuickType bar always reflects the latest saved logins.
## Use Cases
### Third-party password manager
A password manager app (like 1Password or Bitwarden) registers all stored credentials in the `ASCredentialIdentityStore` so they appear in the QuickType bar. The extension authenticates the user with biometrics or a master password before releasing credentials.
### Enterprise single sign-on
A corporate identity provider extension that integrates with an organization's identity service. When employees sign in to internal apps, the extension provides credentials from the enterprise directory, enforcing company security policies before filling.
### Passkey manager
A cross-platform credential manager that stores passkeys alongside passwords. On iOS 17+, the extension creates passkeys during registration flows and performs WebAuthn assertions during sign-in, enabling passwordless authentication across all apps and Safari.
### One-time code provider
Starting with iOS 18, a credential manager can provide time-based one-time passwords (TOTP codes) during two-factor authentication flows. The extension surfaces the current code in the QuickType bar without the user needing to open the authenticator app.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `ASCredentialProviderViewController` | The view controller subclass your extension provides. Override its lifecycle methods to present UI and return credentials. |
| `ASCredentialIdentityStore` | Singleton store where you register `ASPasswordCredentialIdentity` and `ASPasskeyCredentialIdentity` objects. These populate the QuickType bar. |
| `ASPasswordCredentialIdentity` | Describes a stored password credential (service identifier, user, record identifier). Registered in the identity store. |
| `ASPasswordCredential` | The actual username + password pair returned to the system when the user selects a credential. |
| `ASPasskeyCredentialRequest` | (iOS 17+) Describes a passkey assertion request, including the relying party and challenge. |
| `ASPasskeyAssertionCredential` | (iOS 17+) The signed WebAuthn assertion response returned to the system for passkey authentication. |
| `ASCredentialServiceIdentifier` | Identifies the service (domain or bundle ID) the user is signing in to. Passed to your extension for filtering. |
| `ASCredentialProviderExtensionContext` | The extension context. Use `completeRequest(withSelectedCredential:)` to return credentials or `cancelRequest(withError:)` to abort. |
## Implementation
```swift
import AuthenticationServices
// 1. Subclass ASCredentialProviderViewController -- the system instantiates this
// when the user interacts with AutoFill and selects your provider.
class CredentialProviderViewController: ASCredentialProviderViewController {
// 2. Called when the user taps a QuickType bar suggestion.
// Provide the credential immediately if the store is unlocked,
// otherwise signal that user interaction is required.
override func provideCredentialWithoutUserInteraction(
for credentialIdentity: ASPasswordCredentialIdentity
) {
guard let store = CredentialStore.shared.unlocked else {
// 3. Store is locked -- tell the system we need to show UI.
self.extensionContext.cancelRequest(
withError: NSError(
domain: ASExtensionErrorDomain,
code: ASExtensionError.userInteractionRequired.rawValue
)
)
return
}
// 4. Look up the credential by its record identifier.
guard let entry = store.findEntry(
byRecordIdentifier: credentialIdentity.recordIdentifier ?? ""
) else {
self.extensionContext.cancelRequest(
withError: NSError(
domain: ASExtensionErrorDomain,
code: ASExtensionError.credentialIdentityNotFound.rawValue
)
)
return
}
// 5. Return the credential to AutoFill.
let credential = ASPasswordCredential(user: entry.username, password: entry.password)
self.extensionContext.completeRequest(
withSelectedCredential: credential,
completionHandler: nil
)
}
// 6. Called when the system presents your full credential list UI.
// The serviceIdentifiers describe the app or website the user is signing in to.
override func prepareCredentialList(
for serviceIdentifiers: [ASCredentialServiceIdentifier]
) {
// 7. Build your credential list UI. Filter and sort credentials
// by matching against the service identifiers.
let matchingCredentials = CredentialStore.shared.credentials(
matching: serviceIdentifiers
)
// 8. Present a table/list view. When the user selects a credential
// (after optional biometric/master password auth), complete the request.
let listVC = CredentialListViewController(
credentials: matchingCredentials,
onSelect: { [weak self] entry in
let credential = ASPasswordCredential(
user: entry.username,
password: entry.password
)
self?.extensionContext.completeRequest(
withSelectedCredential: credential,
completionHandler: nil
)
},
onCancel: { [weak self] in
self?.extensionContext.cancelRequest(
withError: NSError(
domain: ASExtensionErrorDomain,
code: ASExtensionError.userCanceled.rawValue
)
)
}
)
// 9. Push or present your list view controller.
self.addChild(listVC)
self.view.addSubview(listVC.view)
listVC.view.frame = self.view.bounds
listVC.didMove(toParent: self)
}
// 10. Called when provideCredentialWithoutUserInteraction fails with
// userInteractionRequired. Show authentication UI, then provide the credential.
override func prepareInterfaceToProvideCredential(
for credentialIdentity: ASPasswordCredentialIdentity
) {
// Show biometric prompt or master password entry.
// On success, look up the credential and call completeRequest.
}
}
// 11. Populate the identity store so credentials appear in the QuickType bar.
// Call this from your containing app whenever the credential database changes.
func updateCredentialIdentityStore(entries: [CredentialEntry]) {
let identities = entries.map { entry in
ASPasswordCredentialIdentity(
serviceIdentifier: ASCredentialServiceIdentifier(
identifier: entry.domain,
type: .domain
),
user: entry.username,
recordIdentifier: entry.id
)
}
// 12. Replace all identities in the store with the current set.
ASCredentialIdentityStore.shared.replaceCredentialIdentities(
with: identities
) { success, error in
if let error = error {
print("Failed to update identity store: \(error)")
}
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target credentials-provider
```
```json
// targets/credentials-provider/expo-target.config.json
{
"type": "credentials-provider"
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 12.0+ | Password AutoFill. Passkeys in 17.0+. One-time codes in 18.0+. |
| iPadOS | 12.0+ | Same as iOS. |
| macOS | 11.0+ | Supported via AuthenticationServices. Some developers report code-signing entitlement issues on macOS. |
| watchOS | -- | Not supported. |
| visionOS | 1.0+ | Supported for password and passkey AutoFill. |
| tvOS | -- | Not supported. |
## Gotchas
- **AutoFill Credential Provider entitlement is required.** Both the extension and the containing app must include the `com.apple.developer.authentication-services.autofill-credential-provider` entitlement. Without it, the extension will not appear in Settings and AutoFill will not invoke it.
- **User must explicitly enable your provider in Settings.** Your extension is disabled by default. The user must go to Settings > Passwords > Password Options and enable your app. Guide the user to this screen from your containing app, as there is no API to deep-link directly to it.
- **Data sharing requires App Groups or Shared Keychain.** The extension runs in a separate process. Use App Groups (`UserDefaults(suiteName:)` or shared file containers) or Shared Keychain Access Groups to share the credential database between the app and extension.
- **provideCredentialWithoutUserInteraction must be fast.** This method is called inline during the QuickType bar flow. If it takes too long or shows UI, the system cancels the request. Return the credential immediately or throw `userInteractionRequired` to fall back to the interactive flow.
- **Identity store must be kept in sync.** The QuickType bar only shows credentials that have been registered in `ASCredentialIdentityStore`. If the user adds or deletes credentials in your app, you must update the store immediately. Stale identities lead to a broken user experience where tapping a QuickType suggestion fails to find the credential.
- **Passkey support requires Info.plist capabilities (iOS 17+).** To provide passkeys, add `ASCredentialProviderExtensionCapabilities` to your extension's Info.plist with `ProvidesPasskeys` set to `true`. Without this key, passkey requests are never routed to your extension.
- **One-time code support requires additional capability (iOS 18+).** To provide TOTP codes, add `ProvidesOneTimeCodes` to `ASCredentialProviderExtensionCapabilities`. This feature was introduced in iOS 18.
- **No direct biometric API in the extension.** While you can use `LAContext` for Local Authentication within the extension, the UI presentation context can be tricky. Test biometric prompts thoroughly on real devices, as Simulator behavior differs.
- **Extension has limited memory and runtime.** Like all app extensions, credential providers run with constrained resources. Avoid loading the entire credential database into memory. Use efficient queries against your encrypted store.
- **iCloud Keychain is always an option alongside your provider.** Users can enable multiple credential providers simultaneously. Your extension competes with iCloud Keychain and other providers for QuickType bar space. Ensure your identities have accurate service identifiers to rank well.
device-activity-monitor.md
---
title: Device Activity Monitor Extension
description: Runs code in response to device activity schedule intervals starting or ending and usage thresholds being reached, enabling parental controls and digital wellbeing features without a visible UI.
version: iOS 15.0+
---
# Device Activity Monitor Extension (`device-activity-monitor`)
A Device Activity Monitor extension executes code when a `DeviceActivitySchedule` interval starts or ends, or when a monitored app or category reaches a usage threshold. It is one of three extension types in Apple's Screen Time API suite (alongside Shield Action and Shield Configuration) and requires the Family Controls entitlement. The extension runs entirely in the background with no user interface -- it typically responds to events by updating `ManagedSettingsStore` shields, writing to shared storage via App Groups, or triggering notifications. The host app schedules monitoring via `DeviceActivityCenter`, and the system invokes the extension's callbacks even when the app is not running.
## Apple Documentation
- [DeviceActivity Framework Overview](https://developer.apple.com/documentation/deviceactivity)
- [DeviceActivityMonitor](https://developer.apple.com/documentation/deviceactivity/deviceactivitymonitor)
- [DeviceActivityCenter](https://developer.apple.com/documentation/deviceactivity/deviceactivitycenter)
- [DeviceActivitySchedule](https://developer.apple.com/documentation/deviceactivity/deviceactivityschedule)
- [DeviceActivityEvent](https://developer.apple.com/documentation/deviceactivity/deviceactivityevent)
- [FamilyControls Framework](https://developer.apple.com/documentation/familycontrols)
- [ManagedSettings Framework](https://developer.apple.com/documentation/managedsettings)
## WWDC History
- **[WWDC 2021, Session 10123 -- Meet the Screen Time API](https://developer.apple.com/videos/play/wwdc2021/10123/)** -- Introduced the three Screen Time frameworks (FamilyControls, ManagedSettings, DeviceActivity) and the Device Activity Monitor extension for schedule-based and threshold-based callbacks.
- **[WWDC 2022, Session 110336 -- What's New in Screen Time API](https://developer.apple.com/videos/play/wwdc2022/110336/)** -- Added individual (non-child) authorization via `AuthorizationCenter.shared.requestAuthorization(for: .individual)`, enabling digital wellbeing apps for adults. Renamed `ShieldConfigurationProvider` to `ShieldConfigurationDataSource` and `ShieldActionHandler` to `ShieldActionDelegate`.
## What It Does
1. **Host app schedules monitoring.** Your app calls `DeviceActivityCenter().startMonitoring(_:during:events:)` with a named activity, a `DeviceActivitySchedule` defining the time window, and optional `DeviceActivityEvent` entries that specify usage thresholds for specific apps or categories.
2. **System launches the extension.** When the schedule interval begins, the system launches your extension process and calls `intervalDidStart(for:)`.
3. **Threshold callbacks fire.** If a monitored app or category accumulates usage that hits an event threshold, the system calls `eventDidReachThreshold(_:activity:)`.
4. **Interval ends.** When the schedule's end time arrives, the system calls `intervalDidEnd(for:)`.
5. **Warning callbacks (optional).** If the schedule includes a `warningTime`, the system calls `intervalWillStartWarning(for:)` or `intervalWillEndWarning(for:)` shortly before the boundary. Similarly, `eventWillReachThresholdWarning` fires before a threshold is hit.
6. **Extension responds.** The extension typically updates a `ManagedSettingsStore` to apply or remove shields, writes state to App Group shared storage, or posts a local notification.
## Use Cases
### Parental controls
A guardian authorizes the app via `AuthorizationCenter.shared.requestAuthorization(for: .child)`. The app lets the guardian pick apps and time limits using `FamilyActivityPicker`. A schedule is set for school hours, and when the interval starts the extension shields the selected apps via `ManagedSettingsStore`. When the interval ends, the extension clears the shields.
### Digital wellbeing for adults
Using individual authorization (iOS 16+), a user sets their own focus schedule. The extension monitors social media usage and applies a shield when cumulative usage crosses a 30-minute threshold, encouraging the user to take a break.
### Enterprise device management
A corporate MDM companion app schedules work-hour monitoring. During work hours the extension shields entertainment apps. Outside work hours, shields are lifted automatically via `intervalDidEnd`.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `DeviceActivityMonitor` | Base class for the extension's principal class. Override its callback methods to respond to schedule and threshold events. |
| `DeviceActivityCenter` | Used by the host app to start and stop monitoring named activities. |
| `DeviceActivitySchedule` | Defines a repeating time window (start `DateComponents`, end `DateComponents`, optional `warningTime`). |
| `DeviceActivityEvent` | Pairs an `FamilyActivitySelection` (apps/categories) with a usage threshold `DateComponents`. |
| `DeviceActivityName` | A `RawRepresentable<String>` that uniquely identifies a monitored activity. |
| `DeviceActivityEvent.Name` | Identifies a specific threshold event within an activity. |
| `ManagedSettingsStore` | Reads and writes managed settings (shields, restrictions). Used inside the extension to apply or clear shields. |
| `AuthorizationCenter` | Requests Family Controls authorization (`.child` or `.individual`). |
## Implementation
```swift
import DeviceActivity
import ManagedSettings
// 1. Subclass DeviceActivityMonitor. The class name must match the
// NSExtensionPrincipalClass value in Info.plist.
class DeviceActivityMonitorExtension: DeviceActivityMonitor {
let store = ManagedSettingsStore()
// 2. Called when the scheduled interval begins (e.g., "school hours start").
// Apply shields to the apps the guardian selected.
override func intervalDidStart(for activity: DeviceActivityName) {
super.intervalDidStart(for: activity)
let selection = SharedDataStore.loadSelection()
let appTokens = selection.applicationTokens
let categoryTokens = selection.categoryTokens
store.shield.applications = appTokens.isEmpty ? nil : appTokens
store.shield.applicationCategories = categoryTokens.isEmpty
? nil
: .specific(categoryTokens)
}
// 3. Called when the scheduled interval ends. Remove all shields.
override func intervalDidEnd(for activity: DeviceActivityName) {
super.intervalDidEnd(for: activity)
store.shield.applications = nil
store.shield.applicationCategories = nil
store.clearAllSettings()
}
// 4. Called when cumulative usage for a monitored event crosses its threshold.
// For example, "30 minutes of social media" reached.
override func eventDidReachThreshold(
_ event: DeviceActivityEvent.Name,
activity: DeviceActivityName
) {
super.eventDidReachThreshold(event, activity: activity)
// Apply shields to all apps in the selection when the threshold hits.
let selection = SharedDataStore.loadSelection()
store.shield.applications = selection.applicationTokens.isEmpty
? nil
: selection.applicationTokens
}
// 5. Optional: warn the user a few minutes before the interval ends.
override func intervalWillEndWarning(for activity: DeviceActivityName) {
super.intervalWillEndWarning(for: activity)
// Post a local notification, update shared state, etc.
}
// 6. Optional: warn before a threshold is reached.
override func eventWillReachThresholdWarning(
_ event: DeviceActivityEvent.Name,
activity: DeviceActivityName
) {
super.eventWillReachThresholdWarning(event, activity: activity)
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target device-activity-monitor
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 15.0+ | Full support. Individual authorization added in 16.0+. |
| iPadOS | 15.0+ | Same as iOS. |
| macOS | -- | Not supported. Screen Time API is iOS/iPadOS only. |
| watchOS | -- | Not supported. |
| visionOS | -- | Not supported. |
| tvOS | -- | Not supported. |
## Gotchas
- **Hard 6 MB memory limit.** The extension process is capped at approximately 5-6 MB of memory. Exceeding this causes immediate termination (Jetsam crash). Avoid loading images, large data structures, or heavyweight frameworks. Keep work minimal and write results to disk.
- **No UI of any kind.** The extension cannot present views, alerts, or SwiftUI content. It runs headless. If you need to show UI in response to an event, update `ManagedSettingsStore` shields (which the system renders) or write data that a Shield Configuration extension reads.
- **Minimum 15-minute schedule intervals.** The `DeviceActivitySchedule` requires at least 15 minutes between start and end. Shorter intervals are silently ignored. This makes rapid iteration during development tedious.
- **Must test on a physical device.** The iOS Simulator does not track device activity or fire schedule callbacks. You need a real device with a valid Apple ID.
- **`print()` and breakpoints do not work by default.** The extension runs in a separate process. To debug, attach to the extension process explicitly in Xcode (Debug > Attach to Process) and check the Console app for log output.
- **App Group is required for shared data.** The extension and host app run in different sandboxes. Use `UserDefaults(suiteName:)` or a shared App Group container to exchange data such as `FamilyActivitySelection` tokens.
- **Family Controls entitlement requires Apple approval.** You must request the `com.apple.developer.family-controls` entitlement through Apple's developer portal. Without approval, the entitlement works only in development builds and cannot be distributed via TestFlight or the App Store.
- **`intervalDidEnd` is not guaranteed to fire on time.** If the device is locked, in standby, or powered off, the callback fires when the device wakes. Do not rely on exact timing for critical logic.
- **Tokens are opaque and process-bound.** `ApplicationToken` and `ActivityCategoryToken` values from `FamilyActivitySelection` are opaque. They cannot be serialized into human-readable app names. Share them between app and extension via `Codable` in App Group storage.
- **Only one schedule per `DeviceActivityName`.** Calling `startMonitoring` with the same name replaces the previous schedule. Use distinct names if you need concurrent schedules (e.g., `"school-hours"` and `"bedtime"`).
entitlements/app-groups.md
---
title: App Groups Entitlement
description: Share data between your main app, extensions, and App Clips using shared containers and UserDefaults.
version: iOS 8.0+, macOS 10.10+
---
# App Groups Entitlement
App Groups enable multiple application targets within the same developer account to share data via a common container directory and shared UserDefaults. The entitlement grants read/write access to a shared file system location and keychain access group, allowing your main app, extensions (widgets, share extensions, notification service extensions, etc.), and App Clips to exchange files, preferences, databases, and credentials.
## Apple Documentation
- [App Groups Entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_application-groups) -- official entitlement reference covering configuration and usage.
- [Sharing Data with Your Containing App](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionScenarios.html#//apple_ref/doc/uid/TP40014214-CH21-SW6) -- Apple's guide to using App Groups for extension-to-app communication.
- [FileManager.containerURL(forSecurityApplicationGroupIdentifier:)](https://developer.apple.com/documentation/foundation/filemanager/1412643-containerurl) -- returns the shared container directory for a given group identifier.
- [UserDefaults(suiteName:)](https://developer.apple.com/documentation/foundation/userdefaults/1409957-init) -- creates a UserDefaults instance backed by a shared App Group.
- [Keychain Services](https://developer.apple.com/documentation/security/keychain_services) -- with `kSecAttrAccessGroup`, allows keychain items to be shared across targets.
## WWDC History
- **[WWDC 2014, Session 205 -- Creating Extensions for iOS and OS X, Part 1](https://developer.apple.com/videos/play/wwdc2014/205/)** -- introduced app extensions and the App Groups entitlement for sharing data between the extension and containing app.
- **[WWDC 2014, Session 217 -- Creating Extensions for iOS and OS X, Part 2](https://developer.apple.com/videos/play/wwdc2014/217/)** -- covered data flow patterns using App Groups with `NSUserDefaults(suiteName:)` and shared file containers.
- **[WWDC 2015, Session 224 -- App Extension Best Practices](https://developer.apple.com/videos/play/wwdc2015/224/)** -- best practices for managing shared data, file coordination, and avoiding conflicts when multiple processes access the same App Group container.
- **[WWDC 2020, Session 10174 -- Explore App Clips](https://developer.apple.com/videos/play/wwdc2020/10174/)** -- demonstrated using App Groups to migrate data from an App Clip to the full app after installation.
## Extension Storage in @bacons/apple-targets
The `@bacons/apple-targets` plugin uses **Continuous Native Generation (CNG)** to keep extension source code **outside** the generated `ios/` directory. This allows you to version control your Swift files without checking in the entire Xcode project.
**Directory structure:**
```
your-expo-app/
targets/ # Extension targets live here
widgets/ # Widget extension
expo-target.config.js # Configuration (type, entitlements, frameworks)
WidgetsBundle.swift # Your Swift code
widgets.entitlements # Generated entitlements file
share/ # Share extension
expo-target.config.js
ShareViewController.swift
share.entitlements
ios/ # Generated by expo prebuild --clean
YourApp.xcodeproj # Xcode project with linked targets
YourApp/ # Main app
YourApp.entitlements
app.json # Expo config with main app entitlements
```
**Key points:**
- **Extension source files** live in `targets/<extension-name>/` and are version-controlled (never in `ios/`).
- **Xcode project** links to files in `targets/` directories — Swift code changes are automatically synchronized without needing to re-run prebuild.
- **Entitlements** are defined in `expo-target.config.js` and synced to `.entitlements` files when you run prebuild after config changes.
- **App Groups** are automatically synced from the main app (`app.json`) to extensions that have `appGroupsByDefault: true`.
- **Never edit files in `ios/`** — the directory is regenerated on prebuild. All customization happens in `app.json` and `targets/`.
**How it works:**
1. You create extensions with `bunx create-target <type>`, which scaffolds `targets/<name>/`.
2. You write Swift code and configure entitlements in `expo-target.config.js`.
3. You run `npx expo prebuild --clean`, which generates the Xcode project and links your targets.
4. The plugin reads your `app.json` entitlements and syncs them to extensions that need them.
5. EAS Build (or `npx expo run:ios`) uses the generated project and entitlements for signing.
## What It Does
1. **Developer configures the entitlement in app.json.** You add `com.apple.security.application-groups` to `expo.ios.entitlements` with group identifiers (e.g., `["group.com.example.myapp"]`).
2. **Prebuild generates entitlements files.** Running `npx expo prebuild` creates `.entitlements` XML files for the main app and each extension in the `ios/` and `targets/` directories.
3. **System provisions the entitlement.** When EAS Build (or local build) signs the app, the provisioning profile validates that your developer account owns the App Group identifiers and grants access to the shared containers.
4. **Shared container is created.** On first launch, the system creates a directory at `~/Library/Group Containers/<group-id>/` (iOS/macOS) accessible to all targets sharing that group.
5. **Apps and extensions read/write.** Any target with the entitlement can use `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)` to locate the directory and `UserDefaults(suiteName:)` to access shared preferences.
6. **Data persists across uninstall.** Unlike the main app's container, the App Group container is **not** deleted when the main app is uninstalled if another app on the device still uses the same group. When the last app using the group is removed, the system deletes the container.
7. **Keychain sharing.** Using `kSecAttrAccessGroup` in keychain queries, you can store credentials (passwords, tokens) in a shared keychain access group matching the App Group identifier, accessible from all targets.
## Standard Naming Format
Apple requires App Group identifiers to start with `group.` followed by a reverse-DNS-style identifier matching your team's conventions:
```
group.<bundle-identifier-prefix>
```
### Examples
| App Bundle ID | Recommended App Group ID | Notes |
|---------------|-------------------------|-------|
| `com.example.myapp` | `group.com.example.myapp` | Simplest convention: same as bundle ID with `group.` prefix |
| `com.example.myapp` | `group.com.example` | Shared across multiple apps from the same developer |
| `com.acme.fitness` | `group.com.acme.fitness.shared` | Explicitly named for multi-target sharing |
| `com.company.prod.ios` | `group.com.company.prod` | Production environment-specific group |
**Best practice:** Use `group.<your-bundle-id>` for single-app projects. For multi-app suites (e.g., a family of fitness apps), use a shared prefix like `group.com.company` so all apps can access the same data.
## Common Use Cases
### Widget Extension Sharing Data with Main App
A weather app stores the current forecast in a shared UserDefaults so the widget can display it without making a separate network request. When the app fetches new data, it writes to the App Group and calls `WidgetCenter.shared.reloadTimelines(ofKind:)` to trigger a widget refresh.
### Share Extension Saving Content
A read-later app uses a share extension to receive URLs from Safari. The extension writes the URL and page title to a JSON file in the shared container, then enqueues a background upload task. The main app reads the file on next launch and imports the saved articles.
### Notification Service Extension Decrypting Messages
A messaging app encrypts push notification payloads end-to-end. The encryption keys are stored in the shared keychain using `kSecAttrAccessGroup`. The notification service extension retrieves the key from the keychain, decrypts the payload, and replaces the notification content before display.
### App Clip Migrating User Data
A restaurant ordering App Clip saves the user's favorite items and authentication token to the shared App Group container. When the user installs the full app, the app reads the container on first launch and imports the saved data, providing a seamless transition.
## How to Use App Groups
### 1. Configure the Entitlement in Expo Config
Add the App Groups entitlement to your Expo app configuration. This applies to the main app target.
```json
{
"expo": {
"ios": {
"bundleIdentifier": "com.example.myapp",
"entitlements": {
"com.apple.security.application-groups": [
"group.com.example.myapp"
]
}
},
"plugins": [
["@bacons/apple-targets"]
]
}
}
```
### 2. Automatic Syncing for Extensions (Default Behavior)
For extension types that commonly need App Groups (`widget`, `share`, `clip`, `bg-download`), the `@bacons/apple-targets` plugin **automatically syncs** the App Group identifiers from the main app to the extension **unless you override them manually**.
**Automatic syncing applies to these types:**
- `widget` (WidgetKit extensions)
- `share` (Share extensions)
- `clip` (App Clips)
- `bg-download` (Background Download extensions)
The plugin checks the `appGroupsByDefault` flag in the target registry (see `packages/apple-targets/src/target.ts`). If `true` and you haven't defined `entitlements["com.apple.security.application-groups"]` in your `expo-target.config.js`, the extension inherits the main app's groups.
**Example console output:**
```
[widgets] Syncing app groups with main app. Define entitlements["com.apple.security.application-groups"] in the expo-target.config file to override.
```
### 3. Create Extensions with bunx create-target
Use the `create-target` CLI to scaffold a new extension in the `targets/` directory:
```sh
bunx create-target widget
```
This creates:
```
targets/widgets/
expo-target.config.js # Configuration
WidgetsBundle.swift # Template Swift code
```
The `expo-target.config.js` defines the extension configuration:
```js
// targets/widgets/expo-target.config.js
module.exports = {
type: "widget", // Extension type (required)
name: "Widgets", // Display name for the target
bundleIdentifier: "...", // Optional: override default bundle ID
deploymentTarget: "16.0", // Optional: minimum iOS version
frameworks: ["WidgetKit"], // Optional: additional frameworks to link
entitlements: { // Optional: custom entitlements
// If omitted, App Groups auto-sync from main app for widget/share/clip/bg-download
}
};
```
**For extensions with `appGroupsByDefault: true` (widget, share, clip, bg-download):**
- Omit `entitlements["com.apple.security.application-groups"]` → auto-syncs from `app.json`
- Define it → overrides the main app's groups
### 4. Manual Override in Extension Config (Optional)
To use a **different** App Group for a specific extension (or to add App Groups to an extension type that doesn't sync by default), define the entitlement in `expo-target.config.js`:
```js
// targets/widgets/expo-target.config.js
module.exports = {
type: "widget",
name: "Widgets",
entitlements: {
"com.apple.security.application-groups": [
"group.com.example.widgets-only" // Override: different group
]
}
};
```
This overrides the automatic sync behavior for that specific extension target.
### 5. Run expo prebuild
Generate the native Xcode project with extension targets linked:
```sh
npx expo prebuild --clean
```
This:
- Creates `ios/YourApp.xcodeproj` with the main app target
- Links each extension from `targets/*/` as a separate Xcode target
- Generates `.entitlements` files with App Groups synced
- Updates `extra.eas.build.experimental.ios.appExtensions` for EAS Build
### 6. Access Shared UserDefaults
Use `UserDefaults(suiteName:)` to read/write preferences shared between the app and extension:
```swift
import Foundation
// In both your main app and extension:
let sharedDefaults = UserDefaults(suiteName: "group.com.example.myapp")
// Write from the main app
sharedDefaults?.set("Sample Value", forKey: "shared_key")
sharedDefaults?.synchronize() // Not required on modern iOS, but harmless
// Read from the widget extension
if let value = sharedDefaults?.string(forKey: "shared_key") {
print("Shared value: \(value)")
}
```
**Important:** The suite name **must exactly match** one of the App Group identifiers in your entitlements. Case-sensitive.
### 7. Access Shared File Container
Use `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)` to get the shared directory:
```swift
import Foundation
guard let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.example.myapp"
) else {
fatalError("App Group container not accessible. Check entitlements.")
}
let sharedFileURL = containerURL.appendingPathComponent("data.json")
// Write a file from the main app
let data = try JSONEncoder().encode(myModel)
try data.write(to: sharedFileURL, options: .atomic)
// Read the file from the extension
let readData = try Data(contentsOf: sharedFileURL)
let decodedModel = try JSONDecoder().decode(MyModel.self, from: readData)
```
**Directory structure:**
```
~/Library/Group Containers/group.com.example.myapp/
Library/
Preferences/
group.com.example.myapp.plist (UserDefaults suite)
Caches/
Application Support/
Documents/
tmp/
```
### 8. Share Keychain Items
To share passwords, tokens, or certificates between targets, use the `kSecAttrAccessGroup` attribute:
```swift
import Security
let accessGroup = "group.com.example.myapp"
// Save a keychain item (from the main app)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "user@example.com",
kSecAttrAccessGroup as String: accessGroup,
kSecValueData as String: "my-secret-token".data(using: .utf8)!
]
let status = SecItemAdd(query as CFDictionary, nil)
// Retrieve from the extension
let searchQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "user@example.com",
kSecAttrAccessGroup as String: accessGroup,
kSecReturnData as String: true
]
var result: AnyObject?
let searchStatus = SecItemCopyMatching(searchQuery as CFDictionary, &result)
if searchStatus == errSecSuccess,
let data = result as? Data,
let token = String(data: data, encoding: .utf8) {
print("Retrieved token: \(token)")
}
```
**Note:** On macOS, keychain access groups require a slightly different format: `<TeamID>.<group-id>`. On iOS, use the App Group identifier directly.
## EAS Build and Automatic Code Signing
When using EAS Build, the `@bacons/apple-targets` plugin automatically exports each extension's bundle identifier and entitlements to the `extra.eas.build.experimental.ios.appExtensions` array in the Expo config. EAS reads this metadata and provisions the necessary certificates, provisioning profiles, and App Group identifiers **without manual configuration**.
### How It Works
1. **Plugin scans the Xcode project** after `expo prebuild` and extracts all extension targets, their bundle identifiers, and entitlements (including App Groups).
2. **Config is updated** with the `withAutoEasExtensionCredentials` config plugin, which adds an entry like:
```json
{
"extra": {
"eas": {
"build": {
"experimental": {
"ios": {
"appExtensions": [
{
"targetName": "widgets",
"bundleIdentifier": "com.example.myapp.widgets",
"entitlements": {
"com.apple.security.application-groups": [
"group.com.example.myapp"
]
}
}
]
}
}
}
}
}
}
```
3. **EAS Build provisions App Groups.** When you run `eas build`, the build service:
- Registers the App Group identifier (`group.com.example.myapp`) in your Apple Developer account if it doesn't already exist.
- Creates or updates provisioning profiles for the main app and each extension, including the App Group entitlement.
- Signs each target with the correct profile.
4. **No manual Apple Developer Portal work required.** You do not need to manually create App Group identifiers, add them to App IDs, or regenerate provisioning profiles. EAS handles everything automatically based on the entitlements extracted during `expo prebuild`.
The plugin automatically populates `extra.eas.build.experimental.ios.appExtensions` in your Expo config, so EAS Build knows exactly which entitlements each extension target needs.
## Verifying App Groups are Configured
After running `npx expo prebuild --clean`, check the generated entitlements files:
```sh
# Main app entitlements
cat ios/YourApp/YourApp.entitlements
# Extension entitlements
cat targets/widgets/widgets.entitlements
```
Both should contain:
```xml
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.example.myapp</string>
</array>
```
You can also inspect the EAS metadata:
```sh
npx expo config --type introspect | jq '.extra.eas.build.experimental.ios.appExtensions'
```
## Debugging App Groups
### Common Issues
**"App Group container returns nil"**
- The entitlement is missing from the target's `.entitlements` file (verify in `targets/<name>/<name>.entitlements`).
- The provisioning profile does not include the App Group entitlement (run `npx expo prebuild --clean` and rebuild with EAS).
- The App Group identifier in code does not match the one in the entitlements (case-sensitive).
**"UserDefaults writes from the app are not visible in the extension"**
- Both targets must use the **exact same** suite name.
- Call `.synchronize()` after writing (though not strictly required on iOS 14+, it forces an immediate flush).
- The extension process may be cached; kill the extension process using Xcode's Debug > Attach to Process by PID or Name, or restart the device.
**"Shared file writes from one target cause crashes in another"**
- Use `NSFileCoordinator` for concurrent file access when multiple processes might read/write the same file simultaneously.
- Prefer SQLite with WAL mode (`PRAGMA journal_mode=WAL;`) for databases accessed by multiple processes.
**"Data is not migrating from App Clip to full app"**
- Verify the App Clip and full app have identical App Group identifiers in their entitlements.
- The App Clip's bundle ID must start with the full app's bundle ID (e.g., `com.example.myapp.clip` is a child of `com.example.myapp`).
### Logging and Inspection
**Print the container path at runtime:**
```swift
if let url = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.example.myapp"
) {
print("App Group container: \(url.path)")
} else {
print("App Group container is nil! Check entitlements.")
}
```
**On device (iOS Simulator or macOS):**
Navigate to the logged path in Terminal:
```sh
open ~/Library/Developer/CoreSimulator/Devices/<UUID>/data/Containers/Shared/AppGroup/<UUID>/
```
**Inspect UserDefaults plist:**
```sh
plutil -p ~/Library/Group\ Containers/group.com.example.myapp/Library/Preferences/group.com.example.myapp.plist
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 8.0+ | Full support for app extensions and App Clips. |
| iPadOS | 8.0+ | Same as iOS. |
| macOS | 10.10+ | Supported. Keychain access groups require `<TeamID>.<group-id>` format. |
| watchOS | 2.0+ | Supported. Use for Watch Connectivity data sync or standalone watch apps. |
| tvOS | 9.0+ | Supported (rare use case -- extensions are limited on tvOS). |
| visionOS | 1.0+ | Supported. |
## Gotchas
- **App Group IDs are global to your team.** Once you register `group.com.example.myapp` in the Apple Developer Portal under your team, you cannot delete it. You can disable it on specific App IDs, but the identifier remains reserved. Choose carefully.
- **Case sensitivity matters.** `group.com.example.MyApp` and `group.com.example.myapp` are **different** identifiers. Always use lowercase to avoid mismatches.
- **The `group.` prefix is mandatory.** Attempting to use `com.example.myapp` without the `group.` prefix will cause entitlement validation to fail during code signing.
- **UserDefaults suite name must match exactly.** `UserDefaults(suiteName: "group.com.example.myapp")` only works if `"group.com.example.myapp"` is in the entitlements array. Typographical errors silently create a separate, unshared defaults store.
- **File protection levels affect background access.** Files created in the App Group container inherit the default protection level (`NSFileProtectionCompleteUntilFirstUserAuthentication`). Background extensions (notification service, widget timeline providers) run while the device is locked, so files with `NSFileProtectionComplete` are inaccessible. Use `.completeUntilFirstUserAuthentication` or `.noFileProtection` for shared data accessed from background contexts.
- **Data is NOT automatically migrated.** Adding an App Group entitlement to an existing app does not move data from the app's private container to the shared container. You must write migration code on first launch that copies files from the old location to the new one.
- **Multiple apps can share the same group.** If you publish multiple apps under the same developer account, they can all use `group.com.example.shared` to exchange data. This is intentional but can create privacy issues if sensitive data is exposed to unintended apps. Scope your group identifiers to prevent accidental sharing.
- **App Group containers are not deleted when one app is removed.** If you have two apps using the same group and the user uninstalls one, the container remains. This can lead to unexpectedly large storage usage. The container is only deleted when **all** apps using it are uninstalled.
- **Core Data shared stores require NSPersistentContainer configuration.** When using Core Data across multiple processes, initialize `NSPersistentContainer` with a custom store URL pointing to the App Group container, and enable history tracking (`NSPersistentHistoryTrackingKey`) to merge changes between processes.
- **Simulator vs. device paths differ.** On the simulator, the App Group container is at `~/Library/Developer/CoreSimulator/...`. On a physical device, it is in a sandboxed location invisible from macOS Finder. Use Xcode's Devices and Simulators window > Download Container to inspect device data.
- **TestFlight and App Store builds use different provisioning profiles.** If your development build works but TestFlight does not, ensure you've run `npx expo prebuild --clean` before the EAS build so the entitlements metadata is up-to-date. EAS will provision the correct profiles based on the `appExtensions` array.
- **Entitlements mismatch between targets breaks EAS Build.** If your main app has `["group.A", "group.B"]` but your widget only has `["group.A"]`, EAS provisions both groups for the main app profile and only `group.A` for the widget. This is correct behavior, but if you later add `group.B` to the widget's `expo-target.config.js`, you must run `npx expo prebuild --clean` before building with EAS to re-sync the metadata.
- **Version control `targets/`, ignore `ios/`.** The `targets/` directory contains your source code and should be committed to git. The `ios/` directory is generated by `expo prebuild` and should be in `.gitignore`. Never manually edit files in `ios/` — they will be overwritten when prebuild runs. Swift files in `targets/` are linked into the Xcode project, so code changes don't require re-running prebuild.
- **Generated `.entitlements` files live in `targets/`.** After running `npx expo prebuild`, each extension will have a `.entitlements` file in its `targets/<name>/` directory. These are generated from your `expo-target.config.js` and should **not** be manually edited (they will be regenerated the next time you run prebuild after config changes).
## Example: Full Integration
**app.json:**
```json
{
"expo": {
"slug": "myapp",
"ios": {
"bundleIdentifier": "com.example.myapp",
"entitlements": {
"com.apple.security.application-groups": [
"group.com.example.myapp"
]
}
},
"plugins": [
["@bacons/apple-targets"]
]
}
}
```
**targets/widgets/expo-target.config.js:**
```js
module.exports = {
type: "widget",
name: "Widgets"
// App Groups will be auto-synced from the main app
};
```
**Main app writes data:**
```swift
// ContentView.swift (main app)
import SwiftUI
struct ContentView: View {
var body: some View {
Button("Save Data") {
let sharedDefaults = UserDefaults(suiteName: "group.com.example.myapp")
sharedDefaults?.set(Date().timeIntervalSince1970, forKey: "last_update")
sharedDefaults?.set("Hello from main app!", forKey: "message")
// Trigger widget reload
#if canImport(WidgetKit)
import WidgetKit
WidgetCenter.shared.reloadAllTimelines()
#endif
}
}
}
```
**Widget reads data:**
```swift
// WidgetBundle.swift (widget extension)
import WidgetKit
import SwiftUI
struct SimpleEntry: TimelineEntry {
let date: Date
let message: String
}
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: .now, message: "Placeholder")
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
completion(placeholder(in: context))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> ()) {
let sharedDefaults = UserDefaults(suiteName: "group.com.example.myapp")
let message = sharedDefaults?.string(forKey: "message") ?? "No data"
let entry = SimpleEntry(date: .now, message: message)
let timeline = Timeline(entries: [entry], policy: .never)
completion(timeline)
}
}
struct WidgetEntryView: View {
var entry: SimpleEntry
var body: some View {
Text(entry.message)
.containerBackground(.fill.tertiary, for: .widget)
}
}
@main
struct MyWidget: Widget {
let kind = "MyWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
WidgetEntryView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("Shows data from the main app")
.supportedFamilies([.systemSmall])
}
}
```
**Setup and build:**
```sh
# 1. Create the widget extension
bunx create-target widget
# 2. Add the Swift code above to targets/widgets/WidgetsBundle.swift
# 3. Generate the Xcode project with extension linked
npx expo prebuild --clean
# 4. Build and run
npx expo run:ios
# 5. In the app, tap "Save Data", then add the widget to the home screen
```
The widget displays "Hello from main app!" read from the shared UserDefaults. Both targets access the same App Group container because the plugin automatically synced the entitlements from `app.json`.
## Additional Resources
- **Official Apple Sample Code:** [Today Extension (Legacy)](https://developer.apple.com/library/archive/samplecode/Today/Introduction/Intro.html) -- demonstrates sharing `NSUserDefaults` via App Groups.
- **WWDC 2020 Lab Notes:** [App Clips and Data Sharing](https://developer.apple.com/videos/play/wwdc2020/10120/) -- covers App Group best practices for App Clip-to-app migration.
- **Expo Documentation:** [iOS Entitlements](https://docs.expo.dev/versions/latest/config/app/#entitlements) -- how to configure entitlements in `app.json`.
- **EAS Build Documentation:** [App Extensions and EAS Build](https://docs.expo.dev/build-reference/app-extensions/) -- experimental support for app extensions and automatic credential provisioning.
entitlements/README.md
# Entitlements & Capabilities
This directory contains skill documents for iOS entitlements and capabilities that apply across multiple extension types.
Unlike the extension-specific skills in the parent directory (which focus on a single target type like `widget` or `share`), these guides cover cross-cutting entitlements and frameworks that are used by many different extensions.
## Available Guides
### [app-groups.md](app-groups.md)
Comprehensive guide to the App Groups entitlement (`com.apple.security.application-groups`), which enables data sharing between your main app, extensions, and App Clips via shared containers and UserDefaults.
**Read this when:**
- Setting up widgets that need to display data from the main app
- Building share extensions that save content for the main app to process
- Creating notification service extensions that decrypt messages using shared keychain items
- Implementing App Clips that migrate user data to the full app after installation
- Debugging "container URL returns nil" or "UserDefaults not syncing" issues
- Understanding how EAS Build automatically provisions App Group identifiers
**Topics covered:**
- Standard naming conventions (`group.<bundle-id>`)
- Using `UserDefaults(suiteName:)` for shared preferences
- Accessing shared file containers with `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)`
- Sharing Keychain items with `kSecAttrAccessGroup`
- Automatic syncing for extensions with `appGroupsByDefault: true`
- EAS Build automatic code signing and provisioning
- Common gotchas and debugging techniques
## Future Guides
Additional entitlements that may be documented in this directory:
- **Keychain Access Groups** — Sharing credentials across apps and extensions
- **Associated Domains** — Universal Links, App Clips, handoff, and web credentials
- **Push Notifications** — APNs entitlement and notification capabilities
- **HealthKit** — Accessing health and fitness data from extensions
- **iCloud** — CloudKit, Key-Value Storage, and Documents syncing
- **Network Extensions** — VPN and content filtering entitlements
- **Siri** — SiriKit intents and Shortcuts capabilities
- **Background Modes** — Background fetch, remote notifications, location updates
## Contributing
When adding a new entitlements guide:
1. Use the extension skill template structure (frontmatter, Apple docs, WWDC history, implementation examples)
2. Focus on practical usage across multiple extension types rather than a single API
3. Include EAS-specific guidance for automatic provisioning where applicable
4. Add debugging sections for common entitlement validation failures
5. Update this README and the main `SKILL.md` with a reference to the new guide
file-provider-ui.md
---
title: File Provider UI Extension
description: Presents custom user interface for file provider actions and authentication errors within the Files app.
version: iOS 11.0+
---
# File Provider UI Extension (`file-provider-ui`)
A File Provider UI extension displays custom view controllers inside the Files app when a user triggers a provider-specific action or when the file provider reports an authentication error. It is a companion to the File Provider extension (`file-provider`), which handles file operations but has no UI. The UI extension subclasses `FPUIActionExtensionViewController` from the FileProviderUI framework, and the system presents it modally. Typical uses include sign-in flows, two-factor authentication prompts, server-specific sharing dialogs, and conflict resolution interfaces.
## Apple Documentation
- [FileProviderUI Framework](https://developer.apple.com/documentation/fileproviderui)
- [FPUIActionExtensionViewController](https://developer.apple.com/documentation/fileproviderui/fpuiactionextensionviewcontroller)
- [FPUIActionExtensionContext](https://developer.apple.com/documentation/fileproviderui/fpuiactionextensioncontext)
- [FPUIExtensionErrorCode](https://developer.apple.com/documentation/fileproviderui/fpuiextensionerrorcode)
- [NSFileProviderItem](https://developer.apple.com/documentation/fileprovider/nsfileprovideritem)
## WWDC History
- **[WWDC 2017, Session 243 -- File Provider Enhancements](https://developer.apple.com/videos/play/wwdc2017/243/)** -- Introduced the File Provider UI extension alongside the non-UI file provider. Covered how to declare custom actions in Info.plist and present authentication flows.
- **[WWDC 2021, Session 10182 -- Sync Files to the Cloud with FileProvider on macOS](https://developer.apple.com/videos/play/wwdc2021/10182/)** -- Updated guidance on pairing the UI extension with the replicated file provider model for authentication and error handling.
## What It Does
1. **Custom actions.** You declare named actions in your file provider's Info.plist under `NSExtensionFileProviderActions`. Each action has an identifier and a localized title. When the user selects items and picks your action from the context menu, the system launches your UI extension and calls `prepare(forAction:itemIdentifiers:)`.
2. **Authentication errors.** When your file provider extension throws an `NSFileProviderError` with code `.notAuthenticated`, the Files app shows a system "Sign In" button. Tapping it launches your UI extension and calls `prepare(forError:)`, giving you the opportunity to present a login screen.
3. **Completion.** When the user finishes the action (or cancels), you call `extensionContext.completeRequest()` or `extensionContext.cancelRequest(withError:)`. The system dismisses the view controller and, if needed, re-enumerates the affected items.
## Use Cases
### Sign-in and re-authentication
When a user's OAuth token expires, the file provider throws `.notAuthenticated`. The Files app presents a sign-in prompt, and your UI extension shows a web view or native login form. On success, you store the new token in the shared App Group keychain and call `completeRequest()`.
### Two-factor authentication
Some enterprise storage requires a second factor when accessing sensitive folders. The file provider throws a custom authentication error, and the UI extension presents a code-entry screen. Once verified, it writes the session to shared storage and completes.
### Server-specific sharing
A cloud provider offers advanced sharing options (password-protected links, expiration dates, permissions). The UI extension presents a sharing configuration sheet when the user invokes the custom "Share Link" action on selected files.
### Conflict resolution
When the file provider detects a version conflict during sync, the UI extension can present a side-by-side diff or a "keep local / keep remote / keep both" dialog, then signal which resolution path to take.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `FPUIActionExtensionViewController` | Subclass this. Your principal class for the extension. Receives action and error preparation callbacks. |
| `FPUIActionExtensionContext` | The extension context. Call `completeRequest()` to finish or `cancelRequest(withError:)` to abort. Accessed via `extensionContext`. |
| `FPUIExtensionErrorCode` | Error codes for cancellation (`.userCancelled`) and failure (`.failed`). |
| `NSFileProviderItemIdentifier` | Identifiers of the items the user selected when invoking the action. Passed to `prepare(forAction:itemIdentifiers:)`. |
## Implementation
### Authentication and Custom Action Handler
```swift
import UIKit
import FileProviderUI
import FileProvider
// 1. Subclass FPUIActionExtensionViewController. This is the principal class
// declared in your extension's Info.plist.
class FileProviderUIController: FPUIActionExtensionViewController {
private var currentAction: String?
private var selectedItems: [NSFileProviderItemIdentifier] = []
// 2. Called when the user triggers a custom action declared in Info.plist.
// The actionIdentifier matches your NSExtensionFileProviderActions entry.
override func prepare(forAction actionIdentifier: String,
itemIdentifiers: [NSFileProviderItemIdentifier]) {
currentAction = actionIdentifier
selectedItems = itemIdentifiers
switch actionIdentifier {
case "com.example.myapp.share-link":
// 3. Present a sharing configuration UI for the selected items.
let shareVC = ShareLinkViewController(itemIdentifiers: itemIdentifiers)
shareVC.onComplete = { [weak self] in
self?.extensionContext.completeRequest()
}
addChild(shareVC)
view.addSubview(shareVC.view)
shareVC.view.frame = view.bounds
shareVC.didMove(toParent: self)
case "com.example.myapp.move-to-team":
// 4. Handle a "Move to Team Folder" action.
let moveVC = MoveToTeamViewController(itemIdentifiers: itemIdentifiers)
moveVC.onComplete = { [weak self] in
self?.extensionContext.completeRequest()
}
moveVC.onCancel = { [weak self] in
self?.cancelWithUserCancelled()
}
present(moveVC, animated: true)
default:
// 5. Unknown action -- cancel gracefully.
cancelWithUserCancelled()
}
}
// 6. Called when the file provider throws a .notAuthenticated error.
// Present your sign-in flow here.
override func prepare(forError error: Error) {
let loginVC = LoginViewController()
loginVC.onAuthenticated = { [weak self] token in
// 7. Store the new token in the shared App Group keychain
// so the file provider extension can read it.
SharedKeychain.store(token: token)
self?.extensionContext.completeRequest()
}
loginVC.onCancel = { [weak self] in
self?.cancelWithUserCancelled()
}
present(loginVC, animated: true)
}
// 8. Helper to cancel with the standard user-cancelled error code.
private func cancelWithUserCancelled() {
extensionContext.cancelRequest(
withError: NSError(
domain: FPUIErrorDomain,
code: Int(FPUIExtensionErrorCode.userCancelled.rawValue)
)
)
}
// 9. Wire up navigation bar buttons for simple cases.
@IBAction func doneButtonTapped(_ sender: Any) {
extensionContext.completeRequest()
}
@IBAction func cancelButtonTapped(_ sender: Any) {
cancelWithUserCancelled()
}
}
```
### Info.plist Action Declaration
Custom actions must be declared in the **file provider extension's** (not the UI extension's) Info.plist. The system reads these to build context menu entries:
```xml
<!-- Inside the file provider extension's Info.plist -->
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.fileprovider-nonui</string>
<key>NSExtensionFileProviderActions</key>
<array>
<dict>
<!-- 10. Each action needs an identifier, title, and the
bundle ID of the UI extension that handles it. -->
<key>NSExtensionFileProviderActionIdentifier</key>
<string>com.example.myapp.share-link</string>
<key>NSExtensionFileProviderActionName</key>
<string>Create Share Link</string>
<key>NSExtensionFileProviderActionActivationRule</key>
<string>TRUEPREDICATE</string>
</dict>
</array>
</dict>
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target file-provider-ui
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 11.0+ | Presented modally inside the Files app. |
| iPadOS | 11.0+ | Full support. Displayed as a sheet in the Files app. |
| macOS | 11.0+ | Presented as a sheet in Finder when a File Provider action is invoked. |
| visionOS | 1.0+ | Supported alongside the Files app. |
| tvOS | -- | Not supported. No Files app on tvOS. |
| watchOS | -- | Not supported. |
## Gotchas
- **This extension only provides UI -- it cannot perform file operations.** All file manipulation (download, upload, delete) must happen in the companion `file-provider` extension. The UI extension's sole job is to collect user input and communicate it back via shared storage (App Group UserDefaults, keychain, or a database).
- **Actions are declared in the file provider's Info.plist, not the UI extension's.** A common mistake is adding `NSExtensionFileProviderActions` to the wrong target. The system reads action declarations from the non-UI file provider extension and routes them to the UI extension by convention (same app bundle).
- **`prepare(forError:)` is only called for `.notAuthenticated` errors.** Other file provider errors are handled by the system's default UI. You cannot present custom UI for arbitrary errors -- only authentication failures trigger the UI extension.
- **The extension context must be completed or cancelled.** If you forget to call `completeRequest()` or `cancelRequest(withError:)`, the extension hangs indefinitely and the Files app becomes unresponsive for that provider. Always call one of these in every code path, including error handlers.
- **No access to the file provider's database directly.** The UI extension runs in a separate process. Use a shared App Group container or keychain to pass authentication tokens and action results between the UI extension and the file provider extension.
- **View controller lifecycle is standard UIKit.** Despite running inside Files, the extension view controller follows normal UIKit lifecycle rules. You can use Auto Layout, child view controllers, and navigation controllers. However, the view size is constrained by the modal presentation style the system chooses.
- **Pair with a file-provider target.** This extension type is useless without a companion `file-provider` extension in the same app. Always create both targets together.
file-provider.md
---
title: File Provider Extension
description: Integrates a cloud storage backend into the system Files app, enabling transparent file browsing, downloading, uploading, and syncing via NSFileProviderReplicatedExtension.
version: iOS 11.0+ (legacy), iOS 16.0+ (replicated)
---
# File Provider Extension (`file-provider`)
A File Provider extension surfaces your cloud storage service as a first-class location inside the Files app (iOS) and Finder (macOS). Users can browse, open, move, rename, and delete remote files as if they were local. The modern replicated extension model (iOS 16+/macOS 12.3+) uses `NSFileProviderReplicatedExtension` to maintain a bidirectional sync between a local replica and your remote server. The system manages the on-disk cache, evicts unused files, and calls your extension to materialize content on demand. This is one of the most complex extension types -- production implementations (Dropbox, Google Drive, OneDrive) typically require thousands of lines of sync logic, conflict resolution, and database management.
## Apple Documentation
- [File Provider Framework](https://developer.apple.com/documentation/fileprovider)
- [Replicated File Provider Extension](https://developer.apple.com/documentation/fileprovider/replicated-file-provider-extension)
- [NSFileProviderReplicatedExtension](https://developer.apple.com/documentation/fileprovider/nsfileproviderreplicatedextension)
- [NSFileProviderItem](https://developer.apple.com/documentation/fileprovider/nsfileprovideritem)
- [NSFileProviderEnumerator](https://developer.apple.com/documentation/fileprovider/nsfileproviderenumerator)
- [NSFileProviderManager](https://developer.apple.com/documentation/fileprovider/nsfileprovidermanager)
- [NSFileProviderDomain](https://developer.apple.com/documentation/fileprovider/nsfileproviderdomain)
- [item(for:request:completionHandler:)](https://developer.apple.com/documentation/fileprovider/nsfileproviderreplicatedextension/item(for:request:completionhandler:))
- [fetchContents(for:version:request:completionHandler:)](https://developer.apple.com/documentation/fileprovider/nsfileproviderreplicatedextension/3553303-fetchcontentsforitemwithidentifi)
- [createItem(basedOn:fields:contents:options:request:completionHandler:)](https://developer.apple.com/documentation/fileprovider/nsfileproviderreplicatedextension/createitem(basedon:fields:contents:options:request:completionhandler:))
- [deleteItem(identifier:baseVersion:options:request:completionHandler:)](https://developer.apple.com/documentation/fileprovider/nsfileproviderreplicatedextension/3656550-deleteitemwithidentifier)
## WWDC History
- **[WWDC 2017, Session 243 -- File Provider Enhancements](https://developer.apple.com/videos/play/wwdc2017/243/)** -- Introduced the File Provider framework alongside the Files app in iOS 11. Covered enumeration, thumbnails, and push-based change signaling.
- **[WWDC 2021, Session 10182 -- Sync Files to the Cloud with FileProvider on macOS](https://developer.apple.com/videos/play/wwdc2021/10182/)** -- Major rewrite: introduced `NSFileProviderReplicatedExtension` for macOS with bidirectional sync, icon decorations, Finder integration, and published sample code.
- **[WWDC 2022, Session 10045 -- What's New in File Provider](https://developer.apple.com/videos/play/wwdc2022/10045/)** -- Brought the replicated extension model to iOS 16. Covered progress reporting, eviction policies, and materialization.
## What It Does
1. **Domain registration.** Your main app registers an `NSFileProviderDomain` with `NSFileProviderManager.add(_:completionHandler:)`. This creates a visible location in the Files app sidebar.
2. **Enumeration.** When the user browses a folder, the system asks your extension for an `NSFileProviderEnumerator`. Your enumerator returns pages of `NSFileProviderItem` objects describing files and folders.
3. **Materialization (download).** When the user opens a file, the system calls `fetchContents(for:version:request:completionHandler:)`. Your extension downloads the file from your server and returns a local URL.
4. **Upload.** When the user creates or modifies a file, the system calls `createItem(basedOn:fields:contents:options:request:completionHandler:)` or `modifyItem(...)`. Your extension uploads the content to your server.
5. **Deletion.** The system calls `deleteItem(identifier:baseVersion:options:request:completionHandler:)` when a file is trashed or permanently removed.
6. **Change signaling.** Your app detects remote changes (via polling, push notifications, or websockets) and calls `NSFileProviderManager.signalEnumerator(for:)` to tell the system to re-enumerate and pick up new items.
7. **Eviction.** The system automatically evicts (removes local copies of) files that have not been accessed recently, keeping only metadata. Your extension re-downloads them on demand.
## Use Cases
### Cloud storage integration
Services like Dropbox, Google Drive, and OneDrive use File Provider extensions to appear as native locations in Files and Finder. Users drag and drop files, use Quick Look, and share directly from the cloud provider without opening a separate app.
### Enterprise document management
Corporate DMS platforms (SharePoint, Box) provide File Provider extensions so employees can access company documents directly from Pages, Keynote, or any document picker. IT admins benefit from the system-managed cache and on-demand downloads that minimize local storage use.
### Version-controlled repositories
A developer tool could expose a Git repository as a File Provider domain, letting users browse branches and files from Finder without cloning the entire repo locally.
### Encrypted vault access
Password-manager and encrypted-storage apps (Cryptomator, Boxcryptor) use File Provider extensions to decrypt files transparently when opened and re-encrypt on save, surfacing vaults as normal folders in Files.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `NSFileProviderReplicatedExtension` | The main protocol you implement. Handles item metadata, downloads, uploads, deletions, and enumeration. |
| `NSFileProviderItem` | Protocol describing a single file or folder: identifier, parent, filename, content type, size, version, capabilities. |
| `NSFileProviderEnumerator` | Protocol for listing items in a container. Implements `enumerateItems(for:startingAt:)` and `enumerateChanges(for:from:)`. |
| `NSFileProviderManager` | System manager for a domain. Used to signal changes, get container URLs, add/remove domains, and evict items. |
| `NSFileProviderDomain` | Represents a distinct storage location (e.g., one per account). Registered from the main app. |
| `NSFileProviderItemIdentifier` | Opaque identifier for an item. Special values: `.rootContainer`, `.workingSet`, `.trashContainer`. |
| `NSFileProviderItemVersion` | Tracks the content version and metadata version of an item for conflict detection. |
| `NSFileProviderSyncAnchor` | Opaque token used by change enumeration to track the last known sync point. |
## Implementation
### Replicated File Provider with Remote Sync
```swift
import FileProvider
import UniformTypeIdentifiers
// 1. Implement the replicated extension protocol. The system instantiates one
// per domain, potentially in separate processes for multi-account setups.
class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
let domain: NSFileProviderDomain
let manager: NSFileProviderManager
private let remoteClient: RemoteStorageClient
// 2. The system passes the domain on init. Set up your database/network client here.
required init(domain: NSFileProviderDomain) {
self.domain = domain
self.manager = NSFileProviderManager(for: domain)!
self.remoteClient = RemoteStorageClient(accountID: domain.identifier.rawValue)
super.init()
}
func invalidate() {
// 3. Called before the extension process is terminated.
// Close database connections, cancel pending network requests.
remoteClient.cancelAll()
}
// 4. Return metadata for a single item. The system calls this frequently
// to validate items, populate Finder columns, and resolve conflicts.
func item(for identifier: NSFileProviderItemIdentifier,
request: NSFileProviderRequest,
completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void) -> Progress {
Task {
do {
let metadata = try await remoteClient.fetchMetadata(for: identifier.rawValue)
completionHandler(FileProviderItem(remoteItem: metadata), nil)
} catch {
completionHandler(nil, error)
}
}
return Progress()
}
// 5. Download file contents. Write the data to a temporary file and return its URL.
func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier,
version requestedVersion: NSFileProviderItemVersion?,
request: NSFileProviderRequest,
completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) -> Progress {
let progress = Progress(totalUnitCount: 100)
Task {
do {
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
let metadata = try await remoteClient.download(
itemID: itemIdentifier.rawValue,
to: tempURL,
progress: progress
)
completionHandler(tempURL, FileProviderItem(remoteItem: metadata), nil)
} catch {
completionHandler(nil, nil, error)
}
}
return progress
}
// 6. Upload a new item to the server.
func createItem(basedOn itemTemplate: NSFileProviderItem,
fields: NSFileProviderItemFields,
contents url: URL?,
options: NSFileProviderCreateItemOptions = [],
request: NSFileProviderRequest,
completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress {
Task {
do {
let metadata = try await remoteClient.upload(
name: itemTemplate.filename,
parentID: itemTemplate.parentItemIdentifier.rawValue,
contentURL: url,
contentType: itemTemplate.contentType ?? .data
)
// 7. Return the server-created item. The third Bool indicates
// whether the item already existed (deduplication).
completionHandler(FileProviderItem(remoteItem: metadata), [], false, nil)
} catch {
completionHandler(nil, [], false, error)
}
}
return Progress()
}
// 8. Modify an existing item (rename, move, content change).
func modifyItem(_ item: NSFileProviderItem,
baseVersion version: NSFileProviderItemVersion,
changedFields: NSFileProviderItemFields,
contents newContents: URL?,
options: NSFileProviderModifyItemOptions = [],
request: NSFileProviderRequest,
completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress {
Task {
do {
let metadata = try await remoteClient.modify(
itemID: item.itemIdentifier.rawValue,
fields: changedFields,
contentURL: newContents
)
completionHandler(FileProviderItem(remoteItem: metadata), [], false, nil)
} catch {
completionHandler(nil, [], false, error)
}
}
return Progress()
}
// 9. Delete an item from the server.
func deleteItem(identifier: NSFileProviderItemIdentifier,
baseVersion version: NSFileProviderItemVersion,
options: NSFileProviderDeleteItemOptions = [],
request: NSFileProviderRequest,
completionHandler: @escaping (Error?) -> Void) -> Progress {
Task {
do {
try await remoteClient.delete(itemID: identifier.rawValue)
completionHandler(nil)
} catch {
completionHandler(error)
}
}
return Progress()
}
// 10. Return an enumerator for the given container (folder, root, or working set).
func enumerator(for containerItemIdentifier: NSFileProviderItemIdentifier,
request: NSFileProviderRequest) throws -> NSFileProviderEnumerator {
return FileProviderEnumerator(
containerID: containerItemIdentifier,
remoteClient: remoteClient
)
}
}
```
### Enumerator with Change Tracking
```swift
import FileProvider
// 11. The enumerator lists items in a container and reports incremental changes.
class FileProviderEnumerator: NSObject, NSFileProviderEnumerator {
private let containerID: NSFileProviderItemIdentifier
private let remoteClient: RemoteStorageClient
init(containerID: NSFileProviderItemIdentifier, remoteClient: RemoteStorageClient) {
self.containerID = containerID
self.remoteClient = remoteClient
super.init()
}
func invalidate() {}
// 12. Full enumeration: return all items in the container, paginated.
func enumerateItems(for observer: NSFileProviderEnumerationObserver,
startingAt page: NSFileProviderPage) {
Task {
do {
let (items, nextPage) = try await remoteClient.listItems(
in: containerID.rawValue,
page: page
)
let providerItems = items.map { FileProviderItem(remoteItem: $0) }
observer.didEnumerate(providerItems)
observer.finishEnumerating(upTo: nextPage)
} catch {
observer.finishEnumeratingWithError(error)
}
}
}
// 13. Incremental change enumeration: return only items changed since the anchor.
func enumerateChanges(for observer: NSFileProviderChangeObserver,
from anchor: NSFileProviderSyncAnchor) {
Task {
do {
let (changed, deleted, newAnchor) = try await remoteClient.listChanges(
in: containerID.rawValue,
since: anchor
)
observer.didUpdate(changed.map { FileProviderItem(remoteItem: $0) })
observer.didDeleteItems(withIdentifiers: deleted.map {
NSFileProviderItemIdentifier($0)
})
observer.finishEnumeratingChanges(upTo: newAnchor, moreComing: false)
} catch {
observer.finishEnumeratingWithError(error)
}
}
}
// 14. Return the current sync anchor so the system can track change state.
func currentSyncAnchor(completionHandler: @escaping (NSFileProviderSyncAnchor?) -> Void) {
Task {
let anchor = try? await remoteClient.currentAnchor(for: containerID.rawValue)
completionHandler(anchor)
}
}
}
```
### File Provider Item Model
```swift
import FileProvider
import UniformTypeIdentifiers
// 15. Map your remote data model to the NSFileProviderItem protocol.
class FileProviderItem: NSObject, NSFileProviderItem {
private let remoteItem: RemoteItemMetadata
init(remoteItem: RemoteItemMetadata) {
self.remoteItem = remoteItem
}
var itemIdentifier: NSFileProviderItemIdentifier {
NSFileProviderItemIdentifier(remoteItem.id)
}
var parentItemIdentifier: NSFileProviderItemIdentifier {
NSFileProviderItemIdentifier(remoteItem.parentID)
}
var filename: String { remoteItem.name }
var contentType: UTType {
remoteItem.isFolder ? .folder : (UTType(filenameExtension: remoteItem.fileExtension) ?? .data)
}
var documentSize: NSNumber? {
NSNumber(value: remoteItem.sizeBytes)
}
// 16. The item version is critical for conflict detection. Return separate
// content and metadata version hashes from your server.
var itemVersion: NSFileProviderItemVersion {
NSFileProviderItemVersion(
contentVersion: remoteItem.contentHash.data(using: .utf8)!,
metadataVersion: remoteItem.metadataHash.data(using: .utf8)!
)
}
var capabilities: NSFileProviderItemCapabilities {
[.allowsReading, .allowsWriting, .allowsRenaming,
.allowsReparenting, .allowsTrashing, .allowsDeleting]
}
// 17. Return dates so Finder/Files can display them in list view.
var creationDate: Date? { remoteItem.createdAt }
var contentModificationDate: Date? { remoteItem.modifiedAt }
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target file-provider
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 11.0+ (legacy), 16.0+ (replicated) | Legacy `NSFileProviderExtension` API in iOS 11. Modern replicated model requires iOS 16. |
| iPadOS | 11.0+ (legacy), 16.0+ (replicated) | Same as iOS. Files app is the primary interaction surface. |
| macOS | 11.0+ (legacy), 12.3+ (replicated) | Replicated model introduced in macOS 12.3. Finder integration with icon decorations. |
| visionOS | 1.0+ | Supported. Files app available in visionOS. |
| tvOS | -- | Not supported. No Files app on tvOS. |
| watchOS | -- | Not supported. |
## Gotchas
- **Three-state reconciliation is mandatory.** You must track three independent states: the remote server, your local metadata database, and the system's on-disk replica. Conflicts arise when the user edits a file offline while another client modifies it on the server. Your extension must detect version mismatches and resolve them.
- **Each domain runs in a separate process.** If you support multiple accounts, each `NSFileProviderDomain` gets its own extension process. Your database or data store must handle concurrent access from multiple processes -- use SQLite with WAL mode or a shared App Group container with proper locking.
- **The extension has no UI.** File Provider extensions use the `com.apple.fileprovider-nonui` extension point. If you need to show authentication prompts or error dialogs, you must pair it with a File Provider UI extension (`file-provider-ui`).
- **`signalEnumerator` is your only way to push remote changes.** The system does not poll your extension. You must call `NSFileProviderManager.signalEnumerator(for:)` whenever remote changes occur. Set up push notifications, a websocket, or periodic polling in your main app or extension to detect changes.
- **Completion handlers, not async/await.** The core `NSFileProviderReplicatedExtension` protocol methods use old-style completion handlers, not Swift concurrency. You can bridge to async/await internally with `Task`, but the completion handler must be called exactly once.
- **App Group entitlement is required.** The extension and main app must share an App Group to exchange authentication tokens, database files, and configuration. The `@bacons/apple-targets` plugin configures this automatically when `appGroupsByDefault` is true.
- **50MB memory limit for extensions.** Like all app extensions, File Provider extensions run under a strict memory cap. Avoid loading entire files into memory -- stream large downloads and uploads using temporary files.
- **`NSFileProviderItemVersion` must be accurate.** If you return stale or incorrect version data, the system will skip uploading changes or will re-download unchanged files. Always derive versions from your server's actual content and metadata hashes.
- **The legacy API (`NSFileProviderExtension`) is effectively deprecated.** Apple strongly encourages migrating to `NSFileProviderReplicatedExtension`. The legacy API does not support eviction, progress reporting, or bidirectional change enumeration.
- **Debugging requires `qlmanage` or Finder/Files restarts.** Changes to your extension's enumeration logic may not appear immediately because the system caches metadata aggressively. Use `killall Finder` on macOS or restart the Files app on iOS to force re-enumeration during development.
intent-ui.md
---
title: Siri Intent UI Extension
description: Displays custom UI inline within Siri, Shortcuts, or Maps when a SiriKit intent is being confirmed or handled, replacing the default system-provided snippet with your own branded view controller.
version: iOS 10.0+
---
# Siri Intent UI Extension (`intent-ui`)
An Intents UI extension lets you inject a custom view controller into the Siri transcript, the Shortcuts results card, or the Maps action sheet whenever the system processes one of your supported intents. Instead of the generic Siri snippet, users see your app's branded interface -- showing richer data, custom layouts, or confirmation details -- while the intent is being resolved, confirmed, or handled. The extension is a UIViewController subclass conforming to INUIHostedViewControlling, and it runs in a separate process alongside your Intents extension. It receives the same INInteraction object that your intent handler works with, so it can display contextual information about the ongoing request.
## Apple Documentation
- [Creating an Intents UI Extension](https://developer.apple.com/documentation/sirikit/creating-an-intents-ui-extension)
- [INUIHostedViewControlling](https://developer.apple.com/documentation/intentsui/inuihostedviewcontrolling)
- [Configuring Your Intents UI App Extension Target](https://developer.apple.com/documentation/sirikit/configuring-your-intents-ui-app-extension-target)
- [configureView(for:of:interactiveBehavior:context:completion:)](https://developer.apple.com/documentation/intentsui/inuihostedviewcontrolling/configureview(for:of:interactivebehavior:context:completion:))
- [INUIHostedViewContext](https://developer.apple.com/documentation/intentsui/inuihostedviewcontext)
- [INUIInteractiveBehavior](https://developer.apple.com/documentation/intentsui/inuiinteractivebehavior)
- [IntentsUI Framework](https://developer.apple.com/documentation/intentsui)
## WWDC History
- **[WWDC 2016, Session 217 -- Introducing SiriKit](https://developer.apple.com/videos/play/wwdc2016/217/)** -- Launched SiriKit with two extension points: Intents and Intents UI. Demonstrated how the UI extension presents a "Siri snippet" -- a custom view controller embedded directly in the Siri conversation transcript.
- **[WWDC 2016, Session 225 -- Extending Your Apps with SiriKit](https://developer.apple.com/videos/play/wwdc2016/225/)** -- Walkthrough of building both an Intents extension and an Intents UI extension, including storyboard layout and the `configure(with:context:completion:)` method.
- **[WWDC 2017, Session 214 -- What's New in SiriKit](https://developer.apple.com/videos/play/wwdc2017/214/)** -- Introduced the more granular `configureView(for:of:interactiveBehavior:context:completion:)` API in iOS 11, enabling per-parameter UI replacement in the Siri snippet stack.
- **[WWDC 2018, Session 211 -- Introduction to Siri Shortcuts](https://developer.apple.com/videos/play/wwdc2018/211/)** -- Custom intents via IntentDefinition files can now surface in Shortcuts, where the Intents UI extension provides the result card.
- **[WWDC 2020, Session 10073 -- Empower Your Intents](https://developer.apple.com/videos/play/wwdc2020/10073/)** -- Added interactive behavior support, richer disambiguation with images, and in-app intent handling as an alternative to extension-based handling.
## What It Does
1. The user triggers an intent through Siri, the Shortcuts app, or Maps (e.g., "Send a message using MyApp").
2. The system launches the paired Intents extension to resolve, confirm, and handle the request.
3. At each phase where Siri would display a snippet (confirmation, handling result), the system also launches the Intents UI extension.
4. The system calls `configureView(for:of:interactiveBehavior:context:completion:)` on your `IntentViewController`, passing the current `INInteraction`, the set of `INParameter` values for the current row in the snippet stack, and an `INUIHostedViewContext` indicating whether the view appears in Siri, Maps, or Shortcuts.
5. Your view controller lays out its custom UI, calculates a `desiredSize`, and calls the completion handler with `(true, parameters, size)` to tell the system it will handle those parameters.
6. The system embeds your view controller's view into the Siri transcript, Maps card, or Shortcuts result in place of the default system UI.
## Use Cases
### Branded ride-booking confirmations
A ride-hailing app replaces the default Siri snippet with a map view showing driver location, estimated arrival time, and vehicle details in the app's visual style, giving users confidence they booked the right ride.
### Rich messaging previews
A messaging app displays the sent message in a styled chat bubble with the recipient's avatar and read receipts, matching the look of the main app rather than relying on Siri's plain text summary.
### Payment confirmation cards
A banking app shows a branded transaction summary with account balance, recipient info, and a confirmation checkmark, providing a more trustworthy visual than the generic Siri payment UI.
### Workout summary display
A fitness app presents a workout completion card with a chart of heart rate data, calories burned, and elapsed time when the user ends a workout through Siri.
## Key Classes
| Class / Protocol | Role |
|-----------------|------|
| `INUIHostedViewControlling` | Protocol your view controller conforms to. Provides `configureView(for:of:interactiveBehavior:context:completion:)` for per-parameter UI and the simpler `configure(with:context:completion:)` for full-snippet replacement. |
| `INUIHostedViewContext` | Enum passed to `configureView` indicating where the UI is displayed: `.siri`, `.maps`, or `.hostedInApp` (Shortcuts). Lets you adapt layout for each surface. |
| `INUIInteractiveBehavior` | Enum indicating whether the view can receive interactions: `.none`, `.launch`, `.genericAction`, `.nextView`. Most Siri contexts are `.none` (read-only). |
| `INInteraction` | Contains the `INIntent` and optional `INIntentResponse` for the current request. Extract parameters and results from this to populate your UI. |
| `INParameter` | Identifies a specific parameter in the intent (e.g., recipients, amount). Used with `configureView` to replace individual rows in the Siri snippet stack. |
| `INUIHostedViewSiriProviding` | Optional protocol to tell Siri your extension will handle displaying specific content (maps, messages, payment transactions), preventing duplicate information. |
## Implementation
```swift
import IntentsUI
// 1. Subclass UIViewController and conform to INUIHostedViewControlling.
// This is the principal class declared in the extension's Info.plist.
class IntentViewController: UIViewController, INUIHostedViewControlling {
// 2. Create outlets or subviews for your custom content.
private let titleLabel = UILabel()
private let detailLabel = UILabel()
private let iconView = UIImageView()
override func viewDidLoad() {
super.viewDidLoad()
// 3. Set up your view hierarchy. Use Auto Layout to handle
// varying widths across Siri, Maps, and Shortcuts surfaces.
titleLabel.font = .boldSystemFont(ofSize: 17)
detailLabel.font = .systemFont(ofSize: 14)
detailLabel.textColor = .secondaryLabel
detailLabel.numberOfLines = 0
iconView.contentMode = .scaleAspectFit
iconView.tintColor = .systemBlue
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel, detailLabel])
stack.axis = .vertical
stack.spacing = 8
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
iconView.widthAnchor.constraint(equalToConstant: 40),
iconView.heightAnchor.constraint(equalToConstant: 40),
stack.topAnchor.constraint(equalTo: view.topAnchor, constant: 16),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
stack.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor, constant: -16),
])
}
// 4. Implement configureView to handle per-parameter UI replacement.
// The system calls this once per parameter row in the Siri snippet stack.
func configureView(
for parameters: Set<INParameter>,
of interaction: INInteraction,
interactiveBehavior: INUIInteractiveBehavior,
context: INUIHostedViewContext,
completion: @escaping (Bool, Set<INParameter>, CGSize) -> Void
) {
// 5. Check the context to adapt layout for Siri vs. Maps vs. Shortcuts.
switch context {
case .siri:
view.backgroundColor = .systemBackground
case .mapsCard:
view.backgroundColor = .secondarySystemBackground
default:
view.backgroundColor = .systemBackground
}
// 6. Extract intent data from the interaction to populate the UI.
if let sendIntent = interaction.intent as? INSendMessageIntent {
titleLabel.text = "Message Sent"
detailLabel.text = sendIntent.content ?? "No content"
iconView.image = UIImage(systemName: "checkmark.circle.fill")
}
// 7. Calculate the desired size. Use the maximum allowed width
// and compute the height your content needs.
let maxSize = self.extensionContext!.hostedViewMaximumAllowedSize
let fittingSize = view.systemLayoutSizeFitting(
CGSize(width: maxSize.width, height: UIView.layoutFittingCompressedSize.height),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
)
// 8. Call completion with true to indicate you are handling these
// parameters, pass back the parameters you consumed, and your size.
completion(true, parameters, CGSize(width: maxSize.width, height: fittingSize.height))
}
// 9. Fallback: the desiredSize property is used if configureView is not
// implemented. Return the maximum allowed size as a reasonable default.
var desiredSize: CGSize {
return self.extensionContext!.hostedViewMaximumAllowedSize
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target intent-ui
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 10.0+ | Full support. `configureView` per-parameter API available from iOS 11+. |
| iPadOS | 10.0+ | Full support. Wider snippet area gives more layout room. |
| macOS | 12.0+ | Supported for Shortcuts results. Siri on Mac uses the same extension. |
| watchOS | -- | Not supported. watchOS Siri uses a different UI mechanism (WKInterfaceController). |
| tvOS | -- | Not supported. |
| visionOS | 1.0+ | Supported via compatible iOS intents. |
## Gotchas
- **Must be paired with an Intents extension.** The Intents UI extension only provides the visual layer. You must also have a separate Intents extension (or in-app intent handling on iOS 14+) that actually resolves, confirms, and handles the intent. The UI extension alone does nothing.
- **INUIHostedViewContext tells you where you are.** Your view appears in Siri (`.siri`), Maps (`.mapsCard`), or Shortcuts (`.hostedInApp`), and each surface has different size constraints and visual expectations. Always check the context parameter and adapt your layout accordingly.
- **Views do not receive touch events in Siri.** When `interactiveBehavior` is `.none` (the default in the Siri transcript), your view is read-only. Buttons and gesture recognizers will not fire. Only when the behavior is `.launch` or `.genericAction` can the user interact, and even then the interaction typically just opens your app.
- **Limited vertical space.** The system enforces a maximum height via `hostedViewMaximumAllowedSize`. If your view exceeds this, it will be clipped without warning. Always calculate your `desiredSize` within bounds and test on smaller devices.
- **IntentsSupported must be declared in both extensions.** The Info.plist for the Intents UI extension must list every intent class name it handles in the `IntentsSupported` array, just like the Intents extension. If an intent is missing from the UI extension's list, the system silently falls back to the default Siri snippet.
- **The extension is legacy for new projects.** Apple recommends the App Intents framework (iOS 16+) with `ShowsSnippetView` for new custom intent UI work. The Intents UI extension remains necessary only for built-in SiriKit domains (messaging, payments, ride booking) that have not migrated to App Intents.
- **Siri override control is limited to three content types.** If you implement `INUIHostedViewSiriProviding` to suppress duplicate system UI, only maps, message content, and payment transactions can be overridden. Other content types will still show the system default alongside your custom view.
- **iOS 13 Shortcuts regression.** On iOS 13, Siri sometimes redirects custom intent invocations to the Shortcuts app instead of displaying the intent UI inline. This was largely resolved in iOS 14 but may still affect users on older OS versions.
intent.md
---
title: Siri Intents Extension
description: Handles Siri voice commands and system intents using the legacy SiriKit framework, allowing your app to respond to predefined intent domains like messaging, payments, and ride booking.
version: iOS 10.0+
---
# Siri Intents Extension (`intent`)
A background extension that receives and processes Siri voice requests on behalf of your app. When a user speaks a command that matches one of Apple's predefined intent domains (messaging, payments, workouts, etc.), the system routes the structured intent to your extension, which resolves parameters, confirms feasibility, and handles the action -- all without launching your main app.
## Apple Documentation
- [SiriKit Overview](https://developer.apple.com/documentation/sirikit)
- [Creating an Intents App Extension](https://developer.apple.com/documentation/sirikit/intent_handling_infrastructure/creating_an_intents_app_extension)
- [INExtension](https://developer.apple.com/documentation/intents/inextension)
- [INSendMessageIntentHandling](https://developer.apple.com/documentation/intents/insendmessageintenthandling)
- [Intents Framework](https://developer.apple.com/documentation/intents)
- [NSExtensionPointIdentifier](https://developer.apple.com/documentation/bundleresources/information-property-list/nsextension/nsextensionpointidentifier)
## WWDC History
- **[WWDC 2016, Session 217 -- Introducing SiriKit](https://developer.apple.com/videos/play/wwdc2016/217/)** -- Introduced the Intents framework with support for messaging, VoIP calling, payments, photo search, workouts, and ride booking domains.
- **[WWDC 2016, Session 225 -- Extending Your Apps with SiriKit](https://developer.apple.com/videos/play/wwdc2016/225/)** -- Deep dive into building your first Intents extension and bringing custom UI into Siri.
- **[WWDC 2017, Session 214 -- What's New in SiriKit](https://developer.apple.com/videos/play/wwdc2017/214/)** -- Added lists and notes domain, visual codes, and new payment intents for iOS 11.
- **[WWDC 2018, Session 211 -- Introduction to Siri Shortcuts](https://developer.apple.com/videos/play/wwdc2018/211/)** -- Introduced custom intents via IntentDefinition files and Siri Shortcuts for iOS 12.
- **[WWDC 2019, Session 207 -- Introducing SiriKit Media Intents](https://developer.apple.com/videos/play/wwdc2019/207/)** -- Added INPlayMediaIntent for audio apps, enabling "Play X on MyApp" commands.
- **[WWDC 2020, Session 10073 -- Empower Your Intents](https://developer.apple.com/videos/play/wwdc2020/10073/)** -- In-app intent handling, rich disambiguation with images and subtitles, improved parameters.
- **[WWDC 2020, Session 10068 -- What's New in SiriKit and Shortcuts](https://developer.apple.com/videos/play/wwdc2020/10068/)** -- New shortcut running experience, folders, and event triggers in iOS 14.
- **[WWDC 2022, Session 10032 -- Dive into App Intents](https://developer.apple.com/videos/play/wwdc2022/10032/)** -- Introduced the modern App Intents framework as the successor for custom intents. Legacy SiriKit Intents remain necessary for messaging, media, and other built-in domains.
## What It Does
1. The user speaks a command to Siri that maps to a supported intent domain (e.g., "Send a message to John using MyApp").
2. Siri parses the voice input into a structured `INIntent` object with typed parameters.
3. The system launches your Intents extension in the background and calls `handler(for:)` on your `INExtension` subclass.
4. Your handler implements a three-phase protocol:
- **Resolve** -- Validate and disambiguate each parameter (e.g., which "John" did they mean?).
- **Confirm** -- Verify the action is feasible (e.g., is the user authenticated? Is there network connectivity?).
- **Handle** -- Execute the action and return a response with a success or failure code.
5. Siri presents the result to the user, optionally showing your custom Intent UI extension if one is configured.
## Use Cases
### Messaging apps
A chat application that lets users send messages, search conversations, and mark messages as read entirely through Siri voice commands, without opening the app.
### Ride booking services
A transportation app that allows users to request rides, check ride status, and get ETAs through Siri, integrating with the ride-booking intent domain.
### Payment and banking apps
A banking app that handles "Send $50 to Sarah" commands through the payments domain, resolving contacts, confirming amounts, and executing transfers via Siri.
### Fitness and workout apps
A workout tracker that starts, pauses, and ends workout sessions through Siri voice commands using the workouts intent domain.
## Key Classes
| Class / Protocol | Role |
|-----------------|------|
| `INExtension` | Principal class of the extension. Subclass it and override `handler(for:)` to route intents to the correct handler object. |
| `INIntent` | Base class for all system intents. Each domain has concrete subclasses like `INSendMessageIntent`. |
| `INIntentHandling` | Base protocol. Domain-specific protocols like `INSendMessageIntentHandling` add resolve/confirm/handle methods. |
| `INIntentResponse` | Base class for intent responses. Carry a response code (`.success`, `.failure`, etc.) and an optional `NSUserActivity` for handoff. |
| `INInteraction` | Wraps an intent and its response. Used for donating completed interactions to the system for Siri Suggestions. |
| `INPerson` | Represents a contact in messaging and calling intents. Contains handle, display name, and optional image. |
## Implementation
### Messaging Intent Handler (Resolve / Confirm / Handle)
```swift
import Intents
// 1. Subclass INExtension as the principal class declared in Info.plist.
// Route each intent type to the appropriate handler.
class IntentHandler: INExtension {
override func handler(for intent: INIntent) -> Any {
switch intent {
case is INSendMessageIntent:
return SendMessageHandler()
case is INSearchForMessagesIntent:
return SearchMessagesHandler()
default:
return self
}
}
}
// 2. Implement the full resolve/confirm/handle cycle for sending messages.
class SendMessageHandler: NSObject, INSendMessageIntentHandling {
// 3. RESOLVE: Disambiguate recipients. Siri calls this before confirmation.
func resolveRecipients(
for intent: INSendMessageIntent,
with completion: @escaping ([INSendMessageRecipientResolutionResult]) -> Void
) {
guard let recipients = intent.recipients, !recipients.isEmpty else {
completion([.needsValue()])
return
}
var results = [INSendMessageRecipientResolutionResult]()
for recipient in recipients {
// 4. Look up matching contacts in your app's data store.
let matches = ContactStore.shared.findContacts(matching: recipient)
switch matches.count {
case 0:
results.append(.unsupported())
case 1:
results.append(.success(with: matches[0]))
default:
// 5. Multiple matches -- ask Siri to let the user pick one.
results.append(.disambiguation(with: matches))
}
}
completion(results)
}
// 6. RESOLVE: Validate the message content.
func resolveContent(
for intent: INSendMessageIntent,
with completion: @escaping (INStringResolutionResult) -> Void
) {
if let text = intent.content, !text.isEmpty {
completion(.success(with: text))
} else {
completion(.needsValue())
}
}
// 7. CONFIRM: Verify the app is ready to send (e.g., user is logged in).
func confirm(
intent: INSendMessageIntent,
completion: @escaping (INSendMessageIntentResponse) -> Void
) {
guard AuthManager.shared.isAuthenticated else {
let response = INSendMessageIntentResponse(code: .failureRequiringAppLaunch, userActivity: nil)
completion(response)
return
}
completion(INSendMessageIntentResponse(code: .ready, userActivity: nil))
}
// 8. HANDLE: Actually send the message and return the result.
func handle(
intent: INSendMessageIntent,
completion: @escaping (INSendMessageIntentResponse) -> Void
) {
guard let recipients = intent.recipients,
let content = intent.content else {
completion(INSendMessageIntentResponse(code: .failure, userActivity: nil))
return
}
MessageService.shared.send(
content: content,
to: recipients.compactMap { $0.personHandle?.value }
) { success in
let code: INSendMessageIntentResponseCode = success ? .success : .failure
let activity = NSUserActivity(activityType: NSStringFromClass(INSendMessageIntent.self))
completion(INSendMessageIntentResponse(code: code, userActivity: activity))
}
}
}
// 9. Donate completed interactions so the system can offer Siri Suggestions.
func donateInteraction(for intent: INSendMessageIntent, response: INSendMessageIntentResponse) {
let interaction = INInteraction(intent: intent, response: response)
interaction.donate { error in
if let error = error {
print("Donation failed: \(error.localizedDescription)")
}
}
}
```
### Custom Intent via IntentDefinition File
For domains not covered by built-in intents, you can define custom intents using an `.intentdefinition` file in Xcode:
```swift
import Intents
// 1. Xcode auto-generates OrderCoffeeIntent from your IntentDefinition file.
// Implement the generated handling protocol.
class OrderCoffeeHandler: NSObject, OrderCoffeeIntentHandling {
// 2. Resolve the coffee size parameter.
func resolveCoffeeSize(
for intent: OrderCoffeeIntent,
with completion: @escaping (CoffeeSizeResolutionResult) -> Void
) {
if intent.coffeeSize == .unknown {
completion(.needsValue())
} else {
completion(.success(with: intent.coffeeSize))
}
}
// 3. Confirm the order is valid.
func confirm(
intent: OrderCoffeeIntent,
completion: @escaping (OrderCoffeeIntentResponse) -> Void
) {
completion(OrderCoffeeIntentResponse(code: .ready, userActivity: nil))
}
// 4. Place the order.
func handle(
intent: OrderCoffeeIntent,
completion: @escaping (OrderCoffeeIntentResponse) -> Void
) {
CoffeeOrderService.shared.place(
size: intent.coffeeSize,
flavor: intent.flavor
) { orderId in
let response = OrderCoffeeIntentResponse(code: .success, userActivity: nil)
response.orderNumber = NSNumber(value: orderId)
completion(response)
}
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target intent
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 10.0+ | Full support for all intent domains. |
| iPadOS | 10.0+ | Full support. |
| macOS | 11.0+ | Limited domain support (primarily messaging and media). |
| watchOS | 3.2+ | Supports intents via a separate watchOS extension target. |
| tvOS | -- | Not supported. |
| visionOS | 1.0+ | Supported via compatible iOS intents. |
## Gotchas
- **IntentDefinition file must be in both targets.** If you define custom intents, the `.intentdefinition` file must be included in both the Intents extension and your main app target. Missing it from the app target causes Siri Shortcuts to fail silently.
- **Extension memory limit is ~30 MB.** Intents extensions run in a constrained environment. Loading large frameworks, images, or data stores will cause the system to kill your extension with no crash log visible to the user.
- **The Intents framework is legacy for custom intents.** Apple recommends App Intents (iOS 16+) for all new custom intent work. SiriKit Intents remain required only for built-in domains like messaging (`INSendMessageIntent`) and media (`INPlayMediaIntent`) that have not yet migrated to App Intents.
- **`handler(for:)` must return synchronously.** You cannot perform async work in this method. Return the handler object immediately and do all async work in the resolve/confirm/handle methods.
- **IntentsSupported in Info.plist must match.** The `IntentsSupported` array in your extension's Info.plist must list every intent class name you handle. If an intent is missing from this list, Siri will not route it to your extension, with no error or diagnostic message.
- **Siri Shortcuts donation requires the main app.** Donating `INInteraction` objects to improve Siri Suggestions only works from the main app process, not from the extension. Donate after the user completes the action in your app.
- **In-app intent handling (iOS 14+) can replace the extension.** For simpler use cases, you can handle intents directly in your main app by registering handlers on `INInteraction`. This avoids the extension's memory constraints but requires your app to launch.
keyboard.md
---
title: Custom Keyboard Extension
description: Replaces the system keyboard with a fully custom input interface available across all apps.
version: iOS 8.0+
---
# Custom Keyboard Extension (`keyboard`)
A custom keyboard extension replaces the standard system keyboard with your own input interface, available system-wide across all apps once the user enables it in Settings. The extension subclasses `UIInputViewController` and communicates with the active text field through a `textDocumentProxy` object, which allows inserting and deleting text without direct access to the text field itself. Keyboards run in a restrictive sandbox by default -- network access, pasteboard reading, and shared container access all require the user to grant "Allow Full Access" in Settings. Data sharing with the containing app uses App Groups.
## Apple Documentation
- [Creating a Custom Keyboard](https://developer.apple.com/documentation/uikit/creating-a-custom-keyboard)
- [UIInputViewController](https://developer.apple.com/documentation/uikit/uiinputviewcontroller)
- [Configuring a Custom Keyboard Interface](https://developer.apple.com/documentation/uikit/configuring-a-custom-keyboard-interface)
- [UITextDocumentProxy](https://developer.apple.com/documentation/uikit/uitextdocumentproxy)
- [App Extension Programming Guide: Custom Keyboard](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/CustomKeyboard.html)
- [UILexicon](https://developer.apple.com/documentation/uikit/uilexicon)
## WWDC History
- **[WWDC 2014, Session 205 -- Creating Extensions for iOS and OS X, Part 1](https://developer.apple.com/videos/play/wwdc2014/205/)** -- Introduced the extension model in iOS 8, including custom keyboard extensions as a new extension point.
- **[WWDC 2014, Session 217 -- Creating Extensions for iOS and OS X, Part 2](https://developer.apple.com/videos/play/wwdc2014/217/)** -- Deeper implementation details for building extensions, covering keyboard lifecycle and sandboxing.
- **[WWDC 2017, Session 242 -- The Keys to a Better Text Input Experience](https://developer.apple.com/videos/play/wwdc2017/242/)** -- Best practices for text input, QuickType support, and upgrading a custom keyboard to a system-wide extension.
## What It Does
1. **User enables the keyboard.** The user goes to Settings > General > Keyboards > Add New Keyboard and selects your keyboard from the list of third-party keyboards.
2. **System instantiates UIInputViewController.** When the user switches to your keyboard (via the globe key), iOS loads your extension and calls `viewDidLoad()` on your `UIInputViewController` subclass.
3. **You build the UI.** Add buttons, labels, or host a SwiftUI view inside the controller's `inputView`. The keyboard draws within the bounds of this view.
4. **Text input goes through textDocumentProxy.** Call `textDocumentProxy.insertText(_:)` to type characters and `textDocumentProxy.deleteBackward()` to delete. You can read limited surrounding context via `documentContextBeforeInput` and `documentContextAfterInput`.
5. **Globe key switches keyboards.** Call `advanceToNextInputMode()` or use `handleInputModeList(from:with:)` to let the user switch to the next keyboard. The system manages keyboard order.
6. **Height is controlled via constraints.** Set a height constraint on `inputView` to change the keyboard height from the default.
7. **Full Access unlocks advanced features.** If `RequestsOpenAccess` is set to `true` in Info.plist and the user enables Full Access in Settings, the keyboard gains network access, pasteboard reading, shared container access via App Groups, and location services.
## Use Cases
### Custom layouts and languages
A keyboard that provides a layout not available on the standard system keyboard, such as Dvorak, Colemak, or a layout for a language Apple does not natively support. The extension renders the key grid in `viewDidLoad` and inserts characters via `textDocumentProxy.insertText(_:)`.
### GIF and sticker keyboards
A media keyboard that searches a GIF API and inserts images into the text field. Requires Full Access for network requests. Uses `textDocumentProxy.insertText(_:)` with a special pasteboard workaround, or relies on the host app supporting `UIPasteboard` for image input.
### Specialized input methods
A keyboard for entering mathematical notation, music symbols, or code snippets. The extension presents a domain-specific set of buttons and inserts the corresponding Unicode characters or formatted strings.
### Accessibility keyboards
A keyboard with enlarged keys, high-contrast colors, or simplified layouts designed for users with motor or visual impairments. Can also add haptic feedback via `UIFeedbackGenerator`.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `UIInputViewController` | Primary view controller for the keyboard extension. Subclass this to build your keyboard UI. |
| `UITextDocumentProxy` | Proxy object for interacting with the current text field. Provides `insertText(_:)`, `deleteBackward()`, and context properties. |
| `UILexicon` | System-provided lexicon containing unpaired first names from Contacts and text shortcuts from Settings. Use for autocorrect suggestions. |
| `UIInputView` | The root view of the keyboard. Set `inputView` to a custom instance to control appearance and height. |
| `NSExtensionContext` | The extension context. Rarely used directly in keyboard extensions. |
## Implementation
```swift
import UIKit
// 1. Subclass UIInputViewController -- this is the entry point for any custom keyboard.
class KeyboardViewController: UIInputViewController {
private var nextKeyboardButton: UIButton!
private var keyboardStackView: UIStackView!
override func viewDidLoad() {
super.viewDidLoad()
// 2. Build the keyboard layout using a vertical stack of horizontal rows.
keyboardStackView = UIStackView()
keyboardStackView.axis = .vertical
keyboardStackView.spacing = 6
keyboardStackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(keyboardStackView)
NSLayoutConstraint.activate([
keyboardStackView.topAnchor.constraint(equalTo: view.topAnchor, constant: 8),
keyboardStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 4),
keyboardStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -4),
])
let rows = ["QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"]
for row in rows {
let rowStack = UIStackView()
rowStack.axis = .horizontal
rowStack.spacing = 4
rowStack.distribution = .fillEqually
for char in row {
let key = UIButton(type: .system)
key.setTitle(String(char), for: .normal)
key.titleLabel?.font = .systemFont(ofSize: 22)
key.backgroundColor = UIColor.systemBackground
key.layer.cornerRadius = 5
// 3. Each key inserts its character into the active text field via textDocumentProxy.
key.addTarget(self, action: #selector(keyTapped(_:)), for: .touchUpInside)
rowStack.addArrangedSubview(key)
}
keyboardStackView.addArrangedSubview(rowStack)
}
// 4. Build the bottom row with globe, space, backspace, and return keys.
let bottomRow = UIStackView()
bottomRow.axis = .horizontal
bottomRow.spacing = 4
bottomRow.distribution = .fill
// 5. The globe button is REQUIRED when needsInputModeSwitchKey is true.
// Use handleInputModeList(from:with:) for the long-press keyboard picker.
nextKeyboardButton = UIButton(type: .system)
nextKeyboardButton.setImage(UIImage(systemName: "globe"), for: .normal)
nextKeyboardButton.addTarget(self, action: #selector(handleInputModeList(from:with:)), for: .allTouchEvents)
nextKeyboardButton.widthAnchor.constraint(equalToConstant: 44).isActive = true
bottomRow.addArrangedSubview(nextKeyboardButton)
let spaceButton = UIButton(type: .system)
spaceButton.setTitle("space", for: .normal)
spaceButton.backgroundColor = UIColor.systemBackground
spaceButton.layer.cornerRadius = 5
spaceButton.addTarget(self, action: #selector(spaceTapped), for: .touchUpInside)
bottomRow.addArrangedSubview(spaceButton)
let deleteButton = UIButton(type: .system)
deleteButton.setImage(UIImage(systemName: "delete.left"), for: .normal)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
deleteButton.widthAnchor.constraint(equalToConstant: 44).isActive = true
bottomRow.addArrangedSubview(deleteButton)
let returnButton = UIButton(type: .system)
returnButton.setTitle("return", for: .normal)
returnButton.addTarget(self, action: #selector(returnTapped), for: .touchUpInside)
returnButton.widthAnchor.constraint(equalToConstant: 72).isActive = true
bottomRow.addArrangedSubview(returnButton)
keyboardStackView.addArrangedSubview(bottomRow)
// 6. Set a custom height for the keyboard. The default is ~216pt on iPhone.
let heightConstraint = NSLayoutConstraint(
item: view!, attribute: .height,
relatedBy: .equal,
toItem: nil, attribute: .notAnAttribute,
multiplier: 1.0, constant: 260
)
heightConstraint.priority = .defaultHigh
view.addConstraint(heightConstraint)
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
// 7. Hide the globe button on devices that provide it natively (e.g. iPhone X+).
nextKeyboardButton.isHidden = !needsInputModeSwitchKey
}
@objc func keyTapped(_ sender: UIButton) {
guard let title = sender.title(for: .normal) else { return }
// 8. Insert the character into the current text field.
textDocumentProxy.insertText(title)
}
@objc func spaceTapped() {
textDocumentProxy.insertText(" ")
}
@objc func deleteTapped() {
// 9. Delete one character backward.
textDocumentProxy.deleteBackward()
}
@objc func returnTapped() {
textDocumentProxy.insertText("\n")
}
override func textDidChange(_ textInput: UITextInput?) {
// 10. Adapt key colors to match the host app's keyboard appearance (light/dark).
let isDark = textDocumentProxy.keyboardAppearance == .dark
let textColor: UIColor = isDark ? .white : .black
nextKeyboardButton.tintColor = textColor
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target keyboard
```
```json
// targets/keyboard/expo-target.config.json
{
"type": "keyboard"
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 8.0+ | Full custom keyboard support. Globe key provided by system on iPhone X+ (iOS 11+). |
| iPadOS | 8.0+ | Supports split-screen and floating keyboard contexts. |
| macOS | -- | Not supported. macOS uses Input Methods (IMKit), not keyboard extensions. |
| watchOS | -- | Not supported. |
| visionOS | -- | Not supported. |
| tvOS | -- | Not supported. |
## Gotchas
- **You must implement a globe/next-keyboard button or Apple will reject your app.** Check `needsInputModeSwitchKey` in `viewWillLayoutSubviews` and show or hide the button accordingly. On devices with a system-level globe key (iPhone X and later), `needsInputModeSwitchKey` returns `false` and your button should be hidden. On all other devices, omitting this button makes it impossible to switch keyboards and triggers an App Store rejection.
- **Keyboards are heavily sandboxed by default.** Without Full Access enabled, the extension cannot access the network, read the pasteboard, use shared containers, or access location services. Your keyboard must provide useful basic functionality without Full Access.
- **No camera or microphone access.** Keyboard extensions are explicitly prohibited from accessing the camera and microphone APIs, even with Full Access enabled. Dictation input is not available to third-party keyboards.
- **Secure text fields always use the system keyboard.** When the user taps a password field (`isSecureTextEntry = true`), iOS automatically switches to the system keyboard. Your extension has no control over this behavior.
- **Host apps can block custom keyboards entirely.** An app developer can return `false` from `application(_:shouldAllowExtensionPointIdentifier:)` to force the system keyboard in their app. Banking and healthcare apps commonly do this.
- **Height must be set via a constraint on inputView.** There is no `preferredContentSize` API for keyboards. Set a height constraint with `.defaultHigh` priority on `self.view` (the `inputView`). The default height is approximately 216 points on iPhone.
- **Autocorrect and predictive text must be self-implemented.** The system does not provide autocorrect, spell-checking, or predictive text to third-party keyboards. You can use `requestSupplementaryLexicon(completion:)` to get a `UILexicon` containing the user's text shortcuts and contact names, but building a full autocorrect engine is your responsibility.
- **Memory limit is strict (~40-50 MB).** iOS aggressively terminates keyboard extensions that exceed the memory budget. Avoid loading large assets, dictionaries, or ML models directly into the extension process. Consider lazy loading and memory-mapped files.
- **textDocumentProxy context is limited.** `documentContextBeforeInput` and `documentContextAfterInput` only return a limited window of surrounding text (often just the current paragraph or less). You cannot read the entire document or access text outside the visible context.
- **App Groups are required for sharing data with the containing app.** The keyboard extension runs in a separate process with its own container. Use `UserDefaults(suiteName:)` or `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)` to share settings, learned words, or user preferences. The `@bacons/apple-targets` plugin syncs app groups automatically when `appGroupsByDefault` is set.
location-push.md
---
title: Location Push Service Extension
description: Receives a special APNs push notification and requests a one-shot device location for location-sharing apps without requiring the app to be running.
version: iOS 15.0+
---
# Location Push Service Extension (`location-push`)
A Location Push Service Extension gives location-sharing apps a power-efficient way to retrieve a user's location on demand. When your server sends a specially typed APNs push (`"location"` push type), the system wakes the extension in the background -- even if the app is not running. The extension creates a `CLLocationManager`, calls `requestLocation()`, and sends the result back to your server. This avoids the need for continuous background location tracking, dramatically reducing battery impact. The extension is gated behind a managed entitlement (`com.apple.developer.location.push`) that requires explicit approval from Apple.
## Apple Documentation
- [CLLocationPushServiceExtension](https://developer.apple.com/documentation/corelocation/cllocationpushserviceextension)
- [Creating a Location Push Service Extension](https://developer.apple.com/documentation/corelocation/creating-a-location-push-service-extension)
- [Location Push Service Extension Entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.location.push)
- [CLLocationManager](https://developer.apple.com/documentation/corelocation/cllocationmanager)
- [Core Location Framework](https://developer.apple.com/documentation/corelocation)
## WWDC History
- **[WWDC 2021, Session 10102 -- Meet the Location Button](https://developer.apple.com/videos/play/wwdc2021/10102/)** -- Introduced the Location Push Service Extension alongside the CLLocationButton, covering the push-based location retrieval architecture and entitlement requirements.
## What It Does
1. **App registers for location pushes.** Your main app calls `CLLocationManager.startMonitoringLocationPushes(completion:)` to receive an APNs device token specific to location pushes. You send this token to your server.
2. **Server sends a location push.** Your server sends an APNs notification with push type `"location"` and topic `<bundle-id>.location-query` to the device token. The payload can contain arbitrary data (e.g. a request ID).
3. **System wakes the extension.** iOS launches your `CLLocationPushServiceExtension` and calls `didReceiveLocationPushPayload(_:completion:)` with the push payload dictionary.
4. **Extension requests location.** You create a `CLLocationManager`, set its delegate, and call `requestLocation()`. The system delivers a single location fix to your delegate's `locationManager(_:didUpdateLocations:)` method.
5. **Extension sends location to server.** You process the location (ideally end-to-end encrypting it for the requesting user), send it to your server via a network request, and call the provided `completion()` handler.
6. **System terminates the extension.** After you call `completion()`, or after approximately 30 seconds, the system terminates the extension. If you fail to call `completion()`, `serviceExtensionWillTerminate()` is called as a last chance.
## Use Cases
### Find My-style device location sharing
A family safety app lets users request each other's location. When User A taps "Find User B," the server sends a location push to User B's device. The extension retrieves the location, encrypts it, and posts it back. User A sees the updated location on a map -- all without User B needing to have the app open.
### Fleet and asset tracking
A logistics company needs periodic location updates from delivery drivers' phones. Instead of maintaining constant GPS tracking (which drains battery), the dispatch system sends location pushes at defined intervals (e.g. every 15 minutes) to collect position reports on demand.
### Emergency check-in systems
A workplace safety app allows a control room to ping field workers' devices for their current location during an emergency roll call. The location push fires even if the worker's app is backgrounded or terminated.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `CLLocationPushServiceExtension` | Protocol your extension's principal class adopts. Provides `didReceiveLocationPushPayload(_:completion:)` and `serviceExtensionWillTerminate()`. |
| `CLLocationManager` | Used within the extension to call `requestLocation()` for a single location fix. Also used in the main app to call `startMonitoringLocationPushes(completion:)` for token registration. |
| `CLLocationManagerDelegate` | Delegate protocol that receives the location result via `locationManager(_:didUpdateLocations:)` or errors via `locationManager(_:didFailWithError:)`. |
| `CLLocation` | The location object containing latitude, longitude, altitude, accuracy, and timestamp. |
## Implementation
```swift
import CoreLocation
// 1. Adopt CLLocationPushServiceExtension on your principal class. Also
// conform to CLLocationManagerDelegate to receive the location result.
class LocationPushService: NSObject,
CLLocationPushServiceExtension,
CLLocationManagerDelegate
{
var completion: (() -> Void)?
var locationManager: CLLocationManager?
// 2. Called when a location push arrives. The payload dictionary contains
// whatever your server included in the APNs notification body.
func didReceiveLocationPushPayload(
_ payload: [String: Any],
completion: @escaping () -> Void
) {
self.completion = completion
// 3. Create a location manager and request a single location fix.
// The user must have granted "Always" location permission to the
// main app for this to succeed.
self.locationManager = CLLocationManager()
self.locationManager!.delegate = self
self.locationManager!.requestLocation()
}
// 4. Called if the system is about to terminate the extension before
// completion() was called. Use this as a last-chance cleanup.
func serviceExtensionWillTerminate() {
self.completion?()
}
// MARK: - CLLocationManagerDelegate
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
guard let location = locations.first else {
self.completion?()
return
}
// 5. Process the location. In a real app, you would end-to-end
// encrypt the coordinates and send them to your server.
let latitude = location.coordinate.latitude
let longitude = location.coordinate.longitude
print("Location: \(latitude), \(longitude)")
// 6. Send location to your server (simplified example).
sendLocationToServer(latitude: latitude, longitude: longitude) {
// 7. ALWAYS call completion() when done. Failing to call it
// will cause the system to terminate the extension after ~30s.
self.completion?()
}
}
func locationManager(
_ manager: CLLocationManager,
didFailWithError error: Error
) {
// 8. Location request failed (e.g. location services disabled,
// no GPS fix available). Still must call completion().
print("Location error: \(error.localizedDescription)")
self.completion?()
}
private func sendLocationToServer(
latitude: Double,
longitude: Double,
done: @escaping () -> Void
) {
// 9. In production, create a URLSession data task to POST the
// encrypted location to your server. Call done() in the
// completion handler.
var request = URLRequest(
url: URL(string: "https://api.example.com/location")!
)
request.httpMethod = "POST"
request.httpBody = try? JSONSerialization.data(withJSONObject: [
"lat": latitude,
"lng": longitude,
"timestamp": Date().timeIntervalSince1970,
])
URLSession.shared.dataTask(with: request) { _, _, _ in
done()
}.resume()
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target location-push
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 15.0+ | Full support. Requires managed entitlement approval from Apple. |
| iPadOS | 15.0+ | Supported on iPads with location hardware. |
| macOS | -- | Not supported. |
| watchOS | -- | Not supported. |
| visionOS | -- | Not supported. |
| tvOS | -- | Not supported. |
## Gotchas
- **Requires Apple entitlement approval.** The `com.apple.developer.location.push` entitlement is a managed capability. You must apply through your Apple Developer account and be approved before you can distribute the app. Development provisioning profiles may work without approval, but distribution profiles require it. Approval can take weeks to months.
- **User must grant "Always" location permission.** The extension only fires if the user has granted your app "Always" location authorization. "When In Use" is not sufficient because the extension runs when the app is not in the foreground.
- **APNs push type must be "location".** The push notification sent by your server must use `apns-push-type: location` in the HTTP/2 headers and the topic must be `<bundle-id>.location-query`. Using the wrong push type or topic will silently fail.
- **Extension has approximately 30 seconds to complete.** If you do not call the `completion()` handler within about 30 seconds, the system calls `serviceExtensionWillTerminate()` and kills the extension. Plan your network requests accordingly.
- **Single location fix only.** `requestLocation()` delivers one location and then stops. You cannot start continuous location updates or use `startUpdatingLocation()` within the extension. The fix accuracy depends on available hardware and conditions.
- **Deployment target mismatch causes silent failure.** Xcode may set the extension's deployment target to the latest iOS version by default. If the device runs an older iOS version, the extension silently fails with a `missingPushExtension` error. Ensure the deployment target is set to iOS 15.0.
- **No background processing beyond the location request.** The extension cannot schedule background tasks, play audio, or perform other background activities. It exists solely to handle the location push and respond.
- **End-to-end encryption is strongly recommended.** Apple designed this API for peer-to-peer location sharing. Transmitting raw coordinates to a server without encryption is a privacy concern and may affect App Store review.
- **Token is separate from regular push tokens.** The token returned by `startMonitoringLocationPushes(completion:)` is distinct from your regular APNs push token. You must manage and send this token separately to your server.
matter.md
---
title: Matter Device Setup Extension
description: Provides a custom commissioning flow for pairing Matter smart home devices into your ecosystem, letting you customize network selection, credential validation, and room assignment.
version: iOS 16.1+
---
# Matter Device Setup Extension (`matter`)
A Matter device setup extension lets your smart home app customize the commissioning experience when users add Matter-compatible accessories. The system handles the Matter protocol handshake, QR code scanning, and network credential management, while your extension customizes the setup UX by validating device credentials, selecting Wi-Fi or Thread networks, commissioning the device onto your fabric, and configuring room assignments. The extension launches in response to `MatterAddDeviceRequest.perform()` called from your main app and runs as a separate process.
## Apple Documentation
- [MatterSupport Framework Overview](https://developer.apple.com/documentation/mattersupport/)
- [MatterAddDeviceExtensionRequestHandler](https://developer.apple.com/documentation/mattersupport/matteradddeviceextensionrequesthandler)
- [Adding Matter Support to Your Ecosystem](https://developer.apple.com/documentation/mattersupport/adding-matter-support-to-your-ecosystem)
- [MatterAddDeviceRequest](https://developer.apple.com/documentation/mattersupport/matteradddevicerequest)
- [Matter Framework (low-level)](https://developer.apple.com/documentation/matter)
- [Apple Home -- Matter](https://developer.apple.com/apple-home/matter/)
## WWDC History
- **[WWDC 2021, Session 10298 -- Add Support for Matter in Your Smart Home App](https://developer.apple.com/videos/play/wwdc2021/10298/)** -- Introduced Matter as a new industry standard for smart home accessories, covered the initial HomeKit + Matter integration, and explained how iOS handles accessory pairing, network provisioning, and multi-ecosystem support.
## What It Does
1. **Your app initiates pairing.** The main app creates a `MatterAddDeviceRequest` and calls `perform()`. iOS takes over and presents the pairing UI, including QR code scanning.
2. **System performs device discovery.** iOS scans the Matter setup code, discovers the device over BLE, and begins the PASE (Passcode Authenticated Session Establishment) handshake.
3. **Extension validates credentials.** The system calls `validateDeviceCredential(_:)` so your extension can perform additional attestation checks against your ecosystem's requirements.
4. **Extension selects network.** Depending on the device's capabilities, the system calls `selectWiFiNetwork(from:)` or `selectThreadNetwork(from:)`. Your extension can pick a specific network or return `.defaultSystemNetwork` to use the device's current network.
5. **Extension commissions the device.** The system calls `commissionDevice(in:onboardingPayload:commissioningID:)` with a payload your extension uses to commission the device onto your fabric via your Matter stack.
6. **Extension configures rooms.** The system calls `rooms(in:)` to get available rooms, then `configureDevice(named:in:)` once the user has named the device and assigned it to a room.
## Use Cases
### Smart home hub manufacturers
A company building a smart home ecosystem app uses the extension to commission Matter devices onto their proprietary fabric, ensuring the device is registered with their cloud backend and assigned to the correct home and room in their system.
### Lighting and appliance manufacturers
A lighting company ships a companion app that uses the Matter extension to pair their bulbs and switches. The extension validates that the device is a genuine product via `validateDeviceCredential(_:)` before commissioning, and selects the optimal Thread network based on signal strength.
### Multi-ecosystem pairing
A smart home platform uses the extension to add devices that are already paired with Apple Home or another ecosystem. Matter's multi-admin feature allows the same physical device to exist on multiple fabrics, and the extension handles the second-fabric commissioning flow.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `MatterAddDeviceExtensionRequestHandler` | Abstract base class you subclass. Provides overridable methods for each step of the commissioning flow. |
| `MatterAddDeviceRequest` | Created by your main app to initiate pairing. Call `perform()` to launch the system UI and your extension. |
| `MatterAddDeviceRequest.Home` | Represents a home in your ecosystem. Passed to `commissionDevice(in:...)` and `rooms(in:)`. |
| `MatterAddDeviceRequest.Room` | Represents a room within a home. Return these from `rooms(in:)` for the user to choose from. |
| `MatterAddDeviceExtensionRequestHandler.DeviceCredential` | Contains attestation data for the device. Passed to `validateDeviceCredential(_:)`. |
| `MatterAddDeviceExtensionRequestHandler.WiFiScanResult` | A discovered Wi-Fi network with SSID and signal info. |
| `MatterAddDeviceExtensionRequestHandler.ThreadScanResult` | A discovered Thread network with network name and extended PAN ID. |
| `MatterAddDeviceExtensionRequestHandler.WiFiNetworkAssociation` | Return type for `selectWiFiNetwork(from:)`. Use `.defaultSystemNetwork` or `.network(wifi:)`. |
| `MatterAddDeviceExtensionRequestHandler.ThreadNetworkAssociation` | Return type for `selectThreadNetwork(from:)`. Use `.defaultSystemNetwork` or `.network(thread:)`. |
## Implementation
```swift
import MatterSupport
// 1. Subclass MatterAddDeviceExtensionRequestHandler as the principal class.
class RequestHandler: MatterAddDeviceExtensionRequestHandler {
// 2. Validate the device's attestation certificate against your ecosystem.
override func validateDeviceCredential(
_ deviceCredential: MatterAddDeviceExtensionRequestHandler.DeviceCredential
) async throws {
// Check the device's DAC (Device Attestation Certificate) against
// your ecosystem's list of approved vendors.
let isApproved = try await EcosystemAPI.shared.validateAttestation(
dacCertificate: deviceCredential.certificationDeclaration
)
guard isApproved else {
throw MatterAddDeviceError.attestationFailed
}
}
// 3. Choose a Wi-Fi network for the device.
override func selectWiFiNetwork(
from wifiScanResults: [MatterAddDeviceExtensionRequestHandler.WiFiScanResult]
) async throws -> MatterAddDeviceExtensionRequestHandler.WiFiNetworkAssociation {
// Return .defaultSystemNetwork to use the iPhone's current Wi-Fi,
// or pick a specific network from the scan results.
return .defaultSystemNetwork
}
// 4. Choose a Thread network for Thread-capable devices.
override func selectThreadNetwork(
from threadScanResults: [MatterAddDeviceExtensionRequestHandler.ThreadScanResult]
) async throws -> MatterAddDeviceExtensionRequestHandler.ThreadNetworkAssociation {
// Use the system default Thread network unless your ecosystem
// manages its own border routers.
return .defaultSystemNetwork
}
// 5. Commission the device onto your fabric using the onboarding payload.
override func commissionDevice(
in home: MatterAddDeviceRequest.Home?,
onboardingPayload: String,
commissioningID: UUID
) async throws {
// The system has already opened a commissioning window on the device.
// Use the onboardingPayload to establish a CASE session and write
// your fabric credentials.
try await MatterStack.shared.commission(
payload: onboardingPayload,
commissioningID: commissioningID,
homeID: home?.displayName
)
}
// 6. Return the rooms in the selected home for the user to pick from.
override func rooms(
in home: MatterAddDeviceRequest.Home?
) async -> [MatterAddDeviceRequest.Room] {
guard let home else { return [] }
let rooms = await EcosystemAPI.shared.fetchRooms(for: home.displayName)
return rooms.map { MatterAddDeviceRequest.Room(displayName: $0.name) }
}
// 7. Finalize configuration after the user names the device and picks a room.
override func configureDevice(
named name: String,
in room: MatterAddDeviceRequest.Room?
) async {
await EcosystemAPI.shared.registerDevice(
name: name,
room: room?.displayName
)
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target matter
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 16.1+ | Full MatterSupport framework with device setup extension. |
| iPadOS | 16.1+ | Supported. |
| macOS | -- | Not supported. Matter commissioning is iOS/iPadOS only. |
| watchOS | -- | Not supported. |
| tvOS | 16.1+ | HomePod and Apple TV act as Matter controllers but do not run extensions. |
| visionOS | -- | Not supported. |
## Gotchas
- **Entitlement required for distribution.** Development builds can use the "MatterSupport -- Development Only" entitlement enabled in Xcode. For App Store distribution, you must request the managed `com.apple.developer.matter.allow-setup-payload` entitlement from Apple.
- **Extension and app cannot share data bidirectionally.** You can read App Group data written by the main app inside the extension, but writing data from the extension back to the main app via App Groups is unreliable. Use `os_log` and Console.app for debugging the extension.
- **The system may abort the flow after first-fabric commissioning.** A known issue causes `HomeUIService` to stop the setup flow after commissioning to the first fabric, displaying `HMErrorDomain Code=2`. This can happen intermittently and is outside your control.
- **Thread device commissioning can fail with custom border routers.** If your ecosystem manages its own Thread border router rather than using the Apple Home infrastructure, selecting a custom Thread network in `selectThreadNetwork(from:)` may fail silently. Test thoroughly with your specific hardware.
- **`commissionDevice` is not called on re-pairing.** If a device was previously removed from a third-party ecosystem but its fabric entry remains in the iOS keychain, the system may skip calling `commissionDevice` entirely. The user may need to factory-reset the device.
- **The extension has no UI of its own.** The system manages the entire visual flow (QR scanning, home/room selection, progress). Your extension only provides data and logic via the handler overrides. You cannot present custom view controllers.
- **Matter protocol version matters.** Ensure your Matter stack supports the same specification version as the device. Mismatched versions can cause silent commissioning failures that surface as generic errors in the extension.
message-filter.md
---
title: SMS/MMS Message Filter Extension
description: Filters SMS and MMS messages from unknown senders into Junk, Promotions, or Transactions folders in the Messages app.
version: iOS 11.0+
---
# SMS/MMS Message Filter Extension (`message-filter`)
A Message Filter extension lets your app classify incoming SMS and MMS messages from unknown senders (people not in the recipient's contacts). When a message arrives from an unknown number, iOS passes the sender and message body to your extension, which returns a filtering action -- allow, junk, promotion, or transaction. The system then routes the message to the appropriate folder in the Messages app. The extension operates entirely on-device by default, but can optionally defer to a network server for more sophisticated classification. Only messages from unknown senders are ever passed to the extension; messages from known contacts are never filtered.
## Apple Documentation
- [Creating a Message Filter App Extension](https://developer.apple.com/documentation/identitylookup/creating-a-message-filter-app-extension)
- [ILMessageFilterExtension](https://developer.apple.com/documentation/identitylookup/ilmessagefilterextension)
- [ILMessageFilterQueryHandling](https://developer.apple.com/documentation/identitylookup/ilmessagefilterqueryhandling)
- [ILMessageFilterAction](https://developer.apple.com/documentation/identitylookup/ilmessagefilteraction)
- [ILMessageFilterQueryResponse (subAction)](https://developer.apple.com/documentation/identitylookup/ilmessagefilterqueryresponse/subaction)
- [SMS and MMS Message Filtering](https://developer.apple.com/documentation/identitylookup/sms-and-mms-message-filtering)
- [IdentityLookup Framework](https://developer.apple.com/documentation/identitylookup)
## WWDC History
- **[WWDC 2017, Session 249 -- Filtering Unwanted Messages with Identity Lookup](https://developer.apple.com/videos/play/wwdc2017/249/)** -- Introduced the IdentityLookup framework and the Message Filter extension point, covering on-device classification and network deferral.
- **[WWDC 2022, Session 110341 -- Explore SMS Message Filters](https://developer.apple.com/videos/play/wwdc2022/110341/)** -- Added 12 sub-categories under Promotions and Transactions (iOS 16), plus the `ILMessageFilterCapabilitiesQueryHandling` protocol for declaring supported sub-actions.
## What It Does
1. **Unknown sender message arrives.** When an SMS or MMS is received from a number not in the user's contacts, iOS invokes the enabled Message Filter extension.
2. **Extension receives the query.** Your `ILMessageFilterQueryHandling` implementation receives an `ILMessageFilterQueryRequest` containing the sender and message body.
3. **On-device classification.** Your extension inspects the message content and returns an `ILMessageFilterQueryResponse` with an action: `.allow`, `.junk`, `.promotion`, or `.transaction`.
4. **Optional network deferral.** If your extension returns `.none`, the system forwards the query to your server (URL configured in Info.plist under `ILMessageFilterExtensionNetworkURL`). The server response is passed back through your extension for final classification.
5. **Sub-categories (iOS 16+).** Your extension can declare supported sub-actions (e.g. Finance, Orders, Health under Transactions; Coupons, Offers under Promotions) via `ILMessageFilterCapabilitiesQueryHandling`. Messages are then sorted into sub-folders in the Messages app.
6. **User enables in Settings.** The extension is inactive until the user goes to Settings > Messages > Unknown & Spam and selects your filter.
## Use Cases
### Spam and phishing protection
A security app scans message content for known phishing patterns (shortened URLs, urgency language, OTP interception attempts) and marks matching messages as junk. The user never sees them in their primary inbox.
### Promotional message organization
An e-commerce companion app classifies delivery notifications as Transactions and marketing offers as Promotions, letting the user keep their primary SMS inbox clean while still seeing important order updates.
### Regional SMS filtering
In countries with high SMS spam volume (India, Brazil), a locally popular filter app uses a combination of on-device keyword matching and server-side crowd-sourced databases to classify messages into granular sub-categories like Finance, Health, and Public Services.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `ILMessageFilterExtension` | Abstract base class for the extension's principal class. Subclass this to create your filter. |
| `ILMessageFilterQueryHandling` | Protocol your extension adopts. Requires `handle(_:context:completion:)` to classify each message. |
| `ILMessageFilterQueryRequest` | Contains the sender's address and the message body. Passed to your handler. |
| `ILMessageFilterQueryResponse` | Your reply. Set `.action` to `.allow`, `.junk`, `.promotion`, or `.transaction`. Optionally set `.subAction` (iOS 16+). |
| `ILMessageFilterAction` | Enum of top-level filter actions: `.none`, `.allow`, `.junk`, `.promotion`, `.transaction`. |
| `ILMessageFilterSubAction` | Enum of sub-categories (iOS 16+): `.transactionalFinance`, `.transactionalOrders`, `.transactionalHealth`, `.promotionalOffers`, `.promotionalCoupons`, and more. |
| `ILMessageFilterCapabilitiesQueryHandling` | Protocol (iOS 16+) for declaring which sub-actions your filter supports. The system calls this to configure folder UI. |
| `ILMessageFilterExtensionContext` | Extension context providing `deferQueryRequestToNetwork()` for server-side classification. |
## Implementation
```swift
import IdentityLookup
// 1. Subclass ILMessageFilterExtension -- the system loads this as the
// extension's principal class.
final class MessageFilterExtension: ILMessageFilterExtension {}
// 2. Adopt ILMessageFilterQueryHandling to receive and classify messages.
extension MessageFilterExtension: ILMessageFilterQueryHandling {
func handle(
_ queryRequest: ILMessageFilterQueryRequest,
context: ILMessageFilterExtensionContext,
completion: @escaping (ILMessageFilterQueryResponse) -> Void
) {
// 3. Attempt to classify the message using on-device logic first.
let (action, subAction) = classifyLocally(queryRequest)
if action != .none {
// 4. We have a confident local classification -- respond immediately.
let response = ILMessageFilterQueryResponse()
response.action = action
response.subAction = subAction
completion(response)
} else {
// 5. Uncertain -- defer to the network server configured in
// Info.plist under ILMessageFilterExtensionNetworkURL.
// The system sends the query to your server and calls back
// with the server's JSON response.
context.deferQueryRequestToNetwork { networkResponse, error in
let response = ILMessageFilterQueryResponse()
if let networkResponse = networkResponse {
// 6. Parse the server response and set the action.
response.action = self.action(
for: networkResponse
)
} else {
// 7. Network failed -- allow the message through rather
// than silently dropping legitimate messages.
response.action = .allow
}
completion(response)
}
}
}
// 8. Local classification using keyword matching, regex, or a
// local database of known spam senders.
private func classifyLocally(
_ request: ILMessageFilterQueryRequest
) -> (ILMessageFilterAction, ILMessageFilterSubAction) {
guard let body = request.messageBody?.lowercased() else {
return (.none, .none)
}
// 9. Example: classify OTP and banking messages as transactions.
let transactionKeywords = ["otp", "verification code", "one-time password", "account ending"]
if transactionKeywords.contains(where: { body.contains($0) }) {
return (.transaction, .transactionalFinance)
}
// 10. Example: classify marketing messages as promotions.
let promoKeywords = ["unsubscribe", "limited time", "% off", "free shipping"]
if promoKeywords.contains(where: { body.contains($0) }) {
return (.promotion, .promotionalOffers)
}
// 11. No confident match -- return .none to defer to network or allow.
return (.none, .none)
}
private func action(
for networkResponse: ILNetworkResponse
) -> ILMessageFilterAction {
// 12. Parse the server's JSON response. The format is up to you, but
// the response must map to an ILMessageFilterAction.
guard let data = networkResponse.urlResponse.url.flatMap({ _ in
try? JSONSerialization.jsonObject(with: Data()) as? [String: Any]
}) else {
return .allow
}
switch data["action"] as? String {
case "junk": return .junk
case "promotion": return .promotion
case "transaction": return .transaction
default: return .allow
}
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target message-filter
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 11.0+ | Full support. Sub-categories added in iOS 16.0+. |
| iPadOS | 11.0+ | Supported on iPads with cellular capability. |
| macOS | -- | Not supported. |
| watchOS | -- | Not supported. |
| visionOS | -- | Not supported. |
| tvOS | -- | Not supported. |
## Gotchas
- **Only unknown senders are filtered.** Messages from contacts, known senders, and iMessage conversations are never passed to the extension. Your filter has zero visibility into messages from people in the user's address book.
- **The extension never sees iMessages.** Only SMS and MMS messages are filtered. iMessage (blue bubble) conversations are completely excluded from the IdentityLookup pipeline.
- **Network URL must be HTTPS and statically configured.** The `ILMessageFilterExtensionNetworkURL` in Info.plist must be a hardcoded HTTPS URL. It cannot vary per user or per request. The server must not require any ATS exceptions, and the URL must pass Associated Domains validation.
- **Sub-categories are region-dependent.** While the API is available globally, the Messages app folder UI for Transactions, Promotions, and sub-categories is only surfaced in certain regions (notably India and Brazil as of iOS 16). In other regions, messages are classified as Junk or allowed through without sub-folder sorting.
- **User must manually enable the filter.** Go to Settings > Messages > Unknown & Spam and select your filter. Only one third-party SMS filter can be active at a time across all installed apps.
- **Privacy constraints prevent data exfiltration.** The extension cannot make arbitrary network requests. The only network path is the system-managed deferral to your configured server URL. You cannot send message content to analytics or other endpoints.
- **No access to message metadata beyond sender and body.** The `ILMessageFilterQueryRequest` provides only the sender address and message body text. You do not receive timestamps, attachments, or thread context.
- **Sub-action support requires capabilities declaration (iOS 16+).** To use sub-categories, your extension must also adopt `ILMessageFilterCapabilitiesQueryHandling` and return the sub-actions you support in `handle(_:capabilitiesQueryRequest:context:completion:)`. Without this, the system only uses top-level actions.
network-app-proxy.md
---
title: App Proxy Provider
description: Implements a flow-oriented network proxy that routes specific app traffic through a custom proxy server, operating at the TCP/UDP flow level rather than raw IP packets.
version: iOS 9.0+, macOS 10.11+
---
# App Proxy Provider (`network-app-proxy`)
A network extension that implements the client side of a custom app-layer proxy protocol. Unlike the packet tunnel provider which captures all device traffic as raw IP packets, the app proxy provider operates at the flow level, receiving individual TCP and UDP connections (`NEAppProxyTCPFlow` and `NEAppProxyUDPFlow`) from specific apps matched by per-app VPN rules. This makes it ideal for enterprise per-app VPN solutions where only designated managed apps need to route traffic through a proxy.
## Apple Documentation
- [App Proxy Provider](https://developer.apple.com/documentation/networkextension/app-proxy-provider) -- overview guide for implementing a flow-oriented VPN client
- [NEAppProxyProvider](https://developer.apple.com/documentation/networkextension/neappproxyprovider) -- principal class to subclass
- [NEAppProxyProviderManager](https://developer.apple.com/documentation/networkextension/neappproxyprovidermanager) -- configure and control the proxy from the containing app
- [NEAppProxyTCPFlow](https://developer.apple.com/documentation/networkextension/neappproxytcpflow) -- represents a TCP connection from a matched app
- [NEAppProxyUDPFlow](https://developer.apple.com/documentation/networkextension/neappproxyudpflow) -- represents a UDP datagram session from a matched app
- [NEAppProxyFlow](https://developer.apple.com/documentation/networkextension/neappproxyflow) -- abstract base class for TCP and UDP flows
## WWDC History
- **[WWDC 2015, Session 717 -- What's New in Network Extension and VPN](https://developer.apple.com/videos/play/wwdc2015/717/)** -- Introduced NEAppProxyProvider as one of two tunnel provider types (alongside packet tunnel). Explained the flow-level vs. IP-level distinction and per-app VPN rules.
- **[WWDC 2017, Session 707 -- Advances in Networking, Part 1](https://developer.apple.com/videos/play/wwdc2017/707/)** -- Covered Network Extension framework updates and improvements to provider lifecycle handling.
- **[WWDC 2019, Session 714 -- Network Extensions for the Modern Mac](https://developer.apple.com/videos/play/wwdc2019/714/)** -- Introduced System Extensions on macOS Catalina, enabling app proxy providers to run as system extensions. Also introduced `NETransparentProxyProvider` as a macOS-only alternative.
## What It Does
1. An MDM profile or app configuration defines per-app VPN rules (`NEAppRule`) that specify which apps should route traffic through the proxy.
2. When a matched app opens a network connection, the system launches your extension and calls `startProxy(options:completionHandler:)`.
3. Your extension establishes a connection to the proxy server using your custom protocol.
4. For each new connection from a matched app, the system calls `handleNewFlow(_:)` with an `NEAppProxyTCPFlow` or `NEAppProxyUDPFlow`.
5. Your extension opens the flow, reads data from it, forwards it through the proxy server, and writes response data back to the flow.
6. When the proxy configuration is removed or the system decides to stop, it calls `stopProxy(with:completionHandler:)`.
## Use Cases
### Enterprise Per-App VPN
Route traffic from specific managed apps (e.g., corporate email, internal wikis) through a secure proxy while leaving personal app traffic on the normal network path. This is the primary use case Apple designed the API for.
### App-Specific Proxy Servers
Proxy traffic from designated applications through a custom protocol that performs inspection, logging, or transformation at the application flow level. Because you receive typed TCP and UDP flows rather than raw packets, you can apply proxy logic without IP-level parsing.
### Selective Traffic Routing
Direct traffic from specific apps through different network paths based on business rules -- for example, routing a finance app through a dedicated secure channel while other corporate apps use a general VPN.
## Key Classes
| Class | Role |
|-------|------|
| `NEAppProxyProvider` | Subclass this. The system calls `startProxy`, `stopProxy`, and `handleNewFlow` on it. |
| `NEAppProxyProviderManager` | Used by the containing app to install, configure, and manage the proxy. Holds `NEAppRule` routing rules. |
| `NEAppProxyTCPFlow` | A single TCP connection from a matched app. Call `open` to begin, then `readData` / `write` to shuttle bytes. |
| `NEAppProxyUDPFlow` | A related stream of UDP datagrams. Call `open` then `readDatagrams` / `writeDatagrams`. |
| `NEAppProxyFlow` | Abstract base of TCP/UDP flows. Provides `metaData` (source app bundle ID, etc.) and `networkInterface`. |
| `NETunnelProviderProtocol` | Configuration object holding server address, provider bundle ID, and custom `providerConfiguration` dictionary. |
## Implementation
### App Proxy Provider (Per-App VPN Proxy)
```swift
import NetworkExtension
class AppProxyProvider: NEAppProxyProvider {
private var serverConnection: NWTCPConnection?
// 1. System calls this when a matched app triggers the proxy.
override func startProxy(
options: [String: Any]? = nil,
completionHandler: @escaping (Error?) -> Void
) {
let serverAddress = protocolConfiguration.serverAddress ?? "proxy.example.com"
// 2. Connect to the proxy server outside the tunnel.
let endpoint = NWHostEndpoint(hostname: serverAddress, port: "8443")
serverConnection = createTCPConnectionThroughTunnel(
to: endpoint,
enableTLS: true,
tlsParameters: nil,
delegate: nil
)
serverConnection?.addObserver(self, forKeyPath: "state", context: nil)
completionHandler(nil)
}
// 3. System calls this when a matched app opens a new connection.
override func handleNewFlow(_ flow: NEAppProxyFlow) -> Bool {
if let tcpFlow = flow as? NEAppProxyTCPFlow {
handleTCPFlow(tcpFlow)
return true
} else if let udpFlow = flow as? NEAppProxyUDPFlow {
handleUDPFlow(udpFlow)
return true
}
// 4. Return false to reject flows you cannot handle.
return false
}
// 5. Open the TCP flow and begin reading data from the app.
private func handleTCPFlow(_ flow: NEAppProxyTCPFlow) {
flow.open(withLocalEndpoint: nil) { error in
if let error = error {
flow.closeReadWithError(error)
flow.closeWriteWithError(error)
return
}
self.readFromTCPFlow(flow)
}
}
// 6. Read loop: pull data from the app's TCP connection.
private func readFromTCPFlow(_ flow: NEAppProxyTCPFlow) {
flow.readData { data, error in
if let error = error {
flow.closeReadWithError(error)
return
}
guard let data = data, !data.isEmpty else {
// 7. Empty data means the flow has ended.
flow.closeReadWithError(nil)
return
}
// 8. Forward the data to the proxy server, then continue reading.
self.forwardToServer(data) { responseData in
flow.write(responseData) { writeError in
if writeError != nil {
flow.closeWriteWithError(writeError)
return
}
self.readFromTCPFlow(flow)
}
}
}
}
// 9. Handle UDP flows similarly using readDatagrams/writeDatagrams.
private func handleUDPFlow(_ flow: NEAppProxyUDPFlow) {
flow.open(withLocalEndpoint: nil) { error in
guard error == nil else { return }
self.readFromUDPFlow(flow)
}
}
private func readFromUDPFlow(_ flow: NEAppProxyUDPFlow) {
flow.readDatagrams { datagrams, endpoints, error in
guard let datagrams = datagrams,
let endpoints = endpoints,
error == nil else { return }
// 10. Forward each datagram to the proxy server.
for (datagram, endpoint) in zip(datagrams, endpoints) {
self.forwardDatagramToServer(datagram, destination: endpoint)
}
self.readFromUDPFlow(flow)
}
}
private func forwardToServer(
_ data: Data,
completion: @escaping (Data) -> Void
) {
// Forward data through the proxy server connection and
// return the response. Implementation depends on your protocol.
}
private func forwardDatagramToServer(_ data: Data, destination: NWEndpoint) {
// Forward UDP datagram through the proxy server.
}
// 11. System calls this to tear down the proxy.
override func stopProxy(
with reason: NEProviderStopReason,
completionHandler: @escaping () -> Void
) {
serverConnection?.cancel()
serverConnection = nil
completionHandler()
}
// 12. Receive messages from the containing app.
override func handleAppMessage(
_ messageData: Data,
completionHandler: ((Data?) -> Void)?
) {
completionHandler?(messageData)
}
override func sleep(completionHandler: @escaping () -> Void) {
completionHandler()
}
override func wake() {
// Re-establish server connection if needed.
}
}
```
### Containing App -- Installing the Proxy Configuration
```swift
import NetworkExtension
func installAppProxyProfile() {
let manager = NEAppProxyProviderManager()
let proto = NETunnelProviderProtocol()
proto.providerBundleIdentifier = "com.example.app.AppProxy"
proto.serverAddress = "proxy.example.com"
proto.providerConfiguration = ["port": "8443"]
manager.protocolConfiguration = proto
manager.localizedDescription = "Corporate App Proxy"
manager.isEnabled = true
// Per-app VPN rules: only proxy traffic from these apps.
let mailRule = NEAppRule(signingIdentifier: "com.example.corporate-mail")
let wikiRule = NEAppRule(signingIdentifier: "com.example.internal-wiki")
manager.appRules = [mailRule, wikiRule]
manager.saveToPreferences { error in
if let error = error {
print("Failed to save proxy profile: \(error)")
}
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target network-app-proxy
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 9.0+ | Full support. Requires MDM or configuration profile for per-app VPN rules. |
| iPadOS | 9.0+ | Full support. |
| macOS | 10.11+ | Runs as app extension (10.11) or system extension (10.15+). Consider `NETransparentProxyProvider` on macOS 11+. |
| tvOS | 17.0+ | Added in tvOS 17. |
| visionOS | 1.0+ | Supported. |
| watchOS | -- | Not supported. |
## Gotchas
- **Network Extension entitlement required.** You must request the Network Extension entitlement from Apple via [the request form](https://developer.apple.com/contact/request/network-extension/). Your app will be rejected without it. This is a manual approval process that can take days or weeks.
- **Per-app VPN rules require MDM or configuration profile.** On iOS, `NEAppRule` matching only works when the proxy is configured via a managed app configuration or MDM profile. You cannot set up per-app VPN rules purely in code on unmanaged devices.
- **Returning `false` from `handleNewFlow` drops the connection.** Unlike `NETransparentProxyProvider` (macOS 11+), where returning `false` lets the OS handle the flow normally, `NEAppProxyProvider` will discard the flow entirely if you return `false`. You must proxy every flow you receive.
- **Flows are app-layer, not IP packets.** You receive typed `NEAppProxyTCPFlow` and `NEAppProxyUDPFlow` objects, not raw bytes. This is more convenient than packet tunnel but means you cannot inspect or modify IP headers.
- **`open` must complete before reading.** You must call `open(withLocalEndpoint:completionHandler:)` on a flow and wait for the completion handler before calling `readData` or `readDatagrams`. Reading before the flow is open will fail silently.
- **Memory limits are strict.** Like all network extensions, the app proxy provider runs in a constrained memory environment (~15 MB on iOS). Avoid buffering large amounts of data from multiple concurrent flows.
- **Consider `NETransparentProxyProvider` on macOS.** If you only target macOS 11+, the transparent proxy provider is a better choice: it supports selective proxying (return `false` to let the OS handle a flow) and does not require per-app VPN rules. `NEAppProxyProvider` remains necessary for iOS or older macOS support.
- **`handleNewUDPFlow(_:initialRemoteEndpoint:)` was added in iOS 13.** Before iOS 13, UDP flows arrived via the base `handleNewFlow(_:)` without an initial remote endpoint. Override the newer method if you need the destination address upfront and your minimum target is iOS 13+.
network-dns-proxy.md
---
title: DNS Proxy Provider
description: Intercepts all system DNS queries and resolves them through a custom DNS resolver, enabling encrypted DNS (DoH/DoT) or DNS-based filtering.
version: iOS 11.0+, macOS 10.15+
---
# DNS Proxy Provider (`network-dns-proxy`)
A network extension that intercepts every DNS query on the device and resolves them through your custom DNS resolver. The system diverts all DNS traffic to your `NEDNSProxyProvider` subclass, which receives queries as `NEAppProxyUDPFlow` (and occasionally `NEAppProxyTCPFlow` for TCP-based DNS). You must parse raw DNS packets yourself, forward them to your resolver using any protocol you choose (DNS-over-HTTPS, DNS-over-TLS, or plain DNS), and write the response back to the flow. This gives you complete control over DNS resolution system-wide.
## Apple Documentation
- [DNS Proxy Provider](https://developer.apple.com/documentation/networkextension/dns-proxy-provider) -- overview guide for building a custom DNS proxy
- [NEDNSProxyProvider](https://developer.apple.com/documentation/networkextension/nednsproxyprovider) -- principal class to subclass
- [NEDNSProxyManager](https://developer.apple.com/documentation/networkextension/nednsproxymanager) -- configure and enable the DNS proxy from the containing app
- [NEDNSProxyProviderProtocol](https://developer.apple.com/documentation/networkextension/nednsproxyproviderprotocol) -- configuration parameters for the DNS proxy
- [NEAppProxyUDPFlow](https://developer.apple.com/documentation/networkextension/neappproxyudpflow) -- UDP flow carrying DNS query datagrams
- [NEAppProxyTCPFlow](https://developer.apple.com/documentation/networkextension/neappproxytcpflow) -- TCP flow for DNS-over-TCP queries
## WWDC History
- **[WWDC 2017, Session 707 -- Advances in Networking, Part 1](https://developer.apple.com/videos/play/wwdc2017/707/)** -- Introduced `NEDNSProxyProvider` in iOS 11 as a way to implement custom DNS resolution, including DNS-over-HTTPS and DNS-over-TLS, with full control over all DNS traffic.
- **[WWDC 2019, Session 714 -- Network Extensions for the Modern Mac](https://developer.apple.com/videos/play/wwdc2019/714/)** -- Brought DNS proxy providers to macOS via system extensions. Also introduced `NEDNSSettingsManager` as a simpler alternative for configuring system DNS to use DoH/DoT without writing a full proxy.
- **[WWDC 2025, Session 234 -- Filter and Tunnel Network Traffic with NetworkExtension](https://developer.apple.com/videos/play/wwdc2025/234/)** -- Updated guidance on network extension providers including DNS proxy lifecycle improvements.
## What It Does
1. The containing app uses `NEDNSProxyManager` to install and enable the DNS proxy configuration.
2. Once active, the system diverts all DNS queries to your extension instead of the system resolver.
3. For each DNS query, the system calls `handleNewFlow(_:)` with an `NEAppProxyUDPFlow` (for standard UDP DNS) or `NEAppProxyTCPFlow` (for DNS-over-TCP).
4. Your extension calls `open` on the flow, then reads raw DNS query datagrams from it.
5. You parse the DNS packet, forward the query to your resolver (e.g., via HTTPS for DoH or TLS for DoT), and receive the response.
6. You write the DNS response datagram back to the flow.
7. The system delivers the response to the requesting application.
## Use Cases
### Encrypted DNS (DoH / DoT)
Encrypt all device DNS queries using DNS-over-HTTPS or DNS-over-TLS to prevent ISP snooping, DNS hijacking, and man-in-the-middle attacks. The DNS proxy intercepts plain DNS queries and forwards them over an encrypted channel to a trusted resolver like Cloudflare (1.1.1.1) or Google (8.8.8.8).
### DNS-Based Ad and Tracker Blocking
Intercept DNS queries and block resolution for known advertising and tracking domains by returning NXDOMAIN or 0.0.0.0 responses. This provides system-wide ad blocking at the DNS level without inspecting HTTP traffic.
### Custom DNS Routing
Route DNS queries to different resolvers based on the queried domain -- for example, sending internal corporate domain lookups to an on-premise DNS server while routing all other queries to a public resolver.
## Key Classes
| Class | Role |
|-------|------|
| `NEDNSProxyProvider` | Subclass this. The system calls `startProxy`, `stopProxy`, and `handleNewFlow` on it. Receives all DNS queries system-wide. |
| `NEDNSProxyManager` | Used by the containing app to install, configure, and enable the DNS proxy. |
| `NEDNSProxyProviderProtocol` | Configuration object holding server address and provider bundle identifier. |
| `NEAppProxyUDPFlow` | Carries DNS query/response datagrams for standard UDP DNS. Call `readDatagrams` / `writeDatagrams`. |
| `NEAppProxyTCPFlow` | Carries DNS query/response data for TCP-based DNS (less common). Call `readData` / `write`. |
## Implementation
### DNS Proxy Provider (DoH Resolver)
```swift
import NetworkExtension
class DNSProxyProvider: NEDNSProxyProvider {
// 1. System calls this when the DNS proxy is enabled.
override func startProxy(
options: [String: Any]? = nil,
completionHandler: @escaping (Error?) -> Void
) {
// Initialize your DNS resolver connection (e.g., URLSession
// for DoH or NWConnection for DoT).
completionHandler(nil)
}
override func stopProxy(
with reason: NEProviderStopReason,
completionHandler: @escaping () -> Void
) {
completionHandler()
}
// 2. System calls this for every DNS query on the device.
override func handleNewFlow(_ flow: NEAppProxyFlow) -> Bool {
if let udpFlow = flow as? NEAppProxyUDPFlow {
handleDNSUDPFlow(udpFlow)
return true
} else if let tcpFlow = flow as? NEAppProxyTCPFlow {
handleDNSTCPFlow(tcpFlow)
return true
}
return false
}
// 3. Open the UDP flow and read DNS query datagrams.
private func handleDNSUDPFlow(_ flow: NEAppProxyUDPFlow) {
flow.open(withLocalEndpoint: nil) { error in
guard error == nil else { return }
self.readDNSDatagrams(from: flow)
}
}
// 4. Read loop for UDP DNS queries.
private func readDNSDatagrams(from flow: NEAppProxyUDPFlow) {
flow.readDatagrams { datagrams, endpoints, error in
guard let datagrams = datagrams,
let endpoints = endpoints,
error == nil,
!datagrams.isEmpty else {
return
}
// 5. Process each DNS query datagram.
for (datagram, endpoint) in zip(datagrams, endpoints) {
self.resolveDNSQuery(datagram) { response in
// 6. Write the DNS response back to the flow.
flow.writeDatagrams([response], sentBy: [endpoint]) { writeError in
if writeError != nil {
flow.closeWriteWithError(writeError)
}
}
}
}
// 7. Continue reading subsequent queries on this flow.
self.readDNSDatagrams(from: flow)
}
}
// 8. Handle TCP DNS flows (DNS-over-TCP uses a 2-byte length prefix).
private func handleDNSTCPFlow(_ flow: NEAppProxyTCPFlow) {
flow.open(withLocalEndpoint: nil) { error in
guard error == nil else { return }
self.readDNSTCPData(from: flow)
}
}
private func readDNSTCPData(from flow: NEAppProxyTCPFlow) {
flow.readData { data, error in
guard let data = data, !data.isEmpty, error == nil else { return }
// 9. TCP DNS prepends a 2-byte length. Strip it, resolve,
// then write response with the length prefix back.
let queryData = data.dropFirst(2)
self.resolveDNSQuery(Data(queryData)) { response in
var responseWithLength = Data()
var length = UInt16(response.count).bigEndian
responseWithLength.append(
Data(bytes: &length, count: MemoryLayout<UInt16>.size)
)
responseWithLength.append(response)
flow.write(responseWithLength) { _ in }
}
self.readDNSTCPData(from: flow)
}
}
// 10. Forward the DNS query to a DoH resolver (e.g., Cloudflare).
private func resolveDNSQuery(
_ queryData: Data,
completion: @escaping (Data) -> Void
) {
var request = URLRequest(
url: URL(string: "https://cloudflare-dns.com/dns-query")!
)
request.httpMethod = "POST"
request.setValue("application/dns-message", forHTTPHeaderField: "Content-Type")
request.setValue("application/dns-message", forHTTPHeaderField: "Accept")
request.httpBody = queryData
// 11. Use URLSession to send the query over HTTPS.
// The DNS proxy extension CAN make network requests
// (unlike the filter data provider).
URLSession.shared.dataTask(with: request) { data, _, error in
if let data = data, error == nil {
completion(data)
}
}.resume()
}
override func sleep(completionHandler: @escaping () -> Void) {
completionHandler()
}
override func wake() {
// Re-initialize resolver connections if needed.
}
}
```
### Containing App -- Enabling the DNS Proxy
```swift
import NetworkExtension
func enableDNSProxy() {
let manager = NEDNSProxyManager.shared()
manager.loadFromPreferences { error in
if let error = error {
print("Failed to load DNS proxy preferences: \(error)")
return
}
let proto = NEDNSProxyProviderProtocol()
proto.providerBundleIdentifier = "com.example.app.DNSProxy"
proto.serverAddress = "cloudflare-dns.com"
manager.providerProtocol = proto
manager.isEnabled = true
manager.saveToPreferences { error in
if let error = error {
print("Failed to save DNS proxy: \(error)")
}
}
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target network-dns-proxy
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 11.0+ | Requires supervised device or MDM configuration profile for production. Development signing bypasses for testing. |
| iPadOS | 11.0+ | Same requirements as iOS. |
| macOS | 10.15+ | Runs as a system extension. No supervised requirement. |
| Mac Catalyst | 13.0+ | Supported via Catalyst. |
| tvOS | -- | Not supported. |
| visionOS | -- | Not supported. |
| watchOS | -- | Not supported. |
## Gotchas
- **Network Extension entitlement required.** You must request the Network Extension entitlement from Apple via [the request form](https://developer.apple.com/contact/request/network-extension/). Your app will be rejected without it. This is a manual approval process that can take days or weeks.
- **You must parse DNS packets yourself.** Apple does not provide a DNS parsing API. You receive raw DNS wire-format bytes in each datagram and must parse the query (extracting the domain name, record type, transaction ID, etc.) and construct valid DNS response packets. Use a third-party Swift DNS library or write your own parser.
- **Supervised device requirement on iOS.** Like other network extension providers, the DNS proxy requires a supervised device (managed via MDM) for production builds on iOS. Development-signed builds bypass this check. This limits consumer App Store distribution.
- **`NEDNSSettingsManager` is simpler for basic DoH/DoT.** If you only need to redirect DNS to a specific DoH or DoT resolver without inspecting or modifying queries, use `NEDNSSettingsManager` (iOS 14+) instead. It does not require a network extension, a supervised device, or DNS packet parsing.
- **System DoH/DoT bypasses the proxy.** If the system DNS is already configured to use a DoH or DoT resolver (e.g., via Settings or a configuration profile), those encrypted DNS transactions will not be routed to your `NEDNSProxyProvider`. Your `handleNewFlow` will not be called for those queries. This is by design.
- **You must proxy all DNS traffic.** Unlike transparent proxy providers, you cannot selectively handle some queries and let the OS handle others. Every DNS query arrives at your extension, and you must resolve and respond to all of them. Failing to respond will cause DNS timeouts for the querying app.
- **Multiple DNS queries can arrive on a single flow.** A `NEAppProxyUDPFlow` may carry multiple DNS query datagrams over its lifetime, not just one. Your read loop must continue reading after processing each datagram.
- **TCP DNS uses a 2-byte length prefix.** DNS queries over TCP prepend a 2-byte big-endian length before the DNS message. You must strip this prefix when reading queries and add it back when writing responses. Forgetting this will produce malformed responses.
- **Memory and performance constraints apply.** The DNS proxy handles every DNS query on the device, including system services, background refresh, push notifications, and all apps. Slow resolution or memory issues will degrade the entire device's network performance. Keep resolution fast and avoid caching large amounts of state.
network-filter-data.md
---
title: Content Filter Data Provider
description: Inspects network traffic flows on-device and makes allow/block decisions for content filtering without sending user data off the device.
version: iOS 9.0+, macOS 10.15+
---
# Content Filter Data Provider (`network-filter-data`)
A network extension that examines TCP and UDP flows passing through the device and renders allow, block, or need-more-data verdicts for each one. The filter data provider runs in a heavily sandboxed process with read-only access to flow data -- it cannot make network requests or write to disk, ensuring user privacy. A companion filter control provider extension (with network access but no data access) can supply updated rules. This architecture was designed for parental controls, enterprise content filtering, and educational device management.
## Apple Documentation
- [Content Filter Providers](https://developer.apple.com/documentation/networkextension/content-filter-providers) -- overview guide for building on-device content filters
- [NEFilterDataProvider](https://developer.apple.com/documentation/networkextension/nefilterdataprovider) -- principal class to subclass for inspecting flow data
- [NEFilterControlProvider](https://developer.apple.com/documentation/networkextension/nefiltercontrolprovider) -- companion extension that fetches updated rules (has network access)
- [NEFilterManager](https://developer.apple.com/documentation/networkextension/nefiltermanager) -- configure and enable the filter from the containing app
- [NEFilterNewFlowVerdict](https://developer.apple.com/documentation/networkextension/nefilternewflowverdict) -- initial verdict for a new flow (allow, drop, filterData, needRules)
- [NEFilterDataVerdict](https://developer.apple.com/documentation/networkextension/nefilterdataverdict) -- verdict after inspecting flow data chunks (allow, drop, needRules)
- [NEFilterFlow](https://developer.apple.com/documentation/networkextension/nefilterflow) -- base class representing a network flow with metadata
## WWDC History
- **[WWDC 2015, Session 717 -- What's New in Network Extension and VPN](https://developer.apple.com/videos/play/wwdc2015/717/)** -- Introduced the content filter provider architecture with the split data/control provider model for privacy.
- **[WWDC 2019, Session 714 -- Network Extensions for the Modern Mac](https://developer.apple.com/videos/play/wwdc2019/714/)** -- Brought content filter providers to macOS via system extensions. Covered the macOS-specific content filter behavior including the `pause` verdict.
- **[WWDC 2025, Session 234 -- Filter and Tunnel Network Traffic with NetworkExtension](https://developer.apple.com/videos/play/wwdc2025/234/)** -- Introduced URL-based filtering on iOS 26 as a simpler alternative to data-level inspection for URL-based decisions.
## What It Does
1. The containing app uses `NEFilterManager` to install and enable a filter configuration.
2. On iOS, the device must be supervised (MDM-managed) for production builds. Development-signed builds bypass this restriction for testing.
3. Once active, the system passes every new TCP and UDP flow to `handleNewFlow(_:)` as an `NEFilterFlow`.
4. Your extension returns an `NEFilterNewFlowVerdict`: `.allow()`, `.drop()`, `.needRules()`, or `.filterDataVerdict(withFilterInbound:peekInboundBytes:filterOutbound:peekOutboundBytes:)` to request data inspection.
5. If you requested data inspection, the system calls `handleInboundData(from:readBytesStartOffset:readBytes:)` and `handleOutboundData(from:readBytesStartOffset:readBytes:)` as data chunks arrive.
6. You return `NEFilterDataVerdict` for each chunk: `.allow()`, `.drop()`, `.needRules()`, or request more data with a new byte range.
7. When the flow completes, `handleInboundDataComplete(for:)` and `handleOutboundDataComplete(for:)` are called for final decisions.
## Use Cases
### Parental Controls
Filter inappropriate content on children's devices by inspecting flow hostnames and data patterns. Combine with the filter control provider to download updated blocklists without exposing user data.
### Enterprise Content Filtering
Enforce acceptable-use policies on corporate-managed devices by blocking access to unauthorized services, detecting data exfiltration patterns, or restricting specific protocols.
### Educational Device Management
Schools using supervised iPads can filter student network access during class hours, blocking social media and streaming while allowing educational resources.
## Key Classes
| Class | Role |
|-------|------|
| `NEFilterDataProvider` | Subclass this. The system calls `handleNewFlow`, `handleInboundData`, and `handleOutboundData` on it. Runs in a strict sandbox (no network, no disk writes). |
| `NEFilterControlProvider` | Companion extension that can fetch updated rules from a server. Has network access but no access to flow data. |
| `NEFilterManager` | Used by the containing app to install and enable the content filter configuration. |
| `NEFilterProviderConfiguration` | Holds the filter configuration: organization name, server address, filter browsers flag. |
| `NEFilterFlow` | Represents a network flow. Provides `identifier`, `direction`, and source app info via `sourceAppAuditToken`. |
| `NEFilterSocketFlow` | Subclass of `NEFilterFlow` for socket-based flows. Provides `remoteHostname`, `remoteEndpoint`, `localEndpoint`. |
| `NEFilterNewFlowVerdict` | The initial verdict: allow, drop, need rules, or request data inspection with byte ranges. |
| `NEFilterDataVerdict` | Verdict after inspecting data chunks: allow, drop, need rules, or request more bytes. |
## Implementation
### Content Filter Data Provider
```swift
import NetworkExtension
class FilterDataProvider: NEFilterDataProvider {
// 1. System calls this when the filter is enabled.
override func startFilter(completionHandler: @escaping (Error?) -> Void) {
// Load any cached rules from the shared container.
// The filter control provider writes rules; we read them.
completionHandler(nil)
}
override func stopFilter(
with reason: NEProviderStopReason,
completionHandler: @escaping () -> Void
) {
completionHandler()
}
// 2. Called for every new TCP/UDP flow on the device.
override func handleNewFlow(_ flow: NEFilterFlow) -> NEFilterNewFlowVerdict {
guard let socketFlow = flow as? NEFilterSocketFlow,
let hostname = socketFlow.remoteHostname else {
// 3. No hostname available -- allow by default.
return .allow()
}
// 4. Check the hostname against a blocklist.
if isBlocked(hostname) {
return .drop()
}
// 5. For suspicious domains, request to inspect the first 4 KB
// of inbound and outbound data before deciding.
if needsInspection(hostname) {
return .filterDataVerdict(
withFilterInbound: true,
peekInboundBytes: 4096,
filterOutbound: true,
peekOutboundBytes: 4096
)
}
return .allow()
}
// 6. Called with chunks of inbound data for flows being inspected.
override func handleInboundData(
from flow: NEFilterFlow,
readBytesStartOffset offset: Int,
readBytes: Data
) -> NEFilterDataVerdict {
// 7. Inspect the data for blocked content patterns.
if containsBlockedContent(readBytes) {
return .drop()
}
// 8. If we have seen enough data, allow the rest of the flow.
if offset + readBytes.count >= 4096 {
return .allow()
}
// 9. Request more data before making a final decision.
return .needRules()
}
// 10. Called with chunks of outbound data for flows being inspected.
override func handleOutboundData(
from flow: NEFilterFlow,
readBytesStartOffset offset: Int,
readBytes: Data
) -> NEFilterDataVerdict {
if containsBlockedContent(readBytes) {
return .drop()
}
return .allow()
}
// 11. Called when all inbound data has been delivered.
override func handleInboundDataComplete(
for flow: NEFilterFlow
) -> NEFilterNewFlowVerdict {
return .allow()
}
// 12. Called when all outbound data has been delivered.
override func handleOutboundDataComplete(
for flow: NEFilterFlow
) -> NEFilterNewFlowVerdict {
return .allow()
}
// MARK: - Private Helpers
private func isBlocked(_ hostname: String) -> Bool {
let blocklist = ["blocked-site.example.com", "malware.example.com"]
return blocklist.contains(where: { hostname.hasSuffix($0) })
}
private func needsInspection(_ hostname: String) -> Bool {
let watchlist = ["suspicious.example.com"]
return watchlist.contains(where: { hostname.hasSuffix($0) })
}
private func containsBlockedContent(_ data: Data) -> Bool {
// Inspect data bytes for blocked patterns.
return false
}
}
```
### Containing App -- Enabling the Content Filter
```swift
import NetworkExtension
func enableContentFilter() {
NEFilterManager.shared().loadFromPreferences { error in
if let error = error {
print("Failed to load filter preferences: \(error)")
return
}
let config = NEFilterProviderConfiguration()
config.filterBrowsers = true
config.filterSockets = true
config.organization = "Example Corp"
NEFilterManager.shared().providerConfiguration = config
NEFilterManager.shared().isEnabled = true
NEFilterManager.shared().saveToPreferences { error in
if let error = error {
print("Failed to save filter: \(error)")
}
}
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target network-filter-data
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 9.0+ | Requires supervised device for production builds. Development signing bypasses this for testing. |
| iPadOS | 9.0+ | Same supervised requirement as iOS. Primary deployment target for education. |
| macOS | 10.15+ | Runs as a system extension. No supervised requirement. No WebKit integration (browsers may not use WebKit). |
| tvOS | -- | Not supported. |
| visionOS | -- | Not supported. |
| watchOS | -- | Not supported. |
## Gotchas
- **Network Extension entitlement required.** You must request the Network Extension entitlement from Apple via [the request form](https://developer.apple.com/contact/request/network-extension/). Your app will be rejected without it. This is a manual approval process that can take days or weeks.
- **iOS requires supervised devices in production.** The content filter API enforces supervised-device-only on iOS for any build signed with distribution, Ad Hoc, or Enterprise certificates. Only development-signed builds (with `get-task-allow` entitlement) bypass this check. This effectively limits iOS deployment to MDM-managed enterprise or education devices.
- **The data provider cannot make network requests.** The filter data provider runs in a strict sandbox with no network access and no disk write access. If you need to download updated rules, implement a `NEFilterControlProvider` companion extension which has network access but cannot see flow data.
- **Two extensions are required on iOS.** On iOS, you need both a filter data provider and a filter control provider extension. On macOS, the control provider is optional.
- **`handleNewFlow` receives every flow.** The system sends all TCP and UDP flows to your extension. Returning verdicts quickly for known-good traffic is critical for performance. Slow verdicts will degrade the user's entire network experience.
- **HTTP/3 traffic arrives as UDP flows.** Modern browsers using QUIC/HTTP/3 will send UDP flows, not TCP. If you only check `NEFilterSocketFlow` for TCP connections, you will miss HTTP/3 traffic. Always handle both flow types.
- **`remoteHostname` may be nil.** If DNS resolution happened outside the filter's view or the app connected by IP address directly, `remoteHostname` on `NEFilterSocketFlow` will be nil. Do not rely on it being present for every flow.
- **Screen Time API is an alternative for consumer apps.** If you need content filtering in a consumer (non-supervised) iOS app, look at the Screen Time `ManagedSettings` framework (WWDC 2021 Session 10123) which provides web content filtering without the supervised device requirement.
- **`filterBrowsers` is deprecated on macOS.** Many macOS browsers do not use WebKit or NSURLSession, so the browser filtering path is unreliable. Use `filterSockets = true` on macOS and inspect `NEFilterSocketFlow` instead.
network-packet-tunnel.md
---
title: Packet Tunnel Provider
description: Implements a custom VPN client that captures all device network traffic at the IP layer, tunneling it through your VPN server.
version: iOS 9.0+, macOS 10.11+
---
# Packet Tunnel Provider (`network-packet-tunnel`)
A network extension that implements the client side of a custom VPN tunneling protocol. The system routes all device IP traffic through your extension, which reads and writes raw IP packets via a virtual network interface and forwards them to your VPN server over an encrypted connection.
## Apple Documentation
- [Packet Tunnel Provider](https://developer.apple.com/documentation/networkextension/packet-tunnel-provider) -- overview guide for implementing a VPN client
- [NEPacketTunnelProvider](https://developer.apple.com/documentation/networkextension/nepackettunnelprovider) -- principal class to subclass
- [NEPacketTunnelNetworkSettings](https://developer.apple.com/documentation/networkextension/nepackettunnelnetworksettings) -- virtual interface configuration (DNS, routes, MTU)
- [NEPacketTunnelFlow](https://developer.apple.com/documentation/networkextension/nepackettunnelflow) -- read/write IP packets from the tunnel's virtual interface
- [NETunnelProviderManager](https://developer.apple.com/documentation/networkextension/netunnelprovidermanager) -- configure and control the tunnel from the containing app
- [TN3120: Expected use cases for Network Extension packet tunnel providers](https://developer.apple.com/documentation/technotes/tn3120-expected-use-cases-for-network-extension-packet-tunnel-providers) -- Apple's guidance on approved use cases
## WWDC History
- **[WWDC 2015, Session 717 -- What's New in Network Extension and VPN](https://developer.apple.com/videos/play/wwdc2015/717/)** -- Introduced NEPacketTunnelProvider alongside the NETunnelProvider family of APIs for custom VPN protocols on iOS 9 and OS X 10.11.
- **[WWDC 2017, Session 707 -- Advances in Networking, Part 1](https://developer.apple.com/videos/play/wwdc2017/707/)** -- Covered Network Extension framework updates including improvements to tunnel providers.
- **[WWDC 2019, Session 714 -- Network Extensions for the Modern Mac](https://developer.apple.com/videos/play/wwdc2019/714/)** -- Introduced System Extensions on macOS Catalina, allowing packet tunnel providers to run as system extensions instead of kernel extensions.
## What It Does
1. The user enables the VPN from Settings or your app calls `NETunnelProviderManager.loadAllFromPreferences()` and starts the connection.
2. The system launches your extension and calls `startTunnel(options:completionHandler:)`.
3. Your extension establishes a connection to the VPN server (TLS, UDP, custom protocol).
4. You configure the virtual interface by calling `setTunnelNetworkSettings(_:completionHandler:)` with DNS servers, IP routes, MTU, and proxy settings.
5. The system begins routing device traffic to the tunnel's virtual interface.
6. Your extension reads outbound IP packets from `packetFlow.readPackets(completionHandler:)`, encrypts them, and sends them to the server.
7. Your extension receives encrypted packets from the server, decrypts them, and writes them back via `packetFlow.writePackets(_:withProtocols:)`.
8. When the user disconnects, the system calls `stopTunnel(with:completionHandler:)`.
## Use Cases
### Custom VPN Client
Apps like WireGuard and OpenVPN that implement non-standard VPN protocols. The packet tunnel provider gives you raw IP packets so you can wrap them in any tunneling protocol your server supports.
### Enterprise Network Security
Corporate VPN solutions that route all employee device traffic through a secure gateway for compliance, data loss prevention, and access to internal resources behind a firewall.
### Privacy-Focused Networking
Consumer VPN apps that encrypt all device traffic to protect users on untrusted networks (public Wi-Fi, hotel networks) by tunneling everything to a trusted exit node.
## Key Classes
| Class | Role |
|-------|------|
| `NEPacketTunnelProvider` | Subclass this. The system calls `startTunnel` and `stopTunnel` on it. Read/write IP packets via `packetFlow`. |
| `NEPacketTunnelNetworkSettings` | Configures the virtual TUN interface -- DNS servers, IPv4/IPv6 routes, MTU, proxy settings. |
| `NEPacketTunnelFlow` | The packet I/O object. Call `readPackets` to get outbound IP packets and `writePackets` to inject inbound packets. |
| `NETunnelProviderManager` | Used by the containing app to install, configure, and start/stop the tunnel. |
| `NETunnelProviderProtocol` | Holds the VPN configuration: server address, provider bundle identifier, and custom `providerConfiguration` dictionary. |
## Implementation
### Packet Tunnel Provider (Realistic VPN Client)
```swift
import NetworkExtension
class PacketTunnelProvider: NEPacketTunnelProvider {
private var connection: NWTCPConnection?
private var pendingStartCompletion: ((Error?) -> Void)?
// 1. System calls this when the user enables the VPN.
override func startTunnel(
options: [String: NSObject]?,
completionHandler: @escaping (Error?) -> Void
) {
let serverAddress = protocolConfiguration.serverAddress ?? "vpn.example.com"
let port = (protocolConfiguration as? NETunnelProviderProtocol)?
.providerConfiguration?["port"] as? String ?? "443"
// 2. Establish a connection to the VPN server through the system's
// network stack (not the tunnel itself).
let endpoint = NWHostEndpoint(hostname: serverAddress, port: port)
connection = createTCPConnectionThroughTunnel(
to: endpoint,
enableTLS: true,
tlsParameters: nil,
delegate: nil
)
// 3. Configure the virtual network interface.
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: serverAddress)
// IPv4 settings -- route all traffic through the tunnel.
let ipv4 = NEIPv4Settings(
addresses: ["10.8.0.2"],
subnetMasks: ["255.255.255.0"]
)
ipv4.includedRoutes = [NEIPv4Route.default()]
settings.ipv4Settings = ipv4
// DNS settings
settings.dnsSettings = NEDNSSettings(servers: ["10.8.0.1"])
// MTU
settings.mtu = 1400 as NSNumber
// 4. Apply the settings. Once complete, the system starts routing
// traffic to the tunnel's virtual interface.
setTunnelNetworkSettings(settings) { error in
if let error = error {
completionHandler(error)
return
}
// 5. Begin reading packets from the virtual interface.
self.readPacketsFromTUN()
completionHandler(nil)
}
}
// 6. Continuously read outbound IP packets and forward to the VPN server.
private func readPacketsFromTUN() {
packetFlow.readPackets { [weak self] packets, protocols in
guard let self = self else { return }
for (index, packet) in packets.enumerated() {
// In a real implementation, encrypt the packet and send
// it to the VPN server via self.connection.
self.sendPacketToServer(packet, protocolFamily: protocols[index])
}
// 7. Continue reading -- this is a recursive loop.
self.readPacketsFromTUN()
}
}
private func sendPacketToServer(_ packet: Data, protocolFamily: NSNumber) {
// Encrypt and frame the packet for your VPN protocol, then write
// to self.connection. When encrypted packets arrive from the server,
// decrypt them and call:
// self.packetFlow.writePackets([decryptedData], withProtocols: [protocolFamily])
}
// 8. System calls this when the user disables the VPN.
override func stopTunnel(
with reason: NEProviderStopReason,
completionHandler: @escaping () -> Void
) {
connection?.cancel()
connection = nil
completionHandler()
}
// 9. Receive messages from the containing app (e.g., status queries).
override func handleAppMessage(
_ messageData: Data,
completionHandler: ((Data?) -> Void)?
) {
completionHandler?(messageData)
}
// 10. Power management hooks for sleep/wake.
override func sleep(completionHandler: @escaping () -> Void) {
completionHandler()
}
override func wake() {
// Re-establish server connection if needed.
}
}
```
### Containing App -- Installing the VPN Configuration
```swift
import NetworkExtension
func installVPNProfile() {
let manager = NETunnelProviderManager()
let proto = NETunnelProviderProtocol()
proto.providerBundleIdentifier = "com.example.app.PacketTunnel"
proto.serverAddress = "vpn.example.com"
proto.providerConfiguration = ["port": "443"]
manager.protocolConfiguration = proto
manager.localizedDescription = "My VPN"
manager.isEnabled = true
manager.saveToPreferences { error in
if let error = error {
print("Failed to save VPN profile: \(error)")
return
}
// Now the user can enable the VPN from Settings or programmatically.
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target network-packet-tunnel
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 9.0+ | Full support. |
| iPadOS | 9.0+ | Full support. |
| macOS | 10.11+ | Runs as app extension (10.11) or system extension (10.15+). |
| tvOS | 17.0+ | Added in tvOS 17. |
| visionOS | 1.0+ | Supported. |
| watchOS | -- | Not supported. |
## Gotchas
- **Network Extension entitlement required.** You must request the Network Extension entitlement from Apple via [the request form](https://developer.apple.com/contact/request/network-extension/). Your app will be rejected without it. This is a manual approval process that can take days or weeks.
- **Memory limit is strict.** Packet tunnel providers run in a low-memory extension environment (typically ~15 MB on iOS). Exceeding the limit causes the system to kill your extension silently. Profile memory usage carefully and avoid buffering large amounts of packet data.
- **`readPackets` is a recursive loop, not a delegate.** You must call `readPackets(completionHandler:)` again inside its own completion handler to keep receiving packets. Forgetting to re-call it will silently stop all traffic flow.
- **Use `createTCPConnectionThroughTunnel` for server connections.** If you create a normal `URLSession` or `NWConnection` from within the extension, your traffic will loop through the tunnel itself. Use the provider's `createTCPConnection(to:enableTLS:)` or `createUDPSession(to:from:)` methods to reach your VPN server outside the tunnel.
- **TN3120 lists approved use cases.** Apple explicitly states that packet tunnel providers should only be used for VPN. Using them for content filtering, ad blocking, or local traffic inspection (loopback VPN tricks) may result in App Store rejection. See [TN3120](https://developer.apple.com/documentation/technotes/tn3120-expected-use-cases-for-network-extension-packet-tunnel-providers).
- **`setTunnelNetworkSettings` must complete before reading packets.** If you start calling `readPackets` before the settings completion handler fires, you will get no packets. Always chain the read loop inside the settings callback.
- **Sleep/wake handling is important.** On iOS, the system may suspend your extension when the device sleeps. Override `sleep(completionHandler:)` to save state and `wake()` to re-establish the server connection. Failing to handle this causes VPN disconnections after sleep.
notification-content.md
---
title: Notification Content Extension
description: Displays a custom view controller when a notification is expanded, replacing the default notification UI with rich interactive content like images, maps, or custom controls.
version: iOS 10.0+
---
# Notification Content Extension (`notification-content`)
A notification content extension provides a custom user interface that appears when a user long-presses or 3D Touches a notification. Instead of the system's default layout, your extension renders a full `UIViewController` with any combination of images, video, maps, custom drawing, and -- starting with iOS 12 -- interactive controls like buttons and sliders.
## Apple Documentation
- [UNNotificationContentExtension](https://developer.apple.com/documentation/usernotificationsui/unnotificationcontentextension) -- The protocol your view controller adopts to receive and display notification content.
- [Customizing the Appearance of Notifications](https://developer.apple.com/documentation/usernotificationsui/customizing-the-appearance-of-notifications) -- Apple's guide to building notification content extensions.
- [UNNotificationContentExtensionResponseOption](https://developer.apple.com/documentation/usernotificationsui/unnotificationcontentextensionresponseoption) -- Return values for action handling in the extension.
- [Modifying and Presenting Notifications (Archive)](https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/ModifyingNotifications.html) -- Legacy guide covering both service and content extensions.
## WWDC History
- **[WWDC 2016, Session 708 -- Advanced Notifications](https://developer.apple.com/videos/play/wwdc2016/708/)** -- Introduced notification content extensions alongside the new UserNotifications framework. Covers custom UI, media attachments, and action handling.
- **[WWDC 2017, Session 708 -- Best Practices and What's New in User Notifications](https://developer.apple.com/videos/play/wwdc2017/708/)** -- Refined guidance on content extension best practices and hidden default content.
- **[WWDC 2018, Session 710 -- What's New in User Notifications](https://developer.apple.com/videos/play/wwdc2018/710/)** -- Added interactive controls (buttons, sliders) via `UNNotificationExtensionUserInteractionEnabled`, dynamic action button updates, and `performNotificationDefaultAction` / `dismissNotificationContentExtension` methods.
## What It Does
1. A local or remote notification arrives with a `categoryIdentifier` matching the extension's `UNNotificationExtensionCategory` in Info.plist.
2. The user long-presses (or 3D Touches) the notification banner.
3. The system loads the extension's `UIViewController` and calls `didReceive(_:)` with the full `UNNotification`.
4. Your view controller populates its UI -- images, labels, maps, media players, custom views.
5. If the notification category defines actions, the system renders action buttons below your custom view.
6. When the user taps an action, the system calls `didReceive(_:completionHandler:)` on your view controller, giving you a chance to update the UI before dismissing.
## Use Cases
### Messaging apps with rich media
Display inline images, GIFs, or video thumbnails directly in the notification. A chat app can show the sender's avatar, the message bubble, and a preview of any attached media -- all without opening the app.
### Delivery and ride-sharing apps with maps
Embed a `MKMapView` showing the driver's current location or the package's delivery route. The map updates each time a new notification arrives for the same conversation thread.
### Calendar and event apps with quick RSVP
Show event details (time, location, attendee list) in a styled card layout with interactive "Accept" / "Decline" buttons (iOS 12+) that update the UI inline and call your server.
### Sports and live score apps
Display a custom scoreboard layout with team logos, scores, and game clock. Each score update notification refreshes the same expanded view.
## Key Classes
| Class / Protocol | Role |
|-----------------|------|
| `UNNotificationContentExtension` | Protocol adopted by your `UIViewController`. Provides `didReceive(_:)` to populate UI and optionally `didReceive(_:completionHandler:)` to handle actions. |
| `UNNotification` | The delivered notification passed to your extension, containing the full `UNNotificationContent`. |
| `UNNotificationContent` | The notification payload -- title, subtitle, body, attachments, userInfo, categoryIdentifier. |
| `UNNotificationAction` | An action button registered on the notification category. The system renders these below your view. |
| `NSExtensionContext` | Provides `performNotificationDefaultAction()` to open the app and `dismissNotificationContentExtension()` to dismiss (iOS 12+). |
## Implementation
### Rich Media Notification with Action Handling
A notification content extension that displays an image from the notification's attachments and handles a "Like" action with inline UI feedback.
```swift
import UIKit
import UserNotifications
import UserNotificationsUI
class NotificationViewController: UIViewController, UNNotificationContentExtension {
// 1. Create UI elements programmatically (or use a storyboard)
private let imageView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.clipsToBounds = true
iv.translatesAutoresizingMaskIntoConstraints = false
return iv
}()
private let titleLabel: UILabel = {
let label = UILabel()
label.font = .boldSystemFont(ofSize: 17)
label.numberOfLines = 2
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private let bodyLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 15)
label.textColor = .secondaryLabel
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
override func viewDidLoad() {
super.viewDidLoad()
// 2. Layout the custom UI
view.addSubview(imageView)
view.addSubview(titleLabel)
view.addSubview(bodyLabel)
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: view.topAnchor),
imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
imageView.heightAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.5625),
titleLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 12),
titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
titleLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
bodyLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
bodyLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
bodyLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
bodyLabel.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor, constant: -12),
])
}
// 3. Called when the notification is expanded -- populate your UI
func didReceive(_ notification: UNNotification) {
let content = notification.request.content
titleLabel.text = content.title
bodyLabel.text = content.body
// 4. Load the first attachment (e.g., an image downloaded by a service extension)
if let attachment = content.attachments.first,
attachment.url.startAccessingSecurityScopedResource() {
defer { attachment.url.stopAccessingSecurityScopedResource() }
if let data = try? Data(contentsOf: attachment.url) {
imageView.image = UIImage(data: data)
}
}
// 5. Adjust preferred content size based on whether there's an image
if imageView.image == nil {
imageView.isHidden = true
preferredContentSize = CGSize(width: view.bounds.width, height: 80)
}
}
// 6. Handle action button taps inline (optional)
func didReceive(
_ response: UNNotificationResponse,
completionHandler completion: @escaping (UNNotificationContentExtensionResponseOption) -> Void
) {
switch response.actionIdentifier {
case "LIKE_ACTION":
// 7. Update UI to reflect the action
titleLabel.text = "Liked!"
titleLabel.textColor = .systemBlue
// 8. Dismiss after a short delay so the user sees the feedback
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
completion(.dismiss)
}
case "REPLY_ACTION":
// 9. Open the app for complex interactions
completion(.dismissAndForwardAction)
default:
completion(.doNotDismiss)
}
}
}
```
### Registering the Notification Category (in your main app)
Your main app must register the category that triggers the content extension:
```swift
import UserNotifications
func registerNotificationCategories() {
let likeAction = UNNotificationAction(
identifier: "LIKE_ACTION",
title: "Like",
options: []
)
let replyAction = UNTextInputNotificationAction(
identifier: "REPLY_ACTION",
title: "Reply",
options: [],
textInputButtonTitle: "Send",
textInputPlaceholder: "Type a reply..."
)
// The category identifier must match UNNotificationExtensionCategory in the extension's Info.plist
let category = UNNotificationCategory(
identifier: "myNotificationCategory",
actions: [likeAction, replyAction],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([category])
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target notification-content
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 10.0+ | Full support. Interactive controls from iOS 12+. |
| iPadOS | 10.0+ | Full support. Some layout inconsistencies with `UNNotificationExtensionInitialContentSizeRatio` on iPad. |
| macOS | 11.0+ | Listed in API docs but does not work reliably for native macOS apps. Works via Mac Catalyst. |
| visionOS | -- | Not supported. |
| watchOS | -- | Not supported. watchOS uses WatchKit notification interfaces instead. |
| tvOS | -- | Not supported. |
## Gotchas
- **Category identifier must match exactly.** The `UNNotificationExtensionCategory` value in your extension's Info.plist must be an exact, case-sensitive match to the `categoryIdentifier` on the notification and the category registered with `UNUserNotificationCenter`. A mismatch causes the extension to silently never appear.
- **Touch events are blocked before iOS 12.** Prior to iOS 12, the system prevents delivery of all touch events to your view controller. Gesture recognizers and interactive controls will not work. On iOS 12+, set `UNNotificationExtensionUserInteractionEnabled` to `true` in Info.plist to enable interaction.
- **`preferredContentSize` timing is tricky.** At `viewDidLoad` time, the view's `bounds.size.width` is not yet correct (it reports full screen size). Set `preferredContentSize` in `didReceive(_:)` after layout, or use `UNNotificationExtensionInitialContentSizeRatio` in Info.plist as the initial aspect ratio and let Auto Layout handle the rest.
- **One extension per category set.** Each content extension must handle a unique set of categories. If two extensions claim the same category identifier, behavior is undefined. Use an array value for `UNNotificationExtensionCategory` if one extension handles multiple categories.
- **iPad layout sizing is inconsistent.** Several developers have reported that iPad does not respect `UNNotificationExtensionInitialContentSizeRatio` correctly, and Auto Layout guides can report very large values during the first layout pass. Test on iPad hardware or simulator specifically.
- **Action buttons are system-managed.** The system automatically renders action buttons defined on the `UNNotificationCategory` below your custom view. Do not create your own action buttons -- use `didReceive(_:completionHandler:)` to intercept taps and update your UI.
- **Attachments require security-scoped access.** When reading notification attachment files, you must call `startAccessingSecurityScopedResource()` before and `stopAccessingSecurityScopedResource()` after. Without this, file reads will fail silently or throw permission errors.
- **The extension has a separate memory budget.** Content extensions run in their own process with limited memory (roughly 120 MB on recent devices). Loading large images or video without downsampling can cause the system to kill the extension.
- **Default content is shown unless hidden.** The system shows the standard notification title/body below your custom view by default. Set `UNNotificationExtensionDefaultContentHidden` to `true` in Info.plist if your custom UI already displays this information.
notification-service.md
---
title: Notification Service Extension
description: Intercepts and modifies remote notification content before display, enabling image downloads, payload decryption, and content enrichment in the background.
version: iOS 10.0+
---
# Notification Service Extension (`notification-service`)
A notification service extension runs in the background when a remote push notification arrives, giving you up to 30 seconds to modify the notification content before the system displays it. The most common use is downloading an image from a URL in the payload and attaching it so the notification displays a rich media preview. It also handles end-to-end encryption decryption, content localization, and analytics tracking.
## Apple Documentation
- [UNNotificationServiceExtension](https://developer.apple.com/documentation/usernotifications/unnotificationserviceextension) -- The class you subclass to modify remote notification content before delivery.
- [didReceive(_:withContentHandler:)](https://developer.apple.com/documentation/usernotifications/unnotificationserviceextension/1648229-didreceive) -- Called when a notification arrives. Modify content and call the handler.
- [serviceExtensionTimeWillExpire()](https://developer.apple.com/documentation/usernotifications/unnotificationserviceextension/1648227-serviceextensiontimewillexpire) -- Last-chance callback before the system delivers the original notification.
- [Modifying Content in Newly Delivered Notifications](https://developer.apple.com/documentation/usernotifications/modifying-content-in-newly-delivered-notifications) -- Apple's guide to implementing a notification service extension.
## WWDC History
- **[WWDC 2016, Session 708 -- Advanced Notifications](https://developer.apple.com/videos/play/wwdc2016/708/)** -- Introduced notification service extensions as part of the new UserNotifications framework. Covers the extension lifecycle, mutable-content flag, and the 30-second processing window.
- **[WWDC 2017, Session 708 -- Best Practices and What's New in User Notifications](https://developer.apple.com/videos/play/wwdc2017/708/)** -- Best practices for service extension performance and reliability.
- **[WWDC 2018, Session 710 -- What's New in User Notifications](https://developer.apple.com/videos/play/wwdc2018/710/)** -- Grouped notifications and how service extensions interact with notification threading.
## What It Does
1. Your server sends a remote push notification with `"mutable-content": 1` in the `aps` dictionary.
2. The system launches (or reuses) your service extension process before displaying the notification.
3. The system calls `didReceive(_:withContentHandler:)` with the original notification request.
4. Your code modifies the `UNMutableNotificationContent` -- downloading images, decrypting the body, changing the title, adding attachments.
5. You call the `contentHandler` with the modified content.
6. If you take too long (~30 seconds), the system calls `serviceExtensionTimeWillExpire()` as a last chance to deliver your best-effort modification.
7. If neither method calls the handler in time, the system displays the original unmodified notification.
## Use Cases
### Rich push notifications with images
The most common use case. Your server includes an image URL in the push payload's `userInfo`. The service extension downloads the image, saves it to a temporary file, and attaches it as a `UNNotificationAttachment`. The notification then shows a thumbnail on the right side and a full preview when expanded.
### End-to-end encrypted messaging
Messaging apps that encrypt notification payloads on the server. The service extension decrypts the ciphertext using keys stored in the shared Keychain (via App Groups), then replaces the placeholder title and body with the actual message content.
### Analytics and delivery tracking
Confirm that a push notification was actually delivered to the device. The service extension pings your analytics endpoint when the notification arrives, providing delivery confirmation independent of whether the user opens the notification.
### Content localization and enrichment
Enrich a minimal push payload with locally-available data. For example, a payload containing only a conversation ID can be expanded by the service extension into a full message preview by querying a local database or shared Core Data store.
## Key Classes
| Class | Role |
|-------|------|
| `UNNotificationServiceExtension` | Abstract class to subclass. Override `didReceive(_:withContentHandler:)` and `serviceExtensionTimeWillExpire()`. |
| `UNNotificationRequest` | The incoming notification request containing the original `UNNotificationContent`. |
| `UNMutableNotificationContent` | Mutable copy of the notification content. Modify `title`, `body`, `subtitle`, `attachments`, `userInfo`, `badge`, `sound`, `threadIdentifier`. |
| `UNNotificationAttachment` | A media file (image, audio, video) attached to the notification. Created from a file URL on disk. |
## Implementation
### Downloading an Image for Rich Notifications
The standard implementation: download an image from a URL in the push payload and attach it to the notification.
```swift
import UserNotifications
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
guard let bestAttemptContent = bestAttemptContent else {
contentHandler(request.content)
return
}
// 1. Extract the image URL from the push payload's userInfo
guard let imageURLString = request.content.userInfo["image_url"] as? String,
let imageURL = URL(string: imageURLString) else {
// No image to download -- deliver the content as-is
contentHandler(bestAttemptContent)
return
}
// 2. Download the image to a temporary file
let task = URLSession.shared.downloadTask(with: imageURL) { location, response, error in
defer { contentHandler(bestAttemptContent) }
guard let location = location, error == nil else { return }
// 3. Determine file extension from the response MIME type
let fileExtension: String
if let mimeType = response?.mimeType {
switch mimeType {
case "image/png": fileExtension = "png"
case "image/gif": fileExtension = "gif"
case "image/jpeg": fileExtension = "jpg"
default: fileExtension = "jpg"
}
} else {
fileExtension = "jpg"
}
// 4. Move the file to a uniquely-named temporary path
// (UNNotificationAttachment requires the file extension)
let tempDir = FileManager.default.temporaryDirectory
let tempFile = tempDir
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(fileExtension)
do {
try FileManager.default.moveItem(at: location, to: tempFile)
// 5. Create the attachment and add it to the notification
let attachment = try UNNotificationAttachment(
identifier: "image",
url: tempFile,
options: nil
)
bestAttemptContent.attachments = [attachment]
} catch {
// If attachment creation fails, deliver without the image
print("Notification attachment error: \(error)")
}
}
task.resume()
}
override func serviceExtensionTimeWillExpire() {
// 6. Called when the system is about to kill the extension.
// Deliver whatever you have -- possibly without the image.
if let contentHandler = contentHandler,
let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
}
```
### Decrypting Encrypted Notification Content
A service extension that decrypts an encrypted payload using a key stored in the shared Keychain:
```swift
import UserNotifications
import CryptoKit
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
guard let bestAttemptContent = bestAttemptContent else {
contentHandler(request.content)
return
}
// 1. Extract the encrypted payload from userInfo
guard let encryptedBase64 = request.content.userInfo["encrypted_body"] as? String,
let encryptedData = Data(base64Encoded: encryptedBase64) else {
contentHandler(bestAttemptContent)
return
}
// 2. Retrieve the decryption key from the shared Keychain (via App Groups)
guard let symmetricKey = loadKeyFromSharedKeychain() else {
bestAttemptContent.body = "New encrypted message"
contentHandler(bestAttemptContent)
return
}
// 3. Decrypt the content
do {
let sealedBox = try AES.GCM.SealedBox(combined: encryptedData)
let decryptedData = try AES.GCM.open(sealedBox, using: symmetricKey)
if let decryptedString = String(data: decryptedData, encoding: .utf8) {
// 4. Replace the placeholder body with the decrypted message
bestAttemptContent.body = decryptedString
}
} catch {
bestAttemptContent.body = "New encrypted message"
}
contentHandler(bestAttemptContent)
}
override func serviceExtensionTimeWillExpire() {
if let contentHandler = contentHandler,
let bestAttemptContent = bestAttemptContent {
bestAttemptContent.body = "New encrypted message"
contentHandler(bestAttemptContent)
}
}
private func loadKeyFromSharedKeychain() -> SymmetricKey? {
// Load from Keychain with kSecAttrAccessGroup set to your App Group
// Implementation depends on your key storage strategy
return nil
}
}
```
### Example Push Payload
The push notification must include `mutable-content: 1` to trigger the service extension:
```json
{
"aps": {
"alert": {
"title": "New Photo",
"body": "Sarah shared a photo with you"
},
"mutable-content": 1,
"sound": "default"
},
"image_url": "https://example.com/photos/12345.jpg"
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target notification-service
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 10.0+ | Full support. |
| iPadOS | 10.0+ | Full support. |
| macOS | 10.14+ | Supported for native macOS apps receiving remote notifications. |
| Mac Catalyst | 13.0+ | Supported. |
| visionOS | -- | Not documented. |
| watchOS | -- | Not supported. watchOS notifications are processed by the paired iPhone. |
| tvOS | -- | Not supported. tvOS does not display notification banners. |
## Gotchas
- **Push must include `mutable-content: 1`.** Without this flag in the `aps` dictionary, the system will never launch your service extension. This is the single most common reason the extension appears to "not work." The payload must also include an `alert` dictionary (title, subtitle, or body) -- silent notifications do not trigger the extension.
- **You have approximately 30 seconds.** The system enforces a hard time limit. If neither `didReceive` nor `serviceExtensionTimeWillExpire` calls the content handler in time, the original unmodified notification is displayed. Always implement `serviceExtensionTimeWillExpire()` as a fallback.
- **Memory limit is approximately 24 MB.** The extension runs in a constrained process. Downloading or processing large images can push you over the limit, causing immediate termination with no callback. Resize images before creating attachments if the source is large.
- **The extension does not run when the app is in the foreground.** If your app is active and implements `userNotificationCenter(_:willPresent:withCompletionHandler:)`, the delegate receives the original unmodified payload. The service extension is skipped entirely.
- **The extension process is reused between notifications.** iOS keeps the extension process alive after processing a notification. A second notification arriving shortly after reuses the same process. This means stale state from a previous `didReceive` call can leak into the next one. Always reset `bestAttemptContent` and `contentHandler` at the start of each call.
- **Attachments require files on disk with correct extensions.** `UNNotificationAttachment` reads the file extension to determine the media type. A file named `image` with no extension will fail. Always append `.jpg`, `.png`, `.gif`, or `.mp4` to your temporary files.
- **Shared data requires App Groups.** The extension runs in a separate process and sandbox. To access shared preferences, databases, or Keychain items, configure an App Group on both the main app and the extension. Use `UserDefaults(suiteName:)` or `kSecAttrAccessGroup` for Keychain.
- **Debugging is difficult.** You cannot launch the extension target directly. Attach the debugger to the extension process by selecting Debug > Attach to Process by PID or Name in Xcode after sending a push notification. Use `Console.app` filtering by your extension's bundle ID to see logs.
- **`content-available: 1` is not `mutable-content: 1`.** These are different flags. `content-available` triggers background app refresh (silent push). `mutable-content` triggers the service extension. You can use both, but they serve different purposes. Confusing the two is a common mistake.
- **Notification filtering requires a special entitlement.** Since iOS 13.3, you can suppress notification display entirely by not calling the content handler. However, this requires the `com.apple.developer.usernotifications.filtering` entitlement, which must be requested from Apple. Without it, suppressing delivery will show the original notification.
photo-editing.md
---
title: Photo Editing Extension
description: Adds custom editing tools directly inside the Photos app, allowing users to apply filters, adjustments, and markup without leaving their library.
version: iOS 8.0+, macOS 10.11+
---
# Photo Editing Extension (`photo-editing`)
An extension that integrates custom image and video editing tools into the system Photos app. When a user taps Edit on a photo and selects your extension from the action menu, the Photos app hands your view controller the asset as a `PHContentEditingInput` along with a placeholder image. Your UI applies edits -- filters, crops, overlays, markup -- and returns a `PHContentEditingOutput` containing the rendered result and a `PHAdjustmentData` blob that encodes the edit recipe. Because Photos always preserves the original asset, your edits are non-destructive: the user can reopen your extension later, and if your `canHandle(_:)` method recognizes the adjustment data, you can reconstruct the previous edit state and let them modify or revert changes.
## Apple Documentation
- [Creating Photo Editing Extensions](https://developer.apple.com/documentation/photokit/creating-photo-editing-extensions)
- [PHContentEditingController Protocol](https://developer.apple.com/documentation/photokit/phcontenteditingcontroller)
- [PHContentEditingInput](https://developer.apple.com/documentation/photokit/phcontenteditinginput)
- [PHContentEditingOutput](https://developer.apple.com/documentation/photokit/phcontenteditingoutput)
- [PHAdjustmentData](https://developer.apple.com/documentation/photokit/phadjustmentdata)
- [App Extension Programming Guide -- Photo Editing](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/Photos.html)
- [Sample Photo Editing Extension (Apple Archive)](https://developer.apple.com/library/archive/samplecode/SamplePhotoEditingExtension/Introduction/Intro.html)
## WWDC History
- **[WWDC 2014, Session 511 -- Introducing the Photos Frameworks](https://developer.apple.com/videos/play/wwdc2014/511/)** -- Introduced PhotoKit and the Photo Editing extension point. Demonstrated how `PHContentEditingController` integrates third-party editing into the Photos app with non-destructive adjustment data.
- **[WWDC 2014, Session 514 -- Advances in Core Image](https://developer.apple.com/videos/play/wwdc2014/514/)** -- Showed how custom `CIKernel` and `CIFilter` subclasses can be used inside Photo Editing extensions for GPU-accelerated image processing.
- **[WWDC 2014, Session 217 -- Creating Extensions for iOS and OS X, Part 2](https://developer.apple.com/videos/play/wwdc2014/217/)** -- Covered the broader app extensions architecture that Photo Editing extensions are built on, including lifecycle and memory management.
- **[WWDC 2018, Session 505 -- Integrating with Photos on macOS](https://developer.apple.com/videos/play/wwdc2018/505/)** -- Covered macOS-specific Photo Editing extension integration and the unified Photos experience across platforms.
## What It Does
1. The user opens a photo or video in the Photos app and taps Edit.
2. The user taps the extensions button (the ellipsis icon) and selects your extension from the activity list.
3. Photos calls `canHandle(_:)` with the existing `PHAdjustmentData` (if any previous edit was made). If you return `true`, Photos provides the original, unmodified asset so you can reconstruct the prior edit state. If you return `false`, Photos provides the already-rendered version.
4. Photos calls `startContentEditing(with:placeholderImage:)`, passing a `PHContentEditingInput` (containing the full-size image URL or video AVAsset) and a `UIImage` placeholder for immediate display.
5. Your view controller presents editing controls. The user applies filters, adjustments, or markup.
6. When the user taps Done, Photos calls `finishContentEditing(completionHandler:)`. You render the final image, create a `PHContentEditingOutput` with the rendered JPEG written to `output.renderedContentURL`, and attach a `PHAdjustmentData` object encoding your edit parameters.
7. Photos stores the output alongside the original. The edit is non-destructive -- the user can revert to the original at any time.
8. If the user taps Cancel, Photos calls `cancelContentEditing()` and your extension cleans up any temporary files.
## Use Cases
### Filter and Effects Apps
A photo filter app (VSCO, Snapseed-style) exposes its filter library directly inside Photos. Users apply a look, adjust intensity, and save -- all without leaving the Photos app. The `PHAdjustmentData` stores the filter name and parameter values so users can re-edit later.
### Markup and Annotation Tools
A markup tool adds text, arrows, shapes, and freehand drawings on top of photos. The adjustment data serializes each annotation's position, style, and content, enabling full round-trip editing.
### Professional Retouching
A portrait retouching extension provides skin smoothing, blemish removal, and lighting adjustments. Because it receives the full-resolution image via `PHContentEditingInput.fullSizeImageURL`, it can operate at the native camera resolution.
### Batch Watermarking
A business or photographer's extension applies a watermark overlay with customizable position, opacity, and text. The simple parameter set makes `PHAdjustmentData` round-trips straightforward.
## Key Classes
| Class / Protocol | Role |
|-------|------|
| `PHContentEditingController` | Protocol your `UIViewController` adopts. Provides the lifecycle methods: `canHandle`, `startContentEditing`, `finishContentEditing`, `cancelContentEditing`. |
| `PHContentEditingInput` | Describes the asset being edited. Provides `fullSizeImageURL`, `displaySizeImage`, `avAsset` (for video), and existing `adjustmentData`. |
| `PHContentEditingOutput` | Container for your edited result. Write the rendered JPEG/video to `renderedContentURL` and attach `adjustmentData`. |
| `PHAdjustmentData` | Encodes your edit recipe as a `Data` blob with a `formatIdentifier` (reverse-DNS) and `formatVersion` (semantic versioning recommended). Enables non-destructive round-trip editing. |
| `CIFilter` / `CIContext` | Core Image classes commonly used to apply GPU-accelerated filters to the full-size image inside the extension. |
## Implementation
### Core Image Filter Extension with Adjustment Data Round-Trip
A realistic photo editing extension that applies a sepia + vignette filter chain, serializes the parameters into `PHAdjustmentData`, and supports re-editing previous sessions:
```swift
import UIKit
import Photos
import PhotosUI
class PhotoEditingViewController: UIViewController, PHContentEditingController {
// 1. Define a stable format identifier and version for adjustment data.
// Use reverse-DNS and semantic versioning so you can evolve the format.
private static let formatIdentifier = "com.example.photo-editing"
private static let formatVersion = "1.0"
private var input: PHContentEditingInput?
private let imageView = UIImageView()
private let intensitySlider = UISlider()
private let ciContext = CIContext()
// 2. Track the current filter intensity (0.0 to 1.0)
private var filterIntensity: Float = 0.5
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
imageView.contentMode = .scaleAspectFit
imageView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(imageView)
intensitySlider.minimumValue = 0.0
intensitySlider.maximumValue = 1.0
intensitySlider.value = filterIntensity
intensitySlider.addTarget(self, action: #selector(sliderChanged), for: .valueChanged)
intensitySlider.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(intensitySlider)
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
imageView.bottomAnchor.constraint(equalTo: intensitySlider.topAnchor, constant: -16),
intensitySlider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
intensitySlider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
intensitySlider.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16),
])
}
// MARK: - PHContentEditingController
// 3. Check whether we can resume editing from existing adjustment data.
// Return true only if the data was created by this extension and version.
func canHandle(_ adjustmentData: PHAdjustmentData) -> Bool {
return adjustmentData.formatIdentifier == Self.formatIdentifier
&& adjustmentData.formatVersion == Self.formatVersion
}
// 4. Called before the view appears. Receive the asset and restore prior edits.
func startContentEditing(with contentEditingInput: PHContentEditingInput, placeholderImage: UIImage) {
input = contentEditingInput
// 5. If we have prior adjustment data from a previous session, decode it
// to restore the slider position.
if let adjustmentData = contentEditingInput.adjustmentData,
adjustmentData.formatIdentifier == Self.formatIdentifier,
let params = try? JSONDecoder().decode(EditParameters.self, from: adjustmentData.data) {
filterIntensity = params.intensity
intensitySlider.value = filterIntensity
}
// 6. Show the placeholder immediately, then apply the filter
imageView.image = placeholderImage
applyFilter()
}
// 7. Render the final output and return it via the completion handler.
func finishContentEditing(completionHandler: @escaping (PHContentEditingOutput?) -> Void) {
guard let input = input else {
completionHandler(nil)
return
}
DispatchQueue.global(qos: .userInitiated).async { [self] in
let output = PHContentEditingOutput(contentEditingInput: input)
// 8. Encode the current parameters as adjustment data so the edit
// can be reconstructed in a future session.
let params = EditParameters(intensity: filterIntensity)
guard let paramData = try? JSONEncoder().encode(params) else {
completionHandler(nil)
return
}
output.adjustmentData = PHAdjustmentData(
formatIdentifier: Self.formatIdentifier,
formatVersion: Self.formatVersion,
data: paramData
)
// 9. Load the full-size image and apply the filter at full resolution.
guard let fullSizeURL = input.fullSizeImageURL,
let fullImage = CIImage(contentsOf: fullSizeURL)?
.oriented(forExifOrientation: Int32(input.fullSizeImageOrientation)) else {
completionHandler(nil)
return
}
let filtered = self.applyFilterChain(to: fullImage, intensity: self.filterIntensity)
// 10. Write the rendered JPEG to the output URL.
// Use a CIContext to avoid loading the full image into a UIImage.
guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB),
let jpegData = self.ciContext.jpegRepresentation(
of: filtered,
colorSpace: colorSpace,
options: [kCGImageDestinationLossyCompressionQuality as CIImageRepresentationOption: 0.9]
) else {
completionHandler(nil)
return
}
do {
try jpegData.write(to: output.renderedContentURL)
completionHandler(output)
} catch {
NSLog("[PhotoEditing] Failed to write output: \(error)")
completionHandler(nil)
}
}
}
var shouldShowCancelConfirmation: Bool {
// 11. Return true if the user has made changes worth confirming
return filterIntensity != 0.5
}
func cancelContentEditing() {
// 12. Clean up any temporary files or state
input = nil
}
// MARK: - Filter Logic
@objc private func sliderChanged(_ sender: UISlider) {
filterIntensity = sender.value
applyFilter()
}
private func applyFilter() {
guard let input = input,
let url = input.fullSizeImageURL,
let image = CIImage(contentsOf: url)?
.oriented(forExifOrientation: Int32(input.fullSizeImageOrientation)) else { return }
let filtered = applyFilterChain(to: image, intensity: filterIntensity)
// 13. Render a display-size preview. Use a smaller extent to save memory.
if let cgImage = ciContext.createCGImage(filtered, from: filtered.extent) {
DispatchQueue.main.async {
self.imageView.image = UIImage(cgImage: cgImage)
}
}
}
// 14. Apply a sepia tone + vignette filter chain. The intensity parameter
// controls both the sepia amount and vignette radius.
private func applyFilterChain(to image: CIImage, intensity: Float) -> CIImage {
let sepia = CIFilter(name: "CISepiaTone")!
sepia.setValue(image, forKey: kCIInputImageKey)
sepia.setValue(NSNumber(value: intensity), forKey: kCIInputIntensityKey)
let vignette = CIFilter(name: "CIVignette")!
vignette.setValue(sepia.outputImage!, forKey: kCIInputImageKey)
vignette.setValue(NSNumber(value: intensity * 2.0), forKey: kCIInputRadiusKey)
vignette.setValue(NSNumber(value: intensity), forKey: kCIInputIntensityKey)
return vignette.outputImage!
}
}
// 15. Codable struct for serializing edit parameters into PHAdjustmentData.
// Keep this lightweight -- store the recipe, not the rendered pixels.
struct EditParameters: Codable {
let intensity: Float
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target photo-editing
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 8.0+ | Full support. Extension appears in the Photos app Edit screen. |
| iPadOS | 8.0+ | Full support. Must handle Slide Over and Split View presentations. |
| macOS | 10.11+ | Supported. Uses a combined title bar and toolbar instead of a navigation bar. |
| tvOS | -- | Not supported. Photos app on tvOS does not support editing extensions. |
| visionOS | -- | Not supported. |
| watchOS | -- | Not supported. |
## Gotchas
- **`PHAdjustmentData` round-trips are required for a good user experience.** If your `canHandle(_:)` returns `false` for your own previous edits, the user gets the flattened (already-rendered) image instead of the original, losing the ability to tweak prior adjustments. Always check `formatIdentifier` and `formatVersion` and decode your parameters.
- **Returning `true` from `canHandle` means you get the original image.** This is counterintuitive. When you claim you can handle the adjustment data, Photos gives you the unmodified original so you can reconstruct the edit from scratch. When you return `false`, you get the previously rendered output. Many developers expect the opposite.
- **Store the recipe, not the result, in adjustment data.** The `PHAdjustmentData.data` blob has a size limit enforced by the Photos framework. Serialize filter names and parameter values (a few hundred bytes), not pixel data or thumbnails.
- **Full-size images can be very large.** A 48MP iPhone 15 Pro photo is over 8000x6000 pixels. Loading it uncompressed into a `UIImage` can consume hundreds of megabytes. Use `CIImage` with a `CIContext` for lazy evaluation, and render only the regions you need. Extensions are killed under memory pressure with no warning.
- **Image orientation must be applied manually.** The `fullSizeImageOrientation` property on `PHContentEditingInput` returns the EXIF orientation as an `Int32`. You must apply it to your `CIImage` with `.oriented(forExifOrientation:)` or the image will appear rotated. This is a common source of bugs.
- **Video editing has a different workflow.** For video assets, `PHContentEditingInput` provides an `avAsset` (AVAsset) instead of an image URL. You must use AVFoundation (`AVAssetExportSession` or `AVComposition`) to produce the output video. The `PHAdjustmentData` round-trip requirement still applies.
- **The Photos app provides the navigation bar.** Do not add your own navigation bar or top toolbar. Photos displays a system navigation bar with Cancel and Done buttons. Adding your own creates a confusing double-bar UI.
- **Cross-app adjustment data compatibility is possible but rare.** Multiple apps can agree on a shared `formatIdentifier` and data schema so users can seamlessly switch between editing tools. In practice, almost no apps do this -- but be aware that your extension may receive adjustment data from an unknown source.
- **The Xcode template defaults `canHandle` to `false`.** The generated stub returns `false`, which means re-editing always starts from the flattened output. You must change this to support proper non-destructive editing.
- **Test with varied media formats.** Do not assume all inputs are JPEG from the device camera. HEIF, PNG, RAW (DNG), and Live Photos all flow through the same extension. Verify your `CIFilter` chain handles each format without crashing.
print-service.md
---
title: Print Service Discovery Extension
description: Discovers and configures custom printers that are not supported by AirPrint or standard IPP discovery, primarily used by printer manufacturers.
version: iOS 14.0+
---
# Print Service Discovery Extension (`print-service`)
A Print Service extension locates and sets up printers that cannot be discovered through standard AirPrint or Bonjour/DNS-SD mechanisms. When the user opens the system print dialog, iOS loads installed Print Service extensions to find additional printers beyond those visible via AirPrint. This is an extremely niche extension type used almost exclusively by printer manufacturers who need to support proprietary discovery protocols (e.g., vendor-specific Wi-Fi Direct, Bluetooth, or USB handshakes). Public documentation from Apple is minimal.
## Apple Documentation
- [UIPrintServiceExtension](https://developer.apple.com/documentation/uikit/uiprintserviceextension) -- The principal class your extension subclasses. Apple's documentation is a single page with no discussion or sample code.
- [UIPrinter](https://developer.apple.com/documentation/uikit/uiprinter) -- Represents a printer discovered by your extension.
- [UIPrintInteractionController](https://developer.apple.com/documentation/uikit/uiprintinteractioncontroller) -- The system print UI that triggers printer discovery.
- [Printing (UIKit)](https://developer.apple.com/documentation/uikit/printing) -- Overview of the iOS printing architecture.
- [AirPrint Overview](https://developer.apple.com/airprint/) -- AirPrint is the standard path; this extension exists for printers that cannot use it.
- [Bonjour Printing Specification](https://developer.apple.com/bonjour/printing-specification/) -- The standard discovery protocol that this extension type bypasses.
## WWDC History
There are no dedicated WWDC sessions covering the Print Service extension type. It was introduced quietly alongside other app extension points and has received no public stage time.
- **[WWDC 2016, Session 725 -- Deploying AirPrint in Enterprise](https://developer.apple.com/videos/play/wwdc2016/725/)** -- Covered AirPrint deployment and Bonjour-based discovery in enterprise networks. Did not mention `UIPrintServiceExtension` but provides essential context for understanding the printer discovery landscape.
## What It Does
1. **User taps Print.** An app presents `UIPrintInteractionController`, which triggers the system print UI.
2. **System discovers printers.** iOS runs AirPrint/Bonjour discovery by default. Simultaneously, it loads any installed Print Service extensions.
3. **Extension discovers custom printers.** Your `UIPrintServiceExtension` subclass runs its proprietary discovery logic (e.g., scanning for Bluetooth devices, querying a vendor-specific network protocol, or connecting via USB accessory).
4. **Printers appear in the picker.** Printers found by your extension appear alongside AirPrint printers in the system printer list.
5. **User selects your printer.** The system routes the print job through your extension, which handles communication with the physical printer.
**Note:** The internal mechanics of how print data flows through the extension are not publicly documented. The `UIPrintServiceExtension` class itself exposes no override points in the public headers -- it inherits from `NSObject` and acts as a container for your discovery and communication logic.
## Use Cases
### Printer Manufacturer Apps
HP, Canon, Epson, Brother, and other printer manufacturers build Print Service extensions into their companion iOS apps. When a user installs "HP Smart" or "Canon PRINT," the extension enables the system print dialog to find printers that use vendor-specific protocols beyond standard AirPrint.
### Legacy Enterprise Printers
Organizations with older network printers that do not support AirPrint or IPP Everywhere can build a custom Print Service extension that discovers these printers via their proprietary protocol (e.g., legacy LPR, vendor APIs, or SNMP-based discovery).
### Specialty / Industrial Printers
Label printers (Zebra, DYMO), receipt printers (Star Micronics), and other specialty hardware often use Bluetooth or vendor USB protocols. A Print Service extension can make them appear in the standard iOS print dialog rather than requiring a dedicated printing app.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `UIPrintServiceExtension` | Subclass this as your extension's principal class. The base class provides the extension lifecycle. Discovery and printer setup logic is your responsibility. |
| `UIPrinter` | Represents a discovered printer. Create instances with a `URL` and `displayName`. |
| `UIPrintInteractionController` | System print UI that triggers extension loading. Not used inside the extension itself. |
| `UIPrintInfo` | Describes the print job settings (output type, orientation, duplex). |
## Implementation
**Important:** Apple provides almost no public documentation on the internal API of `UIPrintServiceExtension`. The class has no documented override points beyond the standard `NSObject` lifecycle. The implementation below reflects the minimal template pattern, annotated with what is known.
```swift
import UIKit
// 1. Subclass UIPrintServiceExtension.
// This is the extension's principal class, declared in Info.plist.
class PrintServiceExtension: UIPrintServiceExtension {
// 2. There are no documented override points on UIPrintServiceExtension.
// The extension lifecycle and printer discovery protocol are not
// publicly documented by Apple.
//
// Printer manufacturers receive documentation under NDA through
// the MFi (Made for iPhone) program or direct partnership with
// Apple's AirPrint team (airprint@apple.com).
//
// 3. In practice, manufacturer implementations use private or
// semi-private APIs provided through partnership agreements.
// The discovery logic typically involves:
// - Scanning for Bluetooth LE peripherals (CoreBluetooth)
// - Querying vendor-specific mDNS service types
// - Communicating over vendor USB accessory protocols
//
// 4. If you are a printer manufacturer, contact airprint@apple.com
// to license AirPrint technology and receive the implementation
// documentation for UIPrintServiceExtension.
}
```
### What a Real Implementation Might Look Like (Conceptual)
While the actual API is under NDA, the general pattern used by printer manufacturer extensions involves:
```swift
import UIKit
import CoreBluetooth
// Conceptual only -- actual API details require Apple partnership.
class PrintServiceExtension: UIPrintServiceExtension {
// 1. The system triggers printer discovery when the print dialog opens.
// Your extension scans for printers using your vendor's protocol.
func discoverPrinters() {
// 2. Scan for Bluetooth LE peripherals matching your printer's
// service UUID, or probe a proprietary network protocol.
// 3. For each discovered printer, create a UIPrinter and report
// it to the system so it appears in the printer picker.
}
// 4. When the user selects your printer, the system asks your extension
// to handle the print job data and send it to the hardware.
func handlePrintJob(data: Data, printer: UIPrinter) {
// 5. Convert the print data to your printer's native format
// and transmit it over Bluetooth, USB, or network.
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target print-service
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 14.0+ | Primary platform. Extensions appear in the system print dialog. |
| iPadOS | 14.0+ | Same as iOS. |
| macOS | -- | macOS uses CUPS drivers and Print Dialog Extensions (PDEs) instead, not `UIPrintServiceExtension`. |
| watchOS | -- | Not supported. No printing on watchOS. |
| tvOS | -- | Not supported. No printing on tvOS. |
| visionOS | -- | Not confirmed. Printing support on visionOS is limited. |
## Gotchas
- **Documentation is essentially nonexistent.** Apple's public documentation for `UIPrintServiceExtension` consists of a single page with a one-sentence description and no sample code. The full implementation details are available only to printer manufacturers under NDA or MFi partnership. If you are not a printer hardware vendor, this extension type is likely not what you need.
- **Contact Apple directly for implementation guidance.** Apple directs printer manufacturers to email `airprint@apple.com` to license AirPrint technology and receive the private API documentation needed to build a functional Print Service extension.
- **AirPrint is the preferred path.** Apple strongly encourages all printers to support AirPrint (IPP + Bonjour). The Print Service extension exists as a fallback for hardware that cannot implement the standard protocol. If your printer can speak IPP, use AirPrint instead.
- **No Simulator support.** Print Service extensions require real hardware (both the iOS device and the printer) for testing. The iOS Simulator does not load print service extensions.
- **The extension runs in a sandboxed process.** Like all app extensions, it has limited memory and no access to the main app's data unless you use App Groups.
- **Bluetooth requires background modes and permissions.** If your discovery logic uses CoreBluetooth, you need the `bluetooth-central` background mode and `NSBluetoothAlwaysUsageDescription` in your extension's Info.plist.
- **MFi program may be required.** If your printer uses a Lightning or USB accessory protocol, you likely need MFi certification, which involves a separate Apple partnership process.
quicklook-preview.md
---
title: Quick Look Preview Provider
description: Renders full-size previews of custom file types for Quick Look, enabling space-bar preview in Finder, share sheet previews, and in-app QLPreviewController display.
version: iOS 13.0+, macOS 10.15+
---
# Quick Look Preview Provider (`quicklook-preview`)
A Quick Look Preview extension renders full-size previews for custom file types throughout the system. On macOS, pressing the space bar on a file in Finder triggers Quick Look; on iOS, previews appear in the Files app, share sheets, drag-and-drop, and any app using `QLPreviewController`. There are two approaches: a view-controller-based preview (subclass `UIViewController` conforming to `QLPreviewingController`) for rich interactive previews, or a data-based preview (subclass `QLPreviewProvider`) for returning static content like HTML, PDF, or images. The view-controller approach is the default template and works on iOS; the data-based `QLPreviewProvider` approach was added in iOS 15/macOS 12 and is preferred for cross-platform extensions.
## Apple Documentation
- [Quick Look Framework](https://developer.apple.com/documentation/quicklook)
- [Quick Look UI Framework](https://developer.apple.com/documentation/quicklookui)
- [QLPreviewingController](https://developer.apple.com/documentation/quicklook/qlpreviewingcontroller)
- [QLPreviewProvider](https://developer.apple.com/documentation/quicklookui/qlpreviewprovider)
- [QLPreviewReply](https://developer.apple.com/documentation/quicklookui/qlpreviewreply)
- [QLFilePreviewRequest](https://developer.apple.com/documentation/quicklook/qlfilepreviewrequest)
- [QLPreviewController](https://developer.apple.com/documentation/quicklook/qlpreviewcontroller)
- [Providing Previews of Your Custom File Types](https://developer.apple.com/documentation/quicklook/providing-previews-of-your-custom-file-types)
## WWDC History
- **[WWDC 2019, Session 719 -- What's New in File Management and Quick Look](https://developer.apple.com/videos/play/wwdc2019/719/)** -- Introduced the Quick Look Preview extension point for iOS alongside the thumbnail extension. Covered view-controller-based previews using `QLPreviewingController`.
- **[WWDC 2021, Session 10012 -- What's New in Quick Look](https://developer.apple.com/videos/play/wwdc2021/10012/)** -- Introduced the data-based `QLPreviewProvider` API for cross-platform support (iOS 15+/macOS 12+). Covered `QLPreviewReply` with PDF, data, and string initializers.
## What It Does
1. **Quick Look requests a preview.** When the user presses space bar (macOS), taps a file in Files (iOS), or any app invokes `QLPreviewController`, the system checks for a registered preview extension matching the file's UTI.
2. **View-controller approach (iOS 13+).** The system instantiates your `UIViewController` subclass conforming to `QLPreviewingController` and calls `preparePreviewOfFile(at:completionHandler:)`. You populate your view hierarchy and call the completion handler.
3. **Data-based approach (iOS 15+).** The system calls your `QLPreviewProvider` subclass's `providePreview(for:_:)`. You create a `QLPreviewReply` containing the preview data (HTML, PDF, image, or drawn content) and call the completion handler.
4. **The system displays the preview.** The rendered content is shown in the Quick Look panel (macOS) or the preview controller (iOS). The user can zoom, scroll, and interact with the content.
## Use Cases
### Custom document format
A productivity app (Notion, Bear) exports files in a custom format. The preview extension parses the file and returns an HTML-based `QLPreviewReply` with styled content, so users can preview documents from Finder without opening the app.
### 3D model viewer
A 3D design tool registers custom model file UTIs. The preview extension uses SceneKit or a Metal-backed view controller to render a rotatable 3D preview of the model.
### Code and configuration files
A developer tool registers custom config formats. The preview extension renders syntax-highlighted source code as an HTML page with a monospaced font, making files readable in Quick Look.
### Scientific data visualization
A research application stores data in a custom binary format. The preview extension reads the data, generates a chart or graph using Core Graphics, and returns it as a PDF-based `QLPreviewReply`.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `QLPreviewingController` | Protocol for view-controller-based previews. Implement `preparePreviewOfFile(at:completionHandler:)`. |
| `QLPreviewProvider` | Subclass for data-based previews (iOS 15+). Override `providePreview(for:_:)`. |
| `QLPreviewReply` | The reply object for data-based previews. Initializers for drawing, PDF, data (with content type), file URL, and string. |
| `QLFilePreviewRequest` | The request object for data-based previews. Contains `fileURL` and `contentType`. |
| `QLPreviewController` | Client-side view controller for displaying Quick Look previews. Not used in the extension itself. |
| `QLPreviewReplyAttachment` | Inline attachment (e.g., images) for HTML-based preview replies. |
## Implementation
### View-Controller-Based Preview (iOS 13+)
```swift
import UIKit
import QuickLook
// 1. Subclass UIViewController and conform to QLPreviewingController.
// This is the approach used by the default Xcode template.
class PreviewViewController: UIViewController, QLPreviewingController {
private let textView = UITextView()
override func viewDidLoad() {
super.viewDidLoad()
// 2. Set up your preview UI. You have full UIKit available here,
// including scroll views, collection views, and custom drawing.
textView.isEditable = false
textView.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
textView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(textView)
NSLayoutConstraint.activate([
textView.topAnchor.constraint(equalTo: view.topAnchor),
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
}
// 3. Called when a file needs to be previewed. Load the file and
// populate your UI, then call the handler to signal readiness.
func preparePreviewOfFile(at url: URL, completionHandler handler: @escaping (Error?) -> Void) {
do {
let content = try String(contentsOf: url, encoding: .utf8)
let document = try CustomDocument.parse(content)
// 4. Render the document into attributed text with syntax highlighting.
textView.attributedText = document.attributedRepresentation()
handler(nil)
} catch {
handler(error)
}
}
// 5. Optional: preview searchable items from Spotlight.
func preparePreviewOfSearchableItem(identifier: String,
queryString: String?,
completionHandler handler: @escaping (Error?) -> Void) {
// Resolve the item from your app's Spotlight index and display it.
handler(nil)
}
}
```
### Data-Based Preview with HTML (iOS 15+)
```swift
import QuickLook
import QuickLookUI
import UniformTypeIdentifiers
// 6. Subclass QLPreviewProvider for a data-based (non-view-controller) preview.
// This approach works cross-platform on iOS and macOS.
class PreviewProvider: QLPreviewProvider {
override func providePreview(for request: QLFilePreviewRequest,
_ handler: @escaping (QLPreviewReply?, Error?) -> Void) {
do {
let content = try String(contentsOf: request.fileURL, encoding: .utf8)
let document = try CustomDocument.parse(content)
// 7. Generate an HTML representation of the document.
let html = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, sans-serif; padding: 20px;
background: white; color: #333; }
h1 { color: #007AFF; }
pre { background: #f5f5f5; padding: 12px; border-radius: 8px;
font-size: 13px; overflow-x: auto; }
.metadata { color: #888; font-size: 12px; }
</style>
</head>
<body>
<h1>\(document.title)</h1>
<p class="metadata">Created: \(document.createdAt) | Items: \(document.items.count)</p>
<pre>\(document.formattedContent)</pre>
</body>
</html>
"""
// 8. Create a reply with HTML content. The system renders it in a web view.
let reply = QLPreviewReply(
dataOfContentType: .html,
contentSize: CGSize(width: 600, height: 800)
) { _ in
return html.data(using: .utf8)!
}
// 9. Optionally set a title for the preview window's title bar.
reply.title = document.title
handler(reply, nil)
} catch {
handler(nil, error)
}
}
}
```
### PDF-Based Preview
```swift
import QuickLook
import QuickLookUI
import PDFKit
class PreviewProvider: QLPreviewProvider {
override func providePreview(for request: QLFilePreviewRequest,
_ handler: @escaping (QLPreviewReply?, Error?) -> Void) {
do {
let data = try Data(contentsOf: request.fileURL)
let document = try CustomDocument(data: data)
let pageSize = CGSize(width: 612, height: 792) // US Letter
// 10. Create a PDF-based reply. The closure receives a CGContext
// configured for PDF drawing.
let reply = QLPreviewReply(forPDFWithPageSize: pageSize) {
(pdfContext: CGContext) -> Void in
// 11. Begin a PDF page and draw document content.
pdfContext.beginPDFPage(nil)
// Draw title
let titleAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 24),
.foregroundColor: UIColor.black
]
let title = document.title as NSString
title.draw(at: CGPoint(x: 36, y: pageSize.height - 60),
withAttributes: titleAttributes)
// 12. Draw body content
let bodyAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 12),
.foregroundColor: UIColor.darkGray
]
let body = document.bodyText as NSString
let bodyRect = CGRect(x: 36, y: 36,
width: pageSize.width - 72,
height: pageSize.height - 120)
body.draw(in: bodyRect, withAttributes: bodyAttributes)
pdfContext.endPDFPage()
}
handler(reply, nil)
} catch {
handler(nil, error)
}
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target quicklook-preview
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 13.0+ (view controller), 15.0+ (data-based) | View-controller previews via `QLPreviewingController`. Data-based via `QLPreviewProvider` requires iOS 15. |
| iPadOS | 13.0+ (view controller), 15.0+ (data-based) | Same as iOS. |
| macOS | 10.15+ (view controller), 12.0+ (data-based) | Space-bar Quick Look in Finder. Data-based previews via `QLPreviewProvider` require macOS 12. |
| visionOS | 1.0+ | Supported in the Files app. |
| tvOS | -- | Not supported. |
| watchOS | -- | Not supported. |
## Gotchas
- **You must declare supported UTIs in Info.plist.** Add your UTIs to the `QLSupportedContentTypes` array in the extension's Info.plist. Parent UTIs do not automatically match child types -- list each specific UTI you support.
- **The default template uses the view-controller approach.** The `@bacons/apple-targets` template generates a `QLPreviewingController`-based view controller. If you want the newer data-based `QLPreviewProvider` API, replace the generated files with a `QLPreviewProvider` subclass and update the Info.plist `NSExtensionPrincipalClass` accordingly.
- **`QLPreviewProvider` and `QLPreviewingController` are mutually exclusive.** An extension uses one approach or the other, not both. The choice is determined by the `NSExtensionPrincipalClass` in Info.plist.
- **HTML previews have limited JavaScript support.** When using `QLPreviewReply(dataOfContentType: .html, ...)`, the system renders the HTML in a constrained web view. Complex JavaScript, external network requests, and some CSS features may not work. Keep the HTML self-contained.
- **Preview extensions have a short timeout.** If your extension takes too long to generate a preview, the system cancels the request and shows "No Preview Available." Avoid heavy computation or network I/O. Pre-process data if possible.
- **The `ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES` build setting is required.** This extension type needs embedded Swift libraries. The `@bacons/apple-targets` plugin sets `needsEmbeddedSwift: true` automatically.
- **Test with `qlmanage -p` on macOS.** Run `qlmanage -p /path/to/your/file.custom` from Terminal to trigger a Quick Look preview and debug your extension. Use Xcode's Debug > Attach to Process to set breakpoints.
- **On macOS, reset the Quick Look cache with `qlmanage -r`.** During development, cached previews may persist even after rebuilding your extension. Run `qlmanage -r` and `qlmanage -r cache` to clear both the generator and cache databases.
- **Spotlight integration is optional but valuable.** Implementing `preparePreviewOfSearchableItem(identifier:queryString:completionHandler:)` lets your extension provide previews for Spotlight search results, not just file browsing.
- **File access is read-only.** The extension receives a URL to the file but cannot modify it. If your preview requires transforming the file (e.g., decompressing), write temporary files and clean them up in your extension's dealloc or invalidation.
quicklook-thumbnail.md
---
title: Quick Look Thumbnail Provider
description: Generates system-wide thumbnail images for custom file types, displayed in Files, Finder, Spotlight, and document pickers.
version: iOS 13.0+, macOS 10.15+
---
# Quick Look Thumbnail Provider (`quicklook-thumbnail`)
A Quick Look Thumbnail extension generates thumbnail images for custom file types so they appear with rich previews throughout the system -- in the Files app, Finder, Spotlight results, document pickers, and any app that uses `QLThumbnailGenerator`. You subclass `QLThumbnailProvider` from the QuickLookThumbnailing framework and override `provideThumbnail(for:_:)` to draw or supply an image for each request. The system provides the file URL, maximum size, and scale factor; you return a `QLThumbnailReply` containing a rendered image, a drawing block, or a file URL.
## Apple Documentation
- [QuickLookThumbnailing Framework](https://developer.apple.com/documentation/quicklookthumbnailing)
- [QLThumbnailProvider](https://developer.apple.com/documentation/quicklookthumbnailing/qlthumbnailprovider)
- [provideThumbnail(for:_:)](https://developer.apple.com/documentation/quicklookthumbnailing/qlthumbnailprovider/providethumbnail(for:_:))
- [QLFileThumbnailRequest](https://developer.apple.com/documentation/quicklookthumbnailing/qlfilethumbnailrequest)
- [QLThumbnailReply](https://developer.apple.com/documentation/quicklookthumbnailing/qlthumbnailreply)
- [QLThumbnailGenerator](https://developer.apple.com/documentation/quicklookthumbnailing/qlthumbnailgenerator)
- [Creating Quick Look Thumbnails to Preview Files in Your App](https://developer.apple.com/documentation/quicklookthumbnailing/creating-quick-look-thumbnails-to-preview-files-in-your-app)
- [Providing Thumbnails of Your Custom File Types](https://developer.apple.com/documentation/quicklookthumbnailing/providing-thumbnails-of-your-custom-file-types)
## WWDC History
- **[WWDC 2019, Session 719 -- What's New in File Management and Quick Look](https://developer.apple.com/videos/play/wwdc2019/719/)** -- Introduced the QuickLookThumbnailing framework and the `QLThumbnailProvider` extension point. Covered the three reply modes (context drawing, Core Graphics drawing, image file URL) and cross-platform support for iOS and macOS.
## What It Does
1. **The system requests a thumbnail.** When a file with your declared UTI appears in Finder, Files, Spotlight, or a `QLThumbnailGenerator` request, the system launches your extension and calls `provideThumbnail(for:_:)`.
2. **You receive a `QLFileThumbnailRequest`.** This contains the file `url`, `maximumSize` (CGSize), `minimumDimension` (CGFloat), and `scale` (CGFloat). Your thumbnail should fit within these constraints.
3. **You create a `QLThumbnailReply`.** Three options: (a) draw into the current UIKit graphics context, (b) draw into a provided `CGContext`, or (c) return a URL to a pre-rendered image file.
4. **You call the completion handler.** Pass the reply and `nil` error on success, or `nil` reply and an error on failure.
5. **The system caches the thumbnail.** Thumbnails are cached by the system. Your extension is only called when the cache is cold or the file's modification date changes.
## Use Cases
### Custom document format
A design application (Sketch, Figma) registers a custom `.sketch` or `.fig` UTI. The thumbnail extension reads the file, extracts the first artboard, and renders a scaled-down preview so users can identify documents at a glance in Finder.
### 3D model files
A CAD or 3D modeling app registers UTIs for `.obj`, `.stl`, or custom formats. The thumbnail extension renders a simple wireframe or shaded preview of the model using SceneKit or Metal offline rendering.
### Source code and data files
A developer tool registers UTIs for custom config files (`.yml`, `.toml`, custom DSLs). The thumbnail extension renders syntax-highlighted text into a small image so files are visually distinguishable in Finder.
### Game save files
A game registers a custom save-file UTI. The thumbnail extension reads the save data and renders the player's character portrait or a minimap screenshot as the file icon.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `QLThumbnailProvider` | Abstract base class. Subclass this and override `provideThumbnail(for:_:)`. |
| `QLFileThumbnailRequest` | The request object. Properties: `fileURL`, `maximumSize`, `minimumDimension`, `scale`. |
| `QLThumbnailReply` | The reply object. Initialize with one of three modes: `currentContextDrawing`, `drawing` (CGContext), or `imageFileURL`. |
| `QLThumbnailGenerator` | Client-side class for requesting thumbnails from any app. Not used in the extension itself, but useful for testing. |
| `QLThumbnailRepresentation` | The result type from `QLThumbnailGenerator`. Contains the generated `CGImage` or `UIImage`. |
## Implementation
### Custom Document Thumbnail with Core Graphics
```swift
import UIKit
import QuickLookThumbnailing
class ThumbnailProvider: QLThumbnailProvider {
// 1. Override this single method. The system calls it for each thumbnail request.
override func provideThumbnail(for request: QLFileThumbnailRequest,
_ handler: @escaping (QLThumbnailReply?, Error?) -> Void) {
// 2. Read enough of the file to generate a meaningful preview.
// Keep this fast -- the system expects thumbnails quickly.
guard let documentData = try? Data(contentsOf: request.fileURL),
let document = try? CustomDocument(data: documentData) else {
handler(nil, NSError(domain: "com.example.thumbnail",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Unable to read document"]))
return
}
// 3. Calculate the thumbnail size respecting the aspect ratio
// and the maximum size from the request.
let aspectRatio = document.canvasWidth / document.canvasHeight
let thumbnailSize: CGSize
if aspectRatio > 1 {
// Landscape
thumbnailSize = CGSize(
width: request.maximumSize.width,
height: request.maximumSize.width / aspectRatio
)
} else {
// Portrait or square
thumbnailSize = CGSize(
width: request.maximumSize.height * aspectRatio,
height: request.maximumSize.height
)
}
// 4. Use the CGContext drawing mode for full control over rendering.
let reply = QLThumbnailReply(contextSize: thumbnailSize) { context -> Bool in
// 5. Fill a white background.
context.setFillColor(UIColor.white.cgColor)
context.fill(CGRect(origin: .zero, size: thumbnailSize))
// 6. Draw document content scaled to fit the thumbnail.
// This is where your file-format-specific rendering happens.
let drawRect = CGRect(origin: .zero, size: thumbnailSize)
for element in document.elements {
element.draw(in: context, rect: drawRect)
}
// 7. Draw a subtle border so the thumbnail is distinguishable
// against white backgrounds in Finder.
context.setStrokeColor(UIColor.separator.cgColor)
context.setLineWidth(1.0 / request.scale)
context.stroke(CGRect(origin: .zero, size: thumbnailSize))
// 8. Return true to indicate success. Returning false discards the thumbnail.
return true
}
handler(reply, nil)
}
}
```
### Image-Based Thumbnail (Embedded Preview)
For file formats that embed a preview image (like many document formats), you can extract it to a temporary file:
```swift
import UIKit
import QuickLookThumbnailing
class ThumbnailProvider: QLThumbnailProvider {
override func provideThumbnail(for request: QLFileThumbnailRequest,
_ handler: @escaping (QLThumbnailReply?, Error?) -> Void) {
// 1. Extract the embedded preview image from the file.
guard let previewData = extractEmbeddedPreview(from: request.fileURL) else {
handler(nil, NSError(domain: "com.example.thumbnail", code: -1))
return
}
// 2. Write the preview to a temporary file.
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("png")
do {
try previewData.write(to: tempURL)
// 3. Return the file URL directly. The system reads and scales the image.
handler(QLThumbnailReply(imageFileURL: tempURL), nil)
} catch {
handler(nil, error)
}
}
private func extractEmbeddedPreview(from url: URL) -> Data? {
// Parse your file format and extract the embedded preview image bytes.
guard let fileData = try? Data(contentsOf: url) else { return nil }
return CustomFileFormat.extractPreviewPNG(from: fileData)
}
}
```
### UIKit Context Drawing (Simple Text Preview)
```swift
import UIKit
import QuickLookThumbnailing
class ThumbnailProvider: QLThumbnailProvider {
override func provideThumbnail(for request: QLFileThumbnailRequest,
_ handler: @escaping (QLThumbnailReply?, Error?) -> Void) {
// 1. Read the first few lines of a text-based custom file.
guard let content = try? String(contentsOf: request.fileURL, encoding: .utf8) else {
handler(nil, NSError(domain: "com.example.thumbnail", code: -1))
return
}
let previewText = content.prefix(500)
let size = request.maximumSize
// 2. Use the currentContextDrawing mode (UIKit coordinate system).
let reply = QLThumbnailReply(contextSize: size, currentContextDrawing: { () -> Bool in
// 3. Fill background
UIColor.systemBackground.setFill()
UIRectFill(CGRect(origin: .zero, size: size))
// 4. Draw the text with a small monospaced font.
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.monospacedSystemFont(ofSize: 6 * request.scale, weight: .regular),
.foregroundColor: UIColor.label
]
let textRect = CGRect(origin: .zero, size: size).insetBy(dx: 4, dy: 4)
(previewText as NSString).draw(in: textRect, withAttributes: attributes)
return true
})
handler(reply, nil)
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target quicklook-thumbnail
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 13.0+ | Thumbnails appear in Files, document pickers, Spotlight, and any app using `QLThumbnailGenerator`. |
| iPadOS | 13.0+ | Same as iOS. |
| macOS | 10.15+ | Thumbnails appear in Finder, Spotlight, and Open/Save panels. Replaces the legacy QLThumbnailGenerator C API. |
| visionOS | 1.0+ | Supported in the Files app. |
| tvOS | -- | Not supported. |
| watchOS | -- | Not supported. |
## Gotchas
- **You must declare supported UTIs in Info.plist.** Add your custom UTIs to the `QLSupportedContentTypes` array in the extension's Info.plist. The system will only invoke your extension for file types listed here. Parent UTIs do not match -- you must list the exact UTI.
- **Thumbnails must be generated quickly.** The system imposes a short timeout (a few seconds). If your extension takes too long, the request is cancelled and the system falls back to a generic icon. Avoid heavy computation or network requests.
- **Memory limit applies.** Like all extensions, thumbnail providers run under a strict memory cap. For large files, read only the bytes needed for a preview -- do not load the entire file into memory.
- **The `scale` property matters.** On Retina displays, `request.scale` is 2.0 or 3.0. If you use the `imageFileURL` reply mode, provide an image at the appropriate pixel dimensions (`maximumSize * scale`). The context-based modes handle scaling automatically.
- **`currentContextDrawing` uses UIKit coordinates; `drawing` uses Core Graphics coordinates.** In the UIKit mode, the origin is at the top-left. In the Core Graphics mode, the origin is at the bottom-left. Mixing up coordinate systems produces upside-down thumbnails.
- **Thumbnails are cached aggressively.** During development, the system may continue showing a stale thumbnail even after you update your extension. On macOS, use `qlmanage -r` to reset the Quick Look cache. On iOS, deleting and reinstalling the app clears the cache.
- **Test with `qlmanage -t` on macOS.** Run `qlmanage -t -s 512 /path/to/your/file.custom` from Terminal to generate a thumbnail and verify your extension works. Attach the debugger in Xcode to your extension target for breakpoints.
- **`QLThumbnailMinimumDimension` is optional but recommended.** Set this key in your extension's Info.plist to specify the smallest useful thumbnail size. The system will not request thumbnails smaller than this dimension, avoiding wasted work for tiny icon views.
safari.md
---
title: Safari Web Extension
description: Adds browser functionality to Safari using the cross-browser WebExtension API with manifest.json, content scripts, background scripts, and a popup UI.
version: iOS 15.0+, macOS 11.0+
---
# Safari Web Extension (`safari`)
A Safari Web Extension uses the standard cross-browser WebExtension API -- the same `manifest.json`, content scripts, background scripts, and popup HTML used by Chrome and Firefox extensions -- packaged inside a native iOS or macOS app. Unlike every other Apple extension type, the bulk of the logic is written in JavaScript/HTML/CSS rather than Swift. A thin native `SafariWebExtensionHandler` class (conforming to `NSExtensionRequestHandling`) acts as the bridge for native messaging via `browser.runtime.sendNativeMessage()`. The extension requires a containing app to be distributed through the App Store, and users must explicitly enable it in Safari's settings. This architecture is fundamentally different from other iOS extensions: there is no `NSExtensionContext.inputItems` flow, no `NSItemProvider` attachments, and no share sheet -- instead, the extension operates within Safari's tab and page lifecycle through the WebExtension API.
## Apple Documentation
- [Safari Web Extensions Overview](https://developer.apple.com/documentation/safariservices/safari-web-extensions)
- [Creating a Safari Web Extension](https://developer.apple.com/documentation/safariservices/creating-a-safari-web-extension)
- [Assessing Your Safari Web Extension's Browser Compatibility](https://developer.apple.com/documentation/safariservices/assessing-your-safari-web-extension-s-browser-compatibility)
- [Messaging a Web Extension's Native App](https://developer.apple.com/documentation/safariservices/messaging-a-web-extension-s-native-app)
- [SFSafariExtensionHandler (macOS)](https://developer.apple.com/documentation/safariservices/sfsafariextensionhandler)
- [Converting a Web Extension for Safari](https://developer.apple.com/documentation/safariservices/converting-a-web-extension-for-safari)
- [Running Your Safari Web Extension During Development](https://developer.apple.com/documentation/safariservices/running-your-safari-web-extension-during-development)
## WWDC History
- **[WWDC 2020, Session 10665 -- Meet Safari Web Extensions](https://developer.apple.com/videos/play/wwdc2020/10665/)** -- Introduced Safari Web Extensions on macOS. Covered the `manifest.json` format, `safari-web-extension-converter` tool, and the privacy-focused permissions model.
- **[WWDC 2021, Session 10104 -- Meet Safari Web Extensions on iOS](https://developer.apple.com/videos/play/wwdc2021/10104/)** -- Brought Safari Web Extensions to iOS 15 and iPadOS 15. Demonstrated building a single extension that works across iPhone, iPad, and Mac.
- **[WWDC 2021, Session 10027 -- Explore Safari Web Extension Improvements](https://developer.apple.com/videos/play/wwdc2021/10027/)** -- Introduced non-persistent background pages for better performance, the `declarativeNetRequest` API for content blocking, and tab-override pages.
- **[WWDC 2022, Session 10099 -- What's New in Safari Web Extensions](https://developer.apple.com/videos/play/wwdc2022/10099/)** -- Manifest v3 support, `declarativeNetRequest` improvements, and updated permissions UI.
- **[WWDC 2023, Session 10119 -- What's New in Safari Extensions](https://developer.apple.com/videos/play/wwdc2023/10119/)** -- Per-site permissions, profile-aware extensions, and additional WebExtension API coverage.
## What It Does
1. **User enables the extension.** In iOS Settings > Safari > Extensions (or Safari > Preferences > Extensions on macOS), the user toggles the extension on and grants per-site or all-sites permission.
2. **Content scripts inject into web pages.** Scripts listed in `manifest.json`'s `content_scripts` array run in the context of matching web pages. They can read and modify the DOM, intercept events, and communicate with the background script.
3. **Background script handles events.** The `background.service_worker` (manifest v3) or `background.scripts` (manifest v2) file runs in a separate context. It handles extension events (install, tab updates, messages from content scripts, alarms) and manages extension-wide state.
4. **Popup UI provides controls.** The `action.default_popup` HTML page appears when the user taps the extension icon in Safari's toolbar. It can communicate with the background script to read/write state and trigger actions.
5. **Native messaging bridges to Swift.** Calling `browser.runtime.sendNativeMessage("application.id", message)` from JavaScript sends a message to the native `SafariWebExtensionHandler.beginRequest(with:)` method. The native side can access Keychain, Core Data, file system, or any iOS API and return a response.
6. **Permissions control access.** The `permissions` and `host_permissions` arrays in `manifest.json` declare what APIs and domains the extension needs. Safari's privacy model requires explicit user consent for each site (or all sites).
## Use Cases
### Ad and tracker blocking
A `declarativeNetRequest`-based extension with rules defined in JSON files that block network requests matching specific URL patterns. No background script needed for basic blocking -- Safari evaluates the rules natively for better performance and privacy.
### Password manager autofill
A content script detects login forms on web pages and offers to fill credentials. The content script sends a message to the background script, which calls `browser.runtime.sendNativeMessage()` to request credentials from the native app's Keychain storage via `SafariWebExtensionHandler`.
### Page translation
A content script extracts the page text, sends it to the background script, which calls a translation API. The translated text is sent back to the content script, which replaces the DOM content. The popup provides language selection and toggle controls.
### Reading mode and accessibility
A content script applies custom CSS and DOM transformations to simplify page layouts, adjust fonts, or add dark mode. User preferences are stored via `browser.storage.local` and synced across devices with `browser.storage.sync`.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `SafariWebExtensionHandler` | Native Swift class conforming to `NSExtensionRequestHandling`. Receives messages from `browser.runtime.sendNativeMessage()` and returns responses. The bridge between WebExtension JavaScript and native iOS/macOS APIs. |
| `NSExtensionRequestHandling` | Protocol that `SafariWebExtensionHandler` conforms to. Provides `beginRequest(with:)` where native message handling occurs. |
| `NSExtensionContext` | Delivers the incoming message and provides `completeRequest(returningItems:)` to send the response back to JavaScript. |
| `SFExtensionMessageKey` | String constant used as the key in `NSExtensionItem.userInfo` to extract the message dictionary sent from JavaScript and to set the response dictionary. |
| `SFSafariApplication` (macOS) | On macOS, provides methods to interact with Safari (get active window, dispatch messages to extension). Not available on iOS. |
| `SFContentBlockerManager` | Used by both content blocker extensions and web extensions to reload content blocking rules. Call `reloadContentBlocker(withIdentifier:)` when rules change. |
## Implementation
```swift
import SafariServices
import os.log
// 1. The native handler receives messages from browser.runtime.sendNativeMessage().
class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
func beginRequest(with context: NSExtensionContext) {
// 2. Extract the message sent from JavaScript.
let item = context.inputItems[0] as! NSExtensionItem
guard let message = item.userInfo?[SFExtensionMessageKey] as? [String: Any] else {
context.completeRequest(returningItems: [], completionHandler: nil)
return
}
os_log(.default, "Received native message: %{public}@", String(describing: message))
let action = message["action"] as? String ?? ""
// 3. Handle different message types from the JavaScript side.
var responseData: [String: Any] = [:]
switch action {
case "getCredentials":
// 4. Access native-only APIs like Keychain.
let domain = message["domain"] as? String ?? ""
responseData = [
"username": lookupUsername(for: domain),
"found": true
]
case "savePreference":
// 5. Write to UserDefaults in the shared App Group.
let key = message["key"] as? String ?? ""
let value = message["value"] as? String ?? ""
let defaults = UserDefaults(suiteName: "group.com.example.myapp")
defaults?.set(value, forKey: key)
responseData = ["saved": true]
default:
responseData = ["error": "Unknown action: \(action)"]
}
// 6. Send the response back to the JavaScript callback.
let response = NSExtensionItem()
response.userInfo = [SFExtensionMessageKey: responseData]
context.completeRequest(returningItems: [response], completionHandler: nil)
}
private func lookupUsername(for domain: String) -> String {
// In a real app, query the Keychain or Core Data here.
return "user@example.com"
}
}
```
The `manifest.json` defines the extension structure, permissions, and scripts:
```json
{
"manifest_version": 3,
"name": "My Safari Extension",
"version": "1.0",
"description": "Example Safari Web Extension",
"icons": {
"48": "images/icon-48.png",
"96": "images/icon-96.png",
"128": "images/icon-128.png"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [{
"js": ["content.js"],
"matches": ["*://*.example.com/*"]
}],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "images/toolbar-icon-16.png",
"32": "images/toolbar-icon-32.png"
}
},
"permissions": [
"storage",
"activeTab",
"nativeMessaging"
]
}
```
The `background.js` handles extension lifecycle events and native messaging:
```javascript
// 7. Listen for messages from content scripts.
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === "getCredentials") {
// 8. Forward to the native SafariWebExtensionHandler.
browser.runtime.sendNativeMessage(
"application.id",
{ action: "getCredentials", domain: message.domain },
(response) => {
// 9. Relay the native response back to the content script.
sendResponse(response);
}
);
return true; // Keep the message channel open for async response.
}
});
// 10. Handle extension install and update events.
browser.runtime.onInstalled.addListener((details) => {
if (details.reason === "install") {
console.log("Extension installed");
}
});
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target safari
```
```json
// targets/safari/expo-target.config.json
{
"type": "safari"
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 15.0+ | Safari Web Extensions introduced. Users enable in Settings > Safari > Extensions. |
| iPadOS | 15.0+ | Same as iOS. Extensions work in both compact and regular size classes. |
| macOS | 11.0+ | Safari Web Extensions introduced at WWDC 2020. Full desktop extension support. |
| Mac Catalyst | 15.0+ | Can share the same extension target between iOS and Mac Catalyst builds. |
| watchOS | -- | Not supported. |
| tvOS | -- | Not supported. |
| visionOS | 1.0+ | Safari available; extension support follows iOS model. |
## Gotchas
- **Service worker lifecycle is unreliable on iOS.** On iOS 17.4 through 17.5, the service worker would stop after approximately 30 seconds. On iOS 18+, the hard timeout was removed but the service worker can still die and fail to wake up for subsequent events. Content scripts and popup messages may silently fail when the service worker is dead. Consider using `background.scripts` with a non-persistent page instead of `service_worker` if you need more reliable background processing.
- **`sendNativeMessage` response handler may not fire.** Apple has acknowledged a bug where the response callback from `browser.runtime.sendNativeMessage()` never executes. As a workaround, use `browser.runtime.connectNative()` with port-based messaging instead.
- **Manifest v3 debugging is broken in Safari.** When using manifest v3 with `background.service_worker`, the Web Inspector shows "No Web Extension Background Content" and you cannot inspect or view `console.log` output from the service worker. Manifest v2 background pages can be inspected normally.
- **`preferred_environment` controls background mode.** When both `background.scripts` and `background.service_worker` are in `manifest.json`, Safari uses `scripts` unless `preferred_environment` is set to `"service_worker"`. On iOS, background content cannot be persistent -- set `"persistent": false` or the build will fail.
- **User must explicitly enable the extension.** Unlike other extension types that appear automatically, Safari Web Extensions require the user to navigate to Settings > Safari > Extensions and toggle the extension on. Your containing app should guide users through this with a deep link to the settings page.
- **Per-site permission model.** Safari asks for permission on each domain the extension wants to access. Extensions declared with broad host permissions (`<all_urls>`) trigger a more prominent permission prompt. Users can revoke per-site access at any time.
- **`webRequest` API dropped in Safari 18.4 for manifest v3.** After the macOS 15.4 / Safari 18.4 update, non-persistent background content cannot listen to `webRequest` events. Use `declarativeNetRequest` instead for request interception and blocking.
- **Safari does not extend service worker lifetime for native messaging.** Unlike Chrome, which keeps the service worker alive while a native messaging connection is open, Safari will terminate the service worker on its normal schedule. Long-running native messaging sessions will be interrupted.
- **Cross-browser differences exist.** Safari uses the `browser.*` namespace (with Promise support) rather than Chrome's `chrome.*` namespace. Some APIs like `browser.webRequest` have restrictions. Always test in Safari separately. Apple provides a compatibility assessment tool to check your manifest and API usage.
- **The containing app is required but can be minimal.** Apple requires a native iOS/macOS app to wrap the extension for App Store distribution. The app can be a simple single-screen app that explains the extension and links to Safari settings, but it must exist.
- **Extension resources must be in the `assets/` directory.** The `manifest.json`, JavaScript files, HTML files, CSS, and images for the web extension must all be inside the `assets/` (or `Resources/`) directory of the extension target. The `SafariWebExtensionHandler.swift` lives at the target root alongside them.
share.md
---
title: Share Extension
description: Presents a compose UI in the system share sheet so users can share content from any app to your app or service.
version: iOS 8.0+
---
# Share Extension (`share`)
A Share Extension adds your app to the system share sheet, allowing users to send content -- URLs, images, text, files -- directly from any app into yours. The extension runs in a separate process with tight resource constraints and communicates with its containing app through App Groups. You can either subclass `SLComposeServiceViewController` for a standard compose-style UI with a preview and text field, or provide a fully custom `UIViewController` (or SwiftUI view via `UIHostingController`) for complete control over the presentation. Content arrives via `NSExtensionContext.inputItems` as an array of `NSExtensionItem` objects, each carrying `NSItemProvider` attachments that you load asynchronously by type identifier.
## Apple Documentation
- [App Extension Programming Guide: Share](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/Share.html)
- [SLComposeServiceViewController](https://developer.apple.com/documentation/social/slcomposeserviceviewcontroller)
- [NSExtensionContext](https://developer.apple.com/documentation/foundation/nsextensioncontext)
- [NSItemProvider](https://developer.apple.com/documentation/foundation/nsitemprovider)
- [NSExtensionItem](https://developer.apple.com/documentation/foundation/nsextensionitem)
- [NSExtensionPointIdentifier](https://developer.apple.com/documentation/bundleresources/information-property-list/nsextension/nsextensionpointidentifier)
- [App Groups Entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_application-groups)
## WWDC History
- **[WWDC 2014, Session 205 -- Creating Extensions for iOS and OS X, Part 1](https://developer.apple.com/videos/play/wwdc2014/205/)** -- Introduced app extensions including Share Extensions. Covered `SLComposeServiceViewController`, `NSExtensionContext`, and the share sheet lifecycle.
- **[WWDC 2014, Session 217 -- Creating Extensions for iOS and OS X, Part 2](https://developer.apple.com/videos/play/wwdc2014/217/)** -- Deep dive into data flow between host apps and extensions using `NSExtensionItem` and `NSItemProvider`. Covered returning modified data from Action Extensions (contrast with Share Extensions).
- **[WWDC 2015, Session 224 -- App Extension Best Practices](https://developer.apple.com/videos/play/wwdc2015/224/)** -- Guidance on memory management, activation rules, and sharing data between extension and containing app via App Groups.
## What It Does
1. **User taps Share.** The system presents the share sheet. If the extension's `NSExtensionActivationRule` matches the content being shared, your extension appears in the list.
2. **Extension launches in-process.** The system instantiates your principal class (typically a `SLComposeServiceViewController` subclass or a custom `UIViewController`) in a separate process.
3. **Content is delivered via `NSExtensionContext`.** The `extensionContext.inputItems` array contains `NSExtensionItem` objects. Each item's `attachments` property holds `NSItemProvider` instances representing the shared data.
4. **You load attachments asynchronously.** Call `NSItemProvider.loadItem(forTypeIdentifier:options:)` with the appropriate UTI (e.g., `public.url`, `public.image`, `public.plain-text`) to retrieve the actual data.
5. **User composes and posts.** If using `SLComposeServiceViewController`, the user sees a standard compose view with a text field and content preview. Tapping Post calls `didSelectPost()`.
6. **Extension completes the request.** Call `extensionContext?.completeRequest(returningItems:completionHandler:)` to dismiss the extension. Use `NSURLSession` with a background configuration for any uploads that might outlast the extension process.
7. **Data flows to the main app via App Groups.** Write shared data to an App Group container (`UserDefaults(suiteName:)` or a shared file) so the containing app can access it on next launch.
## Use Cases
### Social media posting
A user selects a photo in the Photos app, taps Share, and picks your social media app. The share extension shows a compose UI where they add a caption. On post, the extension writes the image and caption to the App Group container and enqueues an upload via a background URL session.
### Save to reading list
A user shares a URL from Safari to your read-later app. The extension extracts the URL via `NSItemProvider.loadItem(forTypeIdentifier: "public.url")`, stores it in a shared Core Data store or JSON file in the App Group container, and confirms the save with a brief animation.
### Send to messaging app
A user shares a document from Files to your messaging app. The extension presents a contact picker (custom UI), copies the file to the App Group container, and signals the main app to send the message on next launch or via a background task.
### Quick note capture
A user highlights text in any app and shares it to your note-taking app. The extension grabs the text via `public.plain-text`, optionally lets the user choose a notebook from a configuration item, and writes the note to shared storage.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `SLComposeServiceViewController` | Provides a standard compose UI with text field, character count, content preview, and configuration items. Subclass and override `didSelectPost()`, `isContentValid()`, and `configurationItems()`. |
| `UIViewController` | Use a plain view controller (with `UIHostingController` for SwiftUI) for a fully custom share UI when `SLComposeServiceViewController` is too restrictive. |
| `NSExtensionContext` | The bridge between the host app and your extension. Provides `inputItems` and the `completeRequest(returningItems:completionHandler:)` method. |
| `NSExtensionItem` | Represents one item from the host app. Contains `attachments` (array of `NSItemProvider`), `attributedTitle`, and `attributedContentText`. |
| `NSItemProvider` | Wraps a single attachment. Use `hasItemConformingToTypeIdentifier(_:)` to check availability, then `loadItem(forTypeIdentifier:options:)` to retrieve the data. |
| `SLComposeSheetConfigurationItem` | A table-cell-style row at the bottom of the compose sheet for additional options (e.g., choose an account or album). Returned from `configurationItems()`. |
| `UserDefaults(suiteName:)` | Reads/writes preferences in a shared App Group container so both the extension and main app can access them. |
## Implementation
```swift
import UIKit
import Social
import SwiftUI
import UniformTypeIdentifiers
// 1. Subclass SLComposeServiceViewController for the standard compose UI.
class ShareViewController: SLComposeServiceViewController {
private var sharedURL: URL?
private var sharedText: String?
// 2. Called when the compose sheet appears. Begin loading attachments here.
override func presentationAnimationDidFinish() {
guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
let attachments = item.attachments else { return }
for provider in attachments {
// 3. Check each attachment for known types and load asynchronously.
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] item, _ in
if let url = item as? URL {
DispatchQueue.main.async {
self?.sharedURL = url
self?.validateContent()
}
}
}
} else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { [weak self] item, _ in
if let text = item as? String {
DispatchQueue.main.async {
self?.sharedText = text
self?.validateContent()
}
}
}
}
}
}
// 4. Validate that the user has entered enough content to post.
override func isContentValid() -> Bool {
return !contentText.isEmpty || sharedURL != nil
}
// 5. Called when the user taps Post. Save data to the App Group container.
override func didSelectPost() {
let defaults = UserDefaults(suiteName: "group.com.example.myapp")
var savedItems = defaults?.array(forKey: "sharedItems") as? [[String: String]] ?? []
var entry: [String: String] = ["text": contentText]
if let url = sharedURL { entry["url"] = url.absoluteString }
savedItems.append(entry)
defaults?.set(savedItems, forKey: "sharedItems")
// 6. Complete the request to dismiss the extension.
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
// 7. Add configuration items for additional options (e.g., pick a folder).
override func configurationItems() -> [Any]! {
let folder = SLComposeSheetConfigurationItem()!
folder.title = "Save to"
folder.value = "Inbox"
folder.tapHandler = {
// Push a selection view controller
}
return [folder]
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target share
```
```json
// targets/share/expo-target.config.json
{
"type": "share"
}
```
```sh
# Initial setup or after changing expo-target.config.js
npx expo prebuild --clean
# Subsequent runs when editing Swift code only (extension files live outside /ios)
npx expo prebuild
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 8.0+ | Full share sheet support. |
| iPadOS | 8.0+ | Full share sheet support. Appears in popover on iPad. |
| macOS | 10.10+ | Share menu in Finder and apps. Uses `NSViewController` instead of `UIViewController`. |
| Mac Catalyst | 13.1+ | Uses iOS extension APIs. |
| watchOS | -- | Not supported. |
| tvOS | -- | Not supported. |
| visionOS | 1.0+ | Share sheet supported. |
## Gotchas
- **120 MB memory limit.** iOS enforces a hard ~120 MB memory cap on share extensions. Exceeding it triggers an immediate `EXC_RESOURCE` crash with no warning. Avoid loading large images at full resolution; use `CGImageSourceCreateThumbnailAtIndex` to downsample, and defer heavy work until `presentationAnimationDidFinish()`.
- **`SLComposeServiceViewController` silently loads a default UI.** Even if you override the view entirely, the base class still creates its standard compose popup underneath, consuming memory. On iOS 17 this became worse as the system eagerly loads shared URL content. If you need a fully custom UI, consider subclassing `UIViewController` directly instead.
- **Handle multiple attachment types.** Users can share mixed content (e.g., text + image + URL). Always iterate over all `NSItemProvider` attachments and check `hasItemConformingToTypeIdentifier` for each type you support. Do not assume there is only one attachment.
- **`TRUEPREDICATE` will get you rejected.** The default `NSExtensionActivationRule` is `TRUEPREDICATE`, which shows the extension for all content types. Apple rejects App Store submissions that ship with this value. Replace it with specific activation rule keys or a predicate string before submitting.
- **Uploads must use background URL sessions.** The extension process can be terminated at any time after `completeRequest` returns. Use `URLSession` with a `.background` configuration so uploads continue even after the extension is dismissed.
- **App Groups are essential.** The extension runs in a separate sandboxed process. The only way to pass data to your main app is through an App Group shared container (via `UserDefaults(suiteName:)`, shared files, or Core Data with a shared container).
- **`contentText` is only available with `SLComposeServiceViewController`.** If you use a custom `UIViewController`, you must build your own text input and manage the text yourself.
- **No access to the network in some contexts.** Some MDM configurations and content types can restrict network access in extensions. Always handle network failures gracefully and consider queuing uploads for the main app to retry.
- **Keyboard appearance can be delayed.** In the compose view, the keyboard may not appear immediately. Calling `becomeFirstResponder()` in `viewDidAppear` does not always work; use `presentationAnimationDidFinish()` as the safe trigger point.
shield-action.md
---
title: Shield Action Extension
description: Handles button taps on the Screen Time shield overlay, allowing the app to close the shielded app, defer the action, or do nothing in response to user interaction.
version: iOS 15.0+
---
# Shield Action Extension (`shield-action`)
A Shield Action extension responds to user taps on the primary and secondary buttons of the Screen Time shield overlay. When `ManagedSettingsStore` applies a shield to an app, website, or activity category, the system displays a blocking overlay. This extension controls what happens when the user taps either button on that overlay. It is one of three extension types in Apple's Screen Time API suite (alongside Device Activity Monitor and Shield Configuration) and requires the Family Controls entitlement. The extension subclasses `ShieldActionDelegate` and returns a `ShieldActionResponse` (`.close`, `.defer`, or `.none`) to tell the system how to proceed.
## Apple Documentation
- [ShieldActionDelegate](https://developer.apple.com/documentation/managedsettings/shieldactiondelegate)
- [ShieldAction](https://developer.apple.com/documentation/managedsettings/shieldaction)
- [ShieldActionResponse](https://developer.apple.com/documentation/managedsettings/shieldactionresponse)
- [handle(action:for:completionHandler:) -- Application](https://developer.apple.com/documentation/managedsettings/shieldactiondelegate/handle(action:for:completionhandler:)-9hcqc)
- [handle(action:for:completionHandler:) -- WebDomain](https://developer.apple.com/documentation/managedsettings/shieldactiondelegate/handle(action:for:completionhandler:)-9o7ql)
- [handle(action:for:completionHandler:) -- ActivityCategory](https://developer.apple.com/documentation/managedsettings/shieldactiondelegate/handle(action:for:completionhandler:)-3akjf)
- [ManagedSettings Framework](https://developer.apple.com/documentation/managedsettings)
## WWDC History
- **[WWDC 2021, Session 10123 -- Meet the Screen Time API](https://developer.apple.com/videos/play/wwdc2021/10123/)** -- Introduced the Screen Time API with three frameworks. Demonstrated the shield action handler (originally `ShieldActionHandler`) for responding to button taps on the shield overlay.
- **[WWDC 2022, Session 110336 -- What's New in Screen Time API](https://developer.apple.com/videos/play/wwdc2022/110336/)** -- Renamed `ShieldActionHandler` to `ShieldActionDelegate`. Added individual authorization for non-child use cases. Clarified the extension lifecycle and response model.
## What It Does
1. **Shield is displayed.** When `ManagedSettingsStore.shield.applications` or `.shield.applicationCategories` contains tokens, the system overlays a shield on those apps or websites.
2. **User taps a button.** The shield presents up to two buttons (primary and secondary, whose labels are customized by the Shield Configuration extension). When tapped, the system launches the Shield Action extension.
3. **Extension receives the action.** The system calls `handle(action:for:completionHandler:)` with a `ShieldAction` (`.primaryButtonPressed` or `.secondaryButtonPressed`) and the token identifying the shielded application, web domain, or activity category.
4. **Extension returns a response.** The completion handler is called with one of three `ShieldActionResponse` values:
- `.close` -- Dismisses the shielded app (sends it to background).
- `.defer` -- Tells the system to let the user ask a guardian for permission (triggers the Screen Time approval flow for child authorization).
- `.none` -- Does nothing; the shield remains visible.
5. **Extension can perform side effects.** Before calling the completion handler, the extension can update `ManagedSettingsStore`, write to App Group shared storage, or log analytics.
## Use Cases
### Parental controls with guardian approval
The primary button is labeled "Ask for More Time" (set by the Shield Configuration extension). When tapped, the Shield Action extension returns `.defer`, which triggers the system's built-in guardian approval prompt. If the guardian approves, the shield is temporarily lifted.
### Hard block with dismiss
A digital wellbeing app shields social media apps after a daily limit. The primary button is labeled "Close App" and returns `.close`. The secondary button is hidden (no label set in Shield Configuration). The user can only dismiss the app.
### Logging and analytics
An enterprise management app wants to track how often employees attempt to bypass work-hour restrictions. The extension logs each tap to App Group shared storage before returning `.close`, allowing the management dashboard to display compliance metrics.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `ShieldActionDelegate` | Base class for the extension's principal class. Override `handle(action:for:completionHandler:)` for applications, web domains, and activity categories. |
| `ShieldAction` | Enum with cases `.primaryButtonPressed` and `.secondaryButtonPressed`. |
| `ShieldActionResponse` | Enum with cases `.close`, `.defer`, and `.none`. Returned via the completion handler. |
| `ApplicationToken` | Opaque token identifying a shielded application. |
| `WebDomainToken` | Opaque token identifying a shielded web domain. |
| `ActivityCategoryToken` | Opaque token identifying a shielded activity category. |
| `ManagedSettingsStore` | Can be used inside the extension to modify shields or restrictions in response to a button tap. |
## Implementation
```swift
import ManagedSettings
// 1. Subclass ShieldActionDelegate. The class name must match the
// NSExtensionPrincipalClass value in Info.plist.
class ShieldActionExtension: ShieldActionDelegate {
// 2. Handle button taps on shields covering individual applications.
override func handle(
action: ShieldAction,
for application: ApplicationToken,
completionHandler: @escaping (ShieldActionResponse) -> Void
) {
switch action {
case .primaryButtonPressed:
// 3. Close the shielded app (sends it to background).
completionHandler(.close)
case .secondaryButtonPressed:
// 4. Defer to the guardian approval flow (child authorization)
// or do nothing (individual authorization).
completionHandler(.defer)
@unknown default:
completionHandler(.none)
}
}
// 5. Handle button taps on shields covering web domains (Safari, WebKit views).
override func handle(
action: ShieldAction,
for webDomain: WebDomainToken,
completionHandler: @escaping (ShieldActionResponse) -> Void
) {
switch action {
case .primaryButtonPressed:
completionHandler(.close)
case .secondaryButtonPressed:
completionHandler(.defer)
@unknown default:
completionHandler(.none)
}
}
// 6. Handle button taps on shields covering entire activity categories.
override func handle(
action: ShieldAction,
for category: ActivityCategoryToken,
completionHandler: @escaping (ShieldActionResponse) -> Void
) {
switch action {
case .primaryButtonPressed:
// 7. Example: log the event to shared storage before closing.
let defaults = UserDefaults(suiteName: "group.com.example.myapp")
let count = (defaults?.integer(forKey: "shieldDismissCount") ?? 0) + 1
defaults?.set(count, forKey: "shieldDismissCount")
completionHandler(.close)
case .secondaryButtonPressed:
completionHandler(.defer)
@unknown default:
completionHandler(.none)
}
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target shield-action
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 15.0+ | Full support. Individual authorization added in 16.0+. |
| iPadOS | 15.0+ | Same as iOS. |
| macOS | -- | Not supported. Screen Time API is iOS/iPadOS only. |
| watchOS | -- | Not supported. |
| visionOS | -- | Not supported. |
| tvOS | -- | Not supported. |
## Gotchas
- **Cannot open the parent app.** There is no `ShieldActionResponse` case that opens the host app. Developers have requested `.openParentApp` on Apple Developer Forums, but as of iOS 18 it does not exist. The only options are `.close`, `.defer`, and `.none`.
- **`.defer` only works with child authorization.** When using `AuthorizationCenter.requestAuthorization(for: .individual)`, returning `.defer` has no meaningful effect because there is no guardian to approve the request. Use `.close` or `.none` instead for individual authorization flows.
- **App Group is required for shared state.** The extension runs in a separate process. To share data (e.g., dismiss counts, user preferences) with the host app or other extensions, add the App Groups capability to both the extension target and the main app target and use `UserDefaults(suiteName:)` or a shared file container.
- **Family Controls entitlement requires Apple approval.** The `com.apple.developer.family-controls` entitlement must be requested through Apple's developer portal. Without approval, development builds work but TestFlight and App Store distribution will be rejected.
- **Completion handler must be called.** Failing to call the `completionHandler` leaves the shield in an undefined state. Always call it in every code path, including error cases.
- **No custom UI in this extension.** The Shield Action extension is headless. It processes the button tap and returns a response. All visual customization of the shield (labels, colors, icons) is handled by the separate Shield Configuration extension.
- **The class was renamed in Xcode 14.** WWDC 2021 materials reference `ShieldActionHandler`. This was renamed to `ShieldActionDelegate` in iOS 16 / Xcode 14. Code samples from early tutorials may use the old name and will not compile.
- **Three separate `handle` overloads.** You must override the correct overload for the token type you are shielding (Application, WebDomain, or ActivityCategory). If you only override the Application variant but shield by category, the category button taps will use the default behavior.
shield-config.md
---
title: Shield Configuration Extension
description: Customizes the appearance of the Screen Time shield overlay by providing titles, subtitles, icons, button labels, and colors for shielded apps and websites.
version: iOS 15.0+
---
# Shield Configuration Extension (`shield-config`)
A Shield Configuration extension controls the visual appearance of the Screen Time shield overlay. When `ManagedSettingsStore` shields an app, website, or activity category, the system asks this extension for a `ShieldConfiguration` describing the title, subtitle, icon, button labels, button colors, and background color to display. The extension subclasses `ShieldConfigurationDataSource` (not a view controller) and overrides `configuration(shielding:)` methods for applications, web domains, and their category variants. It is one of three extension types in Apple's Screen Time API suite (alongside Device Activity Monitor and Shield Action) and requires the Family Controls entitlement.
## Apple Documentation
- [ShieldConfigurationDataSource](https://developer.apple.com/documentation/managedsettingsui/shieldconfigurationdatasource)
- [ShieldConfiguration](https://developer.apple.com/documentation/managedsettingsui/shieldconfiguration)
- [ShieldConfiguration.Label](https://developer.apple.com/documentation/managedsettingsui/shieldconfiguration/label)
- [configuration(shielding:) -- Application](https://developer.apple.com/documentation/managedsettingsui/shieldconfigurationdatasource/configuration(shielding:)-5uqm1)
- [configuration(shielding:in:) -- Application in Category](https://developer.apple.com/documentation/managedsettingsui/shieldconfigurationdatasource/configuration(shielding:in:)-7mqr5)
- [configuration(shielding:) -- WebDomain](https://developer.apple.com/documentation/managedsettingsui/shieldconfigurationdatasource/configuration(shielding:)-98nkb)
- [configuration(shielding:in:) -- WebDomain in Category](https://developer.apple.com/documentation/managedsettingsui/shieldconfigurationdatasource/configuration(shielding:in:)-5moeh)
- [ManagedSettingsUI Framework](https://developer.apple.com/documentation/managedsettingsui)
## WWDC History
- **[WWDC 2021, Session 10123 -- Meet the Screen Time API](https://developer.apple.com/videos/play/wwdc2021/10123/)** -- Introduced the Screen Time API and the shield configuration provider (originally `ShieldConfigurationProvider`) for customizing the shield overlay's text, icon, and colors.
- **[WWDC 2022, Session 110336 -- What's New in Screen Time API](https://developer.apple.com/videos/play/wwdc2022/110336/)** -- Renamed `ShieldConfigurationProvider` to `ShieldConfigurationDataSource`. Added individual authorization. Clarified the extension architecture and data flow.
## What It Does
1. **Shield is about to appear.** When `ManagedSettingsStore` contains shield entries for applications or categories, the system prepares to display the shield overlay.
2. **System queries the extension.** The system launches the Shield Configuration extension and calls the appropriate `configuration(shielding:)` overload based on whether the shielded item is an individual application, a web domain, or a category member.
3. **Extension returns a `ShieldConfiguration`.** The configuration object includes optional properties: `title`, `subtitle`, `icon`, `primaryButtonLabel`, `primaryButtonBackgroundColor`, `secondaryButtonLabel`, `backgroundColor`, and `backgroundBlurStyle`.
4. **System renders the overlay.** The system uses the returned configuration to render a full-screen shield. Properties set to `nil` use system defaults (for `title` and `primaryButtonLabel`) or are hidden (for `secondaryButtonLabel` and `icon`).
5. **Button taps go to the Shield Action extension.** The labels defined here determine what the user sees, but the behavior when tapped is handled by the separate Shield Action extension.
## Use Cases
### Branded parental controls
A parental controls app customizes the shield to match its brand. The title says "Time's Up!", the subtitle shows the app name, the icon is the app's logo, the primary button reads "Close App", and the secondary button reads "Ask Parent". Colors match the app's theme.
### Motivational digital wellbeing
A focus app displays an encouraging message when a social media app is shielded. The title reads "Stay Focused", the subtitle shows how much focus time remains, and only a primary "Got It" button is shown (secondary label is nil to hide the second button).
### Context-aware messaging
The extension reads from App Group shared storage to display different messages depending on the time of day, the specific app being shielded, or how many times the user has hit the shield today. Since tokens are opaque, context is typically passed via shared UserDefaults rather than by identifying the app directly.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `ShieldConfigurationDataSource` | Base class for the extension's principal class. Override `configuration(shielding:)` methods to customize shield appearance. Not a view controller. |
| `ShieldConfiguration` | Data object describing the shield's visual properties: title, subtitle, icon, button labels, colors, and blur style. |
| `ShieldConfiguration.Label` | A struct pairing a localized `text` string with an optional `color` (`UIColor`). Used for title, subtitle, and button labels. |
| `Application` | Represents a shielded application. Provided by the system to the `configuration(shielding:)` method. |
| `WebDomain` | Represents a shielded web domain. Provided by the system to the `configuration(shielding:)` method. |
| `ActivityCategory` | Represents an activity category. Provided to the `configuration(shielding:in:)` variant. |
## Implementation
```swift
import ManagedSettings
import ManagedSettingsUI
import UIKit
// 1. Subclass ShieldConfigurationDataSource (NOT a UIViewController).
// The class name must match the NSExtensionPrincipalClass in Info.plist.
class ShieldConfigurationExtension: ShieldConfigurationDataSource {
// 2. Customize the shield for an individually shielded application.
override func configuration(
shielding application: Application
) -> ShieldConfiguration {
return ShieldConfiguration(
// 3. Set the background color and blur style.
backgroundBlurStyle: .systemMaterial,
backgroundColor: UIColor.systemBackground,
// 4. Provide a custom icon (use a bundled PNG, not a PDF).
icon: UIImage(named: "ShieldIcon"),
// 5. Set title and subtitle with text and color.
title: ShieldConfiguration.Label(
text: "Time's Up!",
color: .label
),
subtitle: ShieldConfiguration.Label(
text: "You've reached your screen time limit.",
color: .secondaryLabel
),
// 6. Configure button labels. Setting secondaryButtonLabel
// to nil hides the secondary button entirely.
primaryButtonLabel: ShieldConfiguration.Label(
text: "Close App",
color: .white
),
primaryButtonBackgroundColor: .systemBlue,
secondaryButtonLabel: ShieldConfiguration.Label(
text: "Ask for More Time",
color: .systemBlue
)
)
}
// 7. Customize the shield for an application shielded as part of a category.
override func configuration(
shielding application: Application,
in category: ActivityCategory
) -> ShieldConfiguration {
// 8. Read shared state to provide context-aware messaging.
let defaults = UserDefaults(suiteName: "group.com.example.myapp")
let dismissCount = defaults?.integer(forKey: "shieldDismissCount") ?? 0
return ShieldConfiguration(
backgroundBlurStyle: .systemThickMaterial,
title: ShieldConfiguration.Label(
text: "Stay Focused",
color: .label
),
subtitle: ShieldConfiguration.Label(
text: dismissCount > 3
? "You've tried to open this \(dismissCount) times today."
: "This app is restricted right now.",
color: .secondaryLabel
),
primaryButtonLabel: ShieldConfiguration.Label(
text: "Got It",
color: .white
),
primaryButtonBackgroundColor: .systemIndigo
)
}
// 9. Customize the shield for a web domain.
override func configuration(
shielding webDomain: WebDomain
) -> ShieldConfiguration {
return ShieldConfiguration(
title: ShieldConfiguration.Label(
text: "Website Blocked",
color: .label
),
primaryButtonLabel: ShieldConfiguration.Label(
text: "Go Back",
color: .white
),
primaryButtonBackgroundColor: .systemRed
)
}
// 10. Customize the shield for a web domain within a category.
override func configuration(
shielding webDomain: WebDomain,
in category: ActivityCategory
) -> ShieldConfiguration {
return ShieldConfiguration()
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target shield-config
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 15.0+ | Full support. Individual authorization added in 16.0+. |
| iPadOS | 15.0+ | Same as iOS. |
| macOS | -- | Not supported. Screen Time API is iOS/iPadOS only. |
| watchOS | -- | Not supported. |
| visionOS | -- | Not supported. |
| tvOS | -- | Not supported. |
## Gotchas
- **This is NOT a view controller.** `ShieldConfigurationDataSource` is a data source, not a `UIViewController`. You cannot present custom SwiftUI or UIKit views. You return a `ShieldConfiguration` struct and the system renders the shield. The customization options are intentionally limited to labels, colors, an icon, and a blur style.
- **Use PNG images, not PDFs.** Developers have reported that PDF images for the `icon` property cause the shield to render blank or with a missing icon. Convert assets to PNG format before bundling them in the extension.
- **`nil` properties behave differently.** Setting `primaryButtonLabel` to `nil` shows a system-default label ("OK"). Setting `secondaryButtonLabel` to `nil` hides the secondary button entirely. Setting `icon` to `nil` hides the icon area. Be intentional about which properties you leave nil.
- **The class was renamed in Xcode 14.** WWDC 2021 materials and early tutorials reference `ShieldConfigurationProvider`. This was renamed to `ShieldConfigurationDataSource` in iOS 16 / Xcode 14. Old code samples will not compile.
- **Four overloads to cover all cases.** There are four `configuration(shielding:)` methods: Application, Application-in-Category, WebDomain, and WebDomain-in-Category. If you only override the Application variant but shield by category, the category variant will use the default system appearance.
- **Token matching can be unreliable.** Some developers report that `Application` tokens passed to the Shield Configuration extension do not match tokens stored from `FamilyActivityPicker`, particularly after iOS updates. Avoid relying on token comparison for conditional logic; use App Group shared state instead.
- **Family Controls entitlement requires Apple approval.** The `com.apple.developer.family-controls` entitlement must be requested through Apple's developer portal. Without approval, development builds work but TestFlight and App Store distribution will be rejected.
- **Deployment target must match the main app.** Developers have found that setting a different iOS deployment target on the Shield Configuration extension than the main app causes customizations to silently fail. Ensure both targets have the same minimum deployment target.
- **App Group is required for dynamic content.** The extension runs in a separate sandboxed process. To display dynamic text (like usage counts or user names), read from `UserDefaults(suiteName:)` or a shared App Group file container.
- **Shield may cache configurations.** The system does not always call the extension for every shield presentation. Configurations may be cached. To force a refresh, toggle the shield off and on in `ManagedSettingsStore` or restart the shielded app.
SKILL.md
---
name: apple-targets
description: Build, configure, and debug native Apple extension and target types (widgets, App Clips, share extensions, watchOS apps, Safari extensions, notification extensions, VPN/network extensions, and 40+ more) in an Expo app using the @bacons/apple-targets config plugin. Use when a user wants to add home screen presence, share sheets, custom keyboards, Siri/App Intents, push notification handling, AutoFill/passkeys, Screen Time controls, or any other Apple app extension to a React Native / Expo project.
---
# Apple Extension Target Skills Reference
This directory contains comprehensive skill documents for all supported Apple extension and target types. Each skill document provides everything an AI agent needs to understand, implement, and troubleshoot a specific Apple extension type, including:
- Apple documentation links and WWDC session references
- Runtime behavior and lifecycle explanation
- Real-world use cases and implementation patterns
- Production-ready Swift code examples
- Platform availability and version requirements
- Common gotchas and debugging tips
## Installing This Skill
Install this skill into your coding agent (Claude Code, Cursor, Codex, etc.) with [`npx skills`](https://github.com/vercel-labs/skills):
```sh
npx skills add EvanBacon/expo-apple-targets/tree/main/skills/apple-targets
```
The CLI auto-detects which agents you have installed and copies `SKILL.md` (plus the supporting reference docs in this directory) into the agent's skills directory (e.g. `.claude/skills/` or `.agents/skills/`). Once installed, the agent loads this guidance automatically whenever you ask it to build an Apple extension or target.
## How to Use These Skills
**For AI Agents:**
1. **When a user asks about a specific extension type** (e.g., "How do I build a widget?" or "I need to add a share extension"), read the corresponding skill document to get complete implementation guidance.
2. **When planning a feature** that might require an extension (e.g., "Add home screen presence" → read `widget.md`), consult the skill document to understand requirements and constraints.
3. **When debugging extension issues**, refer to the "Gotchas" section of the relevant skill document — these capture common pitfalls from Apple Developer Forums and real-world experience.
4. **When uncertain which extension type fits a use case**, scan the "Use Cases" sections across related skills to find the best match.
**Structure of Each Skill Document:**
Every skill document follows the template defined in `_template.md`:
- **Frontmatter**: Title, one-line description, minimum OS version
- **Apple Documentation**: Official reference links
- **WWDC History**: Session links showing evolution of the feature
- **What It Does**: Step-by-step runtime behavior
- **Use Cases**: Real-world scenarios organized by industry/app type
- **Key Classes**: Table of essential framework types
- **Implementation**: Production-ready Swift code with inline comments
- **Using with @bacons/apple-targets**: Setup instructions for this plugin
- **Platform Availability**: OS version matrix
- **Gotchas**: Common mistakes, memory limits, and silent failure modes
## Skills by Category
### User Interface Extensions
| Skill | Extension Type | When to Read |
|-------|---------------|--------------|
| [widget.md](widget.md) | `widget` | User wants home screen widgets, Lock Screen widgets, Live Activities, StandBy mode UI, or Control Center controls (iOS 18+). Timeline-based SwiftUI views. |
| [clip.md](clip.md) | `clip` | User wants instant-launch mini-apps triggered by NFC, QR codes, App Clip Codes, Safari banners, or Messages links. Not an extension—a size-constrained application target. |
| [share.md](share.md) | `share` | User wants their app in the system share sheet to receive URLs, images, text, or files from other apps. |
| [action.md](action.md) | `action` | User wants context menu actions in Safari, Photos, or Files that transform content and optionally return modified data. |
| [keyboard.md](keyboard.md) | `keyboard` | User wants a custom system keyboard with specialized input (emoji pickers, GIF search, language-specific layouts, calculators). |
| [photo-editing.md](photo-editing.md) | `photo-editing` | User wants to add filters, adjustments, or effects to photos/videos directly in the Photos app editing UI. |
| [quicklook-preview.md](quicklook-preview.md) | `quicklook-preview` | User wants rich previews of custom file types in Quick Look with interactive controls and multiple pages. |
| [quicklook-thumbnail.md](quicklook-thumbnail.md) | `quicklook-thumbnail` | User wants to generate thumbnail images for custom file types shown in Finder, Files app, and Spotlight. |
### Notifications
| Skill | Extension Type | When to Read |
|-------|---------------|--------------|
| [notification-service.md](notification-service.md) | `notification-service` | User wants to download images, decrypt payloads, or modify push notification content before display. Runs in background with 30-second time limit. |
| [notification-content.md](notification-content.md) | `notification-content` | User wants custom SwiftUI/UIKit UI for expanded push notifications with interactive controls and rich media. |
### Siri & Intents
| Skill | Extension Type | When to Read |
|-------|---------------|--------------|
| [intent.md](intent.md) | `intent` | User wants Siri voice commands for built-in domains (messaging, payments, workouts, media playback, ride booking). Uses legacy SiriKit framework. |
| [intent-ui.md](intent-ui.md) | `intent-ui` | User wants custom UI in the Siri interface when confirming or handling intents. Companion to the `intent` extension. |
| [app-intent.md](app-intent.md) | `app-intent` | User wants modern App Intents (iOS 16+) for Shortcuts, Spotlight, Siri, Focus Filters, and Control Center widgets. Replaces custom `.intentdefinition` files. |
### Networking & VPN
| Skill | Extension Type | When to Read |
|-------|---------------|--------------|
| [network-packet-tunnel.md](network-packet-tunnel.md) | `network-packet-tunnel` | User wants to build a VPN client that tunnels all device traffic through a custom protocol. Classic and modern Personal VPN. |
| [network-app-proxy.md](network-app-proxy.md) | `network-app-proxy` | User wants app-level network filtering (per-app VPN) where specific apps route traffic through a custom proxy. Requires MDM or supervised devices. |
| [network-dns-proxy.md](network-dns-proxy.md) | `network-dns-proxy` | User wants to intercept and respond to DNS queries system-wide, enabling DNS filtering, logging, or custom resolution. |
| [network-filter-data.md](network-filter-data.md) | `network-filter-data` | User wants deep packet inspection for content filtering (parental controls, enterprise security). Requires special Apple entitlement. |
### File Management
| Skill | Extension Type | When to Read |
|-------|---------------|--------------|
| [file-provider.md](file-provider.md) | `file-provider` | User wants to expose cloud storage or remote file systems in the Files app with on-demand download, search, and sync. |
| [file-provider-ui.md](file-provider-ui.md) | `file-provider-ui` | User wants custom UI for file actions (sharing, permissions) in the document picker. Companion to File Provider extension. |
### Communication & Telephony
| Skill | Extension Type | When to Read |
|-------|---------------|--------------|
| [message-filter.md](message-filter.md) | `message-filter` | User wants to filter incoming SMS/MMS messages to classify spam, transactions, or promotions into separate tabs. |
| [call-directory.md](call-directory.md) | `call-directory` | User wants to block phone numbers or provide caller ID labels for incoming calls. Updates a system-managed SQLite database. |
| [unwanted-communication.md](unwanted-communication.md) | `unwanted-communication` | User wants a UI for users to report unwanted calls, messages, or FaceTime attempts to a reporting service. Requires special entitlement. |
### Content & Media
| Skill | Extension Type | When to Read |
|-------|---------------|--------------|
| [broadcast-upload.md](broadcast-upload.md) | `broadcast-upload` | User wants to stream ReplayKit screen recordings to a live streaming service (Twitch, YouTube Live). Receives video/audio buffers in real time. |
| [broadcast-setup-ui.md](broadcast-setup-ui.md) | `broadcast-setup-ui` | User wants custom UI for configuring a live broadcast (selecting stream key, privacy settings). Companion to broadcast upload extension. |
| [safari.md](safari.md) | `safari` | User wants Safari Web Extensions (Manifest V3) with content scripts, background pages, and native messaging to the containing app. |
| [content-blocker.md](content-blocker.md) | `content-blocker` | User wants rule-based ad blocking, tracker blocking, or content hiding in Safari using JSON declarative rules. No JavaScript execution. |
### Education & Screen Time
| Skill | Extension Type | When to Read |
|-------|---------------|--------------|
| [classkit-context.md](classkit-context.md) | `classkit-context` | User wants to report student progress in educational apps to Apple's Schoolwork app via ClassKit framework. |
| [device-activity-monitor.md](device-activity-monitor.md) | `device-activity-monitor` | User wants to monitor app/website usage and trigger actions when Screen Time limits are reached. Parental control and focus apps. |
| [shield-action.md](shield-action.md) | `shield-action` | User wants custom UI when a user attempts to bypass a Screen Time shield (blocked app/website). Handle or deny the request. |
| [shield-config.md](shield-config.md) | `shield-config` | User wants custom shield UI (blocking screen) shown when apps or websites are restricted by Screen Time rules. |
### Authentication & Security
| Skill | Extension Type | When to Read |
|-------|---------------|--------------|
| [authentication-services.md](authentication-services.md) | `authentication-services` | User wants AutoFill for passwords and passkeys in Safari and apps. Modern replacement for `credentials-provider`. Supports passkey creation/assertion (WebAuthn). |
| [credentials-provider.md](credentials-provider.md) | `credentials-provider` | User wants legacy password AutoFill (iOS 12+). For new projects, use `authentication-services` instead, which supports both passwords and passkeys. |
| [smart-card.md](smart-card.md) | `smart-card` | User wants to authenticate with smart cards or hardware tokens (YubiKey, PIV cards) via the CryptoTokenKit framework. Enterprise use. |
| [account-auth.md](account-auth.md) | `account-auth` | User wants to modify authentication requests for Kerberos or other enterprise single sign-on (SSO) protocols. Requires special entitlement. |
### Location & Background Services
| Skill | Extension Type | When to Read |
|-------|---------------|--------------|
| [location-push.md](location-push.md) | `location-push` | User wants silent push notifications that wake the app for high-accuracy location updates. Used for fleet tracking, delivery apps, and location-based alerts. |
| [bg-download.md](bg-download.md) | `bg-download` | User wants on-demand resources or large asset downloads managed by the system's background asset downloader. Used for games and media apps. |
### Smart Home & Matter
| Skill | Extension Type | When to Read |
|-------|---------------|--------------|
| [matter.md](matter.md) | `matter` | User wants to add Matter smart home device support for device commissioning via Home app. Requires MFi enrollment and Matter certification. |
### Printing
| Skill | Extension Type | When to Read |
|-------|---------------|--------------|
| [print-service.md](print-service.md) | `print-service` | User wants to discover and print to network printers without AirPrint, or provide custom print rendering for specialized printers. |
### Search & Indexing
| Skill | Extension Type | When to Read |
|-------|---------------|--------------|
| [spotlight.md](spotlight.md) | `spotlight` | User wants to index app content in Spotlight, provide rich search results, and handle taps from Spotlight search. Uses Core Spotlight and `NSUserActivity`. |
| [spotlight-delegate.md](spotlight-delegate.md) | `spotlight-delegate` | User wants a background extension for large-scale Spotlight indexing that runs periodically on a system-managed schedule. macOS only (not iOS). |
### Collaboration & Conferencing
| Skill | Extension Type | When to Read |
|-------|---------------|--------------|
| [virtual-conference.md](virtual-conference.md) | `virtual-conference` | User wants to integrate a video conferencing app with the system call UI and CallKit, enabling Handoff, picture-in-picture, and system call controls. |
### Apple Watch
| Skill | Extension Type | When to Read |
|-------|---------------|--------------|
| [watch.md](watch.md) | `watch` | User wants a standalone watchOS app with complications, workout sessions, HealthKit integration, and Watch Connectivity for iPhone sync. Not an extension—a full application target. |
## Target Types vs. Extensions
**Application Targets** (not extensions):
- **App Clip** (`clip`) — uses product type `com.apple.product-type.application.on-demand-install-capable`
- **watchOS App** (`watch`) — uses product type `com.apple.product-type.application`
These are full applications with their own `Info.plist`, app icons, and lifecycle. They do not use `NSExtensionPointIdentifier`.
**All other types are app extensions** that use product type `com.apple.product-type.app-extension` and declare an extension point identifier in their `Info.plist`.
## Quick Reference: Common Use Cases
| User Says... | Read This Skill |
|-------------|----------------|
| "Add a home screen widget" | [widget.md](widget.md) |
| "Add a share extension" | [share.md](share.md) |
| "Build an instant app / App Clip" | [clip.md](clip.md) |
| "Integrate with Siri" | [intent.md](intent.md) or [app-intent.md](app-intent.md) |
| "Modify push notifications" | [notification-service.md](notification-service.md) |
| "Custom notification UI" | [notification-content.md](notification-content.md) |
| "Build a VPN app" | [network-packet-tunnel.md](network-packet-tunnel.md) |
| "Password manager AutoFill" | [authentication-services.md](authentication-services.md) |
| "Block spam calls" | [call-directory.md](call-directory.md) |
| "Filter SMS spam" | [message-filter.md](message-filter.md) |
| "Add to Files app" | [file-provider.md](file-provider.md) |
| "Custom keyboard" | [keyboard.md](keyboard.md) |
| "Photo filter extension" | [photo-editing.md](photo-editing.md) |
| "Safari ad blocker" | [content-blocker.md](content-blocker.md) |
| "Safari extension" | [safari.md](safari.md) |
| "Live streaming / screen recording" | [broadcast-upload.md](broadcast-upload.md) |
| "watchOS app" | [watch.md](watch.md) |
| "Screen Time parental controls" | [device-activity-monitor.md](device-activity-monitor.md) |
| "Smart home / Matter" | [matter.md](matter.md) |
| "Print to network printer" | [print-service.md](print-service.md) |
| "Spotlight search integration" | [spotlight.md](spotlight.md) |
## Creating a New Extension with @bacons/apple-targets
Every skill document includes a "Using with @bacons/apple-targets" section with the three-step setup:
```sh
# 1. Run the CLI to scaffold the extension
bunx create-target <type>
# 2. Ensure the plugin is in app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
# 3. Generate the native project
npx expo prebuild --clean
```
The `<type>` parameter corresponds to the extension type slug shown in each skill document's title (e.g., `widget`, `share`, `clip`, `notification-service`).
### When to Run Prebuild
Because target source files live outside the `/ios` directory and are **linked** into the Xcode project (not copied), Swift code changes are automatically synchronized. You don't need to re-run prebuild when editing code.
- **Run `prebuild --clean`** when:
- Initial setup after creating a new target
- Changing `expo-target.config.js` (icon, colors, entitlements, bundle ID, frameworks)
- Changing `app.json` plugin configuration
- Adding or removing targets
- Xcode project corruption or mysterious build failures
- **Run `prebuild` (without `--clean`)** when:
- You need to regenerate the project but want to preserve manual Xcode modifications
- Updating the plugin version with minor project changes
- **No prebuild needed** for:
- Editing Swift code in `targets/<name>/` directories — files are linked, changes appear immediately in Xcode
- Adding/modifying Swift files within an existing target
- Most day-to-day development work on target implementations
This architecture allows you to iterate on extension code without constantly regenerating the entire Xcode project, speeding up development and preserving manual project customizations.
## Entitlements & Capabilities Guides
The `entitlements/` directory contains guides for iOS entitlements and capabilities that apply across multiple extension types:
| Guide | When to Read |
|-------|--------------|
| [entitlements/app-groups.md](entitlements/app-groups.md) | User needs to share data between the main app and extensions (widgets, share, notifications, App Clips). Covers naming conventions, UserDefaults suite, shared containers, Keychain access groups, and automatic EAS provisioning. |
**Common questions answered by entitlements guides:**
- "How do I share data between my app and widget?" → Read `app-groups.md`
- "My extension can't access UserDefaults from the main app" → Read `app-groups.md`
- "How does EAS handle App Groups code signing?" → Read `app-groups.md`
## Template for New Skills
If you need to write a new skill document, use `_template.md` as the starting point. It defines the required frontmatter, section structure, code example format, and quality checklist.
## Additional Resources
- **CLAUDE.md** (project root): Development setup, build instructions, and architecture notes for the `@bacons/apple-targets` plugin itself
- **Apple Developer Documentation**: Every skill links to official Apple docs and WWDC sessions
- **Expo Documentation**: https://docs.expo.dev/config-plugins/introduction/
- **Continuous Native Generation**: https://docs.expo.dev/workflow/continuous-native-generation/
smart-card.md
---
title: Smart Card Extension
description: CryptoTokenKit-based app extension that acts as a driver for physical smart cards (PIV, CAC) and USB/NFC cryptographic tokens. Makes certificates and keys stored on hardware tokens available system-wide through the keychain and SecKey APIs.
version: iOS 16.0+, macOS 10.12+
---
# Smart Card Extension (`smart-card`)
## Apple Documentation
- [CryptoTokenKit Framework](https://developer.apple.com/documentation/cryptotokenkit)
- [Authenticating Users with a Cryptographic Token](https://developer.apple.com/documentation/cryptotokenkit/authenticating-users-with-a-cryptographic-token)
- [Using Cryptographic Assets Stored on a Smart Card](https://developer.apple.com/documentation/cryptotokenkit/using-cryptographic-assets-stored-on-a-smart-card)
- [Configuring Smart Card Authentication](https://developer.apple.com/documentation/cryptotokenkit/configuring-smart-card-authentication)
- [TKSmartCardTokenDriver](https://developer.apple.com/documentation/cryptotokenkit/tksmartcardtokendriver)
- [TKSmartCardToken](https://developer.apple.com/documentation/cryptotokenkit/tksmartcardtoken)
- [TKSmartCardTokenSession](https://developer.apple.com/documentation/cryptotokenkit/tksmartcardtokensession)
- [Apple Deployment Guide: Smart Card Integration](https://support.apple.com/guide/deployment/depd0b888248/web)
- [Supported Smart Card Functions on Mac](https://support.apple.com/guide/deployment/depc47f60521/web)
## WWDC History
- **WWDC 2014** -- CryptoTokenKit framework introduced alongside OS X Yosemite (10.10). Low-level smart card communication APIs (`TKSmartCard`, `TKSmartCardSlotManager`) shipped first.
- **WWDC 2016, Session 706 -- What's New in Security** -- The CryptoTokenKit _extension_ mechanism was announced with macOS Sierra (10.12), replacing the deprecated `tokend` system from OS X Lion. This session described it as "system support for cryptographic devices so that smart cards you might be using to prove your identity... we now have out of the box integration." ([transcript](https://asciiwwdc.com/2016/sessions/706))
- **WWDC 2019, Session 709 -- Cryptography and Your Apps** -- Covered SecKey API improvements relevant to token-backed keys.
- **iOS 16 / WWDC 2022** -- CryptoTokenKit gained iOS support, enabling PIV smart card extensions on iPhone and iPad for the first time.
## What It Does
A smart card extension is a CryptoTokenKit "persistent token" driver. When a compatible smart card is inserted (via USB CCID reader or NFC on iOS), the system loads your extension to:
1. **Enumerate objects** -- Read certificates, public keys, and key references from the card.
2. **Expose to keychain** -- Make discovered certificates and identities available in the system keychain so any app using SecItem/SecKey APIs can find them.
3. **Perform crypto operations** -- Sign, decrypt, and authenticate using the private keys that never leave the hardware token.
The extension runs sandboxed in its own process and communicates with the card through APDU commands via `TKSmartCardTokenSession`.
## Use Cases
### Enterprise / Government Authentication
The most common use case. Organizations using PIV (Personal Identity Verification) or CAC (Common Access Card) smart cards deploy CryptoTokenKit extensions to enable:
- **macOS login** with smart card + PIN (two-factor authentication)
- **Screensaver unlock** requiring card presence
- **TLS client authentication** in Safari, Chrome, and other browsers
- **VPN authentication** using certificate-based credentials
- **Kerberos SSO (PKINIT)** for single sign-on to enterprise services
- **S/MIME email** signing and encryption in Mail.app
- **Sudo escalation** backed by smart card credentials
### Hardware Security Key Support (iOS)
Since iOS 16, apps like YubiKey Authenticator use CryptoTokenKit extensions to expose PIV credentials from NFC/Lightning/USB-C security keys to Safari and system services.
### Custom Token Drivers
Organizations with proprietary or non-PIV smart cards build custom drivers to bridge their card's APDU protocol to CryptoTokenKit.
## PIV Slot Reference
Smart cards following the PIV standard use these certificate slots:
| Slot | Name | Purpose |
|------|------|---------|
| 9a | PIV Authentication | Login, TLS client auth |
| 9c | Digital Signing | Email/document signing |
| 9d | Key Management | Encryption/decryption |
| 9e | Card Authentication | Physical access (no PIN required) |
## Key Classes
| Class | Role |
|-------|------|
| `TKSmartCardTokenDriver` | Entry point. Subclass this as your extension's principal class. Receives `createToken(smartCard:aid:reply:)` when a card matching your AID is inserted. |
| `TKSmartCardToken` | Represents the token. Initialize with the card's discovered certificates and keys via `TKTokenKeychainContents`. |
| `TKSmartCardTokenSession` | Handles crypto operations. Override `beginAuthentication(operation:)`, `sign(_:keyObjectID:algorithm:)`, and `decrypt(_:keyObjectID:algorithm:)`. |
| `TKSmartCard` | Low-level card communication. Send/receive APDUs. Available via `session.smartCard`. |
## Extension Info.plist
The extension's `Info.plist` must declare:
```xml
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ctk-tokens</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).TokenExtension</string>
<key>NSExtensionAttributes</key>
<dict>
<key>com.apple.ctk.token-type</key>
<string>smartcard</string>
<key>com.apple.ctk.aid</key>
<array>
<!-- Application Identifier(s) your card responds to -->
<string>A000000308000010000100</string>
</array>
</dict>
</dict>
```
The `com.apple.ctk.aid` array tells the system which smart card Application Identifiers (AIDs) this extension handles. The system selects your extension when a card with a matching AID is inserted.
## Implementation
### Minimal Driver (Template)
This is what `create-target` scaffolds:
```swift
import CryptoTokenKit
class TokenExtension: TKSmartCardTokenDriver {
}
```
### Full Implementation Pattern
A real-world smart card extension typically has three files:
**TokenDriver.swift** -- Entry point that creates a token when a card is detected:
```swift
import CryptoTokenKit
class TokenDriver: TKSmartCardTokenDriver, TKSmartCardTokenDriverDelegate {
func tokenDriver(
_ driver: TKSmartCardTokenDriver,
createTokenFor smartCard: TKSmartCard,
aid AID: Data,
reply: @escaping (TKSmartCardToken?, Error?) -> Void
) {
// Select the PIV applet on the card
smartCard.send(
ins: 0xA4, p1: 0x04, p2: 0x00, data: AID
) { response, sw1, sw2, error in
guard error == nil, sw1 == 0x90, sw2 == 0x00 else {
reply(nil, error)
return
}
// Read certificates from standard PIV slots
let token = MySmartCardToken(
smartCard: smartCard,
aid: AID,
instanceID: smartCard.slot.name // unique per reader
)
reply(token, nil)
}
}
}
```
**Token.swift** -- Enumerates keys and certificates on the card:
```swift
import CryptoTokenKit
class MySmartCardToken: TKSmartCardToken {
init(smartCard: TKSmartCard, aid: Data, instanceID: String) {
super.init(
smartCard: smartCard,
aid: aid,
instanceID: instanceID,
tokenDriver: TokenDriver()
)
// Populate keychain contents with certificates found on card
guard let keychainContents = self.keychainContents else { return }
// Example: add a certificate from PIV slot 9a
let certItem = TKTokenKeychainCertificate(
certificate: pivCertificate, // SecCertificate from card
objectID: "slot-9a-cert"
)
let keyItem = TKTokenKeychainKey(
certificate: pivCertificate,
objectID: "slot-9a-key"
)
keyItem.canSign = true
keyItem.canDecrypt = false
keychainContents.fill(with: [certItem, keyItem])
}
}
```
**TokenSession.swift** -- Handles signing and decryption requests:
```swift
import CryptoTokenKit
class MyTokenSession: TKSmartCardTokenSession {
override func beginAuthentication(
operation: TKTokenOperation,
constraint: Any
) throws -> TKTokenAuthOperation {
// Return a PIN-based auth operation
let pinAuth = TKTokenSmartCardPINAuthOperation()
pinAuth.pinFormat = .fixedLength(8)
pinAuth.smartCard = self.smartCard
return pinAuth
}
override func sign(
_ dataToSign: Data,
keyObjectID: Any,
algorithm: TKTokenKeyAlgorithm
) throws -> Data {
guard let smartCard = self.smartCard else {
throw TKError(.corruptedData)
}
// Select the key on the card and perform signing via APDU
let signedData = try smartCard.send(
ins: 0x87, p1: 0x07, p2: 0x9A,
data: buildSigningAPDU(dataToSign)
)
return signedData
}
}
```
## Using with @bacons/apple-targets
### 1. Create the target
```sh
cd your-expo-app
bunx create-target smart-card
```
This creates:
```
targets/
smart-card/
expo-target.config.js
TokenExtension.swift
```
### 2. Configure the target
```js
// targets/smart-card/expo-target.config.js
/** @type {import('@bacons/apple-targets').Config} */
module.exports = {
type: "smart-card",
};
```
### 3. Add the config plugin
```js
// app.json
{
"expo": {
"plugins": [
["@bacons/apple-targets"]
]
}
}
```
### 4. Prebuild and run
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
The plugin will:
- Create the extension target in the Xcode project
- Link `CryptoTokenKit.framework`
- Set the `NSExtensionPointIdentifier` to `com.apple.ctk-tokens`
- Set the principal class to `$(PRODUCT_MODULE_NAME).TokenExtension`
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| macOS | 10.10+ | Full support since Yosemite. Extension mechanism since Sierra 10.12. |
| iOS | 16.0+ | Requires USB-C or NFC reader. Lightning via MFi adapters. |
| iPadOS | 16.0+ | Same as iOS. |
| watchOS | -- | Not supported. |
| tvOS | -- | Not supported. |
## Entitlements
On macOS, the extension requires the `com.apple.security.smartcard` entitlement. On iOS, no special entitlement is needed beyond the standard app extension sandbox.
If you need to share data between the main app and the extension, enable App Groups.
## Debugging Tips
- **Enable smart card logging**: `sudo defaults write /Library/Preferences/com.apple.security.smartcard Logging -bool true`
- **Register manually**: `pluginkit -a /path/to/YourApp.app/Contents/PlugIns/YourExtension.appex`
- **List registered tokens**: `pluginkit -m -p com.apple.ctk-tokens`
- **Check system log**: `log stream --predicate 'subsystem == "com.apple.CryptoTokenKit"'`
## Open-Source References
- [OpenSCToken](https://github.com/frankmorgner/OpenSCToken) -- OpenSC smart card driver built as a CryptoTokenKit extension (Objective-C). Demonstrates macOS login, TLS auth, and keychain integration.
- [Yubico: How to Implement a CryptoTokenKit Extension on iOS](https://www.yubico.com/blog/how-to-implement-a-cryptotokenkit-extension-on-ios/) -- Detailed walkthrough of building a PIV-based CTK extension for iOS using YubiKey.
## Gotchas
- Apple's CryptoTokenKit documentation is widely regarded as sparse. Filing a DTS tech support incident is recommended for complex questions.
- `TKTokenKeychainContents` is `nil` unless your token inherits from `TKSmartCardToken` (not the base `TKToken`).
- The extension runs in a sandbox. Configuration files must be read from the appex bundle resources; writes go to the container's `Documents/` directory.
- On macOS, the built-in PIV token driver (`com.apple.CryptoTokenKit.pivtoken`) handles standard PIV cards automatically. You only need a custom extension for non-standard cards or cards requiring custom APDU sequences.
- Card communication is asynchronous. All `TKSmartCard.send()` calls use completion handlers or async/await (iOS 16+).
spotlight-delegate.md
---
title: CoreSpotlight Delegate Extension
description: Allows the system to request re-indexing of your app's searchable content even when the app is not running, ensuring Spotlight results stay fresh after backups, restores, and index resets.
version: iOS 9.0+, macOS 10.13+
---
# CoreSpotlight Delegate Extension (`spotlight-delegate`)
A CoreSpotlight delegate extension runs when the system needs your app to re-index its searchable content -- after a device restore from backup, when indexed items are about to expire, or when the on-device Spotlight index is rebuilt. The extension subclasses `CSIndexExtensionRequestHandler` (which conforms to `CSSearchableIndexDelegate`) and overrides two methods: one for full re-indexing and one for re-indexing specific items by identifier. This ensures your app's Spotlight content remains current without requiring the user to open the app.
## Apple Documentation
- [CSSearchableIndexDelegate](https://developer.apple.com/documentation/corespotlight/cssearchableindexdelegate)
- [CSIndexExtensionRequestHandler](https://developer.apple.com/documentation/corespotlight/csindexextensionrequesthandler)
- [CSSearchableIndex](https://developer.apple.com/documentation/corespotlight/cssearchableindex)
- [CSSearchableItem](https://developer.apple.com/documentation/corespotlight/cssearchableitem)
- [CSSearchableItemAttributeSet](https://developer.apple.com/documentation/corespotlight/cssearchableitemattributeset)
- [Core Spotlight Framework Overview](https://developer.apple.com/documentation/corespotlight)
## WWDC History
- **[WWDC 2015, Session 709 -- Introducing Search APIs](https://developer.apple.com/videos/play/wwdc2015/709/)** -- Introduced Core Spotlight on iOS, covering `CSSearchableItem`, `CSSearchableIndex`, and the fundamentals of indexing app content for Spotlight.
- **[WWDC 2016, Session 223 -- Making the Most of Search APIs](https://developer.apple.com/videos/play/wwdc2016/223/)** -- Introduced `CSSearchableIndexDelegate` and the Core Spotlight extension for maintaining indexed content when the app is not running, including disaster recovery and re-indexing flows.
- **[WWDC 2017, Session 231 -- What's New in Core Spotlight for iOS and macOS](https://developer.apple.com/videos/play/wwdc2017/231/)** -- Extended Core Spotlight to macOS, added Drag-and-Drop support through the extension, and emphasized the importance of the delegate for keeping content fresh.
- **[WWDC 2024, Session 10131 -- Support Semantic Search with Core Spotlight](https://developer.apple.com/videos/play/wwdc2024/10131/)** -- Added semantic search capabilities to Core Spotlight, improving result relevance through contextual understanding of indexed content.
## What It Does
1. **System detects stale or missing index data.** This happens after a device restore from backup, when a Spotlight index rebuild occurs, or when previously indexed items are about to expire.
2. **Extension is launched.** The system instantiates your `CSIndexExtensionRequestHandler` subclass even if the app is not running.
3. **Full re-index requested.** The system calls `searchableIndex(_:reindexAllSearchableItemsWithAcknowledgementHandler:)` when the entire index needs rebuilding. Your extension fetches all indexable content and writes it to the provided `CSSearchableIndex`.
4. **Partial re-index requested.** The system calls `searchableIndex(_:reindexSearchableItemsWithIdentifiers:acknowledgementHandler:)` with specific item IDs when only certain items need refreshing (e.g., items about to expire or items that failed to index previously).
5. **Extension acknowledges completion.** After indexing, you call the `acknowledgementHandler()`. This tells the system the re-index is complete. If the extension crashes before acknowledging, the system will retry.
6. **User searches Spotlight.** Indexed items appear in Spotlight results. Tapping a result opens your app via `NSUserActivity` with the `CSSearchableItemActionType` activity type and the item's identifier in `userInfo`.
## Use Cases
### Notes and document apps
A note-taking app indexes thousands of notes for Spotlight search. When the user restores from an iCloud backup on a new device, the extension re-indexes all notes from the local database so they immediately appear in Spotlight, without waiting for the user to open the app.
### Recipe and reference apps
A recipe app indexes its full catalog with titles, ingredients, and cuisine tags. The extension ensures that after an iOS update or index reset, all recipes are re-indexed. Partial re-indexing handles individual recipe updates that were missed while the app was suspended.
### E-commerce and catalog apps
A shopping app indexes product listings for Spotlight. The extension handles periodic re-indexing to refresh prices and availability, and re-indexes specific product IDs when the system reports them as stale.
### Communication apps
A messaging app indexes conversation titles and recent messages. The extension re-indexes conversation metadata after a device restore, and the Drag-and-Drop support (WWDC 2017) allows users to drag conversation items from Spotlight into other apps.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `CSIndexExtensionRequestHandler` | Base class for the extension. Conforms to `CSSearchableIndexDelegate` and `NSExtensionRequestHandling`. Subclass this and override the two re-index methods. |
| `CSSearchableIndexDelegate` | Protocol defining `reindexAllSearchableItems` and `reindexSearchableItemsWithIdentifiers` callbacks. |
| `CSSearchableIndex` | The on-device search index. Call `indexSearchableItems(_:completionHandler:)` on it to add or update items. |
| `CSSearchableItem` | Wraps a `CSSearchableItemAttributeSet` with a unique identifier and optional domain identifier for batch deletion. |
| `CSSearchableItemAttributeSet` | Metadata container with properties like `title`, `contentDescription`, `thumbnailData`, and `keywords`. |
## Implementation
```swift
import CoreSpotlight
// 1. Subclass CSIndexExtensionRequestHandler as the extension's principal class.
class IndexRequestHandler: CSIndexExtensionRequestHandler {
// 2. Called when the system needs a complete re-index of all content.
override func searchableIndex(
_ searchableIndex: CSSearchableIndex,
reindexAllSearchableItemsWithAcknowledgementHandler acknowledgementHandler: @escaping () -> Void
) {
// 3. Fetch all indexable content from your data store.
// Use App Groups if your database is shared with the main app.
let allItems = DataStore.shared.fetchAllItems()
// 4. Convert each item into a CSSearchableItem.
let searchableItems = allItems.map { item -> CSSearchableItem in
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = item.title
attributes.contentDescription = item.body
attributes.keywords = item.tags
attributes.thumbnailData = item.thumbnailData
attributes.contentCreationDate = item.createdAt
return CSSearchableItem(
uniqueIdentifier: item.id,
domainIdentifier: "com.example.myapp.items",
attributeSet: attributes
)
}
// 5. Index all items, then acknowledge so the system knows we finished.
searchableIndex.indexSearchableItems(searchableItems) { error in
if let error {
print("Re-index all failed: \(error)")
}
// 6. Always call the handler, even on error, so the system
// does not keep retrying indefinitely.
acknowledgementHandler()
}
}
// 7. Called when specific items need re-indexing (e.g., about to expire).
override func searchableIndex(
_ searchableIndex: CSSearchableIndex,
reindexSearchableItemsWithIdentifiers identifiers: [String],
acknowledgementHandler: @escaping () -> Void
) {
// 8. Fetch only the items matching the given identifiers.
let items = DataStore.shared.fetchItems(withIDs: identifiers)
let searchableItems = items.map { item -> CSSearchableItem in
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = item.title
attributes.contentDescription = item.body
attributes.keywords = item.tags
attributes.thumbnailData = item.thumbnailData
attributes.contentCreationDate = item.createdAt
return CSSearchableItem(
uniqueIdentifier: item.id,
domainIdentifier: "com.example.myapp.items",
attributeSet: attributes
)
}
// 9. Index the specific items and acknowledge completion.
searchableIndex.indexSearchableItems(searchableItems) { error in
if let error {
print("Re-index items failed: \(error)")
}
acknowledgementHandler()
}
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target spotlight-delegate
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 9.0+ | Full support. The extension and in-app delegate share the same protocol. |
| iPadOS | 9.0+ | Full support. |
| macOS | 10.13+ (High Sierra) | Full support. Extension runs even when the app is not running. |
| watchOS | -- | Not supported. |
| tvOS | -- | Not supported. |
| visionOS | -- | Not supported. |
## Gotchas
- **Always call the acknowledgement handler.** If your extension crashes or returns without calling `acknowledgementHandler()`, the system will retry the re-index request. Call the handler even if indexing partially fails, or you risk an infinite retry loop consuming resources.
- **Share code between the app and extension.** The `CSSearchableIndexDelegate` protocol is the same whether implemented in your app's `AppDelegate` or in the extension. Factor your indexing logic into a shared framework so both targets use the same code. The extension handles re-indexing when the app is not running; the in-app delegate handles it while the app is active.
- **Use `fetchLastClientState` to resume interrupted work.** Call `searchableIndex.fetchLastClientState(completionHandler:)` to retrieve a `Data` blob you previously stored with `searchableIndex.updateClientState(_:completionHandler:)`. Use this to track your indexing progress and resume where you left off if the extension was terminated mid-operation.
- **Item expiration triggers partial re-indexing.** `CSSearchableItem` has an `expirationDate` property (defaults to 30 days). Before items expire, the system calls `reindexSearchableItemsWithIdentifiers` to give you a chance to refresh them. Set a reasonable expiration or use `.distantFuture` for content that does not change.
- **Extension is required for Drag-and-Drop from Spotlight.** On iOS 11+ and macOS, if a user long-presses a Spotlight result to initiate drag, the system calls your extension to provide the item data. Without the extension, Spotlight results from your app cannot participate in Drag-and-Drop.
- **The extension has limited runtime.** Keep indexing operations fast. If you have thousands of items, index them in batches and persist progress to disk so you can resume if the extension is terminated. Avoid blocking network calls in the extension.
- **Domain identifiers enable batch deletion.** Assign a `domainIdentifier` to related items (e.g., `"com.example.recipes"`). You can then call `CSSearchableIndex.deleteSearchableItems(withDomainIdentifiers:)` to remove an entire category at once, which is much faster than deleting by individual ID.
spotlight.md
---
title: Spotlight Import Extension
description: Provides Spotlight search metadata for custom file types by extracting searchable attributes when files are indexed on disk.
version: iOS 15.0+, macOS 12.0+
---
# Spotlight Import Extension (`spotlight`)
A Spotlight import extension extracts metadata from custom file types so they appear in Spotlight search results. When the system indexes a file matching your declared UTI (Uniform Type Identifier), it loads your extension and calls `update(_:forFileAt:)`, where you populate a `CSSearchableItemAttributeSet` with properties like title, content description, author, and keywords. This is the modern replacement for the legacy `mdimporter` plug-in architecture, using Core Spotlight's `CSImportExtension` base class.
## Apple Documentation
- [CSImportExtension](https://developer.apple.com/documentation/corespotlight/csimportextension)
- [CSSearchableItemAttributeSet](https://developer.apple.com/documentation/corespotlight/cssearchableitemattributeset)
- [Core Spotlight Framework Overview](https://developer.apple.com/documentation/corespotlight)
- [Spotlight Importer Programming Guide (Archive)](https://developer.apple.com/library/archive/documentation/Carbon/Conceptual/MDImporters/MDImporters.html)
- [Writing a Spotlight Importer (Archive)](https://developer.apple.com/library/archive/documentation/Carbon/Conceptual/MDImporters/Concepts/WritingAnImp.html)
## WWDC History
- **[WWDC 2015, Session 709 -- Introducing Search APIs](https://developer.apple.com/videos/play/wwdc2015/709/)** -- Introduced Core Spotlight on iOS, covering `CSSearchableItem`, `CSSearchableIndex`, and the ability to index app content for Spotlight search.
- **[WWDC 2017, Session 231 -- What's New in Core Spotlight for iOS and macOS](https://developer.apple.com/videos/play/wwdc2017/231/)** -- Extended Core Spotlight to macOS, discussed the Spotlight import extension architecture, and covered index delegates and Drag-and-Drop support.
- **[WWDC 2024, Session 10131 -- Support Semantic Search with Core Spotlight](https://developer.apple.com/videos/play/wwdc2024/10131/)** -- Added semantic search capabilities to Core Spotlight, improving relevance for text, image, and video content through related terms and synonyms.
## What It Does
1. **System detects a matching file.** When a file with your declared UTI appears on disk (created, modified, or restored from backup), the system's `mdworker` process identifies it for indexing.
2. **Extension is loaded.** The system instantiates your `CSImportExtension` subclass in a sandboxed process.
3. **`update(_:forFileAt:)` is called.** You receive a pre-allocated `CSSearchableItemAttributeSet` and the file's URL. Read the file, extract metadata, and set properties on the attribute set.
4. **Metadata enters the Spotlight index.** The system stores the attributes and associates them with the file. Spotlight queries matching your metadata will surface the file in results.
5. **User taps a result.** When the user selects the file in Spotlight, the system opens it with the registered default app.
## Use Cases
### Custom document formats
A design app saves projects as `.mydesign` files. The Spotlight import extension reads each file's header to extract the project name, canvas dimensions, color palette names, and layer count, making them searchable by project name or content keywords.
### Scientific data files
A research tool stores experiment results in a custom binary format. The importer parses the file header to extract the experiment date, researcher name, instrument type, and summary statistics, so scientists can find datasets by searching for experiment parameters.
### Configuration and recipe files
A cooking app exports recipes as `.recipe` files. The extension parses each file to extract the recipe title, ingredients list, cuisine type, and cook time, enabling Spotlight searches like "chicken tikka 30 minutes."
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `CSImportExtension` | Abstract base class you subclass. Override `update(_:forFileAt:)` to extract metadata from files. |
| `CSSearchableItemAttributeSet` | A container for metadata attributes. Set properties like `title`, `contentDescription`, `keywords`, `creator`, and `contentType`. |
| `UTType` | Uniform Type Identifier describing your custom file type. Declare in your app's Info.plist under `UTExportedTypeDeclarations`. |
## Implementation
```swift
import CoreSpotlight
import UniformTypeIdentifiers
// 1. Subclass CSImportExtension as the extension's principal class.
class ImportExtension: CSImportExtension {
// 2. Override update(_:forFileAt:) to populate metadata for each indexed file.
override func update(
_ attributes: CSSearchableItemAttributeSet,
forFileAt contentURL: URL
) throws {
// 3. Read the file contents. The extension is sandboxed but has
// read access to the specific file URL provided.
let data = try Data(contentsOf: contentURL)
// 4. Parse your custom format to extract searchable metadata.
let document = try MyDocumentParser.parse(data)
// 5. Populate the attribute set with searchable properties.
attributes.title = document.title
attributes.contentDescription = document.summary
attributes.keywords = document.tags
attributes.creator = document.authorName
attributes.contentCreationDate = document.createdAt
attributes.contentModificationDate = document.modifiedAt
// 6. Set a thumbnail if available.
if let thumbnailData = document.thumbnail {
attributes.thumbnailData = thumbnailData
}
// 7. Add domain-specific attributes for richer search results.
attributes.numberOfPages = document.pageCount as NSNumber
attributes.subject = document.category
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target spotlight
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 15.0+ | Supported via Core Spotlight. |
| iPadOS | 15.0+ | Supported via Core Spotlight. |
| macOS | 12.0+ (declared) | Historically non-functional. First reports of working on macOS 15.5+. Legacy `mdimporter` plug-ins remain the reliable alternative on macOS. |
| watchOS | -- | Not supported. |
| tvOS | -- | Not supported. |
| visionOS | -- | Not supported. |
## Gotchas
- **macOS support has been broken for years.** Despite Apple's documentation listing macOS 12.0+ as supported, `CSImportExtension` did not function on macOS until at least macOS 15.5 (Sequoia). Apple DTS has acknowledged the issue. If you need macOS Spotlight indexing, use the legacy MDImporter/CFPlugIn architecture instead.
- **Sandbox restricts bundle access on macOS.** Even on macOS 15.5 where the extension loads, accessing files inside document bundles (packages) fails with a permission error. `NSFileWrapper(url:)` cannot read into the bundle's subdirectories. Only flat files work reliably.
- **`CSSupportedContentTypes` must match your UTI exactly.** The `CSSupportedContentTypes` array in your extension's Info.plist must contain the exact UTI string for your custom file type. If it does not match, the system will never invoke your extension for those files.
- **Declare your UTI in the main app.** The custom UTI must be declared under `UTExportedTypeDeclarations` (or `UTImportedTypeDeclarations`) in your main app's Info.plist, not just the extension's. The system discovers UTIs from the app bundle.
- **The extension is stateless.** Each invocation of `update(_:forFileAt:)` is independent. The extension may be launched and terminated for each file. Do not store data in instance variables and expect it to persist.
- **Verify with `mdimport`.** On macOS, use `mdimport -L` to list installed importers and `mdimport -d2 /path/to/file` to test your importer. On iOS, there is no equivalent diagnostic tool.
- **No network access.** The extension is sandboxed without network entitlements. All metadata must be extractable from the file itself.
unwanted-communication.md
---
title: Unwanted Communication Reporting Extension
description: IdentityLookup-based extension that lets users report spam SMS messages and phone calls as junk directly from the Messages and Phone apps.
version: iOS 12.0+
---
# Unwanted Communication Reporting Extension (`unwanted-communication`)
An app extension that provides a UI for users to report unwanted SMS messages and phone calls as spam. When a user long-presses a message or taps "Report Junk" on a recent call, iOS presents your extension's view controller so the user can classify and submit the report.
## Apple Documentation
- [SMS and Call Reporting (IdentityLookup)](https://developer.apple.com/documentation/identitylookup)
- [SMS and Call Spam Reporting](https://developer.apple.com/documentation/IdentityLookup/sms-and-call-spam-reporting)
- [ILClassificationUIExtensionViewController](https://developer.apple.com/documentation/sms_and_call_reporting/ilclassificationuiextensionviewcontroller)
- [ILClassificationRequest](https://developer.apple.com/documentation/identitylookup/ilclassificationrequest)
- [ILClassificationResponse](https://developer.apple.com/documentation/identitylookup/ilclassificationresponse)
- [Creating a Message Filter App Extension](https://developer.apple.com/documentation/identitylookup/creating-a-message-filter-app-extension)
- [Getting Up-to-Date Calling and Blocking Information](https://developer.apple.com/documentation/identitylookup/getting-up-to-date-calling-and-blocking-information-for-your-app)
## WWDC History
- **WWDC 2017, Session 249 -- [Filtering Unwanted Messages with Identity Lookup](https://developer.apple.com/videos/play/wwdc2017/249/)** -- The `IdentityLookup` framework was introduced with iOS 11 for SMS message filtering extensions. This was the foundation that the unwanted communication extension builds on.
- **WWDC 2018 (iOS 12)** -- Apple introduced the **Unwanted Communication Reporting** extension type, allowing users to report spam calls and SMS messages to third-party apps. This added the `ILClassificationUIExtensionViewController` and network reporting APIs.
- **WWDC 2022, Session 110341 -- [Explore SMS Message Filters](https://developer.apple.com/videos/play/wwdc2022/110341/)** -- Updated SMS filtering APIs for iOS 16 with sub-classification support and improved filtering capabilities.
## What It Does
The Unwanted Communication Reporting extension presents a custom UI when a user reports a message or call as junk. The flow is:
1. User selects a message in Messages.app or a call in Recents and taps **Report Junk**.
2. iOS presents your extension's `ILClassificationUIExtensionViewController`.
3. Your extension displays a UI for the user to classify the communication (spam, scam, etc.).
4. The user taps **Done** and your extension returns an `ILClassificationResponse`.
5. iOS delivers the report to your server via the configured network endpoint.
The extension receives:
- **For SMS**: Full message text, sender information, and content.
- **For calls**: Caller's phone number and the date/time of the call.
## Use Cases
### Spam/Robocall Reporting Apps
The primary use case. Apps like Truecaller, Hiya, RoboKiller, and similar services use this extension to let users report spam directly from the native Messages and Phone apps.
### Enterprise Communication Security
Organizations deploy reporting extensions to collect data about phishing SMS campaigns and scam calls targeting employees.
### Telecom Carrier Apps
Mobile carriers ship unwanted communication extensions so subscribers can report spam, feeding the carrier's spam detection systems.
## Key Classes
| Class | Role |
|-------|------|
| `ILClassificationUIExtensionViewController` | Principal view controller. Subclass this to present your reporting UI. |
| `ILClassificationRequest` | Contains the communication details (message text, sender, call info) to classify. |
| `ILClassificationResponse` | Your extension's response with the chosen classification action. |
| `ILClassificationAction` | Enum of possible actions: `.none`, `.reportJunk`, `.reportNotJunk`, `.reportJunkAndBlockSender`. |
## Classification Actions
| Action | Description |
|--------|-------------|
| `.none` | User cancelled or chose not to classify. |
| `.reportJunk` | Report the communication as junk/spam. |
| `.reportNotJunk` | Mark as not junk (false positive correction). |
| `.reportJunkAndBlockSender` | Report as junk and block the sender. |
## Extension Info.plist
The extension's `Info.plist` must declare the extension point and, optionally, a network reporting destination:
```xml
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.identitylookup.classification-ui</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ClassificationViewController</string>
<key>NSExtensionAttributes</key>
<dict>
<key>ILClassificationExtensionNetworkReportDestination</key>
<string>https://api.example.com/reports/unwanted</string>
</dict>
</dict>
```
## Implementation
### Minimal Extension (Template)
This is what `create-target` scaffolds:
```swift
import IdentityLookupUI
class ClassificationViewController: ILClassificationUIExtensionViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
override func prepare(for classificationRequest: ILClassificationRequest) {
// Configure the view for the classification request
}
override func classificationResponse(
for request: ILClassificationRequest
) -> ILClassificationResponse {
return ILClassificationResponse(action: .none)
}
}
```
### Full Implementation Pattern
A production extension with UI and network reporting:
```swift
import UIKit
import IdentityLookup
import IdentityLookupUI
class ClassificationViewController: ILClassificationUIExtensionViewController {
private var selectedAction: ILClassificationAction = .none
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// IMPORTANT: Enable the Done button. Without this, the user
// cannot submit the report.
self.extensionContext.isReadyForClassificationResponse = true
}
override func prepare(for classificationRequest: ILClassificationRequest) {
// Read the communication details and populate your UI.
//
// For SMS: classificationRequest contains message text and sender.
// For calls: classificationRequest contains caller number and date.
//
// Example: display the sender info in a label
if let comms = classificationRequest.communicationIdentifiers.first {
senderLabel.text = "From: \(comms)"
}
}
override func classificationResponse(
for request: ILClassificationRequest
) -> ILClassificationResponse {
// Return the user's chosen classification.
// The system sends this to your network endpoint automatically.
return ILClassificationResponse(action: selectedAction)
}
// MARK: - UI
private lazy var senderLabel = UILabel()
private func setupUI() {
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 16
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
senderLabel.font = .preferredFont(forTextStyle: .headline)
stack.addArrangedSubview(senderLabel)
let spamButton = makeButton(title: "Report as Spam", action: .reportJunk)
let blockButton = makeButton(title: "Report & Block", action: .reportJunkAndBlockSender)
let notJunkButton = makeButton(title: "Not Junk", action: .reportNotJunk)
stack.addArrangedSubview(spamButton)
stack.addArrangedSubview(blockButton)
stack.addArrangedSubview(notJunkButton)
}
private func makeButton(
title: String,
action: ILClassificationAction
) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.tag = action.rawValue
button.addTarget(self, action: #selector(didTapAction(_:)), for: .touchUpInside)
return button
}
@objc private func didTapAction(_ sender: UIButton) {
selectedAction = ILClassificationAction(rawValue: sender.tag) ?? .none
}
}
```
### Network Reporting Server Setup
When the user submits a report, iOS sends it to your server automatically. You need:
**1. Associated Domains entitlement on the extension:**
Add the `classificationreport:` service to your associated domains:
```
classificationreport:api.example.com
```
**2. Apple App Site Association (AASA) file on your server:**
Host at `https://api.example.com/.well-known/apple-app-site-association`:
```json
{
"classificationreport": {
"apps": [
"TEAMID.com.example.myapp",
"TEAMID.com.example.myapp.unwanted-communication"
]
}
}
```
**3. HTTPS endpoint:**
The URL specified in `ILClassificationExtensionNetworkReportDestination` receives a POST with the classification data. Must be HTTPS with a valid certificate (no ATS overrides allowed).
## User Activation
Users must manually enable the extension:
1. Go to **Settings > Phone > SMS/Call Reporting** (or **Settings > Messages > SMS/Call Reporting**).
2. Select your app's extension.
Only **one** Unwanted Communication Reporting extension can be active at a time.
## Using with @bacons/apple-targets
### 1. Create the target
```sh
cd your-expo-app
bunx create-target unwanted-communication
```
This creates:
```
targets/
unwanted-communication/
expo-target.config.js
ClassificationViewController.swift
```
### 2. Configure the target
```js
// targets/unwanted-communication/expo-target.config.js
/** @type {import('@bacons/apple-targets').Config} */
module.exports = {
type: "unwanted-communication",
};
```
### 3. Add the config plugin
```js
// app.json
{
"expo": {
"plugins": [
["@bacons/apple-targets"]
]
}
}
```
### 4. Prebuild and run
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
The plugin will:
- Create the extension target in the Xcode project
- Link `IdentityLookup.framework` and `IdentityLookupUI.framework`
- Set the `NSExtensionPointIdentifier` to `com.apple.identitylookup.classification-ui`
- Set the principal class to `$(PRODUCT_MODULE_NAME).ClassificationViewController`
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 12.0+ | Full support for SMS and call reporting. |
| iPadOS | -- | Not supported (no Phone or SMS on iPad). |
| macOS | -- | Not supported. |
| watchOS | -- | Not supported. |
| tvOS | -- | Not supported. |
## Privacy Model
Apple enforces strict privacy constraints:
- The extension's container is **deleted after every use**. You cannot persist data between invocations.
- The extension **cannot access the network directly**. All report delivery is handled by the system via the configured `ILClassificationExtensionNetworkReportDestination`.
- Shared `UserDefaults` via App Groups do **not** work reliably from this extension type due to sandbox restrictions.
- Any cookies the server attempts to set are ignored.
## Gotchas
- **Done button stays disabled** unless you set `self.extensionContext.isReadyForClassificationResponse = true` in `viewDidAppear`. This is not set automatically.
- **Network reports fire only once** after install in some iOS versions. Force-quitting the Messages app is a known workaround. This appears to be an iOS bug that has persisted across multiple versions.
- **No UITextField/UITextView** -- Adding text input fields inside the extension view controller may cause crashes related to the dictation feature. Use buttons or segmented controls instead.
- **Associated domains must match exactly** -- Both the containing app and the extension bundle IDs must be listed in the AASA file under `classificationreport`. Missing either one causes "unauthorized to defer" errors.
- **Only one extension active at a time** -- If another app's extension is already enabled, yours will replace it. Users must manually switch in Settings.
- **SMS filtering regional availability** -- SMS filtering (the separate `message-filter` extension type) may only work in certain countries. The unwanted communication reporting extension itself is available globally, but the underlying SMS/call features depend on the device's carrier and region.
virtual-conference.md
---
title: Virtual Conference Provider Extension
description: Integrates your video or voice calling app with the system Calendar so users see a "Join" button and room-type picker directly on their events.
version: iOS 15.0+, macOS 12.0+
---
# Virtual Conference Provider Extension (`virtual-conference`)
A virtual conference extension lets Calendar show a "Join" button on events that use your app's meeting rooms. When a user creates a new event, they can pick one of your conference room types from the location picker (shown alongside your app icon). The system then calls your extension to generate the meeting URL and details, which Calendar attaches to the event. Apps like Zoom, Microsoft Teams, and Webex use this extension to provide one-tap join from Calendar without opening the browser.
## Apple Documentation
- [EKVirtualConferenceProvider](https://developer.apple.com/documentation/eventkit/ekvirtualconferenceprovider)
- [EKVirtualConferenceDescriptor](https://developer.apple.com/documentation/eventkit/ekvirtualconferencedescriptor)
- [EKVirtualConferenceURLDescriptor](https://developer.apple.com/documentation/eventkit/ekvirtualconferenceurldescriptor)
- [EKVirtualConferenceRoomTypeDescriptor](https://developer.apple.com/documentation/eventkit/ekvirtualconferenceroomtypedescriptor)
- [EventKit Framework Overview](https://developer.apple.com/documentation/eventkit)
## WWDC History
- **[WWDC 2021 -- What's New in Managing Apple Devices](https://developer.apple.com/videos/play/wwdc2021/10130/)** -- `EKVirtualConferenceProvider` was introduced alongside iOS 15 and macOS 12, enabling third-party video conferencing apps to integrate with Calendar.
- **[WWDC 2023, Session 10052 -- Discover Calendar and EventKit](https://developer.apple.com/videos/play/wwdc2023/10052/)** -- Provided the first detailed walkthrough of building a virtual conference extension, including room types, URL descriptors, and the user flow in the Calendar location picker.
## What It Does
1. **User creates or edits a Calendar event.** In the location picker, Calendar shows "Video Call" options from installed apps that provide a virtual conference extension.
2. **Room type selection.** The system calls `fetchAvailableRoomTypes(completionHandler:)` on your `EKVirtualConferenceProvider` subclass. You return an array of `EKVirtualConferenceRoomTypeDescriptor` objects, each with a title and unique identifier.
3. **Conference generation.** When the user picks a room type, the system calls `fetchVirtualConference(identifier:completionHandler:)` with the selected room type identifier. Your extension creates the meeting (e.g., calls your backend API) and returns an `EKVirtualConferenceDescriptor` containing a title, one or more URL descriptors, and optional plain-text details.
4. **Calendar displays join affordance.** The event now shows a "Join" button. Tapping it opens the URL, which typically deep-links into your app.
5. **Shared across devices.** Because the conference data is part of the `EKEvent`, it syncs via iCloud/CalDAV to all the user's devices.
## Use Cases
### Video Conferencing Apps
Apps like Zoom, Microsoft Teams, Google Meet, and Webex can generate meeting links directly from the Calendar event creation flow. Users never need to open the conferencing app separately to create a room -- they just pick it from the Calendar location field and share the event invite.
### Internal Enterprise Communication Tools
Companies with proprietary conferencing systems (e.g., Cisco Jabber, custom WebRTC solutions) can integrate with Calendar so employees see a native "Join" button rather than having to copy-paste meeting URLs from emails or chat.
### Audio-Only Calling Apps
The extension is not limited to video. VoIP or audio bridge apps can provide room types like "Audio Bridge" or "Dial-In Conference" so users can join phone-style meetings from Calendar.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `EKVirtualConferenceProvider` | Subclass this as your extension's principal class. Override `fetchAvailableRoomTypes` and `fetchVirtualConference`. |
| `EKVirtualConferenceRoomTypeDescriptor` | Describes one room type (e.g., "Team Meeting", "Webinar"). Has a `title` and a unique `identifier`. |
| `EKVirtualConferenceDescriptor` | The meeting details returned by your extension. Contains a title, an array of `EKVirtualConferenceURLDescriptor`, and optional conference details string. |
| `EKVirtualConferenceURLDescriptor` | A single URL to join the conference. Has a `title` (e.g., "Join via App", "Join via Web") and a `URL`. |
## Implementation
```swift
import EventKit
class VirtualConferenceProvider: EKVirtualConferenceProvider {
// 1. Declare the room types your app supports.
// Calendar shows these in the location picker next to your app icon.
override func fetchAvailableRoomTypes(
completionHandler: @escaping ([EKVirtualConferenceRoomTypeDescriptor]?, Error?) -> Void
) {
let standardRoom = EKVirtualConferenceRoomTypeDescriptor(
title: "Video Meeting",
identifier: "standard"
)
let webinarRoom = EKVirtualConferenceRoomTypeDescriptor(
title: "Webinar",
identifier: "webinar"
)
// 2. Return all available room types.
completionHandler([standardRoom, webinarRoom], nil)
}
// 3. Called when the user selects one of your room types.
// Generate the meeting and return a descriptor with join URLs.
override func fetchVirtualConference(
identifier: EKVirtualConferenceRoomTypeIdentifier,
completionHandler: @escaping (EKVirtualConferenceDescriptor?, Error?) -> Void
) {
// 4. In a real app, call your backend to create a meeting room
// and receive a meeting URL. Here we simulate it.
let meetingId = UUID().uuidString.prefix(8).lowercased()
let meetingURL = URL(string: "https://meet.example.com/\(meetingId)")!
let deepLinkURL = URL(string: "myapp://join/\(meetingId)")!
// 5. Create URL descriptors -- one for the app deep link,
// one as a web fallback.
let appURL = EKVirtualConferenceURLDescriptor(
title: "Open in MyApp",
url: deepLinkURL
)
let webURL = EKVirtualConferenceURLDescriptor(
title: "Join via Browser",
url: meetingURL
)
// 6. Build the conference descriptor.
let title: String
switch identifier {
case "webinar":
title = "MyApp Webinar"
default:
title = "MyApp Meeting"
}
let descriptor = EKVirtualConferenceDescriptor(
title: title,
urlDescriptors: [appURL, webURL],
conferenceDetails: "Meeting ID: \(meetingId)"
)
// 7. Return the descriptor. Calendar attaches it to the event.
completionHandler(descriptor, nil)
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target virtual-conference
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 15.0+ | Full support. Calendar shows room types in location picker. |
| iPadOS | 15.0+ | Same as iOS. |
| macOS | 12.0+ | Calendar.app supports virtual conference extensions. |
| watchOS | -- | Not supported. Calendar on watchOS does not load conference extensions. |
| tvOS | -- | Not supported. |
| visionOS | 1.0+ | Calendar is available; virtual conference extensions are expected to work. |
## Gotchas
- **Your extension is only invoked at event creation time.** The system calls `fetchVirtualConference` when the user picks your room type while creating or editing an event. It is not called again when the event is opened later -- the stored URL descriptors are used directly. If you need to regenerate meeting links, the user must re-select your room type.
- **Room types appear only if your main app is installed.** The extension is bundled inside your app. If the user deletes the app, your room types disappear from the Calendar location picker.
- **No way to programmatically attach a conference to an existing event.** Developers on Apple Developer Forums have reported that there is no public API to set virtual conference data on an `EKEvent` created via `EKEventEditViewController` or `EKEventStore`. The only path is through the user selecting your room type in the Calendar UI.
- **The completion handler must be called promptly.** If `fetchVirtualConference` takes too long (e.g., a slow network call to your backend), the system may time out and show an error. Keep the operation fast or pre-generate meeting IDs.
- **URL descriptors should include a web fallback.** If your first URL uses a custom scheme (`myapp://...`) and the app is not installed on the device receiving the shared event, the join button will fail silently. Always include an `https://` fallback URL.
- **EventKit permission is not required for the extension.** The extension does not need calendar access (`EKEventStore` authorization) to function. It only provides conference details -- it does not read or write events.
- **Zoom has not yet adopted this API.** Despite community requests, as of 2025, Zoom has not shipped an `EKVirtualConferenceProvider` extension. FaceTime is the only first-party provider. Adoption among third-party apps remains limited.
watch.md
---
title: watchOS App
description: A standalone watchOS application target that runs natively on Apple Watch with SwiftUI, supporting complications, workout sessions, and phone connectivity.
version: watchOS 7.0+
---
# watchOS App (`watch`)
A watchOS app target creates a fully independent application that runs on Apple Watch. It uses the product type `com.apple.product-type.application` -- the same as any standard app, not an extension. Since watchOS 7, apps use the SwiftUI `@main` App lifecycle, replacing the legacy WKExtension/WKInterfaceController pattern. Modern watchOS apps are SwiftUI-first: they use `NavigationStack` for hierarchical navigation, `TabView` for paged layouts, and `List` for scrollable content driven by the Digital Crown. The companion iOS app is optional -- watchOS apps can be distributed independently through the App Store and can make their own network requests, store data locally, and access HealthKit, CoreLocation, and other on-device frameworks.
## Apple Documentation
- [Building a watchOS App](https://developer.apple.com/documentation/watchos-apps/building_a_watchos_app) -- primary guide for setting up the app lifecycle, creating the user interface, and managing scenes with SwiftUI.
- [Creating a watchOS App (SwiftUI Tutorial)](https://developer.apple.com/tutorials/swiftui/creating-a-watchos-app) -- hands-on tutorial adapting the Landmarks sample app for watchOS.
- [TN3157: Updating Your watchOS Project for SwiftUI and WidgetKit](https://developer.apple.com/documentation/technotes/tn3157-updating-your-watchos-project-for-swiftui-and-widgetkit) -- tech note covering migration from WatchKit storyboards to SwiftUI and from ClockKit to WidgetKit.
- [WCSession (Watch Connectivity)](https://developer.apple.com/documentation/watchconnectivity/wcsession) -- the singleton session object for bidirectional communication between the iOS app and the watchOS app.
- [WCSessionDelegate](https://developer.apple.com/documentation/watchconnectivity/wcsessiondelegate) -- delegate protocol for receiving messages, application context, and file transfers.
- [WKApplicationRefreshBackgroundTask](https://developer.apple.com/documentation/watchkit/wkapplicationrefreshbackgroundtask) -- background task for scheduling periodic data refreshes on watchOS.
- [watchOS App Planning](https://developer.apple.com/watchos/planning/) -- Apple's overview of design and development considerations for watchOS apps.
## WWDC History
- **[WWDC 2019, Session 208 -- Creating Independent Watch Apps](https://developer.apple.com/videos/play/wwdc2019/208/)** -- introduced watchOS 6 independent apps that no longer require a companion iOS app, with their own App Store presence and direct network access.
- **[WWDC 2019, Session 219 -- SwiftUI on watchOS](https://asciiwwdc.com/2019/sessions/219)** -- first look at building watchOS interfaces with SwiftUI, replacing WKInterfaceController and storyboards.
- **[WWDC 2020, Session 10048 -- Build Complications in SwiftUI](https://developer.apple.com/videos/play/wwdc2020/10048/)** -- introduced SwiftUI-based ClockKit graphic complications for Meridian, Infograph, and other watch faces in watchOS 7.
- **[WWDC 2020, Session 10171 -- What's New in watchOS Design](https://developer.apple.com/videos/play/wwdc2020/10171/)** -- design principles for watchOS 7 apps, including the SwiftUI App lifecycle and notification handling.
- **[WWDC 2022, Session 10050 -- Complications and Widgets: Reloaded](https://developer.apple.com/videos/play/wwdc2022/10050/)** -- unified WidgetKit across iOS and watchOS 9, replacing ClockKit with four accessory widget families.
- **[WWDC 2022, Session 10051 -- Go Further with Complications in WidgetKit](https://developer.apple.com/videos/play/wwdc2022/10051/)** -- watchOS-specific WidgetKit features, rendering modes (full color, accented, vibrant), and migration from ClockKit.
- **[WWDC 2023, Session 10138 -- Design and Build Apps for watchOS 10](https://developer.apple.com/videos/play/wwdc2023/10138/)** -- major redesign of the watchOS UI paradigm with vertically pageable `TabView`, the Smart Stack, and new NavigationSplitView patterns.
## What It Does
1. **System launches the app.** When the user taps the app icon on the watch Home Screen or receives a complication tap, watchOS instantiates the `@main` App struct and renders the initial `WindowGroup` scene.
2. **SwiftUI drives the UI.** The app presents views using `NavigationStack`, `TabView`, and `List`. The Digital Crown controls scrolling. Haptic feedback is provided via `WKInterfaceDevice.current().play()`.
3. **Complications surface data.** The app's WidgetKit extension (watchOS 9+) or ClockKit data source (legacy) provides timeline entries that the system renders on watch faces as complications, giving users at-a-glance information.
4. **Watch Connectivity syncs with iPhone.** `WCSession` enables real-time messaging (`sendMessage`), background transfers (`transferUserInfo`, `transferFile`), and application context (`updateApplicationContext`) between the watch and the paired iPhone.
5. **Background refresh keeps data current.** The app schedules `WKApplicationRefreshBackgroundTask` to wake periodically (budget: approximately one task per hour for dock apps) and fetch updated data.
6. **Workout sessions track activity.** Using `HKWorkoutSession` and `HKLiveWorkoutBuilder`, the app starts, pauses, and ends workout sessions with real-time heart rate, calories, and distance data from HealthKit.
7. **Notifications arrive on the wrist.** The app registers for remote and local notifications. Custom notification views built with SwiftUI replace the default system presentation.
## Use Cases
### Fitness and Workout Tracking
A running app starts an `HKWorkoutSession`, displays real-time heart rate and pace via HealthKit queries, and uses extended runtime sessions to keep the screen active. A complication on the watch face shows the weekly distance goal progress.
### Messaging and Communication
A chat app uses `WCSession.sendMessage` for real-time message relay when the iPhone is reachable, and falls back to `transferUserInfo` for queued delivery. Quick replies use the system text input controller with dictation, scribble, and emoji.
### Smart Home Control
A home automation app presents a list of devices with toggle controls. Tapping a complication on the watch face immediately opens the "lights" scene. Background refresh updates device status every 15 minutes so complications always show current state.
### Travel and Navigation
A transit app shows the next departure time as a complication using a relative date text style. The app uses CoreLocation for turn-by-turn haptic directions and `WKInterfaceDevice` haptics for left/right turn signals.
## Key Classes
| Class | Role |
|-------|------|
| `App` (SwiftUI) | The `@main` entry point for the watchOS app, declaring scenes via `WindowGroup`. |
| `NavigationStack` | Manages hierarchical navigation with push/pop semantics driven by the Digital Crown back gesture. |
| `TabView` | Provides paged or vertically scrollable tab navigation (watchOS 10+ uses vertical pages). |
| `WCSession` | Singleton for Watch Connectivity. Handles `sendMessage`, `updateApplicationContext`, `transferUserInfo`, and `transferFile`. |
| `WCSessionDelegate` | Delegate receiving messages, context updates, and file transfers from the companion iOS app. |
| `HKWorkoutSession` | Manages a workout session lifecycle (start, pause, resume, end) with automatic sensor activation. |
| `HKLiveWorkoutBuilder` | Collects real-time workout samples (heart rate, distance, calories) during an active session. |
| `WKApplicationRefreshBackgroundTask` | A background task the system delivers to refresh app data. Limited to ~1 per hour for dock apps. |
| `WKExtendedRuntimeSession` | Extends app runtime beyond normal limits for workouts, mindfulness, and physical therapy sessions. |
| `WidgetKit` (TimelineProvider) | Powers watch face complications on watchOS 9+ using the same WidgetKit API as iOS. |
| `CLLocationManager` | Provides location updates and authorization on Apple Watch. |
| `UNUserNotificationCenter` | Registers for and handles local and remote notifications on the watch. |
## Implementation
### Full watchOS App with Connectivity and Workout Session
```swift
import SwiftUI
import HealthKit
import WatchConnectivity
// 1. Define the main app entry point using the SwiftUI App lifecycle.
// This replaces the legacy WKExtensionDelegate/WKInterfaceController pattern.
@main
struct FitnessWatchApp: App {
@StateObject private var connectivityManager = ConnectivityManager()
@StateObject private var workoutManager = WorkoutManager()
var body: some Scene {
WindowGroup {
// 2. Use TabView for top-level navigation. On watchOS 10+,
// this renders as vertically pageable sections.
TabView {
DashboardView()
.environmentObject(workoutManager)
.tag("dashboard")
WorkoutView()
.environmentObject(workoutManager)
.tag("workout")
SettingsView()
.environmentObject(connectivityManager)
.tag("settings")
}
.tabViewStyle(.verticalPage)
}
}
}
// 3. ConnectivityManager wraps WCSession for bidirectional phone<->watch
// communication. It must conform to WCSessionDelegate.
class ConnectivityManager: NSObject, ObservableObject, WCSessionDelegate {
@Published var lastReceivedMessage: String = ""
@Published var isReachable = false
private var session: WCSession?
override init() {
super.init()
// 4. Check if Watch Connectivity is supported on this device.
// It is always supported on Apple Watch but only supported on
// iPhone when paired with a watch.
if WCSession.isSupported() {
session = WCSession.default
session?.delegate = self
session?.activate()
}
}
// 5. Required delegate method: called when activation completes.
func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
DispatchQueue.main.async {
self.isReachable = session.isReachable
}
}
// 6. Receive real-time messages from the iPhone app.
// sendMessage only works when both apps are reachable.
func session(
_ session: WCSession,
didReceiveMessage message: [String: Any]
) {
DispatchQueue.main.async {
if let text = message["text"] as? String {
self.lastReceivedMessage = text
}
}
}
// 7. Receive application context updates. Unlike sendMessage,
// application context is delivered even if the counterpart
// app was not running -- the system caches the latest context.
func session(
_ session: WCSession,
didReceiveApplicationContext applicationContext: [String: Any]
) {
DispatchQueue.main.async {
if let text = applicationContext["status"] as? String {
self.lastReceivedMessage = text
}
}
}
// 8. Send a message to the iPhone. The reply handler runs on a
// background thread; dispatch to main for UI updates.
func sendMessageToPhone(_ data: [String: Any]) {
guard let session, session.isReachable else {
// 9. Fallback: use transferUserInfo for queued delivery
// when the phone is not immediately reachable.
session?.transferUserInfo(data)
return
}
session.sendMessage(data, replyHandler: { reply in
DispatchQueue.main.async {
if let response = reply["response"] as? String {
self.lastReceivedMessage = response
}
}
}, errorHandler: { error in
print("Send failed: \(error.localizedDescription)")
})
}
}
// 10. WorkoutManager handles HealthKit workout sessions with real-time
// heart rate, calorie, and distance tracking.
class WorkoutManager: NSObject, ObservableObject, HKWorkoutSessionDelegate,
HKLiveWorkoutBuilderDelegate
{
@Published var isWorkoutActive = false
@Published var heartRate: Double = 0
@Published var activeCalories: Double = 0
@Published var distance: Double = 0
@Published var elapsedTime: TimeInterval = 0
private let healthStore = HKHealthStore()
private var workoutSession: HKWorkoutSession?
private var workoutBuilder: HKLiveWorkoutBuilder?
// 11. Request HealthKit authorization for the data types we need.
func requestAuthorization() {
let typesToShare: Set<HKSampleType> = [
HKQuantityType.workoutType()
]
let typesToRead: Set<HKObjectType> = [
HKQuantityType(.heartRate),
HKQuantityType(.activeEnergyBurned),
HKQuantityType(.distanceWalkingRunning)
]
healthStore.requestAuthorization(
toShare: typesToShare,
read: typesToRead
) { success, error in
if let error {
print("HealthKit auth failed: \(error.localizedDescription)")
}
}
}
// 12. Start a workout session with a specific activity type.
func startWorkout(activityType: HKWorkoutActivityType = .running) {
let configuration = HKWorkoutConfiguration()
configuration.activityType = activityType
configuration.locationType = .outdoor
do {
workoutSession = try HKWorkoutSession(
healthStore: healthStore,
configuration: configuration
)
workoutBuilder = workoutSession?.associatedWorkoutBuilder()
workoutSession?.delegate = self
workoutBuilder?.delegate = self
// 13. Set the data source to collect live samples from
// Apple Watch sensors automatically.
workoutBuilder?.dataSource = HKLiveWorkoutDataSource(
healthStore: healthStore,
workoutConfiguration: configuration
)
let startDate = Date()
workoutSession?.startActivity(with: startDate)
workoutBuilder?.beginCollection(withStart: startDate) { _, _ in }
DispatchQueue.main.async {
self.isWorkoutActive = true
}
} catch {
print("Failed to start workout: \(error.localizedDescription)")
}
}
// 14. End the workout and save it to HealthKit.
func endWorkout() {
workoutSession?.end()
DispatchQueue.main.async {
self.isWorkoutActive = false
}
}
// MARK: - HKWorkoutSessionDelegate
func workoutSession(
_ workoutSession: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date
) {
if toState == .ended {
// 15. Finalize and save the workout when the session ends.
workoutBuilder?.endCollection(withEnd: date) { _, _ in
self.workoutBuilder?.finishWorkout { workout, error in
if let error {
print("Failed to save workout: \(error.localizedDescription)")
}
}
}
}
}
func workoutSession(
_ workoutSession: HKWorkoutSession,
didFailWithError error: Error
) {
print("Workout session error: \(error.localizedDescription)")
}
// MARK: - HKLiveWorkoutBuilderDelegate
func workoutBuilderDidCollectEvent(
_ workoutBuilder: HKLiveWorkoutBuilder
) {}
func workoutBuilder(
_ workoutBuilder: HKLiveWorkoutBuilder,
didCollectDataOf collectedTypes: Set<HKSampleType>
) {
// 16. Update published properties with the latest workout statistics
// so SwiftUI views refresh automatically.
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType,
let statistics = workoutBuilder.statistics(for: quantityType)
else { continue }
DispatchQueue.main.async {
switch quantityType {
case HKQuantityType(.heartRate):
let bpm = statistics.mostRecentQuantity()?
.doubleValue(for: .count().unitDivided(by: .minute()))
self.heartRate = bpm ?? 0
case HKQuantityType(.activeEnergyBurned):
let cal = statistics.sumQuantity()?
.doubleValue(for: .kilocalorie())
self.activeCalories = cal ?? 0
case HKQuantityType(.distanceWalkingRunning):
let meters = statistics.sumQuantity()?
.doubleValue(for: .meter())
self.distance = (meters ?? 0) / 1000.0 // Convert to km
default:
break
}
}
}
}
}
// 17. Dashboard view showing connectivity status and recent data.
struct DashboardView: View {
@EnvironmentObject var workoutManager: WorkoutManager
var body: some View {
NavigationStack {
List {
Section("Today") {
HStack {
Image(systemName: "flame.fill")
.foregroundStyle(.orange)
Text("\(Int(workoutManager.activeCalories)) kcal")
}
HStack {
Image(systemName: "figure.run")
.foregroundStyle(.green)
Text(String(format: "%.1f km", workoutManager.distance))
}
}
}
.navigationTitle("Fitness")
}
}
}
// 18. Workout view with start/stop controls and live metrics.
struct WorkoutView: View {
@EnvironmentObject var workoutManager: WorkoutManager
var body: some View {
NavigationStack {
VStack(spacing: 16) {
if workoutManager.isWorkoutActive {
// 19. Live workout metrics update in real time via
// the @Published properties on WorkoutManager.
VStack(spacing: 12) {
MetricView(
icon: "heart.fill",
value: "\(Int(workoutManager.heartRate))",
unit: "BPM",
color: .red
)
MetricView(
icon: "flame.fill",
value: "\(Int(workoutManager.activeCalories))",
unit: "kcal",
color: .orange
)
MetricView(
icon: "figure.run",
value: String(format: "%.2f", workoutManager.distance),
unit: "km",
color: .green
)
}
Button("End Workout", role: .destructive) {
workoutManager.endWorkout()
}
} else {
Button {
workoutManager.requestAuthorization()
workoutManager.startWorkout()
} label: {
Label("Start Run", systemImage: "figure.run")
}
.buttonStyle(.borderedProminent)
.tint(.green)
}
}
.navigationTitle("Workout")
}
}
}
struct MetricView: View {
let icon: String
let value: String
let unit: String
let color: Color
var body: some View {
HStack {
Image(systemName: icon)
.foregroundStyle(color)
.frame(width: 24)
Text(value)
.font(.title2.monospacedDigit().bold())
Text(unit)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
}
}
struct SettingsView: View {
@EnvironmentObject var connectivityManager: ConnectivityManager
var body: some View {
NavigationStack {
List {
Section("Phone Connection") {
HStack {
Circle()
.fill(connectivityManager.isReachable ? .green : .red)
.frame(width: 8, height: 8)
Text(
connectivityManager.isReachable
? "Connected" : "Not Reachable"
)
}
}
Section("Sync") {
Button("Send Status to Phone") {
connectivityManager.sendMessageToPhone([
"status": "active",
"heartRate": 72,
])
}
}
}
.navigationTitle("Settings")
}
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target watch
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| watchOS | 7.0+ | SwiftUI App lifecycle (`@main`). Storyboards deprecated. Independent app distribution since watchOS 6. |
| watchOS | 9.0+ | WidgetKit complications replace ClockKit. Four accessory families: circular, rectangular, inline, corner. |
| watchOS | 10.0+ | Redesigned UI with vertical `TabView` paging, Smart Stack, and new NavigationSplitView patterns. |
| iOS | -- | The companion iOS app is optional. If present, use WatchConnectivity for data sync. |
## Gotchas
- **WatchKit storyboards are deprecated since watchOS 7.** All new watchOS apps must use the SwiftUI `@main` App lifecycle. The legacy `WKExtensionDelegate` and `WKInterfaceController` APIs still work but receive no new features.
- **WCSession requires both a delegate and activation.** Activating a `WCSession` without setting a delegate first is a programming error. Always set the delegate before calling `activate()`, and do so early in the app lifecycle.
- **`sendMessage` requires reachability.** Real-time messaging only works when both the watch and phone apps are active (the phone app can be woken in the background). Always check `session.isReachable` and fall back to `transferUserInfo` or `updateApplicationContext` for non-urgent data.
- **Background refresh budget is limited.** Dock apps get approximately one `WKApplicationRefreshBackgroundTask` per hour. Apps with an active complication on the watch face get a higher budget. Apps not in the dock or on a watch face receive no regular background time.
- **Background tasks have strict time limits.** A `WKApplicationRefreshBackgroundTask` gives your app 4 seconds of CPU time and 15 seconds of wall-clock time. Use `WKURLSessionRefreshBackgroundTask` with background `URLSession` download tasks for network requests -- the system suspends your app during the download and re-wakes it when data arrives.
- **File protection blocks background access.** Files created with the default complete protection level are inaccessible when the watch screen is locked. Background tasks run while locked, so any files you need to read or write during a background task must use `.noFileProtection` or `.completeUntilFirstUserAuthentication`.
- **HealthKit authorization is per-device.** The user must grant HealthKit permissions directly on the Apple Watch. You cannot request HealthKit authorization from the iPhone on behalf of the watch app.
- **ClockKit is fully replaced by WidgetKit on watchOS 9+.** New complications must use WidgetKit's `TimelineProvider` and accessory widget families. ClockKit complications still work on older watch faces but cannot be added to new faces.
- **Only `plist`-encodable types in WCSession messages.** The `[String: Any]` dictionaries sent via `sendMessage` and `transferUserInfo` only support property list types (String, Int, Double, Data, Array, Dictionary, Date, Bool). Encode custom types to `Data` before sending.
- **The watchOS Simulator does not support all sensors.** Heart rate, accelerometer, and gyroscope data are unavailable in the Simulator. Test workout and motion features on a physical Apple Watch.
- **Independent apps still need an iOS companion for some features.** While watchOS apps can be fully independent, features like `WCSession` communication, handoff, and some CloudKit sync patterns require the paired iPhone app to be installed.
widget.md
---
title: WidgetKit Home Screen Widget
description: Displays glanceable, read-only (or interactive on iOS 17+) views on the Home Screen, Lock Screen, StandBy, and Desktop, powered by timeline-based data and SwiftUI.
version: iOS 14.0+, macOS 11.0+
---
# WidgetKit Home Screen Widget (`widget`)
A widget extension renders small SwiftUI views directly on the Home Screen, Lock Screen, StandBy mode, and macOS Desktop. Widgets refresh on a system-managed timeline, and starting with iOS 17 they can contain interactive controls (buttons and toggles) via App Intents. The same extension can also power Live Activities on the Lock Screen and Dynamic Island, as well as Control Center controls (iOS 18+).
## Apple Documentation
- [WidgetKit Framework Overview](https://developer.apple.com/documentation/widgetkit)
- [Creating a Widget Extension](https://developer.apple.com/documentation/widgetkit/creating-a-widget-extension)
- [Widget Protocol (SwiftUI)](https://developer.apple.com/documentation/swiftui/widget)
- [TimelineProvider](https://developer.apple.com/documentation/widgetkit/timelineprovider)
- [AppIntentTimelineProvider](https://developer.apple.com/documentation/widgetkit/appintenttimelineprovider)
- [WidgetFamily](https://developer.apple.com/documentation/widgetkit/widgetfamily)
- [ActivityKit (Live Activities)](https://developer.apple.com/documentation/activitykit)
- [WidgetKit Widgets Overview (Apple landing page)](https://developer.apple.com/widgets/)
- [Keeping a Widget Up to Date](https://developer.apple.com/documentation/widgetkit/keeping-a-widget-up-to-date)
## WWDC History
- **[WWDC 2020, Session 10028 -- Meet WidgetKit](https://developer.apple.com/videos/play/wwdc2020/10028/)** -- Introduced WidgetKit with timeline providers, widget families (small/medium/large), and SwiftUI-only rendering.
- **[WWDC 2020, Session 10033 -- Build SwiftUI Views for Widgets](https://developer.apple.com/videos/play/wwdc2020/10033/)** -- Deep dive into SwiftUI view techniques specific to widgets.
- **[WWDC 2020, Sessions 10034-10036 -- Widgets Code-Along (Parts 1-3)](https://developer.apple.com/videos/play/wwdc2020/10034/)** -- Three-part hands-on series covering widget families, timelines, URL sessions, and widget bundles.
- **[WWDC 2021, Session 10048 -- Principles of Great Widgets](https://developer.apple.com/videos/play/wwdc2021/10048/)** -- Design guidance for effective widget experiences.
- **[WWDC 2022, Session 10050 -- Complications and Widgets: Reloaded](https://developer.apple.com/videos/play/wwdc2022/10050/)** -- Added Lock Screen widget families (`accessoryCircular`, `accessoryRectangular`, `accessoryInline`) and unified WidgetKit across iOS and watchOS.
- **[WWDC 2022, Session 10051 -- Go Further with Complications in WidgetKit](https://developer.apple.com/videos/play/wwdc2022/10051/)** -- watchOS-specific WidgetKit features and ClockKit migration.
- **[WWDC 2023, Session 10027 -- Bring Widgets to New Places](https://developer.apple.com/videos/play/wwdc2023/10027/)** -- Widgets on Mac Desktop, iPad Lock Screen, StandBy mode, and Apple Watch Smart Stack.
- **[WWDC 2023, Session 10028 -- Bring Widgets to Life](https://developer.apple.com/videos/play/wwdc2023/10028/)** -- Interactive widgets with App Intents (Button and Toggle), animated transitions.
- **[WWDC 2023, Session 10184 -- Meet ActivityKit](https://developer.apple.com/videos/play/wwdc2023/10184/)** -- Live Activities on Lock Screen and Dynamic Island using ActivityKit.
- **[WWDC 2024, Session 10157 -- Extend Your App's Controls Across the System](https://developer.apple.com/videos/play/wwdc2024/10157/)** -- Control Center controls built with WidgetKit (iOS 18+).
- **[WWDC 2025, Session 278 -- What's New in Widgets](https://developer.apple.com/videos/play/wwdc2025/278/)** -- Widget push updates, glass presentation on iOS 26, visionOS pinned widgets.
## What It Does
1. **System requests a timeline.** WidgetKit calls your `TimelineProvider.getTimeline(in:completion:)` (or the `AppIntentTimelineProvider` equivalent) to get an array of dated entries.
2. **Entries are rendered as SwiftUI views.** Each `TimelineEntry` is passed to your widget's `body` view closure. The system snapshots the SwiftUI tree into a static render (pre-iOS 17) or an interactive render (iOS 17+).
3. **The system manages refresh.** Based on your `TimelineReloadPolicy` (`.atEnd`, `.after(date)`, `.never`) the system schedules the next call to your provider. You get a limited daily budget of refreshes.
4. **In-app triggers.** Your main app can call `WidgetCenter.shared.reloadTimelines(ofKind:)` to force a reload after data changes.
5. **Push-driven updates.** A server can send a WidgetKit push notification via APNs to trigger a reload without the app running.
6. **Taps deep-link.** A tap on the widget opens the main app. You control the destination via `.widgetURL()` or `Link` views.
7. **Interactive controls (iOS 17+).** Buttons and Toggles bound to an `AppIntent` execute logic directly in the widget extension process -- no app launch required.
## Use Cases
### Weather and forecasts
A medium-sized widget showing current temperature, conditions, and a 5-hour forecast strip. The timeline provider generates entries at hourly intervals, with a network request in `getTimeline` to fetch fresh data.
### Task and habit trackers
An interactive widget with a checklist. Each row has a Toggle bound to a `ToggleTaskIntent` (AppIntent) so users can mark items complete directly from the Home Screen without opening the app.
### Sports scores and live events
A Live Activity on the Lock Screen and Dynamic Island showing the current score, game clock, and team logos. The app starts the activity at tip-off and updates it via APNs push or in-app `Activity.update()` calls.
### Music and media playback
A small widget showing the currently playing album art and track name. An interactive Play/Pause button uses an AppIntent to control playback via the shared audio session.
## Key Classes
| Class / Protocol | Role |
|------------------|------|
| `TimelineProvider` | Supplies the widget with a timeline of entries. Implement `placeholder`, `getSnapshot`, and `getTimeline`. |
| `AppIntentTimelineProvider` | iOS 17+ replacement that uses App Intents for user-configurable widgets (replaces `IntentTimelineProvider`). |
| `TimelineEntry` | A single dated data point. Must contain a `date` property; add any custom fields your view needs. |
| `WidgetConfiguration` | Declares the widget. Use `StaticConfiguration` (no config) or `AppIntentConfiguration` (user-configurable). |
| `WidgetFamily` | Enum of supported sizes: `.systemSmall`, `.systemMedium`, `.systemLarge`, `.systemExtraLarge`, `.accessoryCircular`, `.accessoryRectangular`, `.accessoryInline`. |
| `WidgetBundle` | Groups multiple widgets from the same extension into a single target. |
| `WidgetCenter` | Lets the main app reload timelines and query current widget configurations. |
| `ActivityAttributes` | Defines the static and dynamic data schema for a Live Activity. |
| `ActivityConfiguration` | Declares the Live Activity UI (Lock Screen banner + Dynamic Island). |
| `Activity<T>` | Manages the lifecycle of a Live Activity instance (request, update, end). |
## Implementation
### Timeline-Based Widget with Interactive Controls (iOS 17+)
```swift
import WidgetKit
import SwiftUI
import AppIntents
// 1. Define the data each timeline entry carries.
struct TaskEntry: TimelineEntry {
let date: Date
let tasks: [TaskItem]
}
struct TaskItem: Identifiable, Codable {
let id: String
var title: String
var isComplete: Bool
}
// 2. Build a timeline provider that reads from shared storage.
struct TaskTimelineProvider: TimelineProvider {
func placeholder(in context: Context) -> TaskEntry {
TaskEntry(date: .now, tasks: [
TaskItem(id: "1", title: "Buy groceries", isComplete: false)
])
}
func getSnapshot(in context: Context, completion: @escaping (TaskEntry) -> Void) {
completion(placeholder(in: context))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<TaskEntry>) -> Void) {
// 3. Read tasks from an App Group shared container.
let tasks = loadTasksFromAppGroup()
let entry = TaskEntry(date: .now, tasks: tasks)
// 4. Reload at the top of the next hour, or rely on in-app triggers.
let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
private func loadTasksFromAppGroup() -> [TaskItem] {
guard let url = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.myapp")?
.appendingPathComponent("tasks.json"),
let data = try? Data(contentsOf: url),
let tasks = try? JSONDecoder().decode([TaskItem].self, from: data)
else { return [] }
return tasks
}
}
// 5. Define an App Intent for toggling a task (iOS 17+).
struct ToggleTaskIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Task"
@Parameter(title: "Task ID")
var taskId: String
init() {}
init(taskId: String) { self.taskId = taskId }
func perform() async throws -> some IntentResult {
// 6. Mutate shared data and tell WidgetKit to refresh.
var tasks = SharedTaskStore.load()
if let idx = tasks.firstIndex(where: { $0.id == taskId }) {
tasks[idx].isComplete.toggle()
SharedTaskStore.save(tasks)
}
return .result()
}
}
// 7. Build the SwiftUI view.
struct TaskWidgetEntryView: View {
var entry: TaskEntry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall:
// Small: show count only
VStack {
Text("\(entry.tasks.filter { !$0.isComplete }.count)")
.font(.system(size: 48, weight: .bold))
Text("tasks left")
.font(.caption)
.foregroundStyle(.secondary)
}
.containerBackground(.fill.tertiary, for: .widget)
case .systemMedium:
// Medium: show interactive checklist
VStack(alignment: .leading, spacing: 6) {
ForEach(entry.tasks.prefix(4)) { task in
// 8. Toggle bound to an AppIntent -- executes without launching the app.
Toggle(isOn: task.isComplete, intent: ToggleTaskIntent(taskId: task.id)) {
Text(task.title)
.strikethrough(task.isComplete)
}
.toggleStyle(.checkbox)
}
}
.padding()
.containerBackground(.fill.tertiary, for: .widget)
default:
Text("Unsupported")
}
}
}
// 9. Declare the widget configuration.
struct TaskWidget: Widget {
let kind = "TaskWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: TaskTimelineProvider()) { entry in
TaskWidgetEntryView(entry: entry)
}
.configurationDisplayName("Tasks")
.description("Track your to-do list at a glance.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
```
### Live Activity (Delivery Tracker)
```swift
import ActivityKit
import WidgetKit
import SwiftUI
// 1. Define attributes: static data + dynamic ContentState.
struct DeliveryAttributes: ActivityAttributes {
let orderNumber: String
let restaurantName: String
struct ContentState: Codable, Hashable {
var status: String // e.g. "Preparing", "On the way", "Delivered"
var estimatedArrival: Date
var driverName: String
}
}
// 2. Build the Live Activity widget.
struct DeliveryLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
// Lock Screen / banner presentation
HStack {
VStack(alignment: .leading) {
Text(context.state.status)
.font(.headline)
Text("ETA: \(context.state.estimatedArrival, style: .timer)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "bicycle")
.font(.title)
}
.padding()
.activityBackgroundTint(.cyan.opacity(0.2))
} dynamicIsland: { context in
// 3. Dynamic Island presentations.
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "bicycle")
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.status)
.font(.headline)
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.estimatedArrival, style: .timer)
.font(.caption)
}
DynamicIslandExpandedRegion(.bottom) {
Text("Driver: \(context.state.driverName)")
}
} compactLeading: {
Image(systemName: "bicycle")
} compactTrailing: {
Text(context.state.estimatedArrival, style: .timer)
} minimal: {
Image(systemName: "bicycle")
}
}
}
}
// 4. Start, update, and end from the main app.
func startDeliveryActivity(orderNumber: String, restaurant: String) throws {
let attributes = DeliveryAttributes(
orderNumber: orderNumber,
restaurantName: restaurant
)
let initialState = DeliveryAttributes.ContentState(
status: "Preparing",
estimatedArrival: Date().addingTimeInterval(30 * 60),
driverName: "Alex"
)
let content = ActivityContent(state: initialState, staleDate: nil)
let _ = try Activity<DeliveryAttributes>.request(
attributes: attributes,
content: content,
pushType: .token // enables APNs updates
)
}
func updateDeliveryActivity(activity: Activity<DeliveryAttributes>, newStatus: String) async {
let updatedState = DeliveryAttributes.ContentState(
status: newStatus,
estimatedArrival: Date().addingTimeInterval(10 * 60),
driverName: "Alex"
)
await activity.update(ActivityContent(state: updatedState, staleDate: nil))
}
func endDeliveryActivity(activity: Activity<DeliveryAttributes>) async {
let finalState = DeliveryAttributes.ContentState(
status: "Delivered",
estimatedArrival: .now,
driverName: "Alex"
)
await activity.end(
ActivityContent(state: finalState, staleDate: nil),
dismissalPolicy: .after(.now.addingTimeInterval(4 * 3600))
)
}
```
### Lock Screen Accessory Widget
```swift
import WidgetKit
import SwiftUI
// 1. Reuse the same provider; declare a separate widget for Lock Screen families.
struct TaskAccessoryWidget: Widget {
let kind = "TaskAccessoryWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: TaskTimelineProvider()) { entry in
// 2. Accessory widgets use a monochrome, compact layout.
let remaining = entry.tasks.filter { !$0.isComplete }.count
ZStack {
AccessoryWidgetBackground()
VStack {
Image(systemName: "checklist")
Text("\(remaining)")
.font(.title2.bold())
}
}
.widgetLabel("\(remaining) left")
}
.configurationDisplayName("Task Count")
.description("Remaining tasks on your Lock Screen.")
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
```
## Using with @bacons/apple-targets
```sh
cd your-expo-app
bunx create-target widget
```
```json
// app.json
{
"expo": {
"plugins": [["@bacons/apple-targets"]]
}
}
```
```sh
# Initial setup, or after changing expo-target.config.js, app.json, or adding/removing targets
npx expo prebuild --clean
# Swift files in targets/ are linked - no prebuild needed for code-only changes
```
## Platform Availability
| Platform | Minimum OS | Notes |
|----------|-----------|-------|
| iOS | 14.0+ | Home Screen widgets. Lock Screen in 16.0+. Interactive in 17.0+. Controls in 18.0+. |
| iPadOS | 14.0+ | Home Screen widgets. Lock Screen in 17.0+. |
| macOS | 11.0+ | Notification Center in 11.0+. Desktop in 14.0+. |
| watchOS | 9.0+ | Complications via WidgetKit (replaces ClockKit). Smart Stack in 10.0+. |
| visionOS | 1.0+ | Supported. Can be pinned to surfaces in visionOS 2.0+. |
| tvOS | -- | Not supported. |
## Gotchas
- **Timeline budget is limited.** The system grants approximately 40-70 reloads per day depending on how often the widget is viewed. Calling `reloadTimelines(ofKind:)` from your app is not rate-limited but still consumes from the same budget. Design your reload policy accordingly.
- **Views must be pure SwiftUI.** UIKit views, web views, maps, and cameras are not available in widget extensions. Use SwiftUI equivalents only.
- **Only Button and Toggle are interactive.** iOS 17 interactive widgets support only `Button` and `Toggle` initialized with an `AppIntent`. Text fields, sliders, pickers, and other controls are not supported.
- **App Group is required for shared data.** The widget extension runs in a separate process. You must use an App Group container (`FileManager.containerURL(forSecurityApplicationGroupIdentifier:)` or `UserDefaults(suiteName:)`) to share data between your app and the widget.
- **`containerBackground` is required on iOS 17+.** Starting in iOS 17, every widget view must include a `.containerBackground(for: .widget)` modifier. Without it, the system shows a default background and logs a warning.
- **Network requests in the provider run on a short timer.** If your `getTimeline` makes network requests, they must complete quickly (under ~30 seconds). For longer operations, use background URLSessions and trigger timeline reloads when data arrives.
- **Live Activities require Info.plist key.** You must add `NSSupportsLiveActivities = YES` in your app's (not the extension's) Info.plist, or Live Activities silently fail to start.
- **Live Activity size limit.** Each ActivityKit update payload must be under 4 KB. The Live Activity itself can remain on screen for up to 8 hours on the Dynamic Island and 12 hours on the Lock Screen.
- **Lock Screen widgets use a limited color palette.** Accessory family widgets render in three modes: `vibrant` (iOS), `accented` (watchOS), and `fullColor` (watchOS only). Do not rely on custom colors in accessory widgets on iOS -- they will be tinted by the system.
- **Widget previews require Xcode 15+.** The `#Preview` macro for widgets was introduced in Xcode 15. Older preview syntax using `PreviewProvider` is still supported but deprecated.
- **`systemExtraLarge` is iPad-only.** The `.systemExtraLarge` widget family is only available on iPadOS 15+ and is not offered on iPhone.