Start a plugin marketplace for Apple platform work

Skills have been living inside one app's repository, which means the next
Xcode project starts without them and a copy is the only way to share.
A marketplace makes them installable once at user scope.

Two kinds of knowledge, handled differently. What Apple ships with Xcode
stays Apple's: this repository carries a script that exports it from the
locally installed Xcode rather than a copy, so nobody redistributes
pre-release material and everyone's guidance matches their own SDK.

What we learned by shipping goes in the skill, and only if it burned us —
the file writes hidden in view bodies, the blobs that froze the main
thread, the watch target that quietly stopped compiling, the test summary
that reads as zero tests when eighty-four passed. Each of those cost real
time twice before being written down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
TomUviandClaude Opus 5 committed 2026-08-16 23:07:08 +02:00
commit 905ad2ce3e
6 files changed
+302

No files matched your search

@@ -0,0 +1,162 @@
---
name: xcode-practice
description: Practices for SwiftUI, Core Data, XcodeGen and Swift Testing that come from shipped apps rather than documentation — work that must not happen in a view body, blobs that must stay off the main thread, derived collections computed once, Keychain access outside view init, multi-target project hygiene, and reading test output correctly. Use when writing or reviewing SwiftUI views, adding files to an XcodeGen project, or interpreting a test run.
---
# Practices for Apple platform apps
These are not restatements of Apple's guidance. Each one is here because it
caused a freeze, a regression, or a wrong conclusion in a real app, and the
same mistake was made more than once.
Apple's own skills — `swiftui-specialist`, `swiftui-whats-new-27`,
`app-intents-specialist` and the rest that ship with Xcode — are the
authority on framework APIs. This skill covers what they do not: the
failure modes of putting those APIs together. Where they overlap, Apple
wins.
## Nothing that touches the disk, the network or a renderer belongs in `body`
A view's `body` runs far more often than it looks. Every unrelated state
change re-runs it, so anything expensive in there runs on the main thread
at the frequency of redraws.
Three separate instances of this shipped before being caught:
- `ShareLink(item: writeVCardFile())` — wrote a file to disk on every
redraw of a detail screen, because the argument is evaluated when the
view is built, not when the user taps share.
- Serialising a whole record to JSON inside a toolbar `Menu`, twice, to
decide whether two menu items should appear.
- Rendering barcodes with Core Image inside `body`, at up to 12× scale.
The fix is always the same shape: prepare the value in `.task(id:)`, hold
it in `@State`, and let `body` read the finished thing.
```swift
@State private var exportURL: URL?
var body: some View {
List { … }
.task(id: exportSignature) {
let payload = Payload(record) // main actor: reads the model
exportURL = await Task.detached { // off the main thread: writes
payload.writeTemporaryFile()
}.value
}
}
```
Give `.task` an `id` that covers every field the output depends on, or the
file goes stale after an edit.
## Keep external-storage blobs off the main thread
Core Data attributes with external storage (photos, PDFs) hit the file
system when read, deleted, or attached — the property access looks free
and is not. Three separate freezes in one app traced back to this.
Delete through `performBackgroundTask`, build attachments in the
background, write temporary files in a detached task. Never touch a blob
attribute in a `body`, in a `ForEach`, or in a synchronous button action.
## Compute derived collections once per pass
A computed property that sorts or filters runs again at every use, and a
computed property used *inside a closure* runs once per element:
```swift
// O(n² log n): `hero` re-sorts the whole collection for every card
let rest = sortedCards.filter { $0 != hero }
```
Hoist it into a local (`let hero = hero`), or better, build one value that
carries everything the body needs — the sorted list, the highlighted item,
the groups, the filter chips — and compute it once at the top of `body`.
Watch for repeated database fetches hidden inside such properties; a
registry lookup called from three computed properties is three fetches per
redraw.
## Keychain access does not belong in a view's `init`
`State(initialValue: Keychain.load(…))` in an initialiser looks like it
runs once. View initialisers run on every parent redraw, and the value is
discarded every time but the first. Read it in the parent's `.task` and
pass the loaded value down as a plain `let`.
Also: check what the Keychain returned. `SecItemUpdate` and `SecItemAdd`
fail on a locked device (`errSecInteractionNotAllowed`). Ignoring the
status means the person walks away believing a PIN was saved that was not.
Return the status and surface it.
## Prefer `sheet(item:)` over hand-built bindings
`sheet(isPresented: Binding(get:set:))` allocates a closure on every body
evaluation and compares poorly. When two optionals must agree — a share
and its container, say — wrap them in one `Identifiable` value so the
sheet cannot open with half the data.
## Cache expensive renderers, not just their output
`CIContext()` is costly to create; one shared instance serves the whole
app. Rendered images are worth an `NSCache` keyed by their inputs, which
fixes every call site at once instead of restructuring each view.
## XcodeGen: regenerate, then build every target
After adding a file: `xcodegen generate`, or the build fails with "cannot
find in scope".
More important, and easier to miss: targets list their sources
individually. A model type added for the phone app is *not* automatically
available to a watch or widget target that compiles the same shared model
file. A watch app sat broken for several commits this way, because only
the iOS and macOS builds were being checked.
Build every target after a change to shared model code, not just the one
being worked on.
## Read the right test summary
With Swift Testing, `xcodebuild`'s familiar line
```
Executed 0 tests, with 0 failures
```
is the XCTest counter and says nothing about Swift Testing suites. Theirs
looks like:
```
✔ Test run with 84 tests in 14 suites passed after 0.809 seconds.
```
Reaching a conclusion from the wrong line means declaring a whole suite
dead when it is passing. Also: do not pipe a test run through `tail` or a
narrow `grep` and then diagnose from what survived — filter the saved log
afterwards instead.
## Core Data in tests
Build objects through the context's own model rather than
`NSManagedObject.entity()`; with more than one coordinator alive, the
latter returns an entity from the wrong store and the object silently
loses its defaults. Keep the context alive for the whole test — a context
that deallocates mid-test takes its objects with it.
## Default main actor isolation
In a project that defaults to main actor isolation, plain data types need
`nonisolated` explicitly: `Codable` structs crossing to background work,
`ActivityAttributes` conformances handed to ActivityKit, enums used from
detached tasks. Swift 6 rejects using a main-actor-isolated conformance
from a nonisolated async context, so this surfaces as a build failure
against a new SDK rather than as a bug.
## Small things that cost an afternoon each
- `navigationDestination` belongs on the root of a stack, not inside rows.
- One `CLMonitor` instance per name; a second one crashes with "already in
use".
- Anchor `confirmationDialog` on the control that triggered it, or iPad
shows a popover floating in the middle of the screen.