Technical Documentation: jacklandrin/OnlySwitch
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (README.md)
OnlySwitch
A smaller menu bar starts with one all-in-one switch.
OnlySwitch puts everyday macOS controls—such as hiding desktop icons, changing appearance, keeping the Mac awake, and managing the notch—into one customizable menu-bar app. Add, remove, and reorder switches and Shortcuts, assign keyboard shortcuts, or place controls on the desktop as widgets.
Installation
Homebrew
brew install only-switchManual download
Download the latest OnlySwitch DMG.
[!IMPORTANT]
Automatic updating is broken in version 2.5.6 and earlier. If you are on one of those versions, update manually or reinstall with Homebrew.
Quick start
- Launch OnlySwitch and enable it under System Settings → Menu Bar if macOS does not show its icon.
- Click the OnlySwitch menu-bar icon to open the control list.
- Open settings to add, remove, and reorder switches or Shortcuts, configure hotkeys, and enable optional features.
- On supported macOS versions, add Only Widget from the widget gallery for desktop or Notification Center access.
To control OnlySwitch from an iPhone or iPad, install OnlyRemote from the App Store, then open iOS Remote in OnlySwitch settings to enable local-network access and pair the device.
What's new in 3.0.0
- Aphrodite: Describe a mini app in natural language, choose a configured AI provider and model, then generate, run, edit, save, share, or import it. Aphrodite requires macOS 26 or later and uses the model-provider settings configured in OnlySwitch.
- Dock Preview: Hover over a Dock app to see thumbnails of its open windows and click a thumbnail to activate that window. Enable the Dock Preview switch and grant Accessibility and Screen Recording permissions when prompted.
- Better switch customization: Built-in switches are grouped by category and can be searched by name from the customization settings.
- Process activation: Click a supported process in the System Monitor CPU or memory lists to bring its owning application to the front.
Features
Highlights
- Import macOS Shortcuts into OnlySwitch and control switches and Shortcuts with keyboard shortcuts.
- Customize which controls appear and arrange them to fit your workflow.
- Use Apple Widgets on macOS Sonoma and later, including Evolution controls.
- Open the compact Only Control interface for a focused control panel.
- Pair OnlyRemote for local-network control from iPhone or iPad.
- Enable the optional Desktop Pet, drag it anywhere, and click it to show or dismiss Only Control.
Built-in switches
| Switch | Status | Switch | Status |
|---|---|---|---|
| Hide desktop | finished | Hide notch | has known issues |
| Dark mode | finished | Low power mode | requires initial authorization |
| Screen Saver | finished | Show Finder Path Bar | finished |
| Night Shift | finished | Mute mic | finished |
| Autohide Dock | finished | Small launchpad icon | finished |
| AirPods | finished | Pomodoro timer | finished |
| Bluetooth | finished | Show extension name | finished |
| Xcode cache | finished | Show user library folder | finished |
| Autohide Menu Bar | finished | Mute | finished |
| Show hidden files | finished | Empty pasteboard | finished |
| Radio Station | finished | Empty trash | finished |
| Keep awake | finished | Show Recent Apps on Dock | finished |
| Spotify | finished | Apple Music | finished |
| Screen Test & Clean | finished | Hide Menu Bar Icons | partially finished |
| FKey | finished | Back Noises | finished |
| Dim Screen | finished | Eject Discs | finished |
| Hide Windows | partially finished | True Tone | finished |
| Top Sticker | partially finished | Key Light | finished |
| Only Agent | finished | Authenticator | finished |
| Sound Mixer | finished | Natural Scrolling | finished |
| Show desktop pet | finished | Codex Usage | finished |
Switches can be added to or removed from the list.
Shortcuts Gallery
Anyone can contribute macOS Shortcuts. See How to contribute to the Shortcuts Gallery.
| Shortcut | Remark | Shortcut | Remark |
|---|---|---|---|
| Toggle Scroll Direction | Monterey | Invert Scroll Direction (Ventura) | Ventura |
| DarkMode Switch | Network Details | ||
| Split-Screen Apps | Passwords | ||
| Google Translate | IP Address Information | ||
| Autohide menu bar in full screen | Monterey | Flush DNS Cache | |
| Do Not Disturb | Monterey or later | Upcoming Events | |
| S-GPT | works with S-GPT Encoder, needs OpenAI API key | S-GPT Encoder |
Shortcuts actions
| Action | Status |
|---|---|
| Get wallpaper image | has known issues |
| Get wallpaper URL | finished |
| Is dark mode | finished |
| Set dark mode | finished |
Evolution
Evolution lets you build custom switches and buttons backed by Shell or AppleScript, invoke them with hotkeys, and share them through the Evolution Gallery. Evolution requires macOS 13 or later, and its settings are implemented with The Composable Architecture.
Evolution scripts run locally with the permissions of OnlySwitch. Review scripts before saving or importing them, especially scripts that execute shell commands, Apple Events, or privileged operations.
Anyone can contribute an Evolution. See How to contribute to the Evolution Gallery.
| Evolution | Remark | Evolution | Remark |
|---|---|---|---|
| Stage Manager | Update Software | installed via App Store | |
| Hide desktop Widget | Sonoma | Hide Desktop Icons | Sonoma |
| Clamshell | Wi-Fi Switch |
Creating an Evolution
Evolution supports two types:
- A Button runs its script when you press Run.
- A Switch has four editable fields:
- Check status: runs when the OnlySwitch list appears or settings change. The debug button shows the script output.
- True condition: defines the output that represents the on state.
- Turn on: changes the state to on.
- Turn off: changes the state to off.
Every script must pass the built-in test before the Evolution can be saved.
Only Agent and OpenClaw
Only Agent lets you describe a task in English and generates AppleScript to control your Mac. When Agent mode is enabled, the generated script is executed immediately. Only Agent supports Ollama, OpenAI, and Gemini providers and requires macOS 26 or later. Provider-backed requests may send your prompt to the provider you select; review both the prompt and generated script before enabling immediate execution.
You can also control OnlySwitch with natural language through OpenClaw. Say things like “empty trash”, “toggle keep awake”, or “turn on dark mode” to trigger the matching switch through a deeplink. See OpenClaw setup for installation as an extra skill directory or under ~/.openclaw/skills.
System Monitor
System Monitor shows live CPU, GPU, memory, disk, and network usage. Open its tab in Only Control, then choose dashboard cards and optional menu-bar indicators under OnlySwitch Settings → System Monitor. CPU and memory cards can expand to show the busiest processes. Network Details includes Wi-Fi or Ethernet information, local and public IP addresses, and—when macOS exposes them—SSID, signal strength, and link rate.
Enabled menu-bar indicators keep sampling while the Only Control popover is closed. The minimum sampling interval is one second. Public-IP lookup sends external requests to ipify's IPv4 and IPv6 endpoints; results are cached for up to five minutes.
Some values may be unavailable when the hardware or macOS does not expose them, including temperature readings. GPU reporting relies on private, undocumented macOS APIs and may stop working after a system update. Network usage is system-wide; per-process network rates are not available.
When a process is associated with a regular macOS application, click its row in the CPU or memory process list to activate that application. Protected or background-only processes may not be activatable.
Codex Usage
Codex Usage is an optional built-in tab showing the signed-in Codex account's plan, remaining five-hour and weekly usage, reset times, available credits, and reset-credit information. Enable the Codex Usage switch to show it in OnlySwitch and Only Control. It uses the existing local Codex sign-in, so OnlySwitch does not require a separate sign-in.
Only Widget
Only Widget can be configured with any built-in switch or button and placed on the desktop or in Notification Center. Clicking a widget triggers the corresponding control. Evolution is supported in widgets.
Screen Test & Clean
Screen Test opens a full-screen solid-color view for checking dead pixels or cleaning the display. Use the left and right arrow keys to cycle through black, white, red, green, and blue.
Known limitations and troubleshooting
OnlySwitch icon missing from the menu bar
OnlySwitch is accessed through its menu-bar icon. On macOS 26.2, the app can appear not to open when it is disabled in the system menu-bar settings.
- Open System Settings → Menu Bar.
- Find OnlySwitch and enable it.
- Click the OnlySwitch icon in the menu bar.
If the icon is still missing, other items may be using all available space. Remove or hide some items. If you use OnlySwitch's collapsing feature, also review Hide Menu Bar Icons. If the issue continues, add your macOS version, OnlySwitch version, and menu-bar setting to issue #188.
Hide Menu Bar Icons
When enabled, items to the left of the divider (arrow) icon are hidden. Hold Command and drag menu-bar icons to configure the hidden section. Settings let you disable the feature or change its auto-hide interval. If the date becomes truncated, choose System Settings → Control Center → Clock Options → Show date → Always. The switch can also be controlled by right-clicking its icons.
On macOS 27 or later, OnlySwitch uses the system's native menu-bar visibility mechanism. The first collapse requests Accessibility permission so OnlySwitch can keep icons to the right of its divider visible. If permission is denied or a macOS update makes the integration unavailable, the menu bar remains unchanged and the switch stays off.
This macOS 27 integration relies on a private API, is not suitable for Mac App Store distribution, and may stop working after a macOS update. While active, macOS also hides Now Playing and may hide Camera, AirDrop, Focus, and Timer; OnlySwitch cannot exempt these system-level items.
For development testing on macOS 27, run the signed build from /Applications, not Xcode's DerivedData directory. The system visibility matcher may fail to recognize a DerivedData-launched app and hide OnlySwitch's icons. Quit the Xcode-run instance before launching the Applications copy.
If both OnlySwitch icons disappear after Command-dragging the divider, re-enable OnlySwitch under System Settings → Menu Bar → Allow in the Menu Bar. macOS 27 can disable the entire app when an older, non-removable status item is dragged; current builds mark both OnlySwitch items as removable to prevent this.
Hide notch
Hide notch appears only on the built-in display of M1 Pro/Max MacBook Pro models. It controls the current desktop rather than every workspace. Dynamic wallpapers are supported but take longer to process.
Low Power Mode
Low Power Mode uses commands that require root access. OnlySwitch asks for initial authorization to install or authorize its privileged helper; subsequent toggles do not prompt again while that authorization remains valid.
AirPods detection
OnlySwitch uses classOfDevice value 2360344 to identify AirPods Pro. Other AirPods generations may report a different value. Reports from AirPods 1–3 owners are welcome; battery-value count may be a possible fallback.
Radio Player
Radio Player supports M3U and AAC streams. If it crashes, include the crash log and stream URL in your report. Disabling the sound-wave effect in Radio settings uses the more stable AVPlayer path. Disabling Radio hides its switch and unregisters it from Now Playing, though macOS may introduce a short delay. Radio lists can be exported and imported.
Widget language after updating
After updating from version 2.5.0, you may need to reset the app language. If widgets do not follow the selected language, quit the Only Widget process so it reloads the setting.
Development
Start with ARCHITECTURE.md, the source of truth for project boundaries and engineering constraints.
OnlySwitch/is the main macOS menu-bar app and owns app-specific UI, persistence, audio, and system integrations.OnlyWidget/is the widget extension and uses extension-safe shared models and effects.OnlySwitchRemote/is the remote-control app and shares protocol contracts without importing main-app implementation details.Modules/is the primary Swift Package boundary for features, domain models, networking, remote transport, and shared utilities.OpenClaw/contains the deeplink-based natural-language integration and its setup documentation.
The app follows The Composable Architecture and uses injected dependencies for effects. Keep business logic out of SwiftUI views, preserve Swift 6 concurrency safety, and treat shell commands, Apple Events, keychain access, networking, system settings, and user permissions as explicit, testable boundaries.
Community and contributions
Join the Telegram group or Discord server.
Pull requests are welcome, especially for bug fixes and additional translations. If you have another idea, open an issue or send the maintainer an email.
Supported languages
English, Simplified Chinese, German, Croatian, Turkish, Polish, Filipino, Dutch, Italian, Russian, Spanish, Japanese, Somali, Korean, French, Ukrainian, Slovak, Portuguese (BR), and Czech.
Donate
If OnlySwitch is useful to you, support its development with a donation.
Press and mentions
Contributors
Translation
| Language | Contributor | Language | Contributor |
|---|---|---|---|
| German | @C0d3Br3aker | Italian | @bellaposa |
| Croatian | @milotype | Russian | @kirillyakopov |
| Turkish | @berkbatuhans | Spanish | @kant |
| Polish | @kpacholak | Japanese | @ShogoKoyama |
| Dutch | Alex | Somali | @abdorizak |
| Filipino | Rosel | Korean | @iosdevted |
| French | @BtKent and Ange Lefrère | Ukrainian | @andryua |
| Slovak | @Svec-Tomas | Portuguese (BR) | @EvertonCa |
| Czech | @ForksApps |
Additional thanks to:
- @wrngwrld for the Radio Player volume slider.
- @kant for syntax fixes.
- @Ryderwe for Authenticator.
- @lou1s19 for Claude models in Only Agent, Sound Mixer, and external-monitor dimming.
- @oecer for settings backup.
Acknowledgements
- Night Shift refers to Nocturnal.
- LaunchAtLogin.
- AirPods Battery refers to AirPods Battery Monitor For Mac OS.
- Dynamic-wallpaper processing refers to this article and wallpapper.
- AlertToast.
- AudioStreamer, modified for live streaming.
- AudioSpectrum, modified for AppKit.
- Alamofire.
- Sounds from Mixkit and Pixabay.
- KeyboardShortcuts.
- Apple Music and Spotify switches refer to SpotMenu.
- The Hide Menu Bar Icons concept comes from Hidden.
- FKey refers to Fluor.
- Hide Windows refers to Later.
- The Composable Architecture.
- Sparkle.
- True Tone refers to Shifty.
- swift-markdown-ui.
- Key Light refers to mac-brightnessctl.
- AIProxySwift.
- ollama-swift.
- CodexKit.
- The Codex Usage experience was inspired by CodexBar.
License
OnlySwitch is available under the MIT License.
Doc Skills README (`doc/skills/README.md`)
Project Skill Notes
Store project-specific skill supplements, routing notes, and reusable instructions here.
These files are not loaded automatically. Load a file only when the task matches its stated scope, such as TCA feature design, Swift 6 concurrency, macOS system effects, remote transport, widget integration, App Store review, or test strategy. Each file should begin with a short When to load section and should avoid duplicating general guidance that is not needed for the relevant task.
Doc Skills App Store Review README (`doc/skills/app-store-review/README.md`)
App Store Review Guidelines Skill
An AI agent skill that exhaustively evaluates iOS, macOS, tvOS, watchOS, and visionOS app code against every point in Apple's App Store Review Guidelines.
Supports: Swift, Objective-C, React Native, and Expo apps
Current through: Apple's official June 8, 2026 App Review Guidelines update
Installation
Codex
Add this repository as a Codex plugin marketplace:
codex plugin marketplace add safaiyeh/app-store-review-skillThen open Codex, run /plugins, choose the App Store Review marketplace, and install the app-store-review plugin. Start a new thread and invoke the skill with $app-store-review or ask for an App Store compliance review.
Codex also discovers direct local skills from ~/.agents/skills, but plugins are the recommended distribution path for reusable skills.
Claude Code Plugin Marketplace
/plugin marketplace add safaiyeh/app-store-review-skill
/plugin install app-store-review@app-store-reviewskills.sh
npx skills add safaiyeh/app-store-review-skillSetup
Supported AI Agents
This skill works with Codex and AI coding agents that support the skills.sh standard:
- Codex
- Claude Code
- Cursor
- Windsurf
- And other compatible agents
How It Works
- Install the skill in your project using the command above
- Start your AI agent in the project directory
- Ask for an App Store review - the agent will automatically load relevant guidelines
- Review the findings - the agent identifies potential rejection issues with code references
Example Prompts
"Review this app for App Store compliance"
"Check if my IAP implementation follows Apple's guidelines"
"Audit the privacy and data collection in this React Native app"
"What App Store issues might block my submission?"Telemetry
The skills CLI collects anonymous usage telemetry. To opt out:
SKILLS_NO_TELEMETRY=1 npx skills add safaiyeh/app-store-review-skillStructure
app-store-review-skill/
├── .agents/
│ └── plugins/marketplace.json # Codex plugin marketplace
├── .claude-plugin/
│ ├── marketplace.json # Claude Code plugin marketplace
│ └── plugin.json # Claude Code plugin manifest
├── .codex-plugin/
│ └── plugin.json # Codex plugin manifest
├── agents/
│ └── openai.yaml # Codex UI metadata
├── SKILL.md # Index with quick reference & checklist
└── rules/
├── 1-safety.md # Section 1: Safety guidelines
├── 2-performance.md # Section 2: Performance guidelines
├── 3-business.md # Section 3: Business guidelines
├── 4-design.md # Section 4: Design guidelines
└── 5-legal.md # Section 5: Legal guidelinesCoverage
This skill covers ALL 5 major sections with EVERY guideline point:
[1. Safety](rules/1-safety.md)
- 1.1 Objectionable Content (1.1.1-1.1.7)
- 1.2 User-Generated Content & Creator Content
- 1.3 Kids Category (parental gates, privacy, analytics)
- 1.4 Physical Harm (medical apps, drug dosage, substances)
- 1.5 Developer Information
- 1.6 Data Security
- 1.7 Reporting Criminal Activity
[2. Performance](rules/2-performance.md)
- 2.1 App Completeness (final versions, IAP)
- 2.2 Beta Testing
- 2.3 Accurate Metadata (2.3.1-2.3.13)
- 2.4 Hardware Compatibility (2.4.1-2.4.5)
- 2.5 Software Requirements (2.5.1-2.5.18)
[3. Business](rules/3-business.md)
- 3.1 Payments (IAP, subscriptions, external links, crypto)
- 3.1.1-3.1.5 In-App Purchase rules
- 3.2 Other Business Models (acceptable/unacceptable)
[4. Design](rules/4-design.md)
- 4.1 Copycats
- 4.2 Minimum Functionality
- 4.3 Spam
- 4.4 Extensions (keyboard, Safari)
- 4.5 Apple Sites and Services
- 4.7 Mini Apps, Chatbots, Game Emulators
- 4.8 Login Services
- 4.9 Apple Pay
- 4.10 Monetizing Built-In Capabilities
[5. Legal](rules/5-legal.md)
- 5.1 Privacy (data collection, use, sharing, health, kids, location)
- 5.2 Intellectual Property
- 5.3 Gaming, Gambling, Lotteries
- 5.4 VPN Apps
- 5.5 Mobile Device Management
- 5.6 Developer Code of Conduct (reviews, developer identity, discovery fraud, app quality)
Features
- Modular structure - Agent loads only relevant sections
- 2000+ lines of comprehensive guidelines
- Checklists for every guideline point
- Code patterns for Swift AND React Native/Expo
- Package references for both Expo and bare React Native
- Quick reference for high-risk rejection patterns
- Pre-submission checklist in main SKILL.md
React Native / Expo Support
Each rule file includes:
- TypeScript/JavaScript code patterns to flag
- Expo package recommendations (preferred)
- Bare React Native package alternatives
- React Native-specific checklists
Key packages covered:
react-native-purchases(RevenueCat — recommended for IAP) /react-native-iapexpo-tracking-transparency/react-native-tracking-transparencyexpo-secure-store/react-native-keychainexpo-apple-authentication/@invertase/react-native-apple-authenticationexpo-local-authentication/react-native-biometrics
What It Checks
Critical Issues (Immediate Rejection)
- Private API usage
- Hardcoded secrets/credentials
- External payment for digital goods
- On-device cryptocurrency mining
- Dynamic code execution
High-Risk Issues
- Missing App Tracking Transparency
- Account creation without deletion
- IAP without restore purchases
- UGC without moderation
- UGC without removal workflows or a compliance improvement plan
- Kids apps without parental gates
- Live Activities or push notifications used for spam, phishing, or unsolicited messages
Medium-Risk Issues
- Vague purpose strings
- Over-requesting permissions
- Unjustified background modes
- References to other platforms
- Custom review prompts instead of the system API
When It Triggers
The skill activates when working on:
- App Store submission preparation
- Code compliance review
- Payment/StoreKit implementation
- Privacy and data handling
- User-generated content features
- Kids Category apps
- Health/medical apps
- VPN/MDM apps
- Gambling/lottery apps
License
MIT
Doc Skills App Store Review SKILL (`doc/skills/app-store-review/SKILL.md`)
name: app-store-review license: MIT metadata: author: safaiyeh version: "1.1.0"
App Store Review Guidelines Checker
Comprehensive guide for evaluating iOS, macOS, tvOS, watchOS, and visionOS app code against Apple's App Store Review Guidelines. This skill covers EVERY guideline point to identify potential rejection issues before submission.
Supports: Swift, Objective-C, React Native, and Expo apps
Guidelines current through: Apple's June 8, 2026 App Review Guidelines update.
When to Apply
Use this skill when:
- Preparing an app for App Store submission
- Reviewing code for compliance issues
- Implementing features that may trigger review concerns
- Auditing existing apps for guideline violations
- Building features involving payments, user data, or sensitive content
Guideline Sections
Read individual rule files for detailed explanations, checklists, and code examples:
| Section | File | Key Topics |
|---|---|---|
| 1. Safety | rules/1-safety.md | Objectionable content, UGC moderation, Kids Category, physical harm, data security |
| 2. Performance | rules/2-performance.md | App completeness, metadata accuracy, hardware compatibility, software requirements |
| 3. Business | rules/3-business.md | In-app purchase, subscriptions, cryptocurrencies, other business models |
| 4. Design | rules/4-design.md | Copycats, minimum functionality, spam, extensions, Apple services, login |
| 5. Legal | rules/5-legal.md | Privacy, data collection, intellectual property, gambling, VPN, MDM, developer code of conduct |
Risk Levels by Category
| Risk Level | Category | Section | Common Rejection Reasons |
|---|---|---|---|
| CRITICAL | Privacy & Data | 5.1 | Missing privacy policy, unauthorized data collection |
| CRITICAL | Payments | 3.1 | Bypassing in-app purchase, unclear pricing |
| HIGH | Safety | 1.x | Objectionable content, inadequate UGC moderation |
| HIGH | Performance | 2.x | Crashes, incomplete features, deprecated APIs |
| MEDIUM | Design | 4.x | Copycat apps, minimum functionality issues |
| MEDIUM | Legal | 5.x | IP violations, gambling without license |
Quick Reference: High-Risk Rejection Patterns
Critical Issues (Immediate Rejection)
Swift:
// 🔴 Private API usage
let selector = NSSelectorFromString("_privateMethod")
// 🔴 Hardcoded secrets
let apiKey = "sk_live_xxxxx"
// 🔴 External payment for digital goods
func purchaseDigitalContent() {
openStripeCheckout() // Use StoreKit instead
}React Native / Expo:
// 🔴 Hardcoded secrets in JS bundle
const API_KEY = 'sk_live_xxxxx'; // REJECTION
// 🔴 External payment for digital goods
Linking.openURL('https://stripe.com/checkout'); // Use react-native-iap
// 🔴 Dynamic code execution
eval(downloadedCode); // REJECTION
// 🔴 Major feature changes via CodePush/expo-updates
// OTA updates for bug fixes only, not new features!High-Risk Issues
Swift:
// 🟡 Missing ATT when using ad SDKs
import FacebookAds // Without ATTrackingManager
// 🟡 Account creation without deletion
func createAccount() { } // But no deleteAccount()React Native / Expo:
// 🟡 Missing ATT (use expo-tracking-transparency)
import analytics from '@react-native-firebase/analytics';
analytics().logEvent('event'); // Without ATT prompt = REJECTION
// 🟡 Account deletion via website only
Linking.openURL('https://example.com/delete'); // Must be in-app!
// 🟡 Social login without a privacy-preserving alternative (4.8)
<GoogleSigninButton /> // Also offer a login meeting 4.8 criteria
// (Sign in with Apple is the simplest option)
// 🟡 Custom review prompts (5.6.1)
showCustomAlert('Rate us 5 stars!'); // Use StoreReview.requestReview()Medium-Risk Issues
// 🟠 Vague purpose strings in Info.plist
"This app needs camera access" // Be specific!
// 🟠 WebView-only app (insufficient native functionality)
const App = () => <WebView source={{ uri: 'https://site.com' }} />;
// 🟠 References to Android in iOS app
const text = "Also available on Android"; // REJECTION
// 🟠 console.log in production
console.log('debug'); // Remove or wrap in __DEV__Pre-Submission Checklist
Privacy (Section 5.1)
- Privacy policy link in App Store Connect
- Privacy policy link accessible within app
- All purpose strings are specific and accurate
- App Privacy details completed in App Store Connect
- ATT implemented if tracking users
- Account deletion available if accounts exist
- Data minimization - only requesting necessary permissions
- User consent obtained before data collection
Payments (Section 3.1)
- StoreKit used for all digital purchases
- Restore purchases implemented
- Subscription terms clearly displayed
- Loot box odds disclosed if applicable
- No external payment for digital goods (unless entitled)
- Credits/currencies don't expire
Safety (Section 1.x)
- No objectionable content
- UGC moderation implemented (filter, report, block, contact)
- UGC violations can be removed quickly and backed by a remediation plan
- Kids and teens receive age-appropriate experiences inside the app
- Parental gates for Kids Category apps
- No false information or prank features
- Medical disclaimers if applicable
- No substance promotion
Performance (Section 2.x)
- No crashes or bugs
- All features complete and functional
- No placeholder content
- IPv6 tested and functional
- Demo account provided if needed
- Using only public APIs
- No deprecated APIs
- Proper background mode usage
Design (Section 4.x)
- Sufficient native functionality (not just web wrapper)
- No copycat concerns
- Original app name and branding
- No duplicate Bundle ID spam or low-effort saturated-category clones
- Live Activities, push notifications, and Game Center are not used for spam, phishing, or unsolicited messages
- Extensions comply with guidelines
- Login alternatives if using social login
- Not monetizing built-in capabilities
Legal (Section 5.x)
- No unlicensed third-party content
- Proper Apple trademark usage
- Gambling license if applicable (with real location-based geo-restriction)
- VPN uses NEVPNManager API
- COPPA/GDPR compliance for kids
- Review prompts use the system API only (no custom prompts)
- No review, chart, search, or referral manipulation (5.6)
References
- App Store Review Guidelines
- Apple Developer Program License Agreement
- June 8, 2026 App Review Guidelines update
- Human Interface Guidelines
- App Store Connect Help
- Apple Developer Documentation
Doc Skills Swift Concurrency Pro SKILL (`doc/skills/swift-concurrency-pro/SKILL.md`)
name: swift-concurrency-pro license: MIT metadata: author: Paul Hudson version: "1.0"
Review Swift concurrency code for correctness, modern API usage, and adherence to project conventions. Report only genuine problems - do not nitpick or invent issues.
Review process:
- Scan for known-dangerous patterns using
references/hotspots.mdto prioritize what to inspect. - Check for recent Swift 6.2 concurrency behavior using
references/new-features.md. - Validate actor usage for reentrancy and isolation correctness using
references/actors.md. - Ensure structured concurrency is preferred over unstructured where appropriate using
references/structured.md. - Check unstructured task usage for correctness using
references/unstructured.md. - Verify cancellation is handled correctly using
references/cancellation.md. - Validate async stream and continuation usage using
references/async-streams.md. - Check bridging code between sync and async worlds using
references/bridging.md. - Review any legacy concurrency migrations using
references/interop.md. - Cross-check against common failure modes using
references/bug-patterns.md. - If the project has strict-concurrency errors, map diagnostics to fixes using
references/diagnostics.md. - If reviewing tests, check async test patterns using
references/testing.md.
If doing a partial review, load only the relevant reference files.
Core Instructions
- Target Swift 6.2 or later with strict concurrency checking.
- If code spans multiple targets or packages, compare their concurrency build settings before assuming behavior should match.
- Prefer structured concurrency (task groups) over unstructured (
Task {}). - Prefer Swift concurrency over Grand Central Dispatch for new code. GCD is still acceptable in low-level code, framework interop, or performance-critical synchronous work where queues and locks are the right tool – don't flag these as errors.
- If an API offers both
async/awaitand closure-based variants, always preferasync/await. - Do not introduce third-party concurrency frameworks without asking first.
- Do not suggest
@unchecked Sendableto fix compiler errors. It silences the diagnostic without fixing the underlying race. Prefer actors, value types, orsendingparameters instead. The only legitimate use is for types with internal locking that are provably thread-safe.
Output Format
Organize findings by file. For each issue:
- State the file and relevant line(s).
- Name the rule being violated.
- Show a brief before/after code fix.
Skip files with no issues. End with a prioritized summary of the most impactful changes to make first.
Example output:
DataLoader.swift
Line 18: Actor reentrancy – state may have changed across the await.
// Before
actor Cache {
var items: [String: Data] = [:]
func fetch(_ key: String) async throws -> Data {
if items[key] == nil {
items[key] = try await download(key)
}
return items[key]!
}
}
// After
actor Cache {
var items: [String: Data] = [:]
func fetch(_ key: String) async throws -> Data {
if let existing = items[key] { return existing }
let data = try await download(key)
items[key] = data
return data
}
}Line 34: Use withTaskGroup instead of creating tasks in a loop.
// Before
for url in urls {
Task { try await fetch(url) }
}
// After
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask { try await fetch(url) }
}
for try await result in group {
process(result)
}
}Summary
- Correctness (high): Actor reentrancy bug on line 18 may cause duplicate downloads and a force-unwrap crash.
- Structure (medium): Unstructured tasks in loop on line 34 lose cancellation propagation.
End of example.
References
references/hotspots.md- Grep targets for code review: known-dangerous patterns and what to check for each.references/new-features.md- Swift 6.2 changes that alter review advice: default actor isolation, isolated conformances, caller-actor async behavior,@concurrent,Task.immediate, task naming, and priority escalation.references/actors.md- Actor reentrancy, shared-state annotations, global actor inference, and isolation patterns.references/structured.md- Task groups over loops, discarding task groups, concurrency limits.references/unstructured.md- Task vs Task.detached, when Task {} is a code smell.references/cancellation.md- Cancellation propagation, cooperative checking, broken cancellation patterns.references/async-streams.md- AsyncStream factory, continuation lifecycle, back-pressure.references/bridging.md- Checked continuations, wrapping legacy APIs,@unchecked Sendable.references/interop.md- Migrating from GCD,Mutex/locks, completion handlers, delegates, and Combine.references/bug-patterns.md- Common concurrency failure modes and their fixes.references/diagnostics.md- Strict-concurrency compiler errors, protocol conformance fixes, and likely remedies.references/testing.md- Async test strategy with Swift Testing, race detection, avoiding timing-based tests.
Doc Skills Swift Testing Pro SKILL (`doc/skills/swift-testing-pro/SKILL.md`)
name: swift-testing-pro license: MIT metadata: author: Paul Hudson version: "1.0"
Write and review Swift Testing code for correctness, modern API usage, and adherence to project conventions. Report only genuine problems - do not nitpick or invent issues.
Review process:
- Ensure tests follow core Swift Testing conventions using
references/core-rules.md. - Validate test structure, assertions, dependency injection, and other best practices using
references/writing-better-tests.md. - Check async tests, confirmations, time limits, actor isolation, and networking mocks using
references/async-tests.md. - Ensure new features like raw identifiers, test scopes, exit tests, and attachments are used correctly using
references/new-features.md. - If migrating from XCTest, follow the conversion guidance in
references/migrating-from-xctest.md.
If doing partial work, load only the relevant reference files.
Core Instructions
- Target Swift 6.2 or later, using modern Swift concurrency.
- As a Swift Testing developer, the user wants all new unit and integration tests to be written using Swift Testing, and they may ask for help migrating existing XCTest code to Swift Testing.
- Swift Testing does not support UI tests – XCTest must be used there.
- Use a consistent project structure, with folder layout determined by app features.
Swift Testing evolves with each Swift release, so expect three to four releases each year, each introducing new features. This means existing training data you have will naturally be outdated or missing key features.
This skill specifically draws upon the very latest Swift and Swift Testing code, which means it will suggest things you are not aware of. Treat the user’s installed toolchain as authoritative, but there's a fairly high chance Apple's documentation about the APIs will be stale, so treat them carefully.
Output Format
If the user asks for a review, organize findings by file. For each issue:
- State the file and relevant line(s).
- Name the rule being violated.
- Show a brief before/after code fix.
Skip files with no issues. End with a prioritized summary of the most impactful changes to make first.
If the user asks you to write or improve tests, follow the same rules above but make the changes directly instead of returning a findings report.
Example output:
UserTests.swift
Line 5: Use struct, not class, for test suites.
// Before
class UserTests: XCTestCase {
// After
struct UserTests {Line 12: Use #expect instead of XCTAssertEqual.
// Before
XCTAssertEqual(user.name, "Taylor")
// After
#expect(user.name == "Taylor")Line 30: Use #require for preconditions, not #expect.
// Before
#expect(users.isEmpty == false)
let first = users.first!
// After
let first = try #require(users.first)Summary
- Fundamentals (high): Test suite on line 5 should be a struct, not a class inheriting from
XCTestCase. - Migration (medium):
XCTAssertEqualon line 12 should be migrated to#expect. - Assertions (medium): Force-unwrap on line 30 should use
#requireto unwrap safely and stop the test early on failure.
End of example.
References
references/core-rules.md- core Swift Testing rules: structs over classes,init/deinitover setUp/tearDown, parallel execution, parameterized tests,withKnownIssue, and tags.references/writing-better-tests.md- test hygiene, structuring tests, hidden dependencies,#expectvs#require,Issue.record(),#expect(throws:), and verification methods.references/async-tests.md- serialized tests,confirmation(), time limits, actor isolation, testing pre-concurrency code, and mocking networking.references/new-features.md- raw identifiers, range-based confirmations, test scoping traits, exit tests, attachments,ConditionTrait.evaluate(), and the updated#expect(throws:)return value.references/migrating-from-xctest.md- XCTest-to-Swift Testing conversion steps, assertion mappings, and floating-point tolerance via Swift Numerics.
Doc Skills Swiftui Pro SKILL (`doc/skills/swiftui-pro/SKILL.md`)
name: swiftui-pro license: MIT metadata: author: Paul Hudson version: "1.0"
Review Swift and SwiftUI code for correctness, modern API usage, and adherence to project conventions. Report only genuine problems - do not nitpick or invent issues.
Review process:
- Check for deprecated API using
references/api.md. - Check that views, modifiers, and animations have been written optimally using
references/views.md. - Validate that data flow is configured correctly using
references/data.md. - Ensure navigation is updated and performant using
references/navigation.md. - Ensure the code uses designs that are accessible and compliant with Apple’s Human Interface Guidelines using
references/design.md. - Validate accessibility compliance including Dynamic Type, VoiceOver, and Reduce Motion using
references/accessibility.md. - Ensure the code is able to run efficiently using
references/performance.md. - Quick validation of Swift code using
references/swift.md. - Final code hygiene check using
references/hygiene.md.
If doing a partial review, load only the relevant reference files.
Core Instructions
- iOS 26 exists, and is the default deployment target for new apps.
- Target Swift 6.2 or later, using modern Swift concurrency.
- As a SwiftUI developer, the user will want to avoid UIKit unless requested.
- Do not introduce third-party frameworks without asking first.
- Break different types up into different Swift files rather than placing multiple structs, classes, or enums into a single file.
- Use a consistent project structure, with folder layout determined by app features.
Output Format
Organize findings by file. For each issue:
- State the file and relevant line(s).
- Name the rule being violated (e.g., "Use
foregroundStyle()instead offoregroundColor()"). - Show a brief before/after code fix.
Skip files with no issues. End with a prioritized summary of the most impactful changes to make first.
Example output:
ContentView.swift
Line 12: Use foregroundStyle() instead of foregroundColor().
// Before
Text("Hello").foregroundColor(.red)
// After
Text("Hello").foregroundStyle(.red)Line 24: Icon-only button is bad for VoiceOver - add a text label.
// Before
Button(action: addUser) {
Image(systemName: "plus")
}
// After
Button("Add User", systemImage: "plus", action: addUser)Line 31: Avoid Binding(get:set:) in view body - use @State with onChange() instead.
// Before
TextField("Username", text: Binding(
get: { model.username },
set: { model.username = $0; model.save() }
))
// After
TextField("Username", text: $model.username)
.onChange(of: model.username) {
model.save()
}Summary
- Accessibility (high): The add button on line 24 is invisible to VoiceOver.
- Deprecated API (medium):
foregroundColor()on line 12 should beforegroundStyle(). - Data flow (medium): The manual binding on line 31 is fragile and harder to maintain.
End of example.
References
references/accessibility.md- Dynamic Type, VoiceOver, Reduce Motion, and other accessibility requirements.references/api.md- updating code for modern API, and the deprecated code it replaces.references/design.md- guidance for building accessible apps that meet Apple’s Human Interface Guidelines.references/hygiene.md- making code compile cleanly and be maintainable in the long term.references/navigation.md- navigation usingNavigationStack/NavigationSplitView, plus alerts, confirmation dialogs, and sheets.references/performance.md- optimizing SwiftUI code for maximum performance.references/data.md- data flow, shared state, and property wrappers.references/swift.md- tips on writing modern Swift code, including using Swift Concurrency effectively.references/views.md- view structure, composition, and animation.
Doc Skills Writing Plans Plan Document Reviewer Prompt (`doc/skills/writing-plans/plan-document-reviewer-prompt.md`)
Plan Document Reviewer Prompt Template
Use this template when dispatching a plan document reviewer subagent.
Purpose: Verify the plan is complete, matches the spec, and has proper task decomposition.
Dispatch after: The complete plan is written.
Subagent (general-purpose):
description: "Review plan document"
prompt: |
You are a plan document reviewer. Verify this plan is complete and ready for implementation.
**Plan to review:** [PLAN_FILE_PATH]
**Spec for reference:** [SPEC_FILE_PATH]
## What to Check
| Category | What to Look For |
|----------|------------------|
| Completeness | TODOs, placeholders, incomplete tasks, missing steps |
| Spec Alignment | Plan covers spec requirements, no major scope creep |
| Task Decomposition | Tasks have clear boundaries, steps are actionable |
| Buildability | Could an engineer follow this plan without getting stuck? |
## Calibration
**Only flag issues that would cause real problems during implementation.**
An implementer building the wrong thing or getting stuck is an issue.
Minor wording, stylistic preferences, and "nice to have" suggestions are not.
Approve unless there are serious gaps — missing requirements from the spec,
contradictory steps, placeholder content, or tasks so vague they can't be acted on.
## Output Format
## Plan Review
**Status:** Approved | Issues Found
**Issues (if any):**
- [Task X, Step Y]: [specific issue] - [why it matters for implementation]
**Recommendations (advisory, do not block approval):**
- [suggestions for improvement]Reviewer returns: Status, Issues (if any), Recommendations
Doc Skills Writing Plans SKILL (`doc/skills/writing-plans/SKILL.md`)
name: writing-plans
Writing Plans
Overview
Write comprehensive implementation plans assuming the engineer has zero context for our codebase and questionable taste. Document everything they need to know: which files to touch for each task, code, testing, docs they might need to check, how to test it. Give them the whole plan as bite-sized tasks. DRY. YAGNI. TDD. Frequent commits.
Assume they are a skilled developer, but know almost nothing about our toolset or problem domain. Assume they don't know good test design very well.
Announce at start: "I'm using the writing-plans skill to create the implementation plan."
Context: If working in an isolated worktree, it should have been created via the superpowers:using-git-worktrees skill at execution time.
Save plans to: doc/plan/YYYY-MM-DD-<feature-name>.md
- This project path is mandatory for OnlySwitch implementation plans.
Scope Check
If the spec covers multiple independent subsystems, it should have been broken into sub-project specs during brainstorming. If it wasn't, suggest breaking this into separate plans — one per subsystem. Each plan should produce working, testable software on its own.
File Structure
Before defining tasks, map out which files will be created or modified and what each one is responsible for. This is where decomposition decisions get locked in.
- Design units with clear boundaries and well-defined interfaces. Each file should have one clear responsibility.
- You reason best about code you can hold in context at once, and your edits are more reliable when files are focused. Prefer smaller, focused files over large ones that do too much.
- Files that change together should live together. Split by responsibility, not by technical layer.
- In existing codebases, follow established patterns. If the codebase uses large files, don't unilaterally restructure - but if a file you're modifying has grown unwieldy, including a split in the plan is reasonable.
This structure informs the task decomposition. Each task should produce self-contained changes that make sense independently.
Task Right-Sizing
A task is the smallest unit that carries its own test cycle and is worth a
fresh reviewer's gate. When drawing task boundaries: fold setup,
configuration, scaffolding, and documentation steps into the task whose
deliverable needs them; split only where a reviewer could meaningfully
reject one task while approving its neighbor. Each task ends with an
independently testable deliverable.
Bite-Sized Task Granularity
Each step is one action (2-5 minutes):
- "Write the failing test" - step
- "Run it to make sure it fails" - step
- "Implement the minimal code to make the test pass" - step
- "Run the tests and make sure they pass" - step
- "Commit" - step
Plan Document Header
Every plan MUST start with this header:
# [Feature Name] Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** [One sentence describing what this builds]
**Architecture:** [2-3 sentences about approach]
**Tech Stack:** [Key technologies/libraries]
## Global Constraints
[The spec's project-wide requirements — version floors, dependency limits,
naming and copy rules, platform requirements — one line each, with exact
values copied verbatim from the spec. Every task's requirements implicitly
include this section.]
---Task Structure
### Task N: [Component Name]
**Files:**
- Create: `exact/path/to/file.py`
- Modify: `exact/path/to/existing.py:123-145`
- Test: `tests/exact/path/to/test.py`
**Interfaces:**
- Consumes: [what this task uses from earlier tasks — exact signatures]
- Produces: [what later tasks rely on — exact function names, parameter
and return types. A task's implementer sees only their own task; this
block is how they learn the names and types neighboring tasks use.]
- [ ] **Step 1: Write the failing test**
```python
def test_specific_behavior():
result = function(input)
assert result == expected
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/path/test.py::test_name -v`
Expected: FAIL with "function not defined"
- [ ] **Step 3: Write minimal implementation**
```python
def function(input):
return expected
```
- [ ] **Step 4: Run test to verify it passes**
Run: `pytest tests/path/test.py::test_name -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add tests/path/test.py src/path/file.py
git commit -m "feat: add specific feature"
```No Placeholders
Every step must contain the actual content an engineer needs. These are plan failures — never write them:
- "TBD", "TODO", "implement later", "fill in details"
- "Add appropriate error handling" / "add validation" / "handle edge cases"
- "Write tests for the above" (without actual test code)
- "Similar to Task N" (repeat the code — the engineer may be reading tasks out of order)
- Steps that describe what to do without showing how (code blocks required for code steps)
- References to types, functions, or methods not defined in any task
Self-Review
After writing the complete plan, look at the spec with fresh eyes and check the plan against it. This is a checklist you run yourself — not a subagent dispatch.
1. Spec coverage: Skim each section/requirement in the spec. Can you point to a task that implements it? List any gaps.
2. Placeholder scan: Search your plan for red flags — any of the patterns from the "No Placeholders" section above. Fix them.
3. Type consistency: Do the types, method signatures, and property names you used in later tasks match what you defined in earlier tasks? A function called clearLayers() in Task 3 but clearFullLayers() in Task 7 is a bug.
If you find issues, fix them inline. No need to re-review — just fix and move on. If you find a spec requirement with no task, add the task.
Execution Handoff
After saving the plan, offer execution choice:
"Plan complete and saved to doc/plan/<filename>.md. Two execution options:
1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration
2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints
Which approach?"
If Subagent-Driven chosen:
- REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
- Fresh subagent per task + two-stage review
If Inline Execution chosen:
- REQUIRED SUB-SKILL: Use superpowers:executing-plans
- Batch execution with checkpoints for review
Doc Skills App Store Review Rules 1 Safety (`doc/skills/app-store-review/rules/1-safety.md`)
name: safety
1. SAFETY
When people install an app from the App Store, they want to feel confident that it's safe to do so. Apps looking to shock and offend people don't belong on the App Store.
Current Apple Emphasis
Apple's June 8, 2026 update revised kid and teen safety guidance. When reviewing an app that children or teens may use, do not rely only on platform parental controls:
- Confirm the in-app experience is age-appropriate for the audience actually using it
- Check onboarding, discovery, recommendations, messaging, ads, and UGC surfaces for age-inappropriate exposure
- Treat inaccurate age ratings or weak age gates as safety risks, not just metadata issues
1.1 Objectionable Content
Apps should not include content that is offensive, insensitive, upsetting, intended to disgust, in exceptionally poor taste, or just plain creepy.
1.1.1 Defamatory, Discriminatory, or Mean-Spirited Content
REJECT if app contains:
- Defamatory, discriminatory, or mean-spirited content — including references or commentary about religion, race, sexual orientation, gender, national/ethnic origin, or other targeted groups — particularly if the app is likely to humiliate, intimidate, or harm a targeted individual or group
- Discriminatory language in any text, assets, or user-facing content
Note: Merely mentioning religion, race, etc. is NOT a violation — the content must be defamatory, discriminatory, or mean-spirited.
Exception: Professional political satirists and humorists are generally exempt.
Code patterns to flag:
// Swift - FLAG: Review all hardcoded strings for discriminatory content
let strings = ["message", "alert", "notification"] // Audit for offensive language
// FLAG: User-facing text that targets groups
// Search for terms related to: religion, race, gender, nationality, orientation// React Native - FLAG: Review all hardcoded strings
const strings = {
message: "...", // Audit for offensive language
alert: "...",
};
// FLAG: Check i18n/localization files for discriminatory content
// en.json, es.json, etc.1.1.2 Realistic Portrayals of Violence
REJECT if app contains:
- Realistic portrayals of people or animals being killed, maimed, tortured, or abused
- Content that encourages violence
- "Enemies" within the context of a game that solely target a specific race, culture, real government, corporation, or any other real entity
Code patterns to flag:
// Swift - FLAG: Violence-related asset names
"kill_", "death_", "blood_", "gore_", "torture_", "maim_"// React Native - FLAG: Violence-related assets
import killIcon from './assets/kill_enemy.png'; // REVIEW
import deathAnimation from './assets/death.json'; // REVIEW
// FLAG: Enemy targeting specific real groups
type EnemyType = 'specificNationality' | 'specificReligion'; // REJECTION RISK1.1.3 Weapons and Dangerous Objects
REJECT if app:
- Depicts content encouraging illegal or reckless use of weapons and dangerous objects
- Facilitates the purchase of firearms or ammunition
Code patterns to flag:
// Swift - FLAG: Weapon purchase functionality
func purchaseFirearm()
func buyAmmunition()
// FLAG: Links to weapon retailers
"gunbroker.com", "ammo.com"// React Native - FLAG: Weapon purchase functionality
const purchaseFirearm = async () => { }; // REJECTION
const buyAmmunition = async () => { }; // REJECTION
// FLAG: Links to weapon retailers
Linking.openURL('https://gunbroker.com'); // REJECTION1.1.4 Overtly Sexual or Pornographic Material
REJECT if app contains:
- Explicit descriptions or displays of sexual organs or activities intended to stimulate erotic rather than aesthetic or emotional feelings
- "Hookup" apps that may include pornography
- Content that could facilitate prostitution or human trafficking/exploitation
Code patterns to flag:
// Swift - FLAG: Adult content indicators
"nsfw", "adult_content", "explicit", "xxx"
// FLAG: Dating/hookup functionality without moderation
func matchUsers() // Ensure proper moderation exists// React Native - FLAG: Adult content indicators
const isNSFW = true; // REVIEW
const contentRating = 'explicit'; // REVIEW
// FLAG: Dating/hookup functionality without moderation
const matchUsers = async () => { }; // Ensure moderation exists1.1.5 Inflammatory Religious Commentary
REJECT if app contains:
- Inflammatory religious commentary
- Inaccurate or misleading quotations of religious texts
1.1.6 False Information and Features
REJECT if app:
- Provides inaccurate device data
- Contains trick/joke functionality (e.g., fake location trackers)
- Enables anonymous or prank phone calls or SMS/MMS messaging
Note: Stating "for entertainment purposes" does NOT overcome this guideline.
Code patterns to flag:
// Swift - FLAG: Fake device data
func getFakeLocation() -> CLLocation // REJECTION
func spoofDeviceID() -> String // REJECTION
// FLAG: Prank call/SMS functionality
func makeAnonymousCall() // REJECTION
func sendPrankSMS() // REJECTION// React Native - FLAG: Fake device data
const getFakeLocation = (): Location => { }; // REJECTION
const spoofDeviceID = (): string => { }; // REJECTION
// FLAG: Using react-native-device-info to spoof
import DeviceInfo from 'react-native-device-info';
const fakeId = 'spoofed-id'; // Don't return fake data
// FLAG: Prank functionality
const makeAnonymousCall = () => { }; // REJECTION
const sendPrankSMS = () => { }; // REJECTION1.1.7 Harmful Concepts Exploiting Current Events
REJECT if app:
- Capitalizes or seeks to profit on violent conflicts
- Exploits terrorist attacks
- Exploits epidemics
- Contains other harmful concepts capitalizing on recent or current events
1.2 User-Generated Content
Apps with user-generated content present particular challenges, ranging from intellectual property infringement to anonymous bullying. To prevent abuse, apps with UGC or social networking services MUST include:
Required UGC Features (ALL MANDATORY)
- Content Filtering: A method for filtering objectionable material from being posted
- Reporting Mechanism: A way to report offensive content with timely responses
- User Blocking: The ability to block abusive users from the service
- Contact Information: Published contact information so users can easily reach you
Swift implementation:
// REQUIRED: Content filtering
protocol ContentFilter {
func filterContent(_ content: String) -> FilterResult
func moderateImage(_ image: UIImage) -> ModerationResult
}
// REQUIRED: Report mechanism
func reportContent(
contentId: String,
reason: ReportReason,
additionalInfo: String?
) async throws
// REQUIRED: Block user
func blockUser(_ userId: String) async throws
func getBlockedUsers() -> [User]
// REQUIRED: Support contact
var supportEmail: String { get }
var supportURL: URL { get }React Native implementation:
// REQUIRED: Content filtering
interface ContentFilter {
filterContent(content: string): Promise<FilterResult>;
moderateImage(imageUri: string): Promise<ModerationResult>;
}
// Example using a moderation API
const filterContent = async (content: string): Promise<FilterResult> => {
// Use a service like OpenAI Moderation, Perspective API, or custom ML
const response = await fetch('https://api.example.com/moderate', {
method: 'POST',
body: JSON.stringify({ content }),
});
return response.json();
};
// REQUIRED: Report mechanism
const reportContent = async (
contentId: string,
reason: ReportReason,
additionalInfo?: string
): Promise<void> => {
await api.post('/reports', { contentId, reason, additionalInfo });
};
// REQUIRED: Block user
const blockUser = async (userId: string): Promise<void> => {
await api.post(`/users/${userId}/block`);
};
const getBlockedUsers = async (): Promise<User[]> => {
return api.get('/users/blocked');
};
// REQUIRED: Support contact - expose in app
const SUPPORT_EMAIL = '[email protected]';
const SUPPORT_URL = 'https://example.com/support';Content That Results in Removal
Apps primarily used for the following will be removed WITHOUT notice:
- Pornographic content
- Chatroulette-style experiences
- Random or anonymous chat
- Objectification of real people (e.g., "hot-or-not" voting)
- Making physical threats
- Bullying
Incidental Mature "NSFW" Content
If your app includes user-generated content from a web-based service:
- Mature content MUST be hidden by default
- Only displayed when user explicitly enables it via your website
// React Native - REQUIRED: NSFW content hidden by default
const [showMatureContent, setShowMatureContent] = useState(false);
// Must be enabled via the developer's website, not an in-app toggle
// Check user preference from backend (set via website)
useEffect(() => {
const checkMatureContentSetting = async () => {
const setting = await api.get('/user/settings/mature-content');
setShowMatureContent(setting.enabled); // Set via website only
};
checkMatureContentSetting();
}, []);Developer Responsibility for Violating UGC
Apple's current 1.2 language makes the developer responsible for removing content that violates the guideline, the app's terms of service, or community standards. If Apple finds violating content, expect to remove it and explain how compliance will improve.
- Violating content must be removable quickly when detected or reported
- App Review may require a concrete compliance improvement plan before the app can stay on the App Store
- Egregious or repeated UGC failures can trigger immediate app removal and Developer Program removal
Review for:
- Admin/moderator tooling that can remove posts, comments, profiles, messages, media, and creator content
- Abuse queues or dashboards with timestamps, severity, status, and reviewer actions
- Escalation paths for threats, bullying, sexual content, child safety issues, and repeat offenders
- Audit logs that can support an App Review response or remediation plan
- Clear terms/community standards surfaced to users and enforced consistently
// React Native/backend contract - REQUIRED for UGC apps
type ModerationStatus = 'pending' | 'actioned' | 'dismissed' | 'escalated';
interface ModerationAction {
reportId: string;
contentId: string;
action: 'remove_content' | 'suspend_user' | 'age_restrict' | 'dismiss';
reason: string;
status: ModerationStatus;
}
const removeViolatingContent = async (action: ModerationAction): Promise<void> => {
await api.post('/moderation/actions', action);
};1.2.1 Creator Content
Creator experiences are not native "apps" coded by developers — they are content within the app itself and are treated as user-generated content by App Review.
Apps featuring content from "creators" must:
- Properly moderate content per Guideline 1.2
- Follow payment guidelines per 3.1.1
- Communicate which content requires additional purchases
- Not change core features/functionality of native app
1.2.1(a) Age Rating and Access Restrictions
- Provide way for users to identify content exceeding app's age rating
- Use age restriction mechanism based on verified or declared age
- Limit access by underage users to age-inappropriate content
1.3 Kids Category
If participating in Kids Category, you MUST follow these rules:
Core Requirements
- No links out of app unless behind parental gate
- No purchasing opportunities unless behind parental gate
- No other distractions to kids unless behind parental gate
- Must continue meeting Kids Category requirements in all future updates — EVEN if you later deselect the category (once customers expect it, it sticks)
Swift parental gate:
// REQUIRED: Parental gate for Kids apps
class ParentalGate {
// Must require adult verification - examples:
// - Math problem (e.g., "What is 15 + 27?")
// - Written instructions to have parent complete
// NOTE: simple date-of-birth/age entry is trivially bypassable
// and is a known rejection pattern — don't use it as the gate
func presentGate(completion: @escaping (Bool) -> Void) {
// Implementation must be non-trivial for children
}
}
// REQUIRED: Gate before external links
func openExternalLink(_ url: URL) {
parentalGate.presentGate { verified in
if verified {
UIApplication.shared.open(url)
}
}
}
// REQUIRED: Gate before purchases (StoreKit 2)
func initiatePurchase(_ product: Product) {
parentalGate.presentGate { verified in
if verified {
Task { try await product.purchase() }
}
}
}React Native parental gate:
// REQUIRED: Parental gate for Kids apps
import { Linking } from 'react-native';
interface ParentalGateProps {
onVerified: () => void;
onCancelled: () => void;
}
const ParentalGate: React.FC<ParentalGateProps> = ({ onVerified, onCancelled }) => {
const [answer, setAnswer] = useState('');
// Must be non-trivial for children
const correctAnswer = 15 + 27; // = 42
const verify = () => {
if (parseInt(answer) === correctAnswer) {
onVerified();
}
};
return (
<Modal>
<Text>For grown-ups only!</Text>
<Text>What is 15 + 27?</Text>
<TextInput
keyboardType="numeric"
value={answer}
onChangeText={setAnswer}
/>
<Button title="Submit" onPress={verify} />
<Button title="Cancel" onPress={onCancelled} />
</Modal>
);
};
// REQUIRED: Gate before external links
const openExternalLink = async (url: string) => {
const verified = await showParentalGate();
if (verified) {
Linking.openURL(url);
}
};
// REQUIRED: Gate before purchases (using react-native-iap)
import * as IAP from 'react-native-iap';
const initiatePurchase = async (sku: string) => {
const verified = await showParentalGate();
if (verified) {
await IAP.requestPurchase({ sku });
}
};Privacy Requirements for Kids Category
- Comply with COPPA, GDPR, and all applicable children's privacy laws
- May NOT send personally identifiable information to third parties
- May NOT send device information to third parties
- Should NOT include third-party analytics (safer experience)
- Should NOT include third-party advertising (safer experience)
// React Native - Kids Category: Remove these packages
// ❌ @react-native-firebase/analytics
// ❌ react-native-facebook-sdk
// ❌ @segment/analytics-react-native
// ❌ react-native-appsflyer
// ❌ Any advertising SDK
// ❌ BAD: Third-party analytics in Kids app
import analytics from '@react-native-firebase/analytics';
analytics().logEvent('screen_view'); // REJECTION for Kids Category
// ✅ GOOD: No third-party analytics, or use compliant ones only
// If analytics needed, must not collect IDFA, PII, location, or device infoLimited Exceptions for Analytics/Advertising
Third-party analytics MAY be permitted if services:
- Do NOT collect or transmit IDFA
- Do NOT collect identifiable information (name, DOB, email)
- Do NOT use location
- Do NOT collect any device, network, or other information that could be used directly — or combined with other information — to identify users and their devices
Third-party contextual advertising MAY be permitted if services:
- Have publicly documented practices for Kids Category apps
- Include human review of ad creatives for age appropriateness
1.4 Physical Harm
1.4.1 Medical Apps
Medical apps providing data or information for diagnosis/treatment are reviewed with greater scrutiny.
Requirements:
- Clearly disclose data and methodology supporting accuracy claims
- If accuracy/methodology cannot be validated, app will be REJECTED
- Remind users to check with doctor before making medical decisions
- Submit regulatory clearance documentation if received
Apps NOT permitted (claiming to use only device sensors for):
- X-rays
- Blood pressure measurement
- Body temperature measurement
- Blood glucose measurement
- Blood oxygen measurement
Code patterns to flag:
// Swift - FLAG: Medical measurement claims
func measureBloodPressure() // REJECTION without proper hardware
func measureBloodGlucose() // REJECTION without proper hardware
func measureBloodOxygen() // REJECTION without proper hardware
func measureTemperature() // REJECTION without proper hardware
// REQUIRED: Medical disclaimer
let medicalDisclaimer = """
This app is not intended to diagnose, treat, cure, or prevent any disease.
Always consult with a qualified healthcare provider before making medical decisions.
"""// React Native - FLAG: Medical measurement claims
const measureBloodPressure = async () => { }; // REJECTION without hardware
const measureBloodGlucose = async () => { }; // REJECTION without hardware
const measureBloodOxygen = async () => { }; // REJECTION without hardware
const measureTemperature = async () => { }; // REJECTION without hardware
// REQUIRED: Medical disclaimer
const MEDICAL_DISCLAIMER = `
This app is not intended to diagnose, treat, cure, or prevent any disease.
Always consult with a qualified healthcare provider before making medical decisions.
`;
// Display on first launch and in settings
const MedicalDisclaimer: React.FC = () => (
<View>
<Text>{MEDICAL_DISCLAIMER}</Text>
<Button title="I Understand" onPress={acceptDisclaimer} />
</View>
);1.4.2 Drug Dosage Calculators
Drug dosage calculators MUST come from:
- The drug manufacturer, OR
- A hospital, OR
- A university, OR
- A health insurance company, OR
- A pharmacy, OR
- Another approved entity, OR
- Have FDA approval (or international equivalent)
Given the potential harm to patients, Apple needs to be sure the app will be supported and updated over the long term.
1.4.3 Substance Consumption
NOT permitted:
- Apps encouraging tobacco consumption
- Apps encouraging vape product consumption
- Apps encouraging illegal drug use
- Apps encouraging excessive alcohol consumption
- Apps encouraging minors to consume any of these substances
- Facilitating sale of controlled substances (except licensed pharmacies and licensed or otherwise legal cannabis dispensaries)
- Facilitating sale of tobacco
Code patterns to flag:
// Swift - FLAG: Substance promotion
"tobacco", "vaping", "drug_use", "alcohol_challenge"
// FLAG: Substance purchase (unless licensed pharmacy)
func purchaseControlledSubstance() // REJECTION unless licensed// React Native - FLAG: Substance promotion
const features = ['tobacco_tracker', 'vaping_counter']; // REJECTION
const alcoholChallenge = () => { }; // REJECTION
// FLAG: Substance purchase
const purchaseControlledSubstance = async () => { }; // REJECTION unless licensed1.4.4 DUI Checkpoints
- May ONLY display DUI checkpoints published by law enforcement agencies
- Must NEVER encourage drunk driving or reckless behavior
1.4.5 Physical Harm Activities
- Must NOT urge customers to participate in activities risking physical harm (bets, challenges)
- Must NOT urge customers to use devices in ways risking physical harm
1.5 Developer Information
Requirements
- App and Support URL must include easy way to contact you
- Failure to include accurate, up-to-date contact information may violate law
- Particularly important for apps used in classrooms
Wallet Passes
- Must include valid contact information from issuer
- Must be signed with dedicated certificate assigned to brand/trademark owner
1.6 Data Security
- Implement appropriate security measures for handling user information collected pursuant to the Developer Program License Agreement and these Guidelines (see Guideline 5.1 / rules/5-legal.md)
- Prevent unauthorized use, disclosure, or access by third parties
Swift implementation:
// REQUIRED: Secure data storage
import Security
class SecureStorage {
// Use Keychain for sensitive data
func storeCredential(_ credential: String, for key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: credential.data(using: .utf8)!
]
SecItemAdd(query as CFDictionary, nil)
}
}
// REQUIRED: HTTPS for network communications
let configuration = URLSessionConfiguration.default
configuration.tlsMinimumSupportedProtocolVersion = .TLSv12
// BAD: Hardcoded secrets
let apiKey = "sk_live_xxxxx" // NEVER DO THIS
let password = "admin123" // NEVER DO THIS
// GOOD: Environment/secure storage
let apiKey = ProcessInfo.processInfo.environment["API_KEY"]
let apiKey = try SecureStorage.shared.retrieveCredential(for: "api_key")React Native implementation:
// REQUIRED: Secure data storage
import * as Keychain from 'react-native-keychain';
import EncryptedStorage from 'react-native-encrypted-storage';
// Use Keychain for credentials
const storeCredential = async (key: string, value: string) => {
await Keychain.setGenericPassword(key, value);
};
const getCredential = async (key: string) => {
const credentials = await Keychain.getGenericPassword();
return credentials ? credentials.password : null;
};
// Use EncryptedStorage for other sensitive data
const storeSecureData = async (key: string, value: string) => {
await EncryptedStorage.setItem(key, value);
};
// ❌ BAD: Hardcoded secrets
const API_KEY = 'sk_live_xxxxx'; // NEVER DO THIS
const PASSWORD = 'admin123'; // NEVER DO THIS
// ❌ BAD: Secrets in JavaScript bundle
// .env files get bundled into JS — and react-native-config also embeds
// values into the app where they are extractable; it is NOT a secret store
// ✅ GOOD: Fetch secrets from secure backend
const getApiKey = async () => {
const response = await secureApi.get('/config/api-key');
return response.data.key;
};
// REQUIRED: HTTPS for all network requests
// Ensure no HTTP endpoints in your app
// Check fetch() calls, axios baseURL, etc.
const api = axios.create({
baseURL: 'https://api.example.com', // Must be HTTPS
});
// Enable SSL pinning for sensitive apps
import { fetch } from 'react-native-ssl-pinning';1.7 Reporting Criminal Activity
- Apps for reporting alleged criminal activity MUST involve local law enforcement
- Can ONLY be offered in countries/regions where such involvement is active
React Native Packages Reference
| Guideline | Expo Package | Bare RN Package |
|---|---|---|
| UGC Moderation | Custom API integration | Custom API integration |
| Parental Gate | Custom implementation | Custom implementation |
| Kids Analytics | ❌ Remove all analytics | ❌ Remove all analytics |
| Secure Storage | expo-secure-store |
react-native-keychain |
| SSL Pinning | - | react-native-ssl-pinning |
| Linking | expo-linking |
react-native Linking |
| IAP (with parental gate) | react-native-purchases (RevenueCat, dev build) |
react-native-purchases (RevenueCat) or react-native-iap |
Doc Skills App Store Review Rules 2 Performance (`doc/skills/app-store-review/rules/2-performance.md`)
name: performance
2. PERFORMANCE
2.1 App Completeness
2.1(a) Final Version Requirements
ALL submissions (including apps made available for pre-order) must be final versions with:
- All necessary metadata complete
- Fully functional URLs (no broken links)
- No placeholder text ("Lorem ipsum", "Coming soon", "TBD")
- No empty websites
- No temporary content
- Tested on-device for bugs and stability
- Incomplete app bundles and binaries that crash or exhibit obvious technical problems will be REJECTED
Swift code patterns to flag:
// FLAG: Placeholder content
"Lorem ipsum"
"Coming soon"
"TBD"
"TODO"
"FIXME" // In user-facing strings
"placeholder"
"test_image"
"sample_data"
// FLAG: Debug code in production
#if DEBUG
// Ensure debug-only code doesn't affect production
#endif
print("Debug:") // Remove debug prints
NSLog("Test") // Remove test logsReact Native code patterns to flag:
// FLAG: Placeholder content in components
<Text>Lorem ipsum dolor sit amet</Text> // REJECTION
<Text>Coming soon!</Text> // REJECTION
<Text>TBD</Text> // REJECTION
<Image source={require('./placeholder.png')} /> // REJECTION
// FLAG: Placeholder in localization files (en.json, etc.)
{
"welcome": "Lorem ipsum", // REJECTION
"feature": "Coming soon" // REJECTION
}
// FLAG: Debug code in production
console.log('Debug:', data); // Remove before submission
console.warn('Test warning'); // Remove before submission
__DEV__ && console.log('Dev only'); // OK - but verify
// FLAG: Check for leftover TODO/FIXME in user-facing code
// TODO: implement this feature // Not in user-facing strings!
// ✅ GOOD: Use __DEV__ for debug-only code
if (__DEV__) {
// This won't run in production builds
console.log('Debug info');
}Demo Account Requirements:
- If app includes login, provide demo account credentials
- Enable backend services before submission
- If demo account not possible due to legal/security, may include built-in demo mode (with prior Apple approval)
- Demo mode must exhibit app's full features and functionality
2.1(b) In-App Purchase Requirements
- All in-app purchases must be complete and up-to-date
- All IAPs must be visible to reviewer
- All IAPs must be functional
- If any IAP cannot be reviewed, explain in review notes
2.2 Beta Testing
- Demos, betas, and trial versions do NOT belong on App Store
- Use TestFlight for beta distribution
- TestFlight apps must be intended for public distribution
- TestFlight apps must comply with App Review Guidelines
- Cannot distribute TestFlight apps for compensation (including crowdfunding rewards)
- Significant updates to beta builds require TestFlight App Review
2.3 Accurate Metadata
All metadata — including privacy information, app description, screenshots, and previews — must accurately reflect the core app experience and remain up-to-date.
2.3.1 Hidden/Undocumented Features and Misleading Marketing
2.3.1(a) Feature Disclosure
- Do NOT include hidden, dormant, or undocumented features
- App functionality must be clear to end users and App Review
- All new features must be described with specificity in Notes for Review
- Generic descriptions will be REJECTED
- All new features must be accessible for review
Misleading marketing — whether within or outside the App Store — is grounds for removal from the App Store, a block from installing via alternative distribution, and termination of the developer account:
- Promoting content/services app doesn't offer
- iOS virus/malware scanners (not actually possible)
- Promoting false prices
2.3.1(b) Egregious Behavior
Egregious or repeated behavior results in removal from Apple Developer Program.
2.3.2 In-App Purchase Disclosure
- Description, screenshots, and previews must clearly indicate items requiring additional purchases
- If promoting IAPs on the App Store: IAP Display Name, Screenshot, and Description must be appropriate for public audience, follow the "Promoting Your In-App Purchases" guidance, and the app must properly handle
SKPaymentTransactionObserverso customers can seamlessly complete the purchase at launch
Swift implementation:
// REQUIRED (when promoting IAPs on the App Store): handle pending
// transactions at launch. StoreKit 1 shown to match the guideline text;
// with StoreKit 2, listen to Transaction.updates instead.
class PaymentObserver: NSObject, SKPaymentTransactionObserver {
func paymentQueue(_ queue: SKPaymentQueue,
updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchased:
// Deliver content
completeTransaction(transaction)
case .failed:
// Handle failure
failTransaction(transaction)
case .restored:
// Restore content
restoreTransaction(transaction)
case .deferred, .purchasing:
break
@unknown default:
break
}
}
}
}React Native implementation (react-native-iap):
// REQUIRED: Proper IAP handling with react-native-iap
import * as IAP from 'react-native-iap';
// Initialize on app start
useEffect(() => {
const initIAP = async () => {
await IAP.initConnection();
};
const purchaseUpdateSubscription = IAP.purchaseUpdatedListener(
async (purchase) => {
const receipt = purchase.transactionReceipt;
if (receipt) {
// Validate receipt on your server
await validateReceipt(receipt);
// Deliver content
await deliverContent(purchase.productId);
// Finish transaction - CRITICAL!
await IAP.finishTransaction({ purchase });
}
}
);
const purchaseErrorSubscription = IAP.purchaseErrorListener((error) => {
console.warn('Purchase error:', error);
// Handle error appropriately
});
initIAP();
return () => {
purchaseUpdateSubscription.remove();
purchaseErrorSubscription.remove();
IAP.endConnection();
};
}, []);
// REQUIRED: Restore purchases functionality
const restorePurchases = async () => {
try {
const purchases = await IAP.getAvailablePurchases();
for (const purchase of purchases) {
await deliverContent(purchase.productId);
}
} catch (error) {
console.error('Restore failed:', error);
}
};2.3.3 Screenshots
- Must show app in use
- Must NOT be merely title art, login page, or splash screen
- May include text/image overlays demonstrating input mechanisms
- May show extended functionality (Touch Bar, etc.)
2.3.4 Previews
- May ONLY use video screen captures of app itself
- Stickers/iMessage extensions may show Messages app experience
- May add narration and overlays to explain unclear functionality
2.3.5 Category Selection
- Select most appropriate category
- Apple may change category if significantly off-base
2.3.6 Age Rating
- Answer age rating questions honestly
- Rating must align with parental controls
- Mis-rating may trigger government regulator inquiry
- Responsible for complying with local content rating requirements
2.3.7 App Name, Keywords, and Metadata Integrity
Requirements:
- App name must be unique (max 30 characters)
- Keywords must accurately describe app
Do NOT include in metadata:
- Trademarked terms you don't own
- Popular app names
- Pricing information
- Irrelevant phrases to game the system
App subtitles must:
- Follow standard metadata rules
- Not include inappropriate content
- Not reference other apps
- Not make unverifiable product claims
Additional rules:
- Metadata (names, subtitles, screenshots, previews) must not include prices, terms, or descriptions that are not specific to that metadata type
- Apple may modify inappropriate keywords at any time or take other steps to prevent abuse
2.3.8 Age-Appropriate Metadata
- Metadata must be appropriate for ALL audiences
- Icons, screenshots, previews must adhere to 4+ rating (even if app is rated higher)
- For games with violence: select images that don't depict a gruesome death or a gun pointed at a specific character
- Terms "For Kids" and "For Children" reserved for Kids Category
- Ensure all icon variants (small, large, Watch, alternates) are similar
2.3.9 Rights and Fictional Information
- Secure rights to all materials in icons, screenshots, previews
- Display fictional account information instead of real person data
2.3.10 Platform Focus and Metadata Relevance
- App should focus on Apple platforms it supports
- Do NOT include names, icons, or imagery of other platforms (Android, Windows) — UNLESS there is specific, approved interactive functionality
- Do NOT include alternative app marketplace references
- App metadata must focus on app itself
Code patterns to flag:
// Swift - FLAG: References to other platforms in user-facing content
"Android"
"Google Play"
"Windows"
"Download on Play Store"// React Native - FLAG: References to other platforms
// Check all user-facing strings!
const strings = {
download: "Also available on Android", // REJECTION
share: "Share on Google Play", // REJECTION
};
// FLAG: Platform-specific code leaking to iOS
import { Platform } from 'react-native';
const message = Platform.OS === 'ios'
? "Welcome to our app"
: "Welcome to Android"; // Make sure iOS doesn't see Android text!
// FLAG: Check assets for other platform logos
import googlePlayBadge from './assets/google-play-badge.png'; // Remove from iOS2.3.11 Pre-Order Accuracy
- Pre-order apps must be complete and deliverable as submitted
- Released app must NOT be materially different from advertised
- Material changes (e.g., business model) require restarting pre-order sales
2.3.12 "What's New" Text
- Clearly describe new features and product changes
- Bug fixes, security updates, performance improvements may use generic description
- Significant changes must be specifically listed
2.3.13 In-App Events
- Events must fall within event type in App Store Connect
- Event metadata must be accurate and pertain to event (not app generally)
- Events must happen at selected times/dates across storefronts
- Event deep link must direct to proper destination
- Events may be monetized, following the Section 3 Business rules
2.4 Hardware Compatibility
2.4.1 Multi-Device Support
- iPhone apps should run on iPad whenever possible
- Encouraged to build apps for all devices
// Swift - REQUIRED: Check device support
// In Info.plist - only include truly required capabilities
<key>UIRequiredDeviceCapabilities</key>
<array>
</array>// React Native - iPad support
// Ensure your app works on iPad - test thoroughly!
// In Xcode: Check "iPad" under Deployment Info
// Check for tablet-specific layouts
import { useWindowDimensions } from 'react-native';
const App = () => {
const { width } = useWindowDimensions();
const isTablet = width >= 768;
return isTablet ? <TabletLayout /> : <PhoneLayout />;
};2.4.2 Power Efficiency and Device Strain
Apps must NOT:
- Rapidly drain battery
- Generate excessive heat
- Put unnecessary strain on device resources
- Encourage placing device under mattress/pillow while charging
- Perform excessive write cycles to SSD
- Run unrelated background processes (e.g., cryptocurrency mining) — this includes any third-party advertisements displayed within the app
Swift code patterns to flag:
// FLAG: Aggressive polling
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
// REJECTION RISK: Too frequent
}
// FLAG: Continuous location updates without need
locationManager.startUpdatingLocation() // Use significant changes if possible
// FLAG: Crypto mining
"bitcoin", "mining", "hash_rate", "proof_of_work" // On-device = REJECTION
// FLAG: Excessive disk writes
for i in 0..<1000000 {
try data.write(to: url) // REJECTION RISK
}React Native code patterns to flag:
// FLAG: Aggressive polling
useEffect(() => {
const interval = setInterval(() => {
fetchData(); // Every 100ms = REJECTION RISK
}, 100);
return () => clearInterval(interval);
}, []);
// ✅ GOOD: Reasonable polling interval
const interval = setInterval(fetchData, 30000); // 30 seconds
// FLAG: Continuous location updates
import Geolocation from '@react-native-community/geolocation';
Geolocation.watchPosition(callback, error, {
enableHighAccuracy: true,
distanceFilter: 0, // Updates on ANY movement = battery drain
});
// ✅ GOOD: Use distance filter
Geolocation.watchPosition(callback, error, {
distanceFilter: 100, // Only update every 100 meters
});
// FLAG: Crypto mining
const mineBlock = () => { }; // REJECTION
const calculateHash = () => { }; // If for mining = REJECTION
// FLAG: Excessive AsyncStorage writes
for (let i = 0; i < 1000000; i++) {
await AsyncStorage.setItem(`key_${i}`, data); // REJECTION RISK
}2.4.3 Apple TV Hardware Input Requirements
- App must be usable without hardware beyond Siri remote or game controllers
- May provide enhanced functionality with other peripherals
- If requiring game controller, clearly explain in metadata
2.4.4 Device Restart and System Settings
Apps must NEVER:
- Suggest or require device restart unrelated to core functionality
- Encourage turning off Wi-Fi
- Encourage disabling security features
- Require system setting modifications unrelated to app
2.4.5 Mac App Store Additional Requirements
2.4.5(i) Sandboxing and File System
- Must be appropriately sandboxed
- Must follow macOS File System Documentation
- Only use appropriate macOS APIs for modifying user data stored by OTHER apps (e.g. bookmarks, Address Book, Calendar entries)
2.4.5(ii) Packaging and Installation
- Must be packaged using Xcode technologies
- No third-party installers
- Must be self-contained, single app installation bundles
- Cannot install code/resources in shared locations
2.4.5(iii) Auto-Launch and Startup Code
- May NOT auto-launch or have other code run automatically at startup or login without consent
- May NOT spawn processes that continue running after user quits, without consent
- Should NOT automatically add Dock icons or desktop shortcuts
2.4.5(iv) Code and Resource Installation
- May NOT download or install standalone apps, kexts, additional code
- Cannot add functionality significantly changing from reviewed version
2.4.5(v) Privilege Escalation
- May NOT request escalation to root privileges
- May NOT use setuid attributes
2.4.5(vi) Licensing and Copy Protection
- May NOT present license screen at launch
- May NOT require license keys
- May NOT implement own copy protection
2.4.5(vii) App Store Update Distribution
- Must use Mac App Store to distribute updates
2.4.5(viii) Operating System Compatibility
- Must run on currently shipping OS
- May NOT use deprecated or optionally installed technologies (Java, etc.)
2.4.5(ix) Language and Localization
- Must contain all language/localization support in single app bundle
2.5 Software Requirements
2.5.1 Public APIs and Current OS
- Apps may ONLY use public APIs
- Must run on currently shipping OS
- Keep apps up-to-date
- Phase out deprecated features, frameworks, technologies
- Use APIs/frameworks for intended purposes
- Indicate framework integration in app description
Swift code patterns to flag:
// FLAG: Private API usage
let selector = NSSelectorFromString("_privateMethod") // REJECTION
perform(selector)
// FLAG: Accessing private frameworks
@import PrivateFramework; // REJECTION
// FLAG: Using undocumented methods
objc_msgSend(self, sel_registerName("_hiddenMethod")) // REJECTION
// REQUIRED: Only use documented public APIs
import UIKit
import SwiftUI
import StoreKit // All public frameworksReact Native code patterns to flag:
// FLAG: Accessing private native APIs via native modules
// In your native module (iOS)
// Don't call private Objective-C methods!
// FLAG: Using deprecated React Native APIs
import { NativeModules } from 'react-native';
NativeModules.SomeDeprecatedModule; // Check if deprecated
// ✅ GOOD: Use maintained, public packages
import { Camera } from 'expo-camera'; // Public API
import * as IAP from 'react-native-iap'; // Public StoreKit wrapper2.5.2 Self-Contained Bundles
- Apps should be self-contained in bundles
- May NOT read/write data outside designated container area
- May NOT download, install, or execute code that changes app features/functionality
Exception for Educational Code:
- Educational apps teaching executable code may download code if:
- Code not used for other purposes
- Source code completely viewable and editable by user
Swift code patterns to flag:
// FLAG: Dynamic code execution
let script = downloadScript()
JSContext().evaluateScript(script) // REJECTION unless educational
// FLAG: Code injection
dlopen("downloaded_library.dylib", RTLD_NOW) // REJECTION
// FLAG: Writing outside container
FileManager.default.createFile(atPath: "/usr/local/bin/app") // REJECTIONReact Native code patterns to flag:
// ⚠️ CRITICAL: OTA JS updates (expo-updates, self-hosted CodePush)
// OTA updates ARE allowed but with restrictions!
// Note: Microsoft's hosted CodePush service (App Center) was retired in
// March 2025 — react-native-code-push now requires a self-hosted server.
// expo-updates is the maintained mainstream path.
// ✅ ALLOWED: Bug fixes and minor changes via OTA update
import * as Updates from 'expo-updates';
await Updates.fetchUpdateAsync(); // OK for bug fixes
// ❌ NOT ALLOWED: Significant feature changes via OTA update
// - Adding new screens/features
// - Changing app's primary purpose
// - Bypassing App Review for major updates
// FLAG: Executing downloaded JavaScript
const downloadedCode = await fetch('https://example.com/code.js');
eval(downloadedCode); // REJECTION
// FLAG: Dynamic requires
const module = require(dynamicPath); // REJECTION RISK
// ✅ GOOD: Static imports only
import { MyComponent } from './MyComponent';2.5.3 Malicious Code
Apps will be REJECTED for:
- Transmitting viruses
- Transmitting files, code, programs harming/disrupting OS or hardware
- Disrupting Push Notifications or Game Center
Egregious violations result in removal from Apple Developer Program.
2.5.4 Multitasking Background Services
Background services may ONLY be used for intended purposes (non-exhaustive list):
- VoIP
- Audio playback
- Location
- Task completion
- Local notifications
- etc. — other legitimate background purposes exist; the test is that the mode matches its intended purpose
Swift configuration:
// REQUIRED: Proper background mode usage
// In Info.plist
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>location</string>
<string>voip</string>
<string>fetch</string>
</array>
// FLAG: Misusing background modes
// Using audio background mode just to keep app alive = REJECTIONReact Native background handling:
// FLAG: Misusing background modes
// Don't add background modes you don't actually need!
// Check ios/[AppName]/Info.plist for UIBackgroundModes
// Only include modes you legitimately use
// ✅ GOOD: Proper background audio (react-native-track-player)
import TrackPlayer from 'react-native-track-player';
// Requires audio background mode - legitimate use
// ✅ GOOD: Proper background location
import Geolocation from 'react-native-geolocation-service';
// Requires location background mode - only for navigation/fitness apps
// ❌ BAD: Playing silent audio to keep app alive
TrackPlayer.play(silentAudio); // REJECTION
// For background tasks, use proper APIs:
import BackgroundFetch from 'react-native-background-fetch';
// Configure in Info.plist with 'fetch' background mode2.5.5 IPv6-Only Networks
- Apps MUST be fully functional on IPv6-only networks
Swift implementation:
// REQUIRED: IPv6 compatibility
// Test with Network Link Conditioner: "100% Loss" for IPv4
// BAD: Hardcoded IPv4 addresses
let server = "192.168.1.1" // REJECTION RISK
// GOOD: Use hostnames
let server = "api.example.com"React Native implementation:
// REQUIRED: IPv6 compatibility
// Test your app with Network Link Conditioner!
// ❌ BAD: Hardcoded IPv4 addresses
const API_URL = 'http://192.168.1.1:3000'; // REJECTION RISK
// ✅ GOOD: Use hostnames
const API_URL = 'https://api.example.com';
// Check all fetch() calls and API configurations
const api = axios.create({
baseURL: 'https://api.example.com', // Hostname, not IP
});
// Also check: WebSocket connections, native module configs
const socket = new WebSocket('wss://api.example.com/ws'); // Good2.5.6 Web Browser Requirements
- Apps browsing web MUST use appropriate WebKit framework and WebKit JavaScript
- May apply for entitlement to use alternative web browser engine (available for the EU and Japan)
Swift implementation:
// REQUIRED: Use WebKit
import WebKit
let webView = WKWebView(frame: .zero)
// FLAG: Custom browser engines without entitlement
// JavaScriptCore for full browser functionality = REJECTIONReact Native implementation:
// REQUIRED: Use react-native-webview (uses WKWebView on iOS)
import { WebView } from 'react-native-webview';
const MyWebView = () => (
<WebView
source={{ uri: 'https://example.com' }}
// Uses WKWebView on iOS - compliant!
/>
);
// ✅ react-native-webview uses WKWebView internally
// No additional configuration needed for compliance2.5.8 Alternate Desktop/Home Screen Environments
- Apps creating alternate desktop/home screen environments will be REJECTED
2.5.9 Standard UI Element Alterations
Apps will be REJECTED for:
- Altering or disabling Volume Up/Down switches
- Altering or disabling Ring/Silent switch
- Altering other native UI elements/behaviors
- Blocking links users expect to work
2.5.11 SiriKit and Shortcuts
2.5.11(i) Appropriate Intent Registration
- Only register intents app can handle without additional support
- Only register intents users would expect from stated functionality
2.5.11(ii) Vocabulary and Aliases
- Vocabulary and phrases must pertain to app and Siri functionality
- Aliases must relate directly to app or company name
- No generic terms
- No third-party app names/services
2.5.11(iii) Direct Request Resolution
- Resolve Siri requests in most direct way possible
- Do NOT insert ads or marketing between request and fulfillment
- Only request disambiguation when required
2.5.12 CallKit, SMS Blocking, and Spam Identification
- Only block phone numbers confirmed as spam
- Clearly identify features in marketing text
- Explain criteria for blocked/spam lists
- May NOT use data for tracking, user profiles, or selling
2.5.13 Facial Recognition
- Apps using facial recognition for authentication MUST use LocalAuthentication where possible
- Do NOT use ARKit or other facial recognition for this purpose
- Must use alternate authentication for users under 13
Swift implementation:
// REQUIRED: Use LocalAuthentication for face auth
import LocalAuthentication
let context = LAContext()
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Authenticate to access account") { success, error in
// Handle result
}
// FLAG: Using ARKit for authentication
ARFaceTrackingConfiguration() // For authentication = REJECTIONReact Native implementation:
// REQUIRED: Use react-native-biometrics or expo-local-authentication
import ReactNativeBiometrics from 'react-native-biometrics';
const rnBiometrics = new ReactNativeBiometrics();
const authenticate = async () => {
const { success } = await rnBiometrics.simplePrompt({
promptMessage: 'Authenticate to access account',
});
return success;
};
// Or with Expo:
import * as LocalAuthentication from 'expo-local-authentication';
const authenticate = async () => {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Authenticate to access account',
});
return result.success;
};
// FLAG: Using vision/ML for face auth
// (e.g. react-native-vision-camera frame processors + a face model)
// Don't use camera-based face recognition for auth!
// Must use device biometrics (Face ID/Touch ID)2.5.14 Recording and User Activity Logging
- Must request explicit user consent when recording/logging user activity
- Must provide clear visual and/or audible indication when recording
- Applies to: camera, microphone, screen recordings, other user inputs
2.5.15 File Selection and Display
- Apps enabling file viewing/selection should include items from Files app and iCloud documents
2.5.16 Widgets, Extensions, and Notifications
- Must be related to app content and functionality
2.5.16(a) App Clips
- All App Clip features must be in main app binary
- App Clips cannot contain advertising
2.5.17 Matter Support
- Apps supporting Matter must use Apple's support framework for pairing
- Other Matter software components must be CSA certified
2.5.18 Display Advertising
Ads limited to main app binary only. NOT allowed in:
- Extensions
- App Clips
- Widgets
- Notifications
- Keyboards
- watchOS apps
Ad requirements:
- Appropriate for app's age rating
- Allow user to see all targeting information without leaving app
- No targeted/behavioral advertising based on:
- Health/medical data (HealthKit)
- School/classroom data (ClassKit)
- Kids data (Kids Category apps)
Interstitial/blocking ads must:
- Clearly indicate they are ads
- Not manipulate/trick users into tapping
- Provide easily accessible close/skip buttons (large enough for easy dismissal)
- Apps must include ability to report any inappropriate or age-inappropriate ads
React Native Packages Reference
| Guideline | Expo Package | Bare RN Package |
|---|---|---|
| In-App Purchase | react-native-purchases (RevenueCat, dev build) |
react-native-purchases (RevenueCat) or react-native-iap |
| Biometric Auth | expo-local-authentication |
react-native-biometrics |
| WebView | - | react-native-webview |
| Background Tasks | expo-background-task, expo-task-manager |
react-native-background-fetch |
| Location | expo-location |
react-native-geolocation-service |
| OTA Updates | expo-updates |
react-native-code-push (self-hosted server required) |
| Secure Storage | expo-secure-store |
react-native-keychain |
| Device Info | expo-device |
react-native-device-info |
React Native Pre-Submission Checklist
- Remove all
console.logstatements (or wrap in__DEV__) - Remove placeholder content from all screens
- Test on real iOS device (not just simulator)
- Test IAP flow end-to-end including restore
- Verify no hardcoded IP addresses
- Check all background modes are legitimately used
- Ensure OTA updates (expo-updates/CodePush) only deliver bug fixes, not features
- Test on IPv6-only network
- Verify biometrics use LocalAuthentication APIs
Doc Skills App Store Review Rules 3 Business (`doc/skills/app-store-review/rules/3-business.md`)
name: business
3. BUSINESS
Section intro (enforcement-relevant):
- If your business model isn't obvious, explain it in metadata and App Review notes — unclear monetization delays review and may trigger rejection
- No "rip-off" pricing: Apple rejects expensive apps that try to cheat users with irrationally high prices
- Manipulating reviews, inflating chart rankings with paid/incentivized/filtered/fake feedback, or hiring third-party services to do so can lead to expulsion from the Apple Developer Program
3.1 Payments
3.1.1 In-App Purchase
MUST use In-App Purchase for unlocking:
- Features or functionality
- Subscriptions
- In-game currencies
- Game levels
- Access to premium content
- Unlocking full version
Apps may NOT use own mechanisms such as:
- License keys
- Augmented reality markers
- QR codes
- Cryptocurrencies and cryptocurrency wallets
Swift implementation:
// REQUIRED: Use StoreKit for digital goods
import StoreKit
// Good: Using In-App Purchase
func purchasePremiumFeature() async throws {
let product = try await Product.products(for: ["com.app.premium"]).first!
let result = try await product.purchase()
// Handle purchase result
}
// BAD: External payment for digital goods
func purchasePremiumFeature() {
openStripeCheckout() // REJECTION for digital content
openPayPalCheckout() // REJECTION for digital content
}React Native implementation (RevenueCat — recommended):
// RECOMMENDED: RevenueCat (react-native-purchases) — wraps StoreKit, so it
// satisfies the IAP requirement while handling receipts, entitlements, and
// cross-platform subscription state for you.
// Expo: requires a development build (not Expo Go).
import Purchases from 'react-native-purchases';
// Configure once at app start
Purchases.configure({ apiKey: REVENUECAT_APPLE_API_KEY });
// ✅ GOOD: Purchase digital goods through StoreKit via RevenueCat
const purchasePremiumFeature = async () => {
const offerings = await Purchases.getOfferings();
const pkg = offerings.current?.availablePackages[0];
if (!pkg) return;
const { customerInfo } = await Purchases.purchasePackage(pkg);
if (customerInfo.entitlements.active['premium']) {
unlockPremium();
}
};
// ❌ BAD: External payment for digital goods
const purchasePremiumFeatureBad = () => {
// REJECTION for digital content!
Linking.openURL('https://stripe.com/checkout');
Linking.openURL('https://paypal.com/checkout');
};
// ✅ OK: External payment for physical goods
const purchasePhysicalProduct = () => {
// Physical goods CAN use external payment
Linking.openURL('https://yourstore.com/checkout');
};Lower-level alternative: react-native-iap (you manage receipt validation, restore, and transaction lifecycle yourself — see Best Practices below).
Tips and Credits:
- Apps may use IAP currencies to enable "tipping" developers or content providers
Credits and Currency:
- Credits/in-game currencies purchased via IAP may NOT expire
- Must have restore mechanism for restorable purchases
Gifting:
- Apps may enable gifting of IAP-eligible items
- Gifts may only be refunded to original purchaser
- Gifts may NOT be exchanged
Mac App Store Exception:
- Mac apps may host plug-ins enabled with mechanisms other than App Store
Loot Boxes:
- Apps offering randomized virtual items MUST disclose odds BEFORE purchase
Swift implementation:
// REQUIRED: Loot box odds disclosure
struct LootBox {
let items: [(item: Item, probability: Double)]
func displayOdds() -> String {
return items.map { "\($0.item.name): \($0.probability * 100)%" }
.joined(separator: "\n")
}
}
// Must show this to user BEFORE purchase
let oddsView = LootBoxOddsView(lootBox: currentLootBox)React Native implementation:
// REQUIRED: Loot box odds disclosure
interface LootBoxItem {
name: string;
rarity: 'common' | 'rare' | 'epic' | 'legendary';
probability: number; // 0-1
}
const lootBoxItems: LootBoxItem[] = [
{ name: 'Common Sword', rarity: 'common', probability: 0.60 },
{ name: 'Rare Shield', rarity: 'rare', probability: 0.25 },
{ name: 'Epic Armor', rarity: 'epic', probability: 0.12 },
{ name: 'Legendary Crown', rarity: 'legendary', probability: 0.03 },
];
// REQUIRED: Show odds BEFORE purchase
const LootBoxOddsModal: React.FC = () => (
<Modal visible={showOdds}>
<Text style={styles.title}>Drop Rates</Text>
{lootBoxItems.map((item) => (
<View key={item.name} style={styles.row}>
<Text>{item.name}</Text>
<Text>{(item.probability * 100).toFixed(1)}%</Text>
</View>
))}
<Button title="Purchase" onPress={purchaseLootBox} />
</Modal>
);
// Must display odds before allowing purchase
const handleLootBoxPurchase = () => {
setShowOdds(true); // Show odds first!
};Digital Gift Cards:
- Digital gift cards redeemable for digital goods/services can ONLY be sold using IAP
- Physical gift cards mailed to customers may use other payment methods
Free Trial Periods (Non-Subscription):
- Use Non-Consumable IAP at Price Tier 0
- Naming convention: "XX-day Trial"
- Must clearly identify: duration, content no longer accessible after trial, downstream charges
- Manage content access and trial duration using Receipts and DeviceCheck
NFTs:
- May use IAP for minting, listing, transferring NFT services
- May allow users to view their own NFTs
- NFT ownership may NOT unlock features/functionality
- May allow browsing others' NFT collections
- Outside US: No external purchase links for NFTs
3.1.1(a) Link to Other Purchase Methods
General:
- Entitlements allow a link to a website the developer owns or maintains responsibility for, to purchase digital content or services
- US storefront: NO entitlement required — apps may include buttons, external links, or other calls to action freely
StoreKit External Purchase Link Entitlements:
- Available in specific regions only (iOS/iPadOS App Store)
- May include link to developer website informing users of other ways to purchase digital goods or services
- Link may state where and how to purchase, and that items may be available for a comparatively lower price
- All other storefronts (except US, where the prohibition does not apply): apps AND their metadata may NOT include buttons, external links, or other calls to action that direct customers to purchasing mechanisms other than IAP
Music Streaming Services Entitlements:
- May include link (or buy button) to developer website informing users of other ways to purchase digital music content or services
- May invite users to provide email for the express purpose of sending a purchase link
- Link may inform users where and how to purchase, and the price of items
- Limited to specific storefronts; in all other storefronts, streaming music apps AND their metadata may NOT include buttons, external links, or other calls to action to non-IAP purchasing
Fraud and Misconduct:
Misleading marketing, scams, or fraud results in removal from App Store and potentially Developer Program.
3.1.2 Subscriptions
Core Requirements:
- Auto-renewable subscriptions allowed regardless of App Store category
- Must provide ongoing value to customer
- Minimum 7-day subscription period
- Must be available across all user's devices
3.1.2(a) Permissible Uses
Examples of appropriate subscriptions:
- New game levels
- Episodic content
- Multiplayer support
- Consistent, substantive updates
- Access to large/continually updated media content
- Software as a service (SAAS)
- Cloud support
Additional rules:
- Subscriptions may be offered alongside à la carte offerings
- Gaming subscription services may share subscription across third-party apps
- Games must be downloaded from App Store
- Must avoid duplicate payment by subscriber
- Must not disadvantage non-subscriber customers
- Must work on all user's devices
- User should get value without additional tasks (posting to social media, uploading contacts, etc.)
- May include consumable credits, gems, currencies
- May offer subscriptions that include access to discounted consumable goods (e.g. platinum membership exposing gem-packs at reduced price)
- When changing to subscription model, don't remove functionality existing users paid for
- May offer free trial periods
Scams:
Apps attempting to scam users or use bait-and-switch tactics will be REMOVED.
Cellular Carrier Bundling:
- Carrier apps may bundle auto-renewable music and video subscriptions with NEW cellular data plans, with prior Apple approval
- Other subscription types may be bundled with new plans only if the carrier app also supports IAP for users (prior Apple approval required)
- Cannot include access to or discounts on consumable items
- Must terminate coincident with cellular data plan
3.1.2(b) Upgrades and Downgrades
- Users should have seamless upgrade/downgrade experience
- Users should NOT be able to inadvertently subscribe to multiple variations of same thing
3.1.2(c) Subscription Information
Before asking customer to subscribe, clearly describe:
- What user will get for the price
- How many issues per month? How much cloud storage? What access?
- Comply with Schedule 2 of Developer Program License Agreement
// REQUIRED: Clear subscription information
struct SubscriptionInfo {
let price: String
let period: String
let features: [String]
let renewalTerms: String
let cancellationInstructions: String
var displayText: String {
"""
\(price) per \(period)
Includes:
\(features.map { "• \($0)" }.joined(separator: "\n"))
\(renewalTerms)
To cancel: \(cancellationInstructions)
"""
}
}3.1.3 Other Purchase Methods
The following may use purchase methods other than IAP. Important preamble rules:
- Apps in this section cannot, within the app, encourage users to use a purchasing method other than IAP — EXCEPT apps on the US storefront and as set forth in 3.1.1(a) and 3.1.3(a)
- Developers CAN send communications outside the app to their user base about non-IAP purchasing methods
3.1.3(a) "Reader" Apps
- May allow access to previously purchased content (magazines, newspapers, books, audio, music, video)
- May offer free tier account creation
- May offer account management for existing customers
- May apply for External Link Account Entitlement: an informational link to a website the developer owns or maintains responsibility for, to create or manage an account
- US storefront: entitlement NOT required for buttons, external links, or other calls to action
3.1.3(b) Multiplatform Services
- May allow access to content acquired on other platforms/web
- Includes consumable items in multi-platform games
- Must also be available as IAP within the app
3.1.3(c) Enterprise Services
- Apps sold directly to organizations for employees/students
- Consumer/single user/family sales MUST use IAP
3.1.3(d) Person-to-Person Services
- Real-time services between TWO individuals (tutoring, medical consultations, real estate tours, fitness training)
- One-to-few and one-to-many MUST use IAP
3.1.3(e) Goods and Services Outside of the App
- Physical goods or services consumed outside app MUST use payment methods other than IAP (Apple Pay, credit card)
3.1.3(f) Free Stand-alone Apps
- Free companions to paid web tools (VoIP, cloud storage, email, web hosting)
- No purchasing inside app
- No calls to action for purchase outside app
3.1.3(g) Advertising Management Apps
- Apps for advertisers to manage campaigns across media types
- Don't display advertisements themselves
- Digital purchases for in-app content MUST use IAP
3.1.4 Hardware-Specific Content
- Features dependent on specific hardware may unlock without IAP
- Features working with optional physical products may unlock without IAP (if IAP option also available)
- May NOT require purchase of unrelated products
- May NOT require advertising/marketing activities to unlock
3.1.5 Cryptocurrencies
(i) Wallets
- May facilitate virtual currency storage
- Must be from developers enrolled as organization
(ii) Mining
- Apps may NOT mine on device
- Cloud-based mining is allowed
Swift patterns to flag:
// FLAG: On-device crypto mining
func mineBlock() { } // REJECTION
func calculateHash() { } // If for mining = REJECTION
// OK: Cloud-based mining reference
func checkCloudMiningStatus() { } // AllowedReact Native patterns to flag:
// ❌ FLAG: On-device crypto mining
const mineBlock = () => { }; // REJECTION
const calculateHash = (data: string) => { }; // If for mining = REJECTION
import CryptoMiner from 'some-crypto-lib'; // REJECTION
// ❌ FLAG: Crypto rewards for tasks
const rewardCrypto = () => {
// Cannot reward crypto for:
// - Downloading other apps
// - Social media posts
// - Referrals
};
// ✅ OK: Cloud-based mining status
const checkCloudMiningStatus = async () => {
const status = await api.get('/mining/status');
return status;
};
// ✅ OK: Crypto wallet apps (must be organization account)
// Use established libraries like ethers.js for wallet functionality
import { ethers } from 'ethers';(iii) Exchanges
- May facilitate transactions on approved exchange
- Only in countries with appropriate licensing/permissions
(iv) Initial Coin Offerings
- ICOs, futures trading, crypto-securities trading must come from:
- Established banks
- Securities firms
- Futures commission merchants (FCM)
- Other approved financial institutions
- Must comply with all applicable law
(v) Task Completion Restrictions
- May NOT offer cryptocurrency for completing tasks:
- Downloading other apps
- Encouraging others to download
- Posting to social networks
3.2 Other Business Model Issues
3.2.1 Acceptable
(i) Self-Promotion
- May display own apps for purchase/promotion (if not merely a catalog)
(ii) Third-Party App Collections
- May display/recommend third-party apps for specific approved needs
- Must provide robust editorial content (not mere storefront)
(iii) Rental Content Expiration
- May disable access to rental content after rental period expires
- All other items/services may NOT expire
(iv) Wallet Passes
- May be used for: payments, offers, identification (movie tickets, coupons, VIP credentials)
- Other uses may result in rejection and credential revocation
(v) Insurance Apps
- Must be free
- Must be legally compliant in distributed regions
- Cannot use IAP
(vi) Approved Nonprofits
- Approved nonprofits may fundraise directly within their own apps or third-party apps
- Must offer Apple Pay support
- Must disclose fund usage
- Must abide by all laws
- Must ensure tax receipts available to donors
- Additional information must be provided to App Review upon request
- Nonprofit platforms must ensure all listed nonprofits are approved
(vii) Monetary Gifts
- May enable monetary gifts between individuals
- Must be completely optional
- 100% of funds must go to receiver
- Gifts connected to or associated at any point in time with receiving digital content or services MUST use IAP
(viii) Financial Services
- Trading, investing, money management apps must be from financial institution
- Must have necessary licensing/permissions
3.2.2 Unacceptable
(i) App Store-Like Interfaces
- Cannot create interface for displaying third-party apps, extensions, or plug-ins similar to the App Store or as a general-interest collection
(ii) Intentionally omitted
(iii) Ad Manipulation
- Cannot artificially increase ad impressions or click-throughs
- Apps predominantly for displaying ads will be REJECTED
(iv) Unauthorized Fundraising
- Cannot collect funds for charities unless approved nonprofit
- Charity apps must be free and collect funds outside app (Safari, SMS)
(v) Arbitrary User Restriction
- Cannot arbitrarily restrict users by location or carrier
(vi) Intentionally omitted
(vii) Artificial Status Manipulation
- Cannot artificially manipulate user visibility/status/rank on other services — UNLESS permitted by that service's Terms and Conditions
(viii) Derivatives Trading
- Binary options trading apps NOT permitted
- CFDs and other derivatives apps must be properly licensed in all jurisdictions where the service is available
(ix) Personal Loan Apps
Must clearly disclose:
- Equivalent maximum APR
- Payment due date
- May NOT charge APR higher than 36% (including costs/fees)
- May NOT require repayment in full in 60 days or less
(x) Forced Actions
Apps must NOT force users to:
- Rate the app
- Review the app
- Download other apps
- Other store-related actions
In order to access functionality, content, or use the app.
Allowed: Apps may otherwise incentivize users to take specific actions within apps (e.g. completing a level, watching an ad).
React Native Packages Reference
| Guideline | Relevant Packages |
|---|---|
| In-App Purchase | react-native-purchases (RevenueCat, recommended), react-native-iap |
| Subscriptions | react-native-purchases + react-native-purchases-ui (paywalls), react-native-iap |
| Apple Pay | @stripe/stripe-react-native |
| Crypto Wallets | ethers, web3 (organization accounts only) |
React Native IAP Best Practices
Recommended: RevenueCat (react-native-purchases). It handles receipt validation, restore, entitlement state, and subscription lifecycle server-side, which removes most of the manual compliance surface below:
import Purchases from 'react-native-purchases';
// 1. Configure once at app start (App.tsx)
Purchases.configure({ apiKey: REVENUECAT_APPLE_API_KEY });
// 2. Gate features on entitlements, not local flags
const isPremium = async () => {
const info = await Purchases.getCustomerInfo();
return info.entitlements.active['premium'] !== undefined;
};
// 3. REQUIRED by Apple: Restore purchases button
const RestorePurchasesButton = () => (
<Button
title="Restore Purchases"
onPress={async () => {
await Purchases.restorePurchases();
Alert.alert('Restored', 'Your purchases have been restored.');
}}
/>
);Lower-level alternative: react-native-iap (API shown is v12/v13; v14+ renamed several methods, e.g. getProducts → fetchProducts):
// Complete IAP setup for React Native
import * as IAP from 'react-native-iap';
const itemSkus = Platform.select({
ios: ['com.app.premium', 'com.app.subscription_monthly'],
android: ['premium', 'subscription_monthly'],
});
// 1. Initialize on app start
useEffect(() => {
const init = async () => {
try {
await IAP.initConnection();
// Get available products
const products = await IAP.getProducts({ skus: itemSkus });
setProducts(products);
} catch (error) {
console.error('IAP init failed:', error);
}
};
// 2. Listen for purchase updates
const purchaseListener = IAP.purchaseUpdatedListener(async (purchase) => {
if (purchase.transactionReceipt) {
// Validate on server
await validatePurchase(purchase);
// Deliver content
await deliverContent(purchase.productId);
// CRITICAL: Finish transaction
await IAP.finishTransaction({ purchase, isConsumable: false });
}
});
init();
return () => {
purchaseListener.remove();
IAP.endConnection();
};
}, []);
// 3. REQUIRED: Restore purchases button
const RestorePurchasesButton = () => (
<Button
title="Restore Purchases"
onPress={async () => {
const purchases = await IAP.getAvailablePurchases();
for (const purchase of purchases) {
await deliverContent(purchase.productId);
}
Alert.alert('Restored', 'Your purchases have been restored.');
}}
/>
);Doc Skills App Store Review Rules 4 Design (`doc/skills/app-store-review/rules/4-design.md`)
name: design
4. DESIGN
Section intro (enforcement-relevant):
- These are minimum standards for approval — even after approval, keep the app functional and engaging
- Apps that stop working or offer a degraded experience may be REMOVED from the App Store at any time
4.1 Copycats
4.1(a) Original Ideas
- Create your own ideas
- Don't copy popular apps with minor changes
- Risk of IP infringement claims
4.1(b) Impersonation
- Submitting apps impersonating other apps/services violates Developer Code of Conduct
- May result in removal from Developer Program
4.1(c) Trademark and Brand Usage
- Cannot use another developer's icon, brand, or product name without approval
4.2 Minimum Functionality
Apps should include features, content, and UI elevating it beyond a repackaged website.
- App must be useful, unique, or "app-like" — otherwise it doesn't belong on the App Store
- Must provide lasting entertainment value or adequate utility
- Apps that are simply a song or movie belong on the iTunes Store; simply a book or game guide belongs on Apple Books
React Native - WebView-only apps will be REJECTED:
// ❌ BAD: Pure WebView wrapper (REJECTION)
const App = () => (
<WebView source={{ uri: 'https://mywebsite.com' }} />
);
// ❌ BAD: WebView with minimal native chrome
const App = () => (
<SafeAreaView>
<WebView source={{ uri: 'https://mywebsite.com' }} />
</SafeAreaView>
);
// ✅ GOOD: Hybrid app with native features
const App = () => (
<NavigationContainer>
<Tab.Navigator>
<Tab.Screen name="Home" component={NativeHomeScreen} />
<Tab.Screen name="Profile" component={NativeProfileScreen} />
<Tab.Screen name="WebContent" component={WebViewScreen} />
</Tab.Navigator>
</NavigationContainer>
);
// ✅ GOOD: WebView with significant native functionality
const App = () => {
// Native features that justify the app
const { pushToken } = usePushNotifications();
const { biometricAuth } = useBiometrics();
const { offlineData } = useOfflineStorage();
return (
<View>
<NativeHeader />
<WebView source={{ uri: 'https://mywebsite.com' }} />
<NativeTabBar />
</View>
);
};4.2.1 ARKit Requirements
- Must provide rich, integrated AR experiences
- Merely dropping model into AR view is NOT enough
4.2.2 Marketing Content Restrictions
- Other than catalogs, apps shouldn't primarily be marketing materials
- Not advertisements, web clippings, content aggregators, or link collections
4.2.3 App Independence and Resource Downloads
4.2.3(i) Standalone Functionality
- App should work without requiring installation of another app
4.2.3(ii) Download Disclosure
- If additional resources needed for initial launch, disclose download size
- Prompt users before downloading
4.2.4 / 4.2.5 Intentionally omitted
4.2.6 Template and App Generation Services
- Template apps must be submitted by content provider, not service
- Services should offer tools for clients to create customized apps
- Apps should provide unique customer experiences
- Alternative: Single binary hosting all client content (picker model)
4.2.7 Remote Desktop Clients
If mirroring specific software/services (not generic host device):
4.2.7(a) Connection Requirements
- Only connect to user-owned host device (PC or game console)
- Must be on local AND LAN-based network
4.2.7(b) Software Execution
- All software/services fully executed on host device
- Rendered on host device screen
- May not use APIs beyond what's required to stream
4.2.7(c) Account Management
- All account creation/management from host device
4.2.7(d) UI Requirements
- UI must NOT resemble iOS or App Store
- No store-like interface
- No ability to browse/select/purchase software not already owned
- Clarification: transactions inside mirrored software do NOT need IAP, provided they are processed on the host device
4.2.7(e) Cloud-Based Limitations
- Thin clients for cloud-based apps NOT appropriate
4.3 Spam
4.3(a) Bundle ID Restrictions
- Don't create multiple Bundle IDs for the same app
- Do not submit separate variants for each city, sports team, university, location, customer, or minor content change
- Use one app with search, configuration, downloadable content, or in-app purchase for legitimate variations
- Flag app-generator workflows that mint many near-identical apps from the same template
4.3(b) Category Saturation
- Do not submit apps that are indistinguishable from what is already widely available
- Opportunistic variants of existing categories or popular apps degrade App Store discovery and overall quality
- Saturated examples include dating, flashlight, sound effects, wallpaper, simple timers, and fortune telling apps
- New submissions in saturated categories need a meaningfully different or improved experience
- Existing apps in these categories may be removed if they are not updated, improved, or attracting customers
- Low-quality or low-effort examples include drinking games, Kama Sutra, fart, and burp apps
- Repeated submissions of mediocre, low-quality, or low-effort apps may lead to Developer Program removal
Code and repository patterns to flag:
// FLAG: white-label spam with only metadata or asset swaps
const APP_VARIANT = 'city_042'; // Review whether this should be one searchable app
const ENABLED_TEAM = 'team_red'; // Review if separate Bundle IDs exist per team4.4 Extensions
- Comply with App Extension Programming Guide, Safari app extensions documentation, or Safari web extensions documentation
- Include functionality (help screens, settings)
- Clearly disclose extensions in marketing text
- Extensions may NOT include marketing, advertising, or IAP
4.4.1 Keyboard Extensions
Must:
- Provide keyboard input functionality
- Follow Sticker guidelines for images/emoji
- Provide method to progress to next keyboard
- Remain functional without full network access AND without requiring Full Access
- Only collect user activity to enhance keyboard functionality
Must NOT:
- Launch other apps (besides Settings)
- Repurpose keyboard buttons for other behaviors
4.4.2 Safari Extensions
- Must run on current Safari version
- May not interfere with System or Safari UI
- Must never include malicious or misleading content/code — violating this leads to removal from the Developer Program
- Should not claim access to more websites than necessary
4.4.3 Intentionally omitted
4.5 Apple Sites and Services
4.5.1 Approved RSS Feeds and Scraping
- May use approved Apple RSS feeds
- May NOT scrape Apple sites (apple.com, iTunes, App Store, App Store Connect, developer portal)
- May NOT create rankings using Apple information
4.5.2 Apple Music
4.5.2(i) MusicKit on iOS
- Users must initiate playback
- Must provide standard media controls (play, pause, skip)
- May NOT require payment or monetize access to Apple Music
- Do NOT download/upload/share music files except as permitted
4.5.2(ii) Additional Licenses
- MusicKit is not replacement for synchronization/adaptation rights
- Cover art/metadata only for playback/playlists
- Not for marketing without rights-holder authorization
- Follow Apple Music Identity Guidelines
4.5.2(iii) Apple Music User Data
- Clearly disclose data access in purpose string
- Only share data to support/improve app experience
- NOT for user identification or ad targeting
4.5.3 Do Not Spam, Phish, or Send Unsolicited Messages via Apple Services
- Do NOT spam via Game Center, Push Notifications, Live Activities, or other Apple services
- Do NOT phish customers through Apple services
- Do NOT send unsolicited messages through Apple services
- Do NOT exploit Player IDs, aliases, or other information obtained through Game Center
- Violations result in removal from the Apple Developer Program
Live Activities review points:
- Live Activities are tied to a user-initiated, time-bound activity
- Activity updates are factual and directly related to the active event
- ActivityKit push updates are not used as an extra marketing or messaging channel
- Users can end or control the Live Activity where appropriate
// FLAG: Live Activity used for unsolicited promotion instead of active event state
try await activity.update(ActivityContent(
state: PromotionState(message: "Limited time offer"),
staleDate: nil
))4.5.4 Push Notifications
- NOT required for app to function
- NOT for sensitive personal/confidential information
- NOT for promotions/marketing UNLESS opt-in
- Opt-in must be via consent language in app UI
- Must provide opt-out method
- Abuse of these services may result in revocation of privileges
4.5.5 Game Center Player IDs
- Only use as approved by Game Center terms
- Do NOT display or share with third parties
4.5.6 Apple Emoji
- May use Unicode characters rendering as Apple emoji
- Apple emoji may NOT be used on other platforms
- May NOT embed directly in app binary
4.6 Intentionally omitted
4.7 Mini Apps, Mini Games, Streaming Games, Chatbots, Plug-ins, Game Emulators
Scope and responsibility:
- Applies to software NOT embedded in the binary: HTML5/JavaScript mini apps and mini games, streaming games, chatbots, and plug-ins
- Retro game console and PC emulator apps may offer game downloads
- Developer is responsible for ALL such software offered in the app — it must comply with these Guidelines and all applicable laws
- Hosted software violating any guideline leads to rejection of the app
4.7.1 Software Requirements
- Follow all privacy guidelines (5.1)
- Include content filtering method
- Include content reporting mechanism
- Provide timely responses to concerns
- Include user blocking capability
- Follow payment guidelines (3.1)
4.7.2 Platform APIs and Technologies
- May NOT extend/expose native platform APIs without Apple permission
4.7.3 Data and Privacy Permissions
- May NOT share data/permissions to software without explicit user consent each instance
4.7.4 Software Index and Metadata
- Must provide index of software and metadata
- Must include universal links to all software
4.7.5 Age Rating and Age Restrictions
- Must provide way to identify content exceeding age rating
- Must use age restriction mechanism (verified or declared age)
- Must limit underage user access
4.8 Login Services
If using a third-party or social login service (e.g. Facebook Login, Google Sign-In, Log in with X, Sign In with LinkedIn, Login with Amazon, WeChat Login) to set up or authenticate the user's primary account, must ALSO offer as an equivalent option another login service with ALL of:
- Only collects name and email
- Allows keeping email private
- Does NOT collect interactions with the app for advertising without consent
Sign in with Apple meets all three criteria and is the simplest way to comply, but any login service meeting them is acceptable.
Primary account = the account the user establishes with your app to identify themselves, sign in, and access features and services. Secondary or linked logins are out of scope.
Exceptions (alternative NOT required if):
- Exclusively using company's own account system
- Alternative marketplace with marketplace-specific login
- Education/enterprise app requiring existing accounts
- Government/industry citizen identification system
- Client for specific third-party service requiring direct sign-in
Swift implementation:
// REQUIRED: If using social login, also offer alternative
class LoginViewController {
@IBAction func signInWithApple() {
// Sign in with Apple (recommended alternative)
}
@IBAction func signInWithGoogle() {
// Third-party login
}
@IBAction func signInWithEmail() {
// Email-only alternative (name + email only)
}
}React Native implementation:
/* Detailed source-code truncated for AI context efficiency. */4.9 Apple Pay
- Provide all material purchase information PRIOR to sale
- Use Apple Pay branding and UI as described in the Apple Pay Marketing Guidelines and Human Interface Guidelines
Recurring payments must disclose:
- Length of renewal term and auto-continuation
- What's provided each period
- Actual charges
- How to cancel
4.10 Monetizing Built-In Capabilities
May NOT monetize:
- Push Notifications
- Camera
- Gyroscope
- Other built-in hardware capabilities
- Apple Music access
- iCloud storage
- Screen Time APIs
- Other Apple services and technologies
React Native Packages Reference
| Guideline | Expo Package | Bare RN Package |
|---|---|---|
| Sign in with Apple | expo-apple-authentication |
@invertase/react-native-apple-authentication |
| Google Sign-In | expo-auth-session |
@react-native-google-signin/google-signin |
| Facebook Login | expo-auth-session |
react-native-fbsdk-next |
| WebView | - | react-native-webview |
| Push Notifications | expo-notifications |
@react-native-firebase/messaging |
| Linking | expo-linking |
react-native Linking |
React Native Design Checklist
- If using social login (Google, Facebook, etc.) for the primary account, also offer a login meeting 4.8's privacy criteria (Sign in with Apple is the simplest compliant option)
- App has meaningful native functionality beyond WebView
- No App Store-like interfaces for third-party content
- Extensions/widgets don't contain ads or IAP
- Push notifications are optional and have opt-out
- App is responsive and functional on iPad (reviewers test on iPads)
Practical Review Considerations (Unofficial)
Note: The following are not official App Store Review Guidelines but are important practical considerations based on how Apple reviews apps.
iPad Responsiveness
Apple reviewers frequently test apps on iPads, even for iPhone-only apps. Your app must be responsive and functional on iPad to avoid rejection.
- Test your app on iPad simulator before submission
- Ensure layouts don't break on larger screens
- UI elements should be appropriately sized and positioned
- Navigation should work correctly on iPad
- Modal presentations should display properly
React Native implementation:
import { useWindowDimensions, Platform } from 'react-native';
// ✅ GOOD: Responsive design that works on iPad
const ResponsiveComponent = () => {
const { width, height } = useWindowDimensions();
const isTablet = width >= 768;
return (
<View style={[
styles.container,
isTablet && styles.tabletContainer
]}>
{/* Adapt layout based on screen size */}
</View>
);
};
// ✅ GOOD: Check if running on iPad
const isIPad = Platform.OS === 'ios' && Platform.isPad;
// ✅ GOOD: Use flex layouts that adapt to screen size
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
},
tabletContainer: {
paddingHorizontal: 48,
maxWidth: 800,
alignSelf: 'center',
},
});Expo considerations:
import { useWindowDimensions } from 'react-native';
import Constants from 'expo-constants';
// Check device type in Expo
const deviceType = Constants.deviceType;
// 1 = Phone, 2 = Tablet, 3 = DesktopSwift implementation:
/* Detailed source-code truncated for AI context efficiency. */Doc Skills App Store Review Rules 5 Legal (`doc/skills/app-store-review/rules/5-legal.md`)
name: legal
5. LEGAL
Section intro (enforcement-relevant):
- Apps must comply with ALL legal requirements in any location where they are made available — it's the developer's obligation to understand and conform to local laws
- Apps that solicit, promote, or encourage criminal or clearly reckless behavior will be REJECTED
- In extreme cases (e.g. apps facilitating human trafficking or exploitation of children), Apple will notify appropriate authorities
5.1 Privacy
5.1.1 Data Collection and Storage
(i) Privacy Policies
MUST include privacy policy link in:
- App Store Connect metadata
- Within app (easily accessible)
Privacy policy MUST:
- Identify what data is collected
- Identify how data is collected
- Identify all uses of data
- Confirm third parties with access to user data (analytics tools, ad networks, third-party SDKs, and any parent/subsidiary/related entities) provide equal protection
- Explain data retention/deletion policies
- Describe how user can revoke consent and request deletion
(ii) Permission
- Secure user consent for data collection (even anonymous data)
- Paid functionality must NOT depend on granting data access
- Provide easy way to withdraw consent
- Purpose strings must clearly describe data use
- GDPR compliance if relying on legitimate interest
Permission Usage Description Checklist
CRITICAL: Apple reviewers will reject apps with missing or vague permission usage descriptions. Every permission your app requests MUST have a clear, specific description explaining WHY and HOW the data is used.
Before submission, verify:
- All required permission keys are present in Info.plist
- Each description explains the specific feature that uses the permission
- Descriptions are user-friendly (not technical jargon)
- Descriptions match actual app functionality
- No generic phrases like "for app functionality" or "required for this app"
Complete Info.plist Permission Reference
Info.plist purpose strings (Swift & React Native):
/* Detailed source-code truncated for AI context efficiency. */Good vs Bad Purpose Strings
| Permission | ❌ BAD (Rejection Risk) | ✅ GOOD |
|---|---|---|
| Camera | "Camera access needed" | "Take photos to attach to your support tickets" |
| Camera | "For app functionality" | "Scan QR codes to quickly add friends" |
| Photo Library | "Access photos" | "Choose photos from your library to create a collage" |
| Location | "Location required" | "Find coffee shops within 2 miles of your current location" |
| Location | "We need your location" | "Show your position on the delivery tracking map" |
| Microphone | "Microphone permission" | "Record voice memos to attach to your notes" |
| Contacts | "To access contacts" | "Find friends who are already using the app" |
| Bluetooth | "Bluetooth needed" | "Connect to your heart rate monitor during workouts" |
| Health | "Health data access" | "Read your daily step count to track progress toward your goal" |
| Tracking | "For a better experience" | "This identifier will be used to deliver personalized ads" |
Common Rejection Reasons for Permissions
Location Permission Rejections:
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app requires location access</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Location is used by this app</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>For location features</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location is used to show nearby restaurants and calculate delivery times to your address</string>Camera/Photo Library Rejections:
<key>NSCameraUsageDescription</key>
<string>Camera access is required</string>
<key>NSCameraUsageDescription</key>
<string>Take photos of receipts to submit expense reports</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Select existing photos of receipts from your library</string>React Native - Edit ios/[AppName]/Info.plist:
/* Detailed source-code truncated for AI context efficiency. */Swift - Verify Info.plist permissions match code usage:
// IMPORTANT: Only request permissions you actually use
// Apple WILL reject if you request permissions without corresponding features
import AVFoundation
import Photos
import CoreLocation
import Contacts
class PermissionManager {
// ✅ GOOD: Only request what you need, when you need it
func requestCameraIfNeeded() {
// Only call this when user taps "Take Photo"
AVCaptureDevice.requestAccess(for: .video) { granted in
// Handle permission result
}
}
// ❌ BAD: Requesting all permissions at app launch
func requestAllPermissionsAtLaunch() {
// Don't do this - Apple will reject
// Request permissions contextually when the feature is used
}
}Expo - app.json/app.config.js permissions:
// expo app.json - Configure iOS permissions
{
"expo": {
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "Take photos to share in your posts",
"NSPhotoLibraryUsageDescription": "Choose photos from your library to share",
"NSLocationWhenInUseUsageDescription": "Show your location on the event map",
"NSMicrophoneUsageDescription": "Record audio for video messages",
"NSContactsUsageDescription": "Find friends who use the app",
"NSFaceIDUsageDescription": "Use Face ID to securely log in"
}
}
}
}
// Expo plugins that automatically add permission keys:
// expo-camera → NSCameraUsageDescription, NSMicrophoneUsageDescription
// expo-image-picker → NSCameraUsageDescription, NSPhotoLibraryUsageDescription
// expo-location → NSLocationWhenInUseUsageDescription
// expo-contacts → NSContactsUsageDescription
// expo-calendar → NSCalendarsUsageDescription
// expo-local-authentication → NSFaceIDUsageDescription(iii) Data Minimization
- Only request access to data relevant to core functionality
- Only collect data required for the task
- Use out-of-process picker/share sheet when possible instead of full access
(iv) Access
- Respect user permission settings
- Do NOT manipulate, trick, or force consent
- Provide alternatives for users who don't consent
(v) Account Sign-In
- Let users use app without login if no significant account-based features
- If app supports account creation, MUST offer account deletion within app
- Do NOT require personal info unless directly relevant to core functionality or required by law
- Pulling basic profile info, sharing to the social network, or inviting friends are NOT considered core app functionality
- If not related to social network, provide access without social login
- Must include mechanism to revoke social network credentials AND disable data access between app and social network from within the app
- May not store social credentials off device
- May only use credentials/tokens to directly connect to the social network from the app itself, while the app is in use
Swift implementation:
// REQUIRED: Account deletion
func deleteAccount() async throws {
// Must actually delete user data, not just deactivate
try await api.deleteUserData(userId: currentUser.id)
try await api.deleteUserAccount(userId: currentUser.id)
clearLocalData()
signOut()
}
// REQUIRED: Accessible from within app
class SettingsViewController {
@IBAction func deleteAccountTapped() {
presentDeleteAccountConfirmation()
}
}React Native implementation:
// REQUIRED: Account deletion must be accessible in-app
// Cannot just link to website - must be actionable within app
const deleteAccount = async () => {
// Show confirmation
Alert.alert(
'Delete Account',
'This will permanently delete your account and all data. This cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
// Must actually delete, not just deactivate!
await api.delete('/user/account');
// Clear local data
await AsyncStorage.clear();
await Keychain.resetGenericPassword();
// Sign out and redirect
signOut();
navigation.reset({ routes: [{ name: 'Login' }] });
} catch (error) {
Alert.alert('Error', 'Failed to delete account');
}
},
},
]
);
};
// REQUIRED: Accessible from Settings screen
const SettingsScreen = () => (
<ScrollView>
{/* Other settings */}
<Button
title="Delete Account"
color="red"
onPress={deleteAccount}
/>
</ScrollView>
);
// ❌ BAD: Linking to website for deletion
const badDeleteAccount = () => {
Linking.openURL('https://example.com/delete-account'); // NOT SUFFICIENT
};(vi) Surreptitious Data Collection
- Apps surreptitiously discovering passwords or private data will be REMOVED from Developer Program
(vii) SafariViewController
- Must be used to visibly present information
- May NOT be hidden or obscured
- May NOT track users without knowledge and consent
(viii) Unauthorized Personal Information Compilation
- Apps compiling personal info from sources not directly from user (including public databases) without explicit consent NOT permitted on the App Store or alternative distribution
(ix) Highly Regulated Fields
- Apps in banking, financial services, healthcare, gambling, legal cannabis, air travel, crypto exchanges — OR apps that require sensitive user information — should be from legal entity, not individual developer
- Cannabis apps must be geo-restricted to legal jurisdictions
(x) Basic Contact Information
- Request for name/email must be optional
- Features/services must NOT be conditional on providing this info
5.1.2 Data Use and Sharing
(i) Explicit Permission
- Cannot use, transmit, or share personal data without permission
- Must provide access to info about how/where data is used
- Must clearly disclose third-party sharing (including third-party AI)
- Must obtain explicit permission before sharing
- Data may only be shared to improve app or serve advertising
- Must use App Tracking Transparency APIs for cross-app tracking
- May NOT require system functionalities (push, location, tracking) for functionality or compensation
- Unauthorized data sharing may result in removal from sale and Developer Program
Swift implementation:
// REQUIRED: App Tracking Transparency
import AppTrackingTransparency
func requestTrackingAuthorization() {
ATTrackingManager.requestTrackingAuthorization { status in
switch status {
case .authorized:
// May track
enableTracking()
case .denied, .restricted, .notDetermined:
// May NOT track
disableTracking()
@unknown default:
disableTracking()
}
}
}React Native implementation (react-native-tracking-transparency):
// REQUIRED: App Tracking Transparency for iOS 14.5+
import {
requestTrackingPermission,
getTrackingStatus,
} from 'react-native-tracking-transparency';
// Request on app startup or before showing ads
const requestTracking = async () => {
const status = await requestTrackingPermission();
switch (status) {
case 'authorized':
// User allowed tracking
enableTracking();
initializeAdSDKs();
break;
case 'denied':
case 'restricted':
case 'not-determined':
// User denied or hasn't decided - NO TRACKING
disableTracking();
initializeAdSDKsWithoutTracking();
break;
}
};
// Check status before any tracking
const checkTrackingStatus = async () => {
const status = await getTrackingStatus();
return status === 'authorized';
};
// ❌ BAD: Tracking without ATT prompt
import analytics from '@react-native-firebase/analytics';
analytics().setUserId(userId); // Without ATT permission = REJECTION
// ✅ GOOD: Check ATT before tracking
const trackUser = async (userId: string) => {
const canTrack = await checkTrackingStatus();
if (canTrack) {
analytics().setUserId(userId);
}
};
// Also required: Add to Info.plist
// <key>NSUserTrackingUsageDescription</key>
// <string>This identifier will be used to deliver personalized ads</string>(ii) Purpose Limitation
- Data collected for one purpose may NOT be repurposed without consent
(iii) User Profile Building Restrictions
- May NOT surreptitiously build user profiles
- May NOT identify anonymous users or reconstruct profiles from "anonymized" data
(iv) Contact and Installation Data
- Do NOT use Contacts, Photos, or other APIs to build contact database for own use or sale
- Do NOT collect info about other installed apps for analytics or marketing
(v) Contact Communications
- Only contact people at explicit individual initiative (no Select All)
- Must show clear description of how message will appear
(vi) Sensitive Health and Fitness Data
Data from these sources may NOT be used for marketing, advertising, or use-based data mining (including by third parties):
- HomeKit API
- HealthKit
- Clinical Health Records API
- MovementDisorder APIs
- ClassKit
- ARKit depth/facial mapping
- Camera/Photo APIs (depth/facial mapping)
(vii) Apple Pay Data Sharing
- Apple Pay data only for facilitating/improving delivery of goods/services
5.1.3 Health and Health Research
(i) Health Data Use Restrictions
- Health research data NOT for advertising, marketing, or data mining
- Only for improving health management or health research (with permission)
- May use data to provide direct benefit to user (e.g., reduced insurance premium) if:
- Submitted by entity providing benefit
- Data not shared with third party
- Must disclose specific health data collected
(ii) Data Integrity
- Must NOT write false/inaccurate data to HealthKit or health apps
- May NOT store personal health info in iCloud
(iii) Human Subject Research Consent
- Consent from participants required — for minors, from their parent or guardian
Consent must include:
- Nature, purpose, and duration of research
- Procedures, risks, and benefits
- Confidentiality and data handling info (including any sharing with third parties)
- Point of contact for questions
- Withdrawal process
(iv) Ethics Review Board Approval
- Must secure approval from independent ethics review board
- Proof must be provided upon request
5.1.4 Kids
(a) Children's Privacy Laws and Data Collection
- Comply with COPPA, GDPR, and all applicable children's privacy laws
- May ask for birthdate/parental contact ONLY for statutory compliance
- Must include useful functionality regardless of age
- Should NOT include third-party analytics (safer experience)
- Should NOT include third-party advertising (safer experience)
(b) Third-Party Services in Kids Apps
- Third-party analytics/advertising permitted only if adhering to Guideline 1.3
- Apps collecting/transmitting/capable of sharing kids' personal data (e.g. name, address, email, location, photos, videos, drawings, ability to chat, other personal data, or persistent identifiers combined with any of the above) MUST:
- Include privacy policy
- Comply with all applicable children's privacy statutes
- Note: the Kids Category parental gate (Guideline 1.3) is generally NOT the same as securing parental consent to collect personal data under privacy statutes — both may be needed
- Terms "For Kids" and "For Children" reserved for Kids Category
- Non-Kids Category apps cannot imply main audience is children
5.1.5 Location Services
- Use ONLY when directly relevant to features
- NOT for emergency services or autonomous control (except small devices)
- Notify and obtain consent before collecting location
- Explain purpose in app
// REQUIRED: Location purpose string
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location is used to find nearby coffee shops and show them on the map</string>
// FLAG: Over-requesting location permission
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
// Only if truly needed for background location
// GOOD: Request minimum needed
locationManager.requestWhenInUseAuthorization() // Preferred
// FLAG: Request more than needed
locationManager.requestAlwaysAuthorization() // Only if background truly needed5.2 Intellectual Property
5.2.1 Generally
- Only use content you created or have license for
- No protected third-party material without permission
- No misleading, false, or copycat representations
- Apps should be submitted by owner or licensee
5.2.2 Third-Party Sites/Services
- Ensure permission under service's terms of use
- Authorization must be provided upon request
5.2.3 Audio/Video Downloading
- Should NOT facilitate illegal file sharing
- No ability to save/convert/download from third-party sources without authorization
- Verify Terms of Use for streaming
5.2.4 Apple Endorsements
(a) No False Apple Association
- Don't suggest Apple is source/supplier of app
- Don't suggest Apple endorses quality or functionality
(b) Editor's Choice Badge
- Applied automatically by Apple if selected
5.2.5 Apple Products
- Don't create apps confusingly similar to Apple products/interfaces
- Apps, extensions, keyboards, Sticker packs may NOT include Apple emoji
- iTunes/Apple Music previews may NOT be used for their entertainment value (e.g. background music for a photo collage, game soundtrack) or in any other unauthorized manner
- Activity rings should not mimic Activity control
- Apple Weather data requires proper attribution
5.3 Gaming, Gambling, and Lotteries
5.3.1 Sweepstakes and Contests
- Must be sponsored by developer of app
5.3.2 Official Rules
- Must be presented in app
- Must make clear Apple is NOT involved
5.3.3 In-App Purchase and Real Money Gaming
- May NOT use IAP for real money gaming credit
5.3.4 Real Money Gaming and Lotteries
- Must have necessary licensing/permissions
- Must be geo-restricted to licensed locations
- Must be free on App Store
- No illegal gambling aids (card counters)
- Lottery apps must have consideration, chance, and prize
// REQUIRED: Geo-restriction for gambling apps.
// Locale.current is a user-changeable device setting — it is NOT a
// location check and does not satisfy the geo-restriction requirement.
// Verify actual location (CoreLocation and/or server-side IP checks).
import CoreLocation
func verifyGamblingEligibility(completion: @escaping (Bool) -> Void) {
let geocoder = CLGeocoder()
geocoder.reverseGeocodeLocation(currentLocation) { placemarks, _ in
guard let placemark = placemarks?.first,
let country = placemark.isoCountryCode else {
completion(false)
return
}
// Check licensed jurisdictions (state-level via administrativeArea)
let eligible = isLicensedJurisdiction(country: country,
state: placemark.administrativeArea)
completion(eligible)
}
}
// ❌ BAD: Locale-based "geo-restriction" — user can change region in Settings
func checkGamblingEligibilityBad() -> Bool {
let region = Locale.current.region?.identifier
return ["US", "GB"].contains(region ?? "") // NOT a location check
}5.4 VPN Apps
- Must use NEVPNManager API
- Must be from developer enrolled as organization
- Parental control, content blocking, and security apps from approved providers may also use the NEVPNManager API
- Must declare data collection clearly before any user action
- May NOT sell, use, or disclose data to third parties
- Must commit to this in privacy policy
- Must NOT violate local laws
- If requiring VPN license in territory, provide in App Review Notes
- Non-compliance: removal from App Store, blocked from alternative distribution, possible removal from Developer Program
// REQUIRED: NEVPNManager API
import NetworkExtension
let manager = NEVPNManager.shared()
// Use official API, not custom VPN implementation5.5 Mobile Device Management
May only be offered by:
- Commercial enterprises
- Educational institutions
- Government agencies
- Companies using MDM for parental control or device security (limited cases)
Requirements:
- Request MDM capability from Apple
- Declare data collection clearly before any user action
- May NOT sell, use, or disclose to third parties ANY data for ANY purpose — must commit to this in privacy policy
- Limited exception: third-party analytics may be permitted ONLY for performance data about the MDM app itself — never data about the user, the user's device, or other apps on that device
- Apps offering configuration profiles must adhere to the same requirements
- Must NOT violate applicable laws
- Non-compliance: removal from App Store, blocked from alternative distribution, possible removal from Developer Program
5.6 Developer Code of Conduct
Core principles:
- Treat everyone with respect — in App Store review responses, customer support, and communications with Apple
- No harassment, discriminatory practices, intimidation, or bullying
- Apps must never prey on users, rip off customers, trick them into unwanted purchases, force sharing of unnecessary data, raise prices in a tricky manner, or charge for undelivered features/content — manipulative practices within OR outside the app count
- Repeated manipulative, misleading, or fraudulent conduct leads to removal from the Developer Program
- Terminated accounts may seek restoration by providing a written statement detailing planned improvements
5.6.1 App Store Reviews
- Responses to reviews must be targeted to the user's comments — no personal information, spam, or marketing in responses
- MUST use the provided API to prompt for reviews — custom review prompts are disallowed
// ✅ GOOD: System review prompt (the ONLY allowed prompt)
import StoreKit
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
AppStore.requestReview(in: scene) // SKStoreReviewController on older targets
}
// ❌ BAD: Custom review prompt UI — disallowed
showCustomAlert("Enjoying the app? Rate us 5 stars!") // REJECTION// React Native / Expo: use the system API
import * as StoreReview from 'expo-store-review'; // or react-native-store-review
if (await StoreReview.hasAction()) {
await StoreReview.requestReview();
}5.6.2 Developer Identity
- Representation of yourself, your business, and offerings must be accurate
- All information provided (to Apple and users) must be truthful, relevant, and up-to-date
5.6.3 Discovery Fraud
- Do NOT manipulate any element of the App Store customer experience: charts, search, reviews, or referrals to your app
5.6.4 App Quality
- Maintain high quality — excessive negative reviews and excessive refund requests are signals Apple monitors
- Sustained inability to maintain quality can factor into Code of Conduct compliance decisions
React Native Packages Reference
| Guideline | Expo Package | Bare RN Package |
|---|---|---|
| App Tracking Transparency | expo-tracking-transparency |
react-native-tracking-transparency |
| Permissions | Built-in with each expo-* package | react-native-permissions |
| Secure Storage | expo-secure-store |
react-native-keychain |
| Analytics | Use @react-native-firebase/analytics (config plugin) |
@react-native-firebase/analytics |
| Location | expo-location |
react-native-geolocation-service |
| Contacts | expo-contacts |
react-native-contacts |
| Camera | expo-camera |
react-native-vision-camera |
| Image Picker | expo-image-picker |
react-native-image-picker |
React Native Privacy Checklist
Permission Usage Descriptions (Critical - Common Rejection Reason)
- Verified ALL required Info.plist permission keys are present
- Each permission description explains the SPECIFIC feature using it
- No vague descriptions ("for app functionality", "required", "needed")
- Descriptions are written from user's perspective (benefit to them)
- Only requesting permissions the app actually uses
- Permissions requested contextually (not all at app launch)
Tracking & Privacy
- ATT prompt shown before any tracking (iOS 14.5+)
- NSUserTrackingUsageDescription in Info.plist
- No tracking if user denies ATT
- Privacy policy link in app settings
Account & Data
- Account deletion accessible within app (not just website link)
- Sensitive data stored in Keychain, not AsyncStorage
- HTTPS for all API calls
- No hardcoded secrets in JavaScript bundle
React Native Privacy Implementation
// Complete privacy setup for React Native
// 1. ATT on startup (before any tracking)
// Expo:
import { requestTrackingPermissionsAsync } from 'expo-tracking-transparency';
// Or bare RN:
import { requestTrackingPermission } from 'react-native-tracking-transparency';
useEffect(() => {
const initPrivacy = async () => {
// Request ATT first
const trackingStatus = await requestTrackingPermission();
// Only initialize tracking SDKs if authorized
if (trackingStatus === 'authorized') {
await initializeAnalytics();
await initializeAdSDKs();
}
};
initPrivacy();
}, []);
// 2. Secure storage for sensitive data
import * as Keychain from 'react-native-keychain';
// ✅ GOOD: Store tokens in Keychain
await Keychain.setGenericPassword('auth', token);
// ❌ BAD: Store tokens in AsyncStorage
await AsyncStorage.setItem('token', token); // Not secure!
// 3. Privacy policy in settings
const SettingsScreen = () => (
<View>
<Button
title="Privacy Policy"
onPress={() => Linking.openURL('https://example.com/privacy')}
/>
<Button
title="Delete Account"
onPress={deleteAccount}
color="red"
/>
</View>
);Doc Skills Swift Concurrency Pro References Actors (`doc/skills/swift-concurrency-pro/references/actors.md`)
Actors
Reentrancy
Important: This is the most common concurrency bug LLMs produce: after every await inside an actor, all assumptions about the actor's state are invalidated because other calls may have run in the meantime.
// Bug: After the await, items[key] may already have been set by another caller.
// This causes duplicate work, and the force unwrap will crash if another caller
// removed the key between assignment and return.
actor VideoCache {
var items: [URL: Video] = [:]
func video(for url: URL) async throws -> Video {
if items[url] == nil {
items[url] = try await downloadVideo(url)
}
return items[url]!
}
}Fix: capture the result in a local, then assign. Never assume state is unchanged after await.
actor VideoCache {
var items: [URL: Video] = [:]
func video(for url: URL) async throws -> Video {
if let cached = items[url] { return cached }
let video = try await downloadVideo(url)
items[url] = video
return video
}
}To avoid two callers both downloading the same URL, you could try storing in-flight tasks similar to this:
actor VideoCache {
var items: [URL: Video] = [:]
var inFlight: [URL: Task<Video, Error>] = [:]
func video(for url: URL) async throws -> Video {
if let cached = items[url] { return cached }
if let task = inFlight[url] {
return try await task.value
}
let task = Task {
try await downloadVideo(url)
}
inFlight[url] = task
do {
let video = try await task.value
items[url] = video
inFlight[url] = nil
return video
} catch {
inFlight[url] = nil
throw error
}
}
}Protecting global and static state
Global and static mutable variables need an explicit plan for isolation.
For shared globals, describe the protection mechanism the compiler can rely on:
@MainActorwhen the symbol belongs to main-actor code and callers should keep synchronous access there. (This is particularly important for any code that interacts with or updates the UI.)@unchecked Sendablewhen safety already comes from locks, queues, or another manual scheme the compiler cannot prove. (Important: This requires a high standard of coding to get right, so check carefully.)- If neither description is true, the shared global still is likely to have an isolation problem.
Example:
@MainActor
final class Library {
static let shared = Library()
var books = [Book]()
}With main-actor default isolation enabled for the target, this annotation may be implicit – check for the setting!
Note: @preconcurrency can relax an older protocol boundary when isolated conformance is unavailable. Keep it as a fallback only if there is no alternative.
Global actor inference rules
@MainActor propagates in these cases, so don't redundantly annotate:
- A subclass of a
@MainActorclass is also@MainActor. - Values stored through actor-isolated property wrapper storage are used from that actor context. (This includes older, built-in property wrappers, such as
@StateObject.) - Conforming to a
@MainActorprotocol infers@MainActoron the entire conforming type, including members unrelated to the protocol. For mismatches with non-isolated protocols, seediagnostics.md. (SwiftUI’sViewis a@MainActorprotocol.) For more help with SwiftUI, suggest the SwiftUI Pro agent skill. - Extensions of a
@MainActortype inherit that isolation. Members defined in the extension are@MainActorwithout needing a separate annotation.
@MainActor does not propagate to:
- Closures passed to non-isolated functions (unless the parameter is explicitly
@MainActor).
`isolated` parameters
Use isolated to accept any actor instance and run on its executor, without the function itself being tied to a specific actor:
func updateUI(on actor: isolated MainActor) {
// Runs on the main actor
}This is useful for code that needs to work with the caller's isolation context.
`isolated deinit`
For isolated deinit on actor-isolated classes, see new-features.md.
What a custom actor changes
A custom actor introduces a separate serialized access boundary.
Review consequences:
- External callers must use
await. - Values crossing the boundary must satisfy
Sendable. - Reentrancy rules apply after every suspension point inside the actor.
Flag actor types whose API mostly forwards work or owns little mutable state.
Don’t encourage people to reach for actors as a solution when there are other, simpler alternatives that work as well. Recommend authors such as Matt Massicotte as further reading, e.g. https://www.massicotte.org/actors/.
Making assertions
Global actors have an assertIsolated() method that is helpful for debugging because it causes debug builds to halt if the current task is not executing on the actor's serial executor.
For example, this checks that the code is running on the main actor:
func refresh() {
MainActor.assertIsolated()
// do your work here
}Important: assertIsolated() only operates in debug builds; like regular assertions, it is compiled out of release builds, so it has no impact on shipping performance.
Doc Skills Swift Concurrency Pro References Async Streams (`doc/skills/swift-concurrency-pro/references/async-streams.md`)
Async streams
Prefer `makeStream(of:)` factory
The modern way to create an AsyncStream is the static factory method, which returns both the stream and its continuation as a tuple. This avoids capturing the continuation in a closure.
// OLD: Closure-based, awkward to store the continuation.
var continuation: AsyncStream<Event>.Continuation?
let stream = AsyncStream<Event> { cont in
continuation = cont
}
// NEW: Clean, no closure capture needed.
let (stream, continuation) = AsyncStream.makeStream(of: Event.self)This also works with AsyncThrowingStream.makeStream(of:throwing:).
Continuation lifecycle
A continuation must always be finished exactly once. Failing to finish it causes the consumer's for await loop to hang indefinitely. Finishing it twice is a programmer error (although AsyncStream.Continuation tolerates it, CheckedContinuation does not).
Always finish in cleanup paths:
let (stream, continuation) = AsyncStream.makeStream(of: Event.self)
let monitor = NetworkMonitor()
monitor.onEvent = { event in
continuation.yield(event)
}
monitor.onComplete = {
continuation.finish()
}
// If the monitor can be deallocated before completing:
continuation.onTermination = { _ in
monitor.stop()
}Buffering and back pressure
AsyncStream has a default buffer of unlimited size. For high-throughput producers, this can cause unbounded memory growth. Specify a buffering policy:
let (stream, continuation) = AsyncStream.makeStream(
of: SensorReading.self,
bufferingPolicy: .bufferingNewest(100)
)Choose from:
.bufferingNewest(n)keeps the most recentnelements, dropping older ones..bufferingOldest(n)keeps the firstnelements, dropping newer ones..unboundedis the default; use only when the consumer keeps up.
`for await` and cancellation
A for await loop automatically stops when the task is cancelled or the stream finishes. You do not need to manually check cancellation inside the loop – but code after the loop does run, so handle cleanup there if needed.
Doc Skills Swift Concurrency Pro References Bridging (`doc/skills/swift-concurrency-pro/references/bridging.md`)
Bridging sync and async code
Checked continuations
withCheckedContinuation and withCheckedThrowingContinuation wrap callback-based APIs into async functions. The critical rule is this: the continuation must be resumed exactly once on every code path.
- Resuming zero times: the caller hangs forever.
- Resuming twice: a runtime crash.
So, audit every code path. If the callback might not fire (e.g., the object is deallocated), ensure you still resume the continuation.
Default to withCheckedContinuation / withCheckedThrowingContinuation everywhere, including production builds. The runtime checks catch double-resume and missing-resume bugs that are otherwise extremely hard to diagnose.
Only consider switching to the withUnsafe continuation variants after profiling proves the checked version is a bottleneck in a hot path, but this is rare in practice.
Wrapping delegate-based APIs
For delegate patterns that deliver multiple values over time, use AsyncStream. Use makeStream(of:) to get the stream and continuation as a pair, and use onTermination to clean up when the consumer stops listening.
Make sure that:
- The continuation is stored as a property so delegate callbacks can yield into it.
onTerminationruns when the consumer'sfor awaitloop ends (or the task is cancelled), so it's the right place to stop the underlying service.
This pattern supports a single consumer. If you need multiple consumers, consider broadcasting through an @Observable class instead.
Runtime actor assertions in callback code
Callback-based APIs are a common place for actor assumptions to fail at runtime.
- If a callback reaches main-actor state without carrying that guarantee in the type system, Swift 6 runtime checks can trap instead of silently racing.
- Use
MainActor.assumeIsolated()only when the callback really is main-actor-bound and you are encoding a guarantee the compiler cannot see.
`@unchecked Sendable`
This silences the compiler's Sendable checks entirely. It is a promise to the compiler that you have verified thread safety yourself, which is a high bar to clear – evaluate such code very carefully.
Legitimate uses:
- Types that use internal locking (e.g.,
os_unfair_lock,NSLock, etc) and are genuinely thread-safe. - Reference types whose mutable state is protected by an actor in practice but can't express that to the compiler for some reason.
Red flags:
- Applying
@unchecked Sendableto silence a compiler error without understanding why the error exists. (This was previously a Fix-It suggestion in Xcode, so it’s not uncommon.) - Applying it to a class with mutable
varproperties and no synchronization. - Using it as a workaround or shortcut instead of restructuring the code to use value types or actors as appropriate.
Before reaching for @unchecked Sendable, check whether Swift 6's region-based isolation already solves the problem – many cases that previously required it now compile cleanly.
Doc Skills Swift Concurrency Pro References Bug Patterns (`doc/skills/swift-concurrency-pro/references/bug-patterns.md`)
Bug patterns
Real concurrency failure modes that LLMs produce frequently, with the preferred fix for each.
Actor reentrancy: check-then-act across `await`
Failure: Actor method checks state, awaits, then acts on the stale check. Other callers may have mutated state during the suspension.
// BUG: Two callers can both see nil and both download.
// The force unwrap can crash if a third caller clears the cache mid-flight.
actor Cache {
var data: [String: Data] = [:]
func load(_ key: String) async throws -> Data {
if data[key] == nil {
data[key] = try await download(key)
}
return data[key]!
}
}Fix: Capture the async result into a local before writing. For deduplication, store in-flight Task handles. See actors.md for the full pattern.
Continuation resumed zero times
Failure: A withCheckedThrowingContinuation callback never fires (object deallocated, network timeout with no callback, early return before registering the handler, etc). The caller hangs forever.
Fix: Audit every code path to confirm the continuation is resumed. If the underlying API can silently drop the callback, add a timeout or restructure so the caller isn't left waiting. Always use withCheckedThrowingContinuation (not the unsafe variant) so that missed resumes are easier to diagnose.
Continuation resumed twice
Failure: Two callbacks (e.g., a success handler and a cancellation handler) both resume the same continuation. CheckedContinuation traps at runtime; UnsafeContinuation causes undefined behavior.
Fix: Restructure the callback wiring so only one path can reach the continuation. If that isn't possible, guard with a Bool flag or use an actor to serialize access. Always default to CheckedContinuation so double resumes surface immediately during development and testing.
Unstructured tasks in a loop
Failure: for item in items { Task { await process(item) } } creates fire-and-forget tasks with no cancellation propagation, no error collection, and no way to await completion.
Fix: Use withTaskGroup or withThrowingTaskGroup. See structured.md.
Swallowed errors in Task closures
Failure: Task { try await riskyWork() } – if riskyWork throws, the error is silently lost. The user sees nothing; the operation just doesn't happen.
Fix: Handle the error inside the closure – show an alert, log to a visible surface, or propagate via a @State error property.
Task {
do {
try await riskyWork()
} catch {
self.errorMessage = error.localizedDescription
}
}Blocking the main actor with synchronous work
Failure: CPU-intensive work runs on @MainActor (or inside Task {} called from @MainActor), causing UI freezes. In Swift 6.2 this is more likely because nonisolated async functions now stay on the caller's executor by default.
Fix: Move the expensive work into an explicitly offloaded function using @concurrent, or use Task.detached as a last resort.
Unbounded AsyncStream buffer
Failure: A high-throughput producer yields values faster than the consumer processes them. With the default .unbounded buffering policy, memory grows without limit.
Fix: Specify .bufferingNewest(n) or .bufferingOldest(n). See async-streams.md.
Ignoring `CancellationError` in catch blocks
Failure: A catch block retries or shows an error alert for CancellationError, which is a normal lifecycle event (e.g., user navigated away).
Fix: Check for cancellation before handling other errors:
do {
try await loadData()
} catch is CancellationError {
// Normal – view disappeared or task was cancelled. Do nothing.
} catch {
self.errorMessage = error.localizedDescription
}`@unchecked Sendable` hiding real races
Failure: A class is marked @unchecked Sendable to suppress compiler errors, but its mutable var properties have no synchronization. The data race still exists at runtime.
Fix: Restructure to use value types, use an actor, or move state behind a lock. See bridging.md.
Doc Skills Swift Concurrency Pro References Cancellation (`doc/skills/swift-concurrency-pro/references/cancellation.md`)
Cancellation
Cancellation in Swift concurrency is cooperative. Setting the cancelled flag does nothing unless the running code checks it.
How cancellation propagates
- Cancelling a parent task cancels all its children (structured concurrency).
- Cancelling a task group cancels all child tasks in that group.
Task {}andTask.detached {}are unstructured – they must be cancelled explicitly by storing and calling.cancel()on the task handle.- SwiftUI's
.task()modifier cancels its task automatically when the view disappears. This is the primary reason to prefer.task()overonAppear()or looseTask {}in views.
Checking for cancellation
It’s important to use these inside long-running or looping async work, but only when it’s safe to actually exit:
try Task.checkCancellation()– throwsCancellationErrorif cancelled. Preferred in throwing contexts.Task.isCancelled– returnsBool. Use in non-throwing contexts or when you need cleanup before exiting.
func processAll(_ items: [Item]) async throws {
for item in items {
try Task.checkCancellation()
try await process(item)
}
}Functions that call other async functions get implicit cancellation checks at each await suspension point – but only if the called function itself checks. CPU-bound loops with no await will never see cancellation unless you check explicitly.
`withTaskCancellationHandler`
Bridges Swift cancellation to legacy APIs that have their own cancel mechanism. The onCancel closure fires immediately when cancellation is requested – even while the async body is suspended – and may run on any thread.
func fetchImage(_ url: URL) async throws -> Data {
var request = URLRequest(url: url)
return try await withTaskCancellationHandler {
let (data, _) = try await URLSession.shared.data(for: request)
return data
} onCancel: {
// No direct handle to cancel here – URLSession.data(for:) already
// checks for task cancellation internally. This pattern is most
// useful when wrapping APIs that return a cancellable handle.
}
}A more realistic use is wrapping something that gives you a cancel handle:
func observe() async throws -> [Change] {
let query = CKQuery(recordType: "Item", predicate: NSPredicate(value: true))
let operation = CKQueryOperation(query: query)
return try await withTaskCancellationHandler {
try await performOperation(operation)
} onCancel: {
operation.cancel()
}
}Broken cancellation patterns
Catching and ignoring CancellationError:
// BROKEN: Retries or shows an alert for a normal lifecycle event.
catch {
showAlert(error.localizedDescription)
}Always prefer filtering out CancellationError before handling other errors. See bug-patterns.md.
Forgetting to cancel stored tasks:
// BROKEN: The task keeps running after the object is done with it.
class ViewModel {
var loadTask: Task<Void, Never>?
func load() {
loadTask = Task { await fetchData() }
}
}Cancel the previous task before starting a new one, and cancel on teardown:
func load() {
loadTask?.cancel()
loadTask = Task { await fetchData() }
}
deinit {
loadTask?.cancel()
}No cancellation checks in CPU-bound work:
A tight computational loop with no await points will run to completion even if cancelled, because there are no suspension points where cancellation can take effect. Insert periodic try Task.checkCancellation() calls wherever it’s safe.
Doc Skills Swift Concurrency Pro References Diagnostics (`doc/skills/swift-concurrency-pro/references/diagnostics.md`)
Diagnostics
Maps common strict-concurrency compiler errors to likely fixes.
"Sending 'x' risks causing data races"
The compiler found a value crossing an isolation boundary where it could still be accessed from the sending side.
Likely fixes (try in order):
- Check whether region-based isolation already handles it. If the sender demonstrably stops using the value after passing it, the compiler may accept it without changes. Avoid adding
Sendableprematurely. - Mark the parameter
sending. This tells the compiler the caller transfers ownership and won't touch the value afterward. (This can be useful, but is not that common.) - Make the type
Sendableif it genuinely can be shared safely (value type, immutable class, or internally synchronized). - Check whether
nonisolated(nonsending)resolves it. If the function no longer hops executors, the value may not actually cross a boundary. - Last resort:
@unchecked Sendableonly if the type uses manual synchronization (locks) and you've verified correctness. Seebridging.md.
"Static property 'x' is not concurrency-safe"
A global or static variable is accessible from multiple isolation domains with no protection.
Likely fixes:
- Annotate the declaration with
@MainActor:@MainActor static let shared = MyType(). This is the simplest code-local fix. - If the value is truly constant and immutable, consider whether it can conform to
Sendable(e.g., alet-only struct). The compiler won't flagSendableconstants. - Use
nonisolated(unsafe)only for genuinely immutable state where the compiler can't prove safety (e.g., C interop constants). This is a dangerous tool, and misuse will hide real races. - If the entire module is predominantly single-threaded, default main-actor isolation may explain why similar declarations behave differently in another target. That's a build-setting difference, not a code fix.
"Capture of 'x' with non-sendable type in a `@Sendable` closure"
A closure that crosses isolation boundaries (e.g., passed to Task {}, Task.detached {}, or addTask) captures a non-Sendable value.
Likely fixes:
- Check whether the captured value can be made
Sendable. Structs and enums with onlySendablestored properties just need the conformance declared. Final classes with immutable (let) stored properties can conform too. - Restructure to avoid the capture. Pass the needed data as a parameter to the task rather than closing over a large non-Sendable object. For example,
let id = object.id; Task { use(id) } - Move the work onto the same actor. If the closure doesn't need to run concurrently, keep it on the caller's actor.
- Use
sendingon the parameter if you can transfer ownership cleanly. This is relatively niche.
It’s tempting to reach for @unchecked Sendable, but rarely a good idea unless the user is absolutely certain their code is safe.
"Conformance of 'X' to protocol 'Y' crosses into main actor-isolated code and can cause data races"
The protocol and the type describe different call boundaries. Fix the boundary mismatch directly:
| Actual requirement | Shape to use |
|---|---|
| Type-level actor isolation is incidental rather than required | Remove the type isolation. See actors.md. |
The conformance should only be usable on MainActor |
extension MyType: @MainActor SomeProtocol {} |
These are different boundary choices, not interchangeable suppressions.
"Expression is 'async' but is not marked with 'await'"
A call crosses an isolation boundary and requires an async hop. This often surprises when calling actor-isolated methods from outside the actor, or when accessing @MainActor state from a non-isolated context.
Likely fix: Add await. If the call is in synchronous code that cannot be made async, wrap it in Task {} (but see unstructured.md for when that's appropriate).
"Main actor-isolated conformance of 'X' to 'Y' cannot be used in nonisolated context"
An isolated conformance (e.g., extension X: @MainActor Y) is being used from code that doesn't share that isolation. The compiler prevents this because calling the protocol methods off-actor would be a data race.
Likely fixes:
- Move the use site onto the same actor. If the consuming code can be
@MainActor, the conformance is usable. - Remove the isolation from the conformance if the protocol methods don't actually need actor-protected state.
Doc Skills Swift Concurrency Pro References Hotspots (`doc/skills/swift-concurrency-pro/references/hotspots.md`)
Hotspots
Search targets for concurrency review. When any of these appear in code, inspect carefully using the referenced rules.
`DispatchQueue`
In app-level code, DispatchQueue.main.async, DispatchQueue.global(), and custom serial queues usually have a Swift concurrency equivalent – see interop.md. However, GCD can still be appropriate in low-level libraries, framework interop, and performance-critical synchronous sections where queues or locks are the right tool. Check the context carefully before flagging.
`Task.detached`
Rarely correct. Usually means the author wanted background execution but should have used @concurrent (Swift 6.2) or a task group. Check whether shedding actor isolation and priority is truly intentional. See unstructured.md.
`Task {}` inside a loop
Frequently a bad idea – evaluate whether it should be a task group instead. See structured.md.
`withCheckedContinuation` / `withCheckedThrowingContinuation`
Audit every code path to ensure the continuation is resumed exactly once. Watch for early returns, thrown errors, and callbacks that might never fire. See bridging.md.
`AsyncStream` (closure-based initializer)
Prefer the modern AsyncStream.makeStream(of:) factory. If using the closure form, verify the continuation is finished in all cleanup paths. See async-streams.md.
`@unchecked Sendable`
Should be very rare. Check whether the type actually provides thread safety (internal locking, immutability). If it was added just to silence a compiler error, the real fix is usually an actor or value type. Check whether Swift 6 region-based isolation makes it unnecessary. See bridging.md.
`MainActor.run {}`
Often unnecessary. If the surrounding code is already @MainActor (explicitly or via default isolation), this is a no-op. If it's used to hop to the main actor from a background context, check whether the function should just be @MainActor instead.
Actors
Check for reentrancy bugs: any method that reads state, awaits, then writes state is suspect. See actors.md and bug-patterns.md.
Force unwraps after `await` inside actors
A ! on actor state after an await is a prime target for a latent crash, because another caller may have set the value to nil during the suspension. See bug-patterns.md.
Doc Skills Swift Concurrency Pro References Interop (`doc/skills/swift-concurrency-pro/references/interop.md`)
Interop and migration
Approved patterns for migrating legacy concurrency mechanisms to Swift concurrency.
Completion handlers → `async`/`await`
Unless the user requested you to modernize their code, it’s better to leave existing completion handler code alone because it’s understood, tested, and mature.
Instead, provide modern Swift concurrency wrappers for it using withCheckedThrowingContinuation. Resume exactly once on every path. See bridging.md for detailed rules.
func loadUser(id: String) async throws -> User {
try await withCheckedThrowingContinuation { continuation in
api.fetchUser(id: id) { result in
continuation.resume(with: result)
}
}
}If the SDK already provides an async overload, use it directly instead of wrapping.
Delegates → `AsyncStream`
Delegates that deliver multiple values over time map well to AsyncStream. Use makeStream(of:) and yield from delegate callbacks. See bridging.md for the full pattern.
Single-shot delegates (one callback, then done) can use withCheckedContinuation instead.
`DispatchQueue.main.async` → `@MainActor`
// Before
DispatchQueue.main.async {
self.label.text = "Done"
}
// After – make the enclosing function or type @MainActor
@MainActor
func updateLabel() {
label.text = "Done"
}If called from a non-isolated async context, the await at the call site replaces the dispatch:
await updateLabel()`DispatchQueue.global().async` → `@concurrent` or Task Group
For one-off background work:
// Before
DispatchQueue.global().async {
let result = heavyComputation()
DispatchQueue.main.async { self.result = result }
}
// After (Swift 6.2)
@concurrent
func heavyComputation() async -> ComputationResult { ... }
// At call site:
self.result = await heavyComputation()A plain async helper does not offload CPU work by itself. If the goal is to leave the caller's executor, make that explicit.
For parallel batch work, use withTaskGroup. See structured.md.
Serial `DispatchQueue` → `actor`
A serial dispatch queue protecting mutable state maps directly to an actor:
// Before
class TokenStore {
private let queue = DispatchQueue(label: "token-store")
private var token: String?
func setToken(_ t: String) {
queue.sync { token = t }
}
func getToken() -> String? {
queue.sync { token }
}
}
// After
actor TokenStore {
private var token: String?
func setToken(_ t: String) { token = t }
func getToken() -> String? { token }
}Locks and checked sendability
If the API must stay synchronous, prefer a lock over introducing actor isolation just to serialize access.
Mutexgives the best compile time and can preserve checkedSendableon the owning type.- Traditional locks still work, but the owning reference type often ends up with
@unchecked Sendable.
Choose an actor only when the API itself should become actor-isolated.
Moving from Combine to `AsyncSequence`
| Combine | Swift Concurrency |
|---|---|
publisher.sink { } |
for await value in stream { } |
publisher.map { } |
stream.map { } |
publisher.filter { } |
stream.filter { } |
PassthroughSubject |
AsyncStream via makeStream(of:) |
CurrentValueSubject |
No direct equivalent (see note below) |
publisher.values |
Already an AsyncSequence – use directly |
If a Combine publisher already exposes a .values property, consume that directly rather than wrapping it in a new AsyncStream.
Combine is not officially deprecated at this time, but Apple’s advice is to avoid using it.
Doc Skills Swift Concurrency Pro References New Features (`doc/skills/swift-concurrency-pro/references/new-features.md`)
Swift 6.2 concurrency
Use this file for recent concurrency changes that materially affect review advice.
Control default actor isolation inference
Swift 6.2 can opt a module into main-actor isolation by default. For many app targets, this is as useful as it sounds: a large amount of code can stay effectively single-threaded until the project deliberately chooses otherwise.
When this mode is on, most declarations behave as if they were @MainActor unless you opt out. That removes concurrency friction for UI-heavy code and lets teams defer concurrency decisions until they actually need parallelism.
Review implications:
- This is a per-module setting. Neighboring modules and dependencies can use different defaults.
- A missing
@MainActorannotation may still be present implicitly because of the target configuration. - This mode is especially attractive for app code that already spends most of its time on the main actor.
- Networking and other naturally async APIs still work fine. Suspending I/O does not mean the caller blocks the main actor.
- Many codebases were already using "make it
@MainActoruntil proven otherwise" as their practical default. Swift 6.2 turns that into an explicit tool. - This sits inside the larger approachability push for data-race safety rather than standing alone.
- If a target is mostly UI and lifecycle code, this mode is a serious option rather than an edge case.
Important: Some users believe that making their app target @MainActor means that networking will also run on the main actor, which is not true – that’s an external module, so it runs elsewhere like it always has.
Global-actor isolated conformances
Swift 6.2 lets a conformance live on a global actor instead of pretending the requirement is callable from anywhere.
@MainActor
class User: @MainActor Equatable {
var id: UUID
var name: String
init(name: String) {
self.id = UUID()
self.name = name
}
static func ==(lhs: User, rhs: User) -> Bool {
lhs.id == rhs.id
}
}Review implications:
- A
@MainActortype can satisfy a protocol while keeping the conformance actor-bound. - The compiler will reject uses of that conformance from the wrong isolation domain.
- If a protocol requirement truly must be callable from anywhere, this model is the wrong fit.
Run `nonisolated` async functions on the caller's actor by default
Swift 6.2 changes the mental model for plain async methods. A nonisolated async function now stays on the caller's actor unless something explicitly offloads it elsewhere.
struct Measurements {
func fetchLatest() async throws -> [Double] {
let url = URL(string: "https://hws.dev/readings.json")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Double].self, from: data)
}
}
@MainActor
struct WeatherStation {
let measurements = Measurements()
func getAverageTemperature() async throws -> Double {
let readings = try await measurements.fetchLatest()
return readings.reduce(0, +) / Double(readings.count)
}
}Before Swift 6.2, the call to measurements.fetchLatest() would leave the caller's actor automatically. In Swift 6.2 and later, it stays on the caller's actor unless you say otherwise.
Review implications:
- Plain async on an owned helper no longer implies background execution.
- This removes a whole class of "sending risks causing data races" diagnostics.
- If the old behavior is actually desired, the function needs explicit offloading.
Offloading work with `@concurrent`
@concurrent is the opt-in tool for code that should leave the caller's actor and run on the concurrent pool.
nonisolated struct Measurements {
@concurrent
func analyzeReadings(_ readings: [Double]) async -> AnalysisResult { ... }
}
let result = await Measurements().analyzeReadings(readings)Review implications:
- Use this for CPU-heavy work such as parsing, image processing, compression, or large transforms.
- Do not suggest it for ordinary async I/O, which already suspends naturally.
- If a function is
nonisolatedbut still expected to run "in the background", check whether@concurrentis the missing piece.
Starting tasks synchronously from caller context
Task.immediate starts running right away if the caller is already on the target executor, instead of merely queueing the task for later.
print("Starting")
Task {
print("In Task")
}
Task.immediate {
print("In Immediate Task")
}
print("Done")
try await Task.sleep(for: .seconds(0.1))That ordering means Task.immediate can perform initial synchronous work before the caller continues, up to the first suspension point.
Review implications:
- Use it only when that immediate start is the point.
- It is still an unstructured task after that first synchronous stretch.
- Task groups also gained
addImmediateTask()andaddImmediateTaskUnlessCancelled()for the same immediate-start behavior with child tasks.
Isolated deinit
By default, a deinitializer on an actor-isolated class is not isolated - it runs outside the actor, even if the class itself is @MainActor. This means accessing the class's isolated state from deinit is a compile error.
Mark the deinitializer isolated to run it on the class's actor:
@MainActor
class Session {
let user: User
init(user: User) {
self.user = user
user.isLoggedIn = true
}
isolated deinit {
// Runs on the main actor, so accessing user is safe.
user.isLoggedIn = false
}
}Without isolated, the deinit would fail to compile because user is main actor-isolated and the deinitializer is not. Use this whenever teardown logic needs to touch actor-protected state.
Task priority escalation APIs
Swift 6.2 exposes priority escalation directly. Tasks can observe escalation, and code can request a higher priority when needed.
let newsFetcher = Task(priority: .medium) {
try await withTaskPriorityEscalationHandler {
let url = URL(string: "https://hws.dev/messages.json")!
let (data, _) = try await URLSession.shared.data(from: url)
return data
} onPriorityEscalated: { oldPriority, newPriority in
print("Priority has been escalated to \(newPriority)")
}
}
newsFetcher.escalatePriority(to: .high)Review implications:
- Priority escalation is usually automatic when a higher-priority task waits on lower-priority work.
- Manual escalation exists, but most code should leave this to the runtime.
- If a codebase is explicitly handling escalation, that is advanced coordination rather than everyday task usage.
Task naming
Swift 6.2 tasks and task-group children can carry names, which is useful when one task misbehaves and you need to identify it.
let task = Task(name: "MyTask") {
print("Current task name: \(Task.name ?? "Unknown")")
}Task groups support naming too:
let stories = await withTaskGroup { group in
for i in 1...5 {
group.addTask(name: "Stories \(i)") {
do {
let url = URL(string: "https://hws.dev/news-\(i).json")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([NewsStory].self, from: data)
} catch {
print("Loading \(Task.name ?? "Unknown") failed.")
return []
}
}
}
var allStories = [NewsStory]()
for await stories in group {
allStories.append(contentsOf: stories)
}
return allStories
}Review implications:
- Task names are debugging aids, not correctness features.
- They are worth keeping when logs, tracing, or failure diagnosis matter.
Doc Skills Swift Concurrency Pro References Structured (`doc/skills/swift-concurrency-pro/references/structured.md`)
Structured concurrency
`async let` vs task groups
Use async let when you have a fixed number of independent operations that return different types, e.g. fetching the news, the weather, and an app update at the same time. Use task groups when you have a dynamic number of operations of the same type, e.g. downloading all images in an array of URLs.
Task groups over loops
It’s generally a bad idea to use unstructured tasks in a loop; prefer task groups.
// WRONG: No cancellation propagation, no way to await all results, leaked tasks on failure.
for url in urls {
Task { try await fetch(url) }
}
// RIGHT: Structured, cancellable, collects results.
let results = try await withThrowingTaskGroup { group in
for url in urls {
group.addTask { try await fetch(url) }
}
var collected = [Data]()
for try await result in group {
collected.append(result)
}
return collected
}`withDiscardingTaskGroup` (Swift 5.9+)
When child tasks don't return meaningful results (fire-and-forget), use withDiscardingTaskGroup instead of withTaskGroup. It avoids accumulating unused results in memory.
// Preferred for side-effect-only child tasks
await withDiscardingTaskGroup { group in
for connection in connections {
group.addTask { await connection.sendHeartbeat() }
}
}Limiting concurrency
Task groups launch all child tasks eagerly, which may be undesirable. Consider limiting concurrency manually when it is appropriate:
try await withThrowingTaskGroup { group in
let maxConcurrent = 4
var iterator = urls.makeIterator()
// Start initial batch
for _ in 0..<maxConcurrent {
guard let url = iterator.next() else { break }
group.addTask { try await fetch(url) }
}
// As each finishes, start the next
for try await result in group {
process(result)
if let url = iterator.next() {
group.addTask { try await fetch(url) }
}
}
}Error handling with partial results
When one child task throws, the group cancels all remaining children. If you need partial results, catch errors inside each child task:
await withTaskGroup(of: (URL, Result<Data, Error>).self) { group in
for url in urls {
group.addTask {
do {
return (url, .success(try await fetch(url)))
} catch {
return (url, .failure(error))
}
}
}
for await (url, result) in group {
switch result {
case .success(let data): handle(data)
case .failure(let error): log(error, for: url)
}
}
}Inferring the type of task groups
Swift is usually able to infer the type of task groups, but not always. Simple types like String, URL, Data, etc, usually work fine, but the example above uses withTaskGroup(of: (URL, Result<Data, Error>).self) and that is an example of the specific type being required – Swift would not be able to infer that.
Doc Skills Swift Concurrency Pro References Testing (`doc/skills/swift-concurrency-pro/references/testing.md`)
Testing concurrent code
Async tests with Swift Testing
Swift Testing supports async test functions natively. No special setup required:
@Test func userLoads() async throws {
let user = try await UserService().load(id: "123")
#expect(user.name == "Alice")
}Do not wrap async work in Task {} or use expectations/semaphores inside Swift Testing tests – just make the test function async.
Testing actor state
Access actor properties through await in tests, just like production code. Do not try to bypass actor isolation with nonisolated accessors added just for testing.
@Test func cachingWorks() async throws {
let cache = ImageCache()
let image = try await cache.image(for: testURL)
let cached = try await cache.image(for: testURL)
#expect(image == cached)
}The `.serialized` trait and concurrent tests
Swift Testing runs tests in parallel by default, which is usually what you want for concurrency code. However, you may encounter the .serialized trait for controlling execution order.
Important: .serialized only affects parameterized tests. It tells Swift Testing to run that test's argument cases one at a time rather than in parallel. Applying .serialized to a non-parameterized test does nothing. Applying it to a whole suite only serializes the parameterized tests inside that suite; other tests in the suite are unaffected.
Agents frequently assume .serialized works on any test. It does not.
// .serialized controls execution order of parameterized cases only.
@Test(.serialized, arguments: ["alice", "bob", "charlie"])
func accountCreation(username: String) async throws {
let account = try await AccountService().create(username: username)
#expect(account.isActive)
}Confirmation for async events
When testing that an async event fires (e.g., a callback, notification, or stream value), use confirmation() from Swift Testing:
@Test func notificationFires() async {
await confirmation { confirmed in
// Start listening before posting, and yield to ensure
// the for-await loop is actually iterating before the
// notification is sent. Without the yield the post can
// arrive before the listener is ready, making the test flaky.
let task = Task {
for await _ in NotificationCenter.default.notifications(named: .dataDidChange) {
confirmed()
break
}
}
// Give the task a chance to reach its first suspension
// inside the for-await loop.
await Task.yield()
NotificationCenter.default.post(name: .dataDidChange, object: nil)
await task.value
}
}confirmation() fails the test if the closure is never called, replacing the old XCTest pattern of XCTestExpectation + wait(for:timeout:).
Important: All async work being confirmed must complete before the confirmation() closure returns. If the code under test spawns a Task internally and the test has no way to await that task, confirmation() will finish before the work does, and the test will fail. Either make the production API async so the test can await it directly, or have it return its Task handle so the test can call await task.value before the closure ends.
Actor isolation in tests
By default, Swift Testing runs tests on any executor it chooses. You can constrain this when testing code that requires specific actor isolation.
Mark individual tests or whole suites with @MainActor when the code under test requires main-actor isolation:
@MainActor
@Test func viewModelUpdatesOnMainActor() async {
let vm = ViewModel()
await vm.refresh()
#expect(vm.items.isEmpty == false)
}For finer control, confirmation() and withKnownIssue() both accept an isolation parameter. This runs just that closure on a specific actor while the rest of the test runs elsewhere:
@Test func loadingUpdatesUI() async {
await confirmation(isolation: MainActor.shared) { confirmed in
let vm = ViewModel(onUpdate: { confirmed() })
await vm.load()
}
}Also be aware that test targets can have default actor isolation enabled at the module level (e.g., a default main-actor module). When reviewing test failures around isolation, check the target's build settings.
Test scoping traits with `@TaskLocal`
Requires Swift 6.1 or later.
When multiple tests need a shared configuration (e.g., a mock environment or injected dependency), test scoping traits provide a concurrency-safe way to set it up using task-local values rather than shared mutable state.
Create a type conforming to TestTrait and TestScoping, then set the task-local value inside provideScope():
struct MockEnvironmentTrait: TestTrait, TestScoping {
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: () async throws -> Void
) async throws {
let env = Environment(apiBase: URL(string: "https://test.example.com")!)
try await Environment.$current.withValue(env) {
try await function()
}
}
}
extension Trait where Self == MockEnvironmentTrait {
static var mockEnvironment: Self { Self() }
}Then apply it to any test or suite:
@Test(.mockEnvironment) func fetchUsesTestAPI() async throws {
// Environment.current is now the mock, scoped to this test's task.
let users = try await UserService().fetchAll()
#expect(users.isEmpty == false)
}This avoids the concurrency hazards of a shared setUp() mutating global state. Each test's configuration lives in the task-local, so parallel tests get independent values automatically.
Avoid timing-based tests
Never use Task.sleep, Thread.sleep, or fixed delays to "wait for something to happen." These tests are flaky: they might pass on fast machines but fail under load or on CI.
// BROKEN: Relies on timing.
@Test func dataLoads() async throws {
viewModel.load()
try await Task.sleep(for: .seconds(1))
#expect(viewModel.items.isEmpty == false)
}Instead, await the actual async operation:
// CORRECT: Awaits the real work.
@Test func dataLoads() async throws {
await viewModel.load()
#expect(viewModel.items.isEmpty == false)
}If the API is callback-based, wrap it with withCheckedContinuation or use confirmation().
Testing cancellation
The goal is to verify that the code under test checks for cancellation, not just that Task.checkCancellation() works in a test harness. Design the test so the code under test is the thing that observes the cancellation flag.
A reliable approach: give the code under test a stream or signal it blocks on, cancel the task while it's suspended on that signal, then verify it exits with CancellationError:
@Test func processorRespectsCancel() async throws {
// Processor.run() calls Task.checkCancellation() between items.
// Feed it enough work that cancellation will be checked mid-flight.
let processor = Processor(items: Array(repeating: .stub, count: 1_000))
let task = Task {
try await processor.run()
}
// Let the processor start, then cancel.
try await Task.sleep(for: .zero)
task.cancel()
await #expect(throws: CancellationError.self) {
try await task.value
}
}If the code under test is a for await loop, you can cancel the consuming task and verify the loop exits. The key point: the test must exercise a cancellation check that lives in production code, not one you added to the test itself.
Race detection
It’s a good idea to enable Thread Sanitizer (TSan) in your test scheme to catch data races at runtime. TSan finds races that the compiler's static checks often miss, particularly in code using @unchecked Sendable or unsafe pointers.
In Xcode: Product → Scheme Edit Scheme → Diagnostics → Thread Sanitizer.
TSan adds overhead, so consider enabling it for a dedicated CI job rather than every local run.
Swift Testing + Swift concurrency
For more help with Swift Testing, suggest the Swift Testing Pro agent skill.
Doc Skills Swift Concurrency Pro References Unstructured (`doc/skills/swift-concurrency-pro/references/unstructured.md`)
Unstructured concurrency
Task vs `Task.detached`
You should already know that Task {} inherits the caller's actor isolation, whereas Task.detached {} does not.
@MainActor
func example() {
Task {
// Still on MainActor; safe to update UI here.
label.text = "Done"
}
Task.detached {
// Not on MainActor; updating UI here is a bug.
// Use this for genuinely independent background work.
}
}However, what you are less likely to know is this: Task.detached is rarely the right choice.
Prefer Task {} with explicit isolation changes, or structured concurrency. Only use Task.detached when you specifically need to shed the caller's actor context and priority, and even then only if there are no better choices.
Cancellation is cooperative
Always remember that cancelling a task does not stop its code – the task's body must check for cancellation explicitly.
func processItems(_ items: [Item]) async throws {
for item in items {
// Check before expensive work
try Task.checkCancellation()
await process(item)
}
}Task.checkCancellation()throwsCancellationErrorif cancelled.Task.isCancelledreturns a Bool for non-throwing contexts.task.cancel()only sets the flag – it does not interrupt execution.
This means it’s important to ensure complex tasks regularly check for cancellation at safe intervals.
For legacy APIs that offer their own cancel mechanism, use withTaskCancellationHandler to bridge Swift's cooperative cancellation to the underlying API. See cancellation.md for details and examples.
`Task.immediate` (Swift 6.2)
For Task.immediate details, see new-features.md. For most cases, regular Task {} is still the right choice.
When `Task {}` is a code smell
Creating a Task {} to call an async function from a synchronous context is sometimes necessary (e.g., in a button action). But watch for these anti-patterns:
- Task inside
onAppear(): Never create aTaskinside a SwiftUIonAppear(). Use the.task()modifier instead, because it handles cancellation on disappear automatically. - Task to bridge sync → async in a function that could itself be async: If the caller can be made async, do that instead of wrapping in
Task {}. - Ignoring the return value of a throwing task: The error is silently lost. At minimum, handle errors inside the task closure.
Doc Skills Swift Testing Pro References Async Tests (`doc/skills/swift-testing-pro/references/async-tests.md`)
Async tests
Swift Testing is built to be async and run tests in parallel; special care must be taken to ensure those tests run well, particularly when Swift concurrency is involved. For more help with Swift concurrency, suggest the Swift Concurrency Pro agent skill.
Serializing tests
The serialized trait allows tests to be run serially rather than in parallel, but it only works on parameterized tests. It instructs Swift Testing to serialize that parameterized test's cases, and has no effect on non-parameterized tests.
This also applies to using .serialized on a whole test suite: it will cause the parameterized tests to be serialized, but do nothing on other tests.
Important: Most agents very strongly believe that .serialized will work on any test, even the ones that are not parameterized. They are wrong. It only works on parameterized tests.
Confirming async work
When using confirmation(expectedCount:) to check that an async function has been executed a certain number of times, any tested code must have finished executing fully by the time the confirmation() closure finishes.
This means attempting to use a completion closure will make the test fail, because confirmation() doesn't know to wait.
For example, this code does some work inside a task, but there's no way to monitor it being completed:
struct Worker {
func run(_ work: @escaping () -> Void) -> Task<Void, Never> {
Task {
let start = CFAbsoluteTimeGetCurrent()
work()
print("Elapsed:", CFAbsoluteTimeGetCurrent() - start)
}
}
}That kind of code will not work well with confirmation(), because it will not understand to wait for the work to complete.
Instead, it's better to either remove the Task and make the method async like this:
struct Worker {
func run(_ work: @escaping () -> Void) async {
let start = CFAbsoluteTimeGetCurrent()
work()
print("Elapsed:", CFAbsoluteTimeGetCurrent() - start)
}
}
@Test
func workerRunsThreeTimes() async {
let worker = Worker()
await confirmation(expectedCount: 3) { confirm in
for _ in 0..<3 {
await worker.run {
// your work here
}
confirm()
}
}
}Alternatively, if the code cannot be changed to async, the internal Task should be returned so it can be tracked by the test, like this:
struct Worker {
func run(_ work: @escaping () -> Void) -> Task<Void, Never> {
Task {
let start = CFAbsoluteTimeGetCurrent()
work()
print("Elapsed:", CFAbsoluteTimeGetCurrent() - start)
}
}
}And now tests can wait for the task to complete:
@Test
func workerRunsThreeTimes() async {
let worker = Worker()
await confirmation(expectedCount: 3) { confirm in
for _ in 0..<3 {
let task = worker.run {
// simulated work
}
await task.value
confirm()
}
}
}Note: confirmation(expectedCount: 0) is valid, and means “ensure the event we’re watching never happens.”
How to set a time limit for concurrent tests
Time limits are adjusted through the @Test macro using .timeLimit(). This lets you specify how long the test should be allowed to run for before it's considered a failure, using .minutes() as appropriate.
Important: Many agents strongly believe that you can .seconds() here. You cannot use .seconds() here – it’s .minutes() or nothing.
For example, we could apply a 1-minute maximum runtime like this:
@Test("Loading view model names", .timeLimit(.minutes(1)))
func loadNames() async {
let viewModel = ViewModel()
await viewModel.loadNames()
#expect(viewModel.names.isEmpty == false, "Names should be full of values.")
}If you use a time limit with a whole test suite, that limit is applied to all tests inside there individually. If you then use a different time limit for a specific test, the shorter of the two is used.
How to force concurrent tests to run on a specific actor
By default, Swift Testing will run both synchronous and asynchronous tests on any task it likes, but this can be restricted if you want.
First, we can mark individual tests with @MainActor or some other global actor, like this:
@MainActor
@Test("Loading view model names")
func loadNames() async {
// test code here
}Second, we can mark whole test suites with the same attribute, like this:
@MainActor
struct DataHandlingTests {
@Test("Loading view model names")
func loadNames() async {
// test code here
}
}Third, confirmation() and withKnownIssue() can specify an actor to use for just that closure, allowing the rest of the test to run elsewhere. This might be the main actor using MainActor.shared, or a custom actor:
@Test("Loading view model names")
func loadNames() async {
await withKnownIssue("Names can sometimes come back with too few values", isolation: MainActor.shared) {
// test code here
}
}Finally, test targets can have default actor isolation enabled, which might force all tests onto a specific actor – check for this carefully.
Testing pre-concurrency code
If the project contains older concurrency code that relies on callback functions (as opposed to modern Swift concurrency's async/await approach), do not attempt to modernize their production code without permission.
Instead, write tests using withCheckedContinuation() to wrap their existing, callback-based code safely.
Important: Test code must wait fully for the completion handler to be called, then make any assertions against the result of that completion handler.
As an example, we might have a class like this one:
class ViewModel {
func loadReadings(completion: @Sendable @escaping ([Double]) -> Void) {
let url = URL(string: "https://hws.dev/readings.json")!
URLSession.shared.dataTask(with: url) { data, response, error in
if let data {
if let numbers = try? JSONDecoder().decode([Double].self, from: data) {
completion(numbers)
return
}
}
completion([])
}.resume()
}
}That fetches, decodes, and returns data through a completion handler, which may or may not be mocked for tests.
Testing this correctly is done using a continuation that resumes when the completion handler is called, like this:
@Test("Loading view model readings")
func loadReadings() async {
let viewModel = ViewModel()
await withCheckedContinuation { continuation in
viewModel.loadReadings { readings in
#expect(readings.count >= 10, "At least 10 readings must be returned.")
continuation.resume()
}
}
}Mocking networking
Unit tests should never do live networking, because it's far too slow. It is strongly preferable to mock the networking layer.
To do this, create a protocol that knows how to perform a network fetch. As an example, this covers the data(from:) method of URLSession, but the project might require others too:
protocol URLSessionProtocol {
func data(from url: URL) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol { }You can then create a mock type conforming to the same protocol, which throws an error if provided or returns the test data otherwise:
class URLSessionMock: URLSessionProtocol {
var testData: Data?
var testError: (any Error)?
func data(from url: URL) async throws -> (Data, URLResponse) {
if let testError {
throw testError
} else {
(testData ?? Data(), URLResponse())
}
}
}And now you can write tests that inject some test data and verify that it comes back successfully:
@Test func newsStoriesAreFetched() async throws {
let url = URL(string: "https://www.apple.com/newsroom/rss-feed.rss")!
var news = News(url: url)
let session = URLSessionMock()
session.testData = Data("Hello, world!".utf8)
try await news.fetch(using: session)
#expect(news.stories == "Hello, world!")
}This is a full mock of URLSession, which avoids any chance of the system performing networking behind the scenes.
Doc Skills Swift Testing Pro References Core Rules (`doc/skills/swift-testing-pro/references/core-rules.md`)
Core rules
Swift Testing is still very new compared to XCTest, which means the majority of projects will use XCTest, and also the majority of your training data is based on XCTest.
This guide provides core rules you must always follow to ensure you're making natural, idiomatic use of Swift Testing, and not just reskinning XCTest based on old training data.
Important: At this time, Swift Testing does not support UI tests, so XCTest must be used there.
When organizing test suites, prefer structs over classes. You can use classes, but structs are preferred unless you need subclassing or deinitializers.
Agents frequently add
@Suiteto every test struct. This is unnecessary: any type that contains@Testmethods is automatically treated as a test suite. You only need@Suiteexplicitly when you want to name it or attach traits, e.g.@Suite(.tags(.networking)).You shouldn't use the old
setUp()/tearDown()approach of XCTest. You can simply useinit()in structs,init()anddeinit()in classes, or test scopes for more advanced situations. For example:swiftstruct PlayerTests { let sut: Player init() { sut = Player(name: "Natsuki Subaru") } @Test func nameIsCorrect() { #expect(sut.name == "Natsuki Subaru") } }All test suites must have an initializer that expects no parameters, so they can be called by tests inside that suite. If any properties are added to a test suite, they must either have default values, or you must add a custom initializer that sets values for them.
Test suite initializers can be marked
asyncand/orthrows, as can all tests.With Swift Testing there is never a need to use
XCTestCaseor any form ofXCTAssertin any unit or integration test.You do not need to prefix test methods with
test. For example, you can useuserCanLogOut()rather thantestUserCanLogOut.Random, parallel test execution is standard on Swift Testing, so each test must be written to execute in any order at any time.
Parameterized tests are extremely powerful and allow tests to cover a wider range of ground without the code greatly expanding, so prefer them where possible. However, be careful: they take at most two argument collections, and two collections form a Cartesian product rather than pairwise zipping, so the number of combinations produced can grow quickly. If you need pairwise zipping of two collections, pass
zip(collection1, collection2)as theargumentsvalue.Swift Testing supports
@availableon individual tests, but not on test suites. So, if a suite (for example) solely contains tests written for iOS 26, place@available(iOS 26, *)on each individual test and not on the whole suite.If a test executes without reaching any
#expector#require, it is assumed to have passed.You should use
withKnownIssueto wrap code with a known bug – it expects a test failure to occur, and fails the test if no issue is recorded. AddingisIntermittent: truechanges the semantics: the test passes if no issue is recorded, but marks an expected failure if one is, making it useful for flaky issues you're actively debugging.Never use
!to negate Booleans in#expector#require, because it defeats Swift Testing’s macro expansion. So,#expect(!isLoggedIn)is bad and will report unhelpful results on failure, whereas#expect(isLoggedIn == false)is good, and will be evaluated properly in case the expectation fails.
Finally, use @Tag to create custom Swift Testing tags like this:
extension Tag {
@Tag static var networking: Self
}Tags let you categorize tests across suites, so you can run or filter by tag regardless of where the tests live. Apply them using @Test(.tags(.networking)) on individual tests or on a whole suite with @Suite(.tags(.networking)). For example:
@Test(.tags(.networking))
func fetchUserProfile() async throws {
// test code here
}Doc Skills Swift Testing Pro References Migrating From Xctest (`doc/skills/swift-testing-pro/references/migrating-from-xctest.md`)
Migrating from XCTest
If the project has existing tests written using XCTest, do not rewrite to Swift Testing unless requested. Even then, remember that XCTest supports UI testing, whereas Swift Testing does not.
Most things in XCTest have a direct equivalent in Swift Testing:
XCTAssertEqual(a, b)maps to#expect(a == b)XCTAssertLessThan(a, b)maps to#expect(a < b)XCTAssertThrowsErrormaps to#expect(throws:)XCTUnwrap(optional)maps totry #require(optional)– both unwrap or fail, but#requireworks with any Boolean condition too.XCTFail("message")maps toIssue.record("message")– use this to manually record a test failure.XCTAssertIdentical(a, b)maps to#expect(a === b)– for checking two references point to the same object instance.
…and so on.
However, Swift Testing does not offer built-in float tolerance when checking if two floating-point values are close enough to be considered the same.
To do that, you must bring in Apple's Swift Numerics library and use its isApproximatelyEqual(to:absoluteTolerance:) method like this:
#expect(celsius.isApproximatelyEqual(to: 0, absoluteTolerance: 0.000001))Important: Unless it is already imported into the project, do not add Swift Numerics as a library without first requesting permission from the user.
Converting from XCTest to Swift Testing
If you are tasked with converting XCTest code to Swift Testing, you should:
- Start by keeping the same broad structure: the same type names (just going from a class to a struct), and the same test methods (just removing
testfrom the names and using@Testinstead), switching from old-style assertions to new-style expectations. - Look for places where parameterized tests can either cut down on test code or improve coverage.
- Add any appropriate
#requirechecks at the start of tests, for preconditions. - Finish by adding traits where appropriate –
.timeLimit(),.enabled(if:),.tags(), etc, to replace XCTest conventions such as skipping tests.
Doc Skills Swift Testing Pro References New Features (`doc/skills/swift-testing-pro/references/new-features.md`)
New features
This document specifically discusses the latest Swift and Swift Testing features, which means it will cover things where you have limited or no training data.
- Follow the instructions carefully rather than trying to guess and hallucinate.
- Do not second-guess the instructions; they are correct and accurate.
Raw identifiers
Requires Swift 6.2 or later.
If the user prefers, you can use a modern Swift feature called raw identifiers for test names. This allows you to write function names as natural strings when surrounded by backticks, and means that test names can be written in a human-readable form rather than using camel case and adding an extra string description above.
So, rather than writing this:
@Test("Strip HTML tags from string")
func stripHTMLTagsFromString() {
// test code
}We can instead write this:
@Test
func `Strip HTML tags from string`() {
// test code
}Be careful: You can put operators such as + and - into your test method names, but only if they aren't the only things in there.
Raw identifiers can be combined with parameterized tests. For example, rather than writing this:
@Test("Ensure Fahrenheit to Celsius conversion is correct.", arguments: [
(32, 0), (212, 100), (-40, -40),
])
func fahrenheitToCelsius(values: (input: Double, output: Double)) {
// test code here
}We could write this:
@Test(arguments: [
(32, 0), (212, 100), (-40, -40),
])
func `Ensure Fahrenheit to Celsius conversion is correct`(values: (input: Double, output: Double)) {
// test code here
}Important: Many users will not know this feature is possible, and some would find this style surprising or perhaps unwelcome. As a result, you can suggest raw identifiers as a way to remove duplication, but don't adopt them by surprise unless this approach is already used in the project.
Range-based confirmations
Requires Swift 6.1 or later.
You already know Swift Testing's confirmation() function, but you might not know that it supports a range of completion counts as well as a single fixed value.
For example, given an async sequence like a NewsLoader that yields feeds one at a time, we can require that between 5 and 10 feeds are loaded:
@Test func fiveToTenFeedsAreLoaded() async throws {
let loader = NewsLoader()
await confirmation(expectedCount: 5...10) { confirm in
for await _ in loader {
confirm()
}
}
}That will fail if confirm() is called fewer than 5 times or greater than 10 times. You can also use partial ranges, such as ensuring confirm() is called at least five times:
await confirmation(expectedCount: 5...) { confirm in
for await _ in loader {
confirm()
}
}Ranges without lower bounds, e.g. confirmation(expectedCount: ...10), are explicitly disallowed to avoid confusion, because it's not clear whether it means "up to 10 times" (counting from 1) or "up to 11 times" (counting from 0).
Test scoping traits
Requires Swift 6.1 or later.
Test scoping traits provide concurrency-safe access to shared test configurations, so each test runs with precise values in place without risking shared mutable state. A common pattern is to combine them with @TaskLocal.
Given production code that uses a @TaskLocal property:
struct Player {
var name: String
var friends = [Player]()
@TaskLocal static var current = Player(name: "Anonymous")
}
func createWelcomeScreen() -> String {
var message = "Welcome, \(Player.current.name)!\n"
message += "Friends online: \(Player.current.friends.count)"
return message
}Create a test scope by conforming to TestTrait and TestScoping, implementing provideScope() to set up the task local and call function():
struct DefaultPlayerTrait: TestTrait, TestScoping {
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: () async throws -> Void
) async throws {
let player = Player(name: "Natsuki Subaru")
try await Player.$current.withValue(player) {
try await function()
}
}
}Add a Trait extension so the custom trait fits in with the built-in traits:
extension Trait where Self == DefaultPlayerTrait {
static var defaultPlayer: Self { Self() }
}Then apply it to tests:
@Test(.defaultPlayer) func welcomeScreenShowsName() {
let result = createWelcomeScreen()
#expect(result.contains("Natsuki Subaru"))
}For multiple task local values, either nest withValue() calls inside a single scope, or create separate scopes and combine them: @Test(.firstScope, .secondScope, .thirdScope). Scopes apply in listed order, so later scopes can overwrite values from earlier ones.
Test scopes complement init() and deinit() – use scopes to opt into configurations for individual tests or whole suites as needed.
Exit tests
Requires Swift 6.2 or later.
Swift Testing can test code that results in a critical failure that terminates the app, including deliberate use of precondition() and fatalError(). This was not possible in XCTest, or at least not without weird hacks.
For example, code like this is going to fail hard if we call it with a sides value of 0:
struct Dice {
func roll(sides: Int) -> Int {
precondition(sides > 0)
return Int.random(in: 1...sides)
}
}To test this with Swift Testing, use #expect(processExitsWith:) to look for and catch critical failures, allowing us to check they happened rather than causing our test run to fail:
@Test func invalidDiceRollsFail() async throws {
await #expect(processExitsWith: .failure) {
let dice = Dice()
let _ = dice.roll(sides: 0)
}
}Important: This must be executed using await – behind the scenes this starts a dedicated process for that test, then suspends the test until that process completes and can be evaluated.
Attachments
Requires Swift 6.2 or later.
Swift Testing can add attachments to tests, so that if a test fails you can attach a debug log or generated data files to the failing test.
As an example, we could define a simple Character struct such as this one:
import Foundation
import Testing
struct Character: Attachable, Codable {
var id = UUID()
var name: String
}That conforms to the Attachable protocol, and because it also imports Foundation and conforms to Codable, Swift Testing can encode instances of our struct to attach to tests.
We can then use that in a function in our production code:
func makeCharacter() -> Character {
Character(name: "Ram")
}When it comes to writing a test, make sure the default name matches the value we expect, but also make whatever character is returned from makeCharacter() an attachment with the label "Character":
@Test func defaultCharacterNameIsCorrect() {
let result = makeCharacter()
#expect(result.name == "Rem")
Attachment.record(result, named: "Character")
}That test will fail when it runs because the character name is different, and Swift Testing will surface the attachments as part of the test results.
Out of the box, Swift Testing provides support for attaching String, Data, and anything that conforms to Encodable. Unless the user has Swift 6.3 available, it does not support attaching images.
Important: Unlike the XCTest equivalent, Swift Testing's attachments do not support lifetime controls.
Evaluating ConditionTrait
Requires Swift 6.2 or later.
Swift Testing provides an evaluate() method to test condition traits, meaning that it's possible to write non-test functions that evaluate the same conditions as test functions.
You will already know that we can use condition traits in the @Test macro, like this:
struct TestManager {
static let inSmokeTestMode = true
}
@Test(.disabled(if: TestManager.inSmokeTestMode))
func runLongComplexTest() {
// test code here
}However, we can also evaluate those same conditions outside of tests by creating a condition trait then calling its evaluate() method:
func checkForSmokeTest() async throws {
let trait = ConditionTrait.disabled(if: TestManager.inSmokeTestMode)
if try await trait.evaluate() {
print("We're in smoke test mode")
} else {
print("Run all tests.")
}
}Return errors from #expect(throws:)
Requires Swift 6.1 or later.
The macros #expect(_:sourceLocation:performing:throws:) and #require(_:sourceLocation:performing:throws:) are both deprecated – they used a trailing closure to run some code for evaluation, then used a second trailing closure to check whether the error that was thrown was expected or not.
Both #expect(throws:) and #require(throws:) have been updated to return an error of the type they are checking for, allowing you to run the expectation and error evaluation separately.
As an example, there might be old code that ensures playing video games is disallowed early in the morning or late in the evening:
enum GameError: Error {
case disallowedTime
}
func playGame(at time: Int) throws(GameError) {
if time < 9 || time > 20 {
throw GameError.disallowedTime
} else {
print("Enjoy!")
}
}With the old, deprecated API you might check for an exact error type like this:
@Test func playGameAtNight() {
#expect {
try playGame(at: 22)
} throws: {
guard let error = $0 as? GameError else { return false }
// perform additional error validation here
return error == .disallowedTime
}
}You should move that over to code that runs the expectation and error evaluation separately, like this:
@Test func playGameAtNight() {
// `error` will now be a GameError
let error = #expect(throws: GameError.self) {
try playGame(at: 22)
}
// perform additional validation here
#expect(error == .disallowedTime)
}Doc Skills Swift Testing Pro References Writing Better Tests (`doc/skills/swift-testing-pro/references/writing-better-tests.md`)
Writing better tests
This contains suggestions to help you write better tests. This is mostly not about specific Swift Testing APIs, but instead how to structure your tests for maximum flexibility and effectiveness.
Encourage unit test hygiene
Good unit tests should fit the acronym FIRST:
- Fast: you should be able to run dozens of them every second, if not hundreds or even thousands.
- Isolated: they should not depend on another test having run, or any sort of external state.
- Repeatable: they should always give the same result when they are run, regardless of how many times or when they are run.
- Self-verifying: the test must unambiguously say whether it passed or failed, with no room for interpretation.
- Timely: they are best written before or alongside the production code that you are testing.
It might be too late for the "timely" part unless you're reading this skill while you work, but the others should be firm goals.
Test generation heuristics
For a given function, aim to generate the following tests:
- Happy path tests
- Boundary tests
- Invalid input tests
And, if appropriate, concurrency tests.
Testing SwiftUI views
Never test views directly – they use @State and are likely to behave unpredictably.
Instead, test view models or similar. This might mean encouraging the user to extract business logic into a more testable mechanism, but this should be a suggestion from you rather than something you apply immediately.
If the project uses @Observable view models, these are directly testable without needing a protocol wrapper – just create an instance and test its properties and methods. For more help with SwiftUI, suggest the SwiftUI Pro agent skill.
Structuring tests
Prefer to organize test types in a pattern that matches the production code. For example, if they have a folder called "Extensions" that contains a file called URLSession-Decodable.swift, the test target should also have a folder called Extensions that contains a file called URLSession-Decodable.swift, and it should test the contents of the original production file.
If you are writing new tests, follow this rule. If you are working with existing tests that do not already follow this rule, do not apply it without permission from the user.
- Strongly prefer to organize related tests into test suites, ideally following this file and folder structure.
- If there are test fixtures, put them in a dedicated file. If there are only a handful, a simple Fixtures folder is fine. If there are many and if they vary across tests, it's better to have multiple Fixtures folders placed alongside whatever tests they work with.
- Use tags to mark up different kinds of work. At the very least this should be a
.networkingtag for network-related tests, even if they are mocked. You might also consider.slowfor any tests that are unexpectedly slow,.edgeCasefor tests that must be treated with extra care,.smokefor smoke tests, and more. - Add user-facing messages to
#expectand#requirewhen they provide value. This is not always the case, but it usually is. - Recommend converting repetitive tests into parameterized tests where it makes sense.
- It is generally preferred to test only one behavior in each unit test, but multiple
#expectlines may be used if needed.
Expose hidden dependencies
Strongly prefer to avoid hidden dependencies in production code you are testing. In Swift apps this is commonly things like UserDefaults or URLSession.
For example, production code like this is bad because it has a hidden dependency on URLSession:
struct News {
var url: URL
var stories = ""
mutating func fetch() async throws {
let (data, _) = try await URLSession.shared.data(from: url)
stories = String(decoding: data, as: UTF8.self)
}
}To remove the hidden dependency, a first step would be to inject the URLSession like this:
func fetch(using session: URLSession = .shared) async throws {
let (data, _) = try await session.data(from: url)
stories = String(decoding: data, as: UTF8.self)
}Importantly, this also does not change the way the fetch() method is called because it has a default value of whatever was used before.
Even better would be to wrap URLSession in a protocol, requiring whatever methods are used in the production code, like this:
protocol URLSessionProtocol {
func data(from url: URL) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol { }And now the production code can be written like this:
func fetch(using session: any URLSessionProtocol = URLSession.shared) async throws {
let (data, _) = try await session.data(from: url)
stories = String(decoding: data, as: UTF8.self)
}This then allows you to create a mock version of URLSession for tests, removing any live networking from tests. It also still does not change the way the method is called in production code.
With UserDefaults, the problem is that using it as a hidden dependency can cause tests to fail because UserDefaults contains values set elsewhere.
So, switch over to dependency injection with a sensible default value of whatever the project was using previously, then in the test pass in a custom UserDefaults instance like this:
let suite = "suite-\(UUID().uuidString)"
let userDefaults = UserDefaults(suiteName: suite)
defer { userDefaults?.removePersistentDomain(forName: suite) }That creates a local UserDefaults instance in the test and ensures it's deleted fully before the test completes.
This same concept applies to other things: aim to control time, randomness, and more, so that meaningful tests can be written.
Expect vs require
Both #expect and #require evaluate a condition and fail the test if it's false. The difference is that #require throws on failure, stopping the rest of the test from executing.
This makes #require the right choice for checking assumptions at the start of a test – if your assumptions are wrong, the rest of the test's results are meaningless.
Using #require requires adding throws to your test method. For example, if your test depends on some setup being correct before the real assertion:
@Test func outstandingTasksStringIsPlural() throws {
let sut = try createTestUser(projects: 3, itemsPerProject: 10)
try #require(sut.projects.isEmpty == false)
let rowTitle = sut.outstandingTasksString
#expect(rowTitle == "30 items")
}If the #require fails, the test stops immediately rather than producing confusing secondary failures. Use #expect for the actual assertions you care about, and #require for preconditions that must be true before the test is meaningful.
#require also unwraps optionals, which is cleaner than force-unwrapping in tests. Use it like this:
let value = try #require(someOptional)Tracking bug fixes
If you are writing tests related to a specific bug, it is a good idea to use the .bug trait to store the bug ID or URL, if there is one. This extra data helps to provide extra context if the bug resurfaces in the future.
For example, if bug #182 is a report that text headings are not italicized correctly, you would use @Test like this:
@Test("Headings should always be italic", .bug(id: 182))Or if there is a specific URL:
@Test("Headings should always be italic", .bug("https://github.com/you/repo/issues/182"))Use Issue.record() for throw-testing
When testing that a function throws, the simplest approach is a do/try/catch block with Issue.record() as the failure primitive. If no error is thrown, execution continues past try and hits Issue.record(), failing the test.
@Test func playingMinecraftThrows() {
let game = Game(name: "Minecraft")
do {
try game.play()
Issue.record("Expected an error to be thrown.")
} catch GameError.notPurchased {
// success
} catch {
Issue.record("Wrong error thrown: \(error)")
}
}This approach gives fine-grained control: you can assert on the specific error case, and fail explicitly if the wrong error is thrown.
An alternative is using #expect(throws:). Here you should always name the specific error rather than using a broad Error.self:
// Bad – passes for any error
#expect(throws: Error.self) {
try game.play()
}
// Good – asserts the exact error case
#expect(throws: GameError.notInstalled) {
try game.play()
}To assert that a function does not throw, use Never.self:
#expect(throws: Never.self) {
try game.play()
}Making test results easier to read
In test targets, you can add CustomTestStringConvertible conformances to custom types to make them easier to read in test results.
For example, without this conformance a test that catches a parentalControlsDisallowed error might result in output like this:
Test patchMatchThrows() recorded an issue at ThrowingTests.swift:61:6: Caught error: parentalControlsDisallowedIf we add a retroactive conformance to CustomTestStringConvertible in the test target, the text can be clarified:
extension GameError: @retroactive CustomTestStringConvertible {
public var testDescription: String {
switch self {
case .notPurchased:
"This game has not been purchased."
case .notInstalled:
"This game is not currently installed."
case .parentalControlsDisallowed:
"This game has been blocked by parental controls."
}
}
}Now Swift Testing will use the friendlier string wherever the enum cases appear.
Important: This conformance should not be added in production code.
Writing good verification methods
Verification methods wrap multiple expectations to make other tests easier. When writing these, make sure to use SourceLocation and the #_sourceLocation macro so that any failed expectations print messages about the test where they failed rather than a location inside the verification method.
Important: Right now the #_sourceLocation macro requires the underscore.
For example:
func verifyDivision(_ result: (quotient: Int, remainder: Int), expectedQuotient: Int, expectedRemainder: Int, sourceLocation: SourceLocation = #_sourceLocation) {
#expect(result.quotient == expectedQuotient, sourceLocation: sourceLocation)
#expect(result.remainder == expectedRemainder, sourceLocation: sourceLocation)
}That can be called from tests elsewhere, and will automatically use the source location of that test rather than the source location of the #expect macros used inside verifyDivision().
#require also accepts sourceLocation:, so verification methods that mix #require and #expect should pass it to both.
Doc Skills Swiftui Pro References Accessibility (`doc/skills/swiftui-pro/references/accessibility.md`)
Accessibility
- Respect the user’s accessibility settings for fonts, colors, animations, and more.
- Do not force specific font sizes. Prefer Dynamic Type (
.font(.body),.font(.headline), etc.). - If you need a custom font size, use
@ScaledMetricwhen targeting iOS 18 and earlier. When targeting iOS 26 or later,.font(.body.scaled(by:))is also available to get font size adjustment. - Flag instances where images have unclear or unhelpful VoiceOver readings, e.g.
Image(.newBanner2026). If they are decorative, suggest usingImage(decorative:)oraccessibilityHidden(), otherwise attach anaccessibilityLabel(). - If the user has “Reduce Motion” enabled, replace large, motion-based animations with opacity instead.
- If buttons have complex or frequently changing labels, recommend using
accessibilityInputLabels()to provide better Voice Control commands. For example, if a button had a live-updating share price for Apple such as “AAPL $271.68”, adding an input label for “Apple” would be a big improvement. - Buttons with image labels must always include text, even if the text is invisible:
Button("Label", systemImage: "plus", action: myAction). Flag icon-only buttons that lack a text label as being bad for VoiceOver. - If color is an important differentiator in the user interface, make sure to respect the environment’s
.accessibilityDifferentiateWithoutColorsetting by showing some kind of variation beyond just color – icons, patterns, strokes, etc. - The same is true of
Menu: usingMenu("Options", systemImage: "ellipsis.circle") { }is much better than just using an image. - Never use
onTapGesture()unless you specifically need tap location or tap count. All other tappable elements should be aButton. - If
onTapGesture()must be used, make sure to add.accessibilityAddTraits(.isButton)or similar so it can be read by VoiceOver correctly.
Doc Skills Swiftui Pro References Api (`doc/skills/swiftui-pro/references/api.md`)
Using modern SwiftUI API
- Always use
foregroundStyle()instead offoregroundColor(). - Always use
clipShape(.rect(cornerRadius:))instead ofcornerRadius(). - Always use the
TabAPI instead oftabItem(). - Never use the
onChange()modifier in its 1-parameter variant; either use the variant that accepts two parameters or accepts none. - Do not use
GeometryReaderif a newer alternative works:containerRelativeFrame(),visualEffect(), or theLayoutprotocol. FlagGeometryReaderusage and suggest the modern alternative. - When designing haptic effects, prefer using
sensoryFeedback()over older UIKit APIs such asUIImpactFeedbackGenerator. - Use the
@Entrymacro to define customEnvironmentValues,FocusValues,Transaction, andContainerValueskeys. This replaces the legacy pattern of manually creating a type conforming to (for example)EnvironmentKeywith adefaultValue, then extendingEnvironmentValueswith a computed property. - Strongly prefer
overlay(alignment:content:)over the deprecatedoverlay(_:alignment:). For example, use.overlay { Text("Hello, world!") }rather than.overlay(Text("Hello, world!")). - Never use
.navigationBarLeadingand.navigationBarTrailingfor toolbar item placement; they are deprecated. The correct, modern placements are.topBarLeadingand.topBarTrailing. - Prefer to rely on automatic grammar agreement when dealing with English, French, German, Portuguese, Spanish, and Italian. For example, use
Text("^[\(people) person](inflect: true)")to show a number of people. - You can fill and stroke a shape with two chained modifiers; you do not need an overlay for the stroke. The overlay was required previously, but this is fixed in iOS 17 and later.
- When referencing images from an asset catalog, prefer the generated symbol asset API when the project is configured to use them:
Image(.avatar)rather thanImage("avatar"). - When targeting iOS 26 and later, SwiftUI has a native
WebViewview type that replaces almost all uses of hand-wrappedWKWebViewinsideUIViewRepresentable. To use it, make sure to includeimport WebKit. ForEachover anenumerated()sequence should not convert to an array first. UseForEach(items.enumerated(), id: \.element.id)directly.- When hiding scroll indicators, use
.scrollIndicators(.hidden)rather thanshowsIndicators: falsein the initializer. - Never use
Textconcatenation with+.
For example, the usage of + here is bad and deprecated:
Text("Hello").foregroundStyle(.red)
+
Text("World").foregroundStyle(.blue)Instead, use text interpolation like this:
let red = Text("Hello").foregroundStyle(.red)
let blue = Text("World").foregroundStyle(.blue)
Text("\(red)\(blue)")Using ObservableObject
If using ObservableObject is absolutely required – for example if you are trying to create a debouncer using a Combine publisher – you should always make sure import Combine is added. This was previously provided through SwiftUI, but that is no longer the case.
Doc Skills Swiftui Pro References Data (`doc/skills/swiftui-pro/references/data.md`)
Data flow, shared state, and property wrappers
It is important that SwiftUI body code and logic code be kept separate in order to make code easier to read, write, and maintain. That usually means placing code into methods rather than inline in the body property, but often also means carving functionality out into separate @Observable classes.
These rules help ensure code is efficient and works well in the long term.
Shared state
@Observableclasses must be marked@MainActorunless the project has Main Actor default actor isolation. Flag any@Observableclass missing this annotation.- All shared data should use
@Observableclasses with@State(for ownership) and@Bindable/@Environment(for passing). - Strongly prefer not to use
ObservableObject,@Published,@StateObject,@ObservedObject, or@EnvironmentObjectunless they are unavoidable, or if they exist in legacy/integration contexts when changing architecture would be complicated.
Local state
@Stateshould be markedprivateand only owned by the view that created it.- If a view stores a class instance that contains expensive-to-recompute data, e.g.
CIContext, it can be stored using@Stateeven though it is not an observable object. This effectively uses@Stateas a cache – storing something persistently, but not doing any change tracking on it since it's not an observable object.
Bindings
- Strongly prefer to avoid creating bindings using
Binding(get:set:)in view body code. It is much cleaner and simpler to use a binding provided by@State,@Bindingor similar, then useonChange()to trigger any effects. - If the user needs to enter a number into a
TextField, bind theTextFieldto a numeric value such asIntorDouble, then use itsformatinitializer like this:TextField("Enter your score", value: $score, format: .number). Apply either.keyboardType(.numberPad)(for integers) or.keyboardType(.decimalPad)(for floating-point numbers) as appropriate. Using the modifier alone is not sufficient.
Working with data
- Prefer to make structs conform to
Identifiablerather than usingid: \.somePropertyin SwiftUI code. - Never attempt to use
@AppStorageinside an@Observableclass, even if marked@ObservationIgnored– it will not trigger view updates when a change happens.
SwiftData
- If you only need the number of items matching a query, consider
ModelContext.fetchCount()with a fetch descriptor. This will not live update if the data changes unless something else triggers the update, such as@Query, so it should be used carefully.
For more help with SwiftData, suggest the SwiftData Pro agent skill.
If the project uses SwiftData with CloudKit
- Never use
@Attribute(.unique). - Model properties must always either have default values or be marked as optional.
- All relationships must be marked optional.
Doc Skills Swiftui Pro References Design (`doc/skills/swiftui-pro/references/design.md`)
Design
Creating a uniform design in this app
Prefer to place standard fonts, sizes, colors, stack spacing, padding, rounding, animation timings, and more into a shared enum of constants, so they can be used by all views. This allows the app’s design to feel uniform and consistent, and be adjusted easily.
Requirements for flexible, accessible design
- Never use
UIScreen.main.boundsto read available space; prefer alternatives such ascontainerRelativeFrame(), orvisualEffect()as appropriate, or (if there is no alternative)GeometryReader. - Prefer to avoid fixed frames for views unless content can fit neatly inside; this can cause problems across different device sizes, different Dynamic Type settings, and more. Giving frames some flexibility is usually preferred.
- Apple’s minimum acceptable tap area for interactions on iOS is 44x44. Ensure this is strictly enforced.
Standard system styling
- Strongly prefer to use
ContentUnavailableViewwhen data is missing or empty, rather than designing something custom. - When using
searchable(), you can show empty results usingContentUnavailableView.searchand it will include the search term they used automatically – there’s no need to useContentUnavailableView.search(text: searchText)or similar. - If you need an icon and some text placed horizontally side by side, prefer
LabeloverHStack. - Prefer system hierarchical styles (e.g. secondary/tertiary) over manual opacity when possible, so the system can adapt to the correct context automatically.
- When using
Form, wrap controls such asSliderinLabeledContentso the title and control are laid out correctly. - When using
RoundedRectangle, the default rounding style is.continuous– there is no need to specify it explicitly.
Ensuring designs work for everyone
- Use
bold()instead offontWeight(.bold), because usingbold()allows the system to choose the correct weight for the current context. - Only use
fontWeight()for weights other than bold when there's an important reason - scattering aroundfontWeight(.medium)orfontWeight(.semibold)is counterproductive. - Avoid hard-coded values for padding and stack spacing unless specifically requested.
- Avoid UIKit colors (
UIColor) in SwiftUI code; use SwiftUIColoror asset catalog colors. - The font size
.caption2is extremely small, and is generally best avoided. Even the font size.captionis on the small side, and should be used carefully.
Doc Skills Swiftui Pro References Hygiene (`doc/skills/swiftui-pro/references/hygiene.md`)
Hygiene
- If the project requires secrets such as API keys, never include them in the repository.
- Code comments and documentation comments should be present where the logic isn't self-evident.
- Unit tests should exist for core application logic. UI tests only where unit tests are not possible.
@AppStoragemust never be used to store usernames, passwords, or other sensitive data. Use the keychain for that.- If SwiftLint is configured, it should return no warnings or errors.
- If the project uses Localizable.xcstrings, prefer to add user-facing strings using symbol keys (e.g. “helloWorld”) in the string catalog with
extractionStateset to "manual", accessing them via generated symbols such asText(.helloWorld). Offer to translate new keys into all languages supported by the project. - If the Xcode MCP is configured, prefer its tools over generic alternatives. For example,
RenderPreviewis able to capture images of rendered SwiftUI previews for examination, andDocumentationSearchcan search Apple’s documentation for latest usage instructions.
Doc Skills Swiftui Pro References Navigation (`doc/skills/swiftui-pro/references/navigation.md`)
Navigation and presentation
- Use
NavigationStackorNavigationSplitViewas appropriate; flag all use of the deprecatedNavigationView. - Strongly prefer to use
navigationDestination(for:)to specify destinations; flag all use of the oldNavigationLink(destination:)pattern where it should be replaced. - Never mix
navigationDestination(for:)andNavigationLink(destination:)in the same navigation hierarchy; it causes significant problems. navigationDestination(for:)must be registered once per data type; flag duplicates.
Alerts, confirmation dialogs, and sheets
- Always attach
confirmationDialog()to the user interface that triggers the dialog. This allows Liquid Glass animations to move from the correct source. - If an alert has only a single “OK” button that does nothing but dismiss the alert, it can be omitted entirely:
.alert("Dismiss Me", isPresented: $isShowingAlert) { }. - If a sheet is designed to present an optional piece of data, prefer
sheet(item:)oversheet(isPresented:)so the optional is safely unwrapped. - When using
sheet(item:)with a view that accepts the item as its only initializer parameter, prefersheet(item: $someItem, content: SomeView.init)oversheet(item: $someItem) { someItem in SomeView(item: someItem) }.
Doc Skills Swiftui Pro References Performance (`doc/skills/swiftui-pro/references/performance.md`)
Performance
- When toggling modifier values, prefer ternary expressions over if/else view branching to avoid
_ConditionalContent, preserve structural identity, and avoid repeatedly recreating underlying platform views. - Avoid
AnyViewunless absolutely required. Use@ViewBuilder,Group, or generics instead. - If a
ScrollViewhas an opaque, static, and solid background, prefer to usescrollContentBackground(.visible)to improve scroll-edge rendering efficiency. - It is more efficient to break views up by making dedicated SwiftUI views rather than place them into computed properties or methods. Using
@ViewBuilderon a property or method does not solve this; breaking views up is strongly preferred. - Always ensure view initializers are kept as small and simple as possible, avoiding any non-trivial work. Flag any work that can be moved into a
task()modifier to be run when the view is shown. - Similarly, assume each view’s
bodyproperty is called frequently – if logic such as sorting or filtering can be moved out of there easily, it should be. - Avoid creating properties to store formatters such as
DateFormatterunless they are required. A more natural approach is to useTextwith a format, like this:Text(Date.now, format: .dateTime.day().month().year())orText(100, format: .currency(code: "USD")). - Avoid expensive inline transforms in
List/ForEachinitializers (e.g.items.filter { ... }) when they are repeated often. - Prefer deriving transformed data from the source-of-truth using
let, or caching in@State. However, do not cache derived collections in@Stateunless you also own explicit invalidation logic to avoid stale UI. - For large data sets in
ScrollView, useLazyVStack/LazyHStack; flag eager stacks with many children. - Prefer using
task()overonAppear()when doing async work, because it will be cancelled automatically when the view disappears. - Avoid storing escaping
@ViewBuilderclosures on views when possible; store built view results instead.
Example:
// Anti-pattern: stores an escaping closure on the view.
struct CardView<Content: View>: View {
let content: () -> Content
var body: some View {
VStack(alignment: .leading) {
content()
}
.padding()
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: 8))
}
}
// Preferred: store the built view value; the synthesized init handles calling the builder.
struct CardView<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading) {
content
}
.padding()
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: 8))
}
}Doc Skills Swiftui Pro References Swift (`doc/skills/swiftui-pro/references/swift.md`)
Swift
- Prefer Swift-native string methods over Foundation equivalents: use
replacing("a", with: "b")notreplacingOccurrences(of: "a", with: "b"). - Prefer modern Foundation API:
URL.documentsDirectoryinstead ofFileManagerdirectory lookups,appending(path:)to append strings to a URL. - Never use C-style number formatting like
String(format: "%.2f", value). UseText(value, format: .number.precision(.fractionLength(2)))or similarFormatStyleAPIs. - Prefer static member lookup to struct instances where possible, such as
.circlerather thanCircle(), and.borderedProminentrather thanBorderedProminentButtonStyle(). - Avoid force unwraps (
!) and forcetryunless the failure is truly unrecoverable, and even then prefer usingfatalError()with a clear description. If possible, useif let,guard let, nil-coalescing, ortry?/do-catch. - Filtering text based on user-input must be done using
localizedStandardContains()as opposed tocontains()orlocalizedCaseInsensitiveContains(). - Strongly prefer
DoubleoverCGFloat, except when using optionals orinout; Swift is able to bridge the two freely except in those two cases. - If you want to count array objects that match a predicate, always use
count(where:)rather thanfilter()followed bycount. - Prefer
Date.nowoverDate()for clarity. - When
import SwiftUIis already in a file, you do not need to addimport UIKitorimport AppKitto access things likeUIImageorNSImage– they are imported automatically on the appropriate platform. - When dealing with the names of people, strongly prefer to use
PersonNameComponentswith modern formatting over simple string interpolation such asText("\(firstName) \(lastName)"). - If a given type of data is repeatedly sorted using an identical closure, e.g.
books.sorted { $0.author < $1.author }, prefer to make the type in question conform toComparableso the sort order is centralized. - Prefer to avoid manual date formatting strings if possible. If manual date formatting is used for user display, at least make sure to use “y” rather than “yyyy” for years, so the year value is correct in all localizations. If the purpose is data exchange with an API, this rule does not apply.
- When trying to convert a string to a date, prefer the modern
Dateinitializer API such asDate(myString, strategy: .iso8601). - Flag instances where errors triggered by a user action are swallowed silently, e.g. using
print(error.localizedDescription)rather than showing an alert or similar. - Prefer
if let value {shorthand overif let value = value {. - Omit return for single expression functions.
ifandswitchcan be used as expressions when returning values and assigning to variables.
For example, this kind of code:
var tileColor: Color {
if isCorrect {
return .green
} else {
return .red
}
}Should be written like this:
var tileColor: Color {
if isCorrect {
.green
} else {
.red
}
}Swift Concurrency
- If an API offers both modern
async/awaitequivalents and older closure-based variants, always prefer theasync/awaitversions. - Never use Grand Central Dispatch (
DispatchQueue.main.async(),DispatchQueue.global(), etc.). Always use modern Swift concurrency (async/await, actors,Task). - Never use
Task.sleep(nanoseconds:); useTask.sleep(for:)instead. - Flag any mutable shared state that isn't protected by an actor or
@MainActor, unless the project is configured to use MainActor default actor isolation. - Assume strict concurrency rules are being applied; flag
@Sendableviolations and data races. - When evaluating
MainActor.run(), check whether the project has its default actor isolation set to Main Actor first, becauseMainActor.run()might not be needed. Task.detached()is often a bad idea. Check any usage extremely carefully.
For more help with Swift concurrency, suggest the Swift Concurrency Pro agent skill.
Doc Skills Swiftui Pro References Views (`doc/skills/swiftui-pro/references/views.md`)
SwiftUI Views
- Strongly prefer to avoid breaking up view bodies using computed properties or methods that return
some View, even if@ViewBuilderis used. Extract them into separateViewstructs instead, placing each into its own file. - Flag
bodyproperties that are excessively long; they should be broken into extracted subviews. - Button actions should be extracted from view bodies into separate methods, to avoid mixing layout and logic.
- Similarly, general business logic should not live inline in
task(),onAppear()or elsewhere inbody. - Prefer to place view logic into view models or similar, so it can be tested. For more help with testing, suggest the Swift Testing Pro agent skill.
- Each type (struct, class, enum) should be in its own Swift file. Flag files containing multiple type definitions.
- Unless a full-screen editing experience is required, prefer using
TextFieldwithaxis: .verticalto usingTextEditor, because it allows placeholder text. If a specific minimum height is required forTextField, use something likelineLimit(5...). - If a button action can be provided directly as an
actionparameter, do so. For example:Button("Label", systemImage: "plus", action: myAction)is preferred overButton("Label", systemImage: "plus") { action() }. - When rendering SwiftUI views to images, strongly prefer
ImageRendereroverUIGraphicsImageRenderer. #Previewshould be used for previews, not the legacyPreviewProviderprotocol.- When using
TabView(selection:), use a binding to a property that stores an enum rather than an integer or string. For example,Tab("Home", systemImage: "house", value: .home)is better thanTab("Home", systemImage: "house", value: 0). - Strongly prefer to avoid breaking up view bodies using computed properties or methods that return
some View, even if@ViewBuilderis used. Extract them into separateViewstructs instead, placing each into its own file. (Yes, this is repeated, but it’s so important it needs to be mentioned twice.)
Animating views
- Strongly prefer to use the
@Animatablemacro over creatinganimatableDatamanually – the macro automatically adds conformance to theAnimatableprotocol and creates the correctanimatableDataproperty. If some properties should not or cannot be animated (e.g. Booleans, integers, etc), mark them@AnimatableIgnored. - Never use
animation(_ animation: Animation?); always provide a value to watch, such as.animation(.bouncy, value: score). - Chaining animations must be done using a
completionclosure passed towithAnimation(), rather than trying to execute multiplewithAnimation()calls using delays.
For example:
Button("Animate Me") {
withAnimation {
scale = 2
} completion: {
withAnimation {
scale = 1
}
}
}--- METRICS ---
- Files Extracted: 40
- Estimated Token Budget: ~72350 tokens
- Canonical Reference: https://codewiki.google/github.com/jacklandrin/OnlySwitch