iOS Share Extension (Convex + Clerk)
Add a "Save to " target to the iOS share sheet that saves the shared link/text through the same authenticated backend path the app already uses — no new server endpoint, no re-login. The extension reuses the app's Clerk session and calls a Convex action as the signed-in user.
The model (read this first)
An iOS Share Extension is a separate process with its own bundle id, its own entitlements, and its own signed-in state. It does NOT inherit the host app's keychain or network session for free. Two bridges make it work:
- Shared Clerk session via a keychain access group — so the extension reads the app's login and calls Convex as that user. This is the part everyone gets wrong (see the gotcha below).
- Shared save-queue via an App Group
UserDefaults— so a save that can't reach the network (offline, cold extension) is never lost; the host app drains the queue when it next becomes active.
Both bridges require entitlements on both targets (app AND extension) and matching constants in code.
THE gotcha: Clerk's keychain service defaults to the bundle id
Clerk stores its session in the keychain under a service that defaults to the
process's bundle id. The app is com.example.myapp; the extension is
com.example.myapp.ShareExtension. Different bundle id → different keychain service →
the extension sees a signed-out user even though the app is logged in.
Fix: pin service AND accessGroup to the same values in Clerk.configure(options:)
on both targets:
Clerk.configure(
publishableKey: Config.clerkPublishableKey,
options: .init(keychainConfig: .init(
service: SharedAuth.keychainService, // pinned, NOT the bundle id
accessGroup: SharedAuth.keychainAccessGroup // teamID.<shared-id>
))
)
Migration cost: pinning service/accessGroup MOVES where the session is stored,
so an already-signed-in user re-signs-in ONCE after you ship this. Call it out in the
PR; it is expected, not a bug.
See reference/SharedAuth.swift for the constants.
Build order
- Entitlements on both targets — App Group + keychain-access-group.
reference/entitlements.md. - Pin Clerk's keychain in the app's
configure()(the migration step above) and in the extension. - Add the app-extension target in
project.yml(XcodeGen), embed it in the app, share the auth/config sources.reference/project.yml.snippet. - Info.plist activation rule — what the share sheet offers the extension for.
reference/Info.plist. - ShareViewController — extract content, crawl a preview, build an authed Convex client, call your save action, fall back to the App Group queue on failure.
reference/ShareViewController.swift. - Branded card UI — SwiftUI in a
UIHostingControllerwith a clear background.reference/ShareCardView.swift. - Host-app drain —
scenePhase == .active→ drain the App Group queue via the normal save path.reference/host-app-drain.swift. - (Optional) live category preview — a read-only Convex action so the card's chip matches what saving produces.
reference/convex-actions.ts.
Quick reference
| Concern | Answer |
|---|---|
| Extension type | app-extension, NSExtensionPointIdentifier = com.apple.share-services |
| Principal class | $(PRODUCT_MODULE_NAME).ShareViewController in Info.plist |
| Share the login | keychain-access-group entitlement + pinned Clerk service/accessGroup on BOTH targets |
| Share a save-queue | App Group entitlement + UserDefaults(suiteName:) on BOTH targets |
| Call the backend | reuse ConvexClientWithAuth + your ClerkConvexAuthProvider; await client.login() then client.action("app:saveFromApp", ...) |
| Never lose a share | on save failure, append to the App Group queue; host app drains on activate |
| What triggers the extension | NSExtensionActivationRule (web URL / web page / text) |
| Link preview | LPMetadataProvider (title + image), 8s timeout |
| Keep the archive light | share only Config.swift + ClerkConvexAuthProvider.swift + Shared/ into the extension target — not the whole app |
Common mistakes
- Extension shows "signed out" but the app is logged in → you didn't pin Clerk's
keychain
service/accessGroup, or the keychain-access-group entitlement is missing on one target, or theaccessGroupstring isn'tteamID.<id>(it needs the team prefix at runtime; the entitlement can use$(AppIdentifierPrefix)<id>). - App Group reads come back empty → the group string doesn't match on both targets,
or the App Group capability isn't enabled in the provisioning profile. It must be the
exact same
group.…on the app entitlement, the extension entitlement, and theUserDefaults(suiteName:)call. - Share sheet doesn't offer your app →
NSExtensionActivationRuledoesn't match the shared type (e.g. noSupportsWebURLWithMaxCount), or the extension isn't embedded in the app (- target: ShareExtensiondependency on the app inproject.yml). GENERATE_INFOPLIST_FILE: YESon the extension → overwrites yourNSExtension/activation rule. Set it toNOand pointINFOPLIST_FILEat your file.- Bundling the whole app into the extension → bloated, slow to launch, and pulls in UI code it can't use. Share only the auth/config/Shared sources it needs.
- Blocking the save on the network with no fallback → offline shares vanish. Always queue to the App Group on failure and drain from the host app.
Why route through the existing save action
The extension calls the same Convex action the app calls (app:saveFromApp), as the
same authenticated user. The server classifies, enriches, and inserts exactly as it
does from inside the app — so there is one code path to reason about, and the reactive
query updates the feed automatically when the app next opens. Don't build a
share-specific backend endpoint.