Crossdeck Docs
Dashboard

@cross-deck/swift — Swift SDK reference

Reference Current version: 1.5.1 · ~18 min read · Updated May 25, 2026

@cross-deck/swift is one native SDK that handles all three Crossdeck pillars across Apple platforms: verified subscriptions and entitlements, behavioural analytics, and error capture. Start one Crossdeck client at app launch and every screen tap, paywall gate, and uncaught exception flows through the same durable queue, the same idempotent network path, and the same dashboard your web users do. Zero runtime dependencies — built on Foundation / URLSession / os.Logger only.

TL;DR

Install

One Swift Package covers every Apple platform we support. The library target ships as a single module — import Crossdeck brings the entire public API into scope. Tested on iOS 13+, iPadOS 13+, macOS 11+, tvOS 13+, and watchOS 7+.

PlatformMinimum targetNotes
iOS / iPadOS13.0+StoreKit purchase rail features require iOS 15+ at runtime; the SDK still compiles + runs on 13/14 for non-purchase consumers (analytics, errors).
macOS11.0 Big Sur+Mac Catalyst apps report platform: "macos".
tvOS13.0+Same SDK; no UIDevice-specific code paths required.
watchOS7.0+Same SDK; lightweight enough to run inside a complication update.

Swift Package Manager — Xcode UI

  1. File → Add Package Dependencies…
  2. Paste the URL into the search field (top-right of the dialog):
    https://github.com/VistaApps-za/crossdeck-swift.git
  3. In the Dependency Rule dropdown on the right, select "Up to Next Major Version" and enter 1.5.0. Do not leave it set to "Branch: main" — branch tracking auto-pulls every commit including breaking changes when v2.0.0 lands. "Up to Next Major Version" gives you patch + minor updates automatically and lets you choose when to take breaking changes.
  4. Click Add Package. Xcode resolves the package and offers to add the Crossdeck library product to your app target — accept.
If your package is already tracking Branch: main, switch it to a tag.

If you added the Crossdeck package before stable tags existed, Xcode auto-anchored your project to Branch: main. That works but pulls every new commit silently. Do not try to fix this via File → Add Package Dependencies… — that dialog is hard-blocked from changing the rule on an existing dependency (the Dependency Rule dropdown greys out with "already depends on … with rule main" at the bottom). Don't waste time fighting it.

Path that works — change the rule in place via the project's Package Dependencies tab:

  1. In Xcode's file navigator (left sidebar), click your project's top-level entry — the one with the blue Xcode icon (the .xcodeproj file).
  2. The main editor now shows project settings. On the LEFT side of that editor are two columns: PROJECT and TARGETS. Click your project (e.g. Clippi) under PROJECTnot under TARGETS. (Easy to mix up; the rule editor only lives on the project, not the target.)
  3. Along the top tab bar of the editor, click Package Dependencies (next to Info, Build Settings, Build Phases).
  4. You'll see a table of every package your project depends on. Find crossdeck-swift.
  5. Double-click the crossdeck-swift row. A small sheet pops up with the Dependency Rule editor — this is the ONLY UI in Xcode that can change a rule on an already-added package.
  6. In the sheet, change the rule from Branch to Up to Next Major Version, set the version to 1.5.0.
  7. Click Done. Xcode re-resolves and rewrites Package.resolved.

If double-click doesn't open the sheet: select the row, then look for a small edit icon (pencil or chevron) on the row — or right-click → Modify Package Settings (exact label varies by Xcode version).

Bulletproof fallback (no Xcode UI): Quit Xcode. Open YourProject.xcodeproj/project.pbxproj in a text editor. Find the XCRemoteSwiftPackageReference "crossdeck-swift" block. Change the requirement entry from branch = main; to kind = upToNextMajorVersion; minimumVersion = 1.0.0;. Save. Re-open Xcode — it picks up the change and re-resolves.

Swift Package Manager — Package.swift

dependencies: [
    .package(url: "https://github.com/VistaApps-za/crossdeck-swift.git", from: "1.0.0"),
],
targets: [
    .target(
        name: "MyApp",
        dependencies: [
            .product(name: "Crossdeck", package: "crossdeck-swift"),
        ]
    ),
]

from: "1.5.0" is the SwiftPM shorthand for "Up to Next Major Version" — same rule as the Xcode picker. SwiftPM will resolve to the highest 1.x.x available, so future patches/minors land automatically; major bumps require an explicit rule change.

The product name is Crossdeck (no scope).

The repository is crossdeck-swift, the package name (in Package.swift) is crossdeck-swift, and the library product you import is Crossdeck. SwiftPM uses the product name for the import statement.

Quickstart — five lines, full mobile pillar

The fastest path from zero to "events flowing, errors captured, entitlements gated" is one start call in your app delegate or App's init — wrapped in do/catch so a misconfigured key never crashes a customer's launch:

import Crossdeck

let cd: Crossdeck?
do {
    cd = try Crossdeck.start(options: CrossdeckOptions(
        appId: "app_ios_xxx",
        publicKey: "cd_pub_live_…",        // or cd_pub_test_… for sandbox
        environment: .production                // MUST match the key prefix — see Initialise
    ))
} catch {
    cd = nil                                  // log + degrade, never crash the host app
}

// Anywhere in your view code (cd? propagates the Optional):
cd?.track("paywall_seen", properties: ["variant": "annual"])

if cd?.isEntitled("pro") == true {
    // gate logic — sync, cache-read fast
}

That single setup gives you, automatically and without writing any more code:

SwiftUI surfaces need two one-line bolt-ons

The auto-tracking above covers sessions, errors, queue durability, and identity with zero per-surface code. Two dashboard surfaces specifically — Pages and Top Actions / CTA labels — need one modifier per screen and one per important button on iOS 16+ SwiftUI. This isn't a CrossDeck regression; Apple's Metal text-rendering pipeline and SwiftUI's accessibility-merge model genuinely hide screen names and button labels from any UIView-level runtime introspection (the same wall every analytics SDK hits — Mixpanel, Amplitude, and PostHog all ship the same per-surface modifier pattern).

Two lines per screen, two lines per CTA — every other surface on the dashboard works the moment Crossdeck.start(...) succeeds. See the dedicated Screen views and Tap labels sections for the pattern matrix (NavigationStack, NavigationLink, .sheet, .fullScreenCover, TabView; Button, Image with .onTapGesture, custom layouts).

What you'll see on the dashboard

Once the bolt-ons are wired, the per-person journey on /dashboard/people/<id> reads like a real human flow — a diary, not a vanity-metric row count. For a user moving through your app, the timeline will read something like:

08:32:51 AM   Opened the app
08:32:52 AM   Viewed "Library"             5s
08:32:57 AM   Tapped "Create Image"
08:32:57 AM   Viewed "Create Image"        32s
08:33:29 AM   Tapped "Generate"
08:33:29 AM   Viewed "Generated Image"     2m 14s
08:35:43 AM   Tapped "Subscribe"
08:35:44 AM   Viewed "Paywall"             8s
08:35:52 AM   Backgrounded the app         3m 8s

Each Viewed line carries a duration pill — that's time-on-screen, computed from the gap to the next event. The Tapped lines render the CTA label resolved from your .crossdeckTap("Name"). The Opened the app / Backgrounded the app prose is mobile-native (web stays on "Arrived from X" / "Session ended") — driven by the event's platform property.

The Pages tab rolls these up: one row per screen name with views, distinct visitors, total events. Click any row to drill into a single screen's traffic with referrer host, country breakdown, and Top Actions (the .crossdeckTap labels grouped by frequency — your conversion signal for "Generate" vs "Subscribe" vs "Dismiss").

End-to-end latency from cd.track(...) to dashboard render is typically 3–8 seconds (SDK batches at 20 events or 5s flush interval → projector resolves identity → Firestore write → dashboard onSnapshot listener fans the update out). Fast enough to feel real-time during a dogfood walkthrough; not synchronous.

Initialise the SDK

Every Apple app constructs Crossdeck exactly once at process boot. The canonical place is the App's init() (SwiftUI) or the application(_:didFinishLaunchingWithOptions:) (UIKit). The shipping pattern wraps start in do/catch and stores the client as Optional — a typo'd key should log + degrade telemetry, never crash the host app.

// SwiftUI — production-grade boot
@main
struct MyApp: App {
    let cd: Crossdeck?

    init() {
        cd = Self.startCrossdeck()
    }

    var body: some Scene {
        WindowGroup { ContentView().environment(\.crossdeck, cd) }
    }

    private static func startCrossdeck() -> Crossdeck? {
        // Build-configuration switch — Debug builds NEVER route into a
        // live dashboard, Release builds NEVER route into sandbox.
        #if DEBUG
        let publicKey = "cd_pub_test_…"
        let environment: Environment = .sandbox
        #else
        let publicKey = "cd_pub_live_…"
        let environment: Environment = .production
        #endif

        do {
            return try Crossdeck.start(options: CrossdeckOptions(
                appId: "app_ios_xxx",
                publicKey: publicKey,
                environment: environment
            ))
        } catch let error as CrossdeckError {
            // invalid_secret_key / env_mismatch / missing_app_id —
            // recoverable misconfig. Log + carry on without telemetry.
            assertionFailure("[Crossdeck] start failed (\(error.code)): \(error.message)")
            return nil
        } catch {
            assertionFailure("[Crossdeck] start failed: \(error)")
            return nil
        }
    }
}
Yes — Crossdeck is both the namespace and the instance type.

The factory call let cd: Crossdeck = try Crossdeck.start(...) looks duplicate-typed at first glance. It isn't. Crossdeck the type holds your client instance; Crossdeck.start(...) the static factory returns one. Mirrors Apple's own URLSession / URLSession.shared shape — single short symbol for both surfaces.

Read the injected client in any child view with @Environment(\.crossdeck):

struct PaywallView: View {
    @Environment(\.crossdeck) var cd: Crossdeck?

    var body: some View {
        if cd?.isEntitled("pro") == true {
            ProContent()
        } else {
            Button("Upgrade") {
                cd?.track("upgrade_tapped")
            }
        }
    }
}
// UIKit — same pattern, AppDelegate edition
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    var cd: Crossdeck?

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        cd = do {
            try Crossdeck.start(options: CrossdeckOptions(
                appId: "app_ios_xxx",
                publicKey: publicKey, environment: environment
            ))
        } catch { return nil }()
        return true
    }
}

How keys and environments fit together

The publishable-key prefix is authoritative. The environment: argument is a redundancy check, not the routing switch:

Drive BOTH from your build configuration (xcconfig, #if DEBUG, environment-specific schemes) so a Debug build can never accidentally embed a live key and Release can never embed a test key. The mismatch check is the safety net, not the source of truth.

Accessing the client outside SwiftUI

@Environment(\.crossdeck) only reaches Views. For services, view models, singletons, UIKit call sites, background workers, and Combine pipelines, use the Crossdeck.current static accessor — a process-singleton that returns the most-recently-started client. Thread-safe, Optional-typed so the call-site idiom is the same cd?. propagation used everywhere else. Shipped in v1.0.2.

// Anywhere in your codebase — services, view models, UIKit, Combine:
final class AuthService {
    static let shared = AuthService()

    func didSignIn(user: User) {
        try? Crossdeck.current?.identify(userId: user.id, email: user.email)
    }
}

// Read a paywall gate from a ViewModel:
final class CheckoutViewModel: ObservableObject {
    var shouldShowProUI: Bool { Crossdeck.current?.isEntitled("pro") == true }
}

// Track an event from an AppDelegate method:
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) {
    try? Crossdeck.current?.track("push_received", properties: ["campaign": userInfo["campaign"] as? String ?? ""])
}

Crossdeck.current returns nil before Crossdeck.start(...) has succeeded in this process, or after the current client's stop() is called. The slot only clears when the stopped instance is the one currently advertised, so concurrent start+stop sequences on a second client never clobber the first.

Inside SwiftUI views, prefer @Environment(\.crossdeck) — it participates in SwiftUI's dependency tracking. The static accessor is for the 50% of the codebase that isn't a View.

DI-container alternative. If you use Swinject, Factory, or your own DI graph, register the instance once at boot and inject it through your container. Same pattern, different plumbing:

// In your App.init() right after startCrossdeck() resolves:
init() {
    let cd = Self.startCrossdeck()
    self.cd = cd
    Container.shared.register(Crossdeck?.self) { cd }
}

Keep one client per process; never call Crossdeck.start a second time. The validation that runs inside start is cheap, but the second instance would install a second pair of UIApplication lifecycle observers and the global error-capture singleton would route through the most-recent install — surprising side effects you don't want.

Publishable keys (cd_pub_) are safe in the binary — secret keys (cd_sk_) are not.

The publishable key (cd_pub_live_… / cd_pub_test_…) is the same shape Stripe uses for its client keys — safe to ship in your .ipa. Never embed a cd_sk_… secret key on the device; secret keys belong in your backend's secret store only.

Lifecycle & idempotency

The Crossdeck.start(options:) call is synchronous. By the time it returns:

The SDK does not auto-initialise via a hidden background mechanism — you control the start moment. If you want deterministic teardown for tests, call cd.stop() to persist all in-memory state and reject subsequent track / identify calls with CrossdeckError(code: "not_initialized").

Identity & users

Three identity primitives — exactly the same vocabulary as the Web, Node, and React Native SDKs:

Pass your auth provider's user ID — never a cdcust_….

The userId: argument to identify(...) is the same string you'd put on your backend's user-record primary key. cdcust_… handles are Crossdeck-side; they live on the server and are not exposed to the client SDK in v1.x.

cd.identify(userId:email:traits:)

Links the device to a stable user identity. email is a first-class top-level option — the platform-wide universal anchor for identity-merge. Pass it whenever you have it; the backend uses it to coalesce the same person across devices and auth-provider migrations.

cd.identify(
    userId: "user_847",
    email: "[email protected]",
    traits: [
        "plan": "pro",
        "signedUpAt": "2026-05-15",
    ]
)

Throws CrossdeckError(code: "missing_user_id") if userId is empty. Trait values are sanitised in-place (NaN / Infinity / functions / cyclic references replaced or dropped) with a debug warning — identify never throws on a bad trait.

Calling identify(...) sets developerUserId synchronously, clears the local entitlement cache, and fires /identity/alias in the background. The backend returns the canonical crossdeckCustomerId (cdcust_…) which the SDK persists for the lifetime of the install and stamps on every subsequent event.

Repeated identify is SAFE but not free — gate it.

Calling identify(userId:) twice with the same id is well-defined and won't break anything, but it isn't a no-op: each call unconditionally clears the entitlement cache (the bank-grade leak guard documented below) and re-fires /identity/alias. Most apps gate it with a lastIdentifiedUserId check so a screen that mounts ten times doesn't fire ten alias POSTs:

if let userId = AuthService.shared.currentUser?.id,
   userId != lastIdentifiedUserId {
    cd?.identify(userId: userId, email: AuthService.shared.currentUser?.email)
    lastIdentifiedUserId = userId
}

Unconditional entitlement cache clear. Every identify(...) call clears the entitlement cache — even a same-id re-identify. This is the defence that prevents a freshly-identified user from briefly seeing the prior user's entitlements through any sync read path. A tiny redundant cache rebuild is cheaper than a leaked entitlement across an account switch.

cd.identifyAndWait(...) — when you need the cdcust_

The sync identify fires the alias POST in the background. identifyAndWait is the same call but awaits the result and returns the canonical AliasResult with the crossdeckCustomerId. Use it when the next step in your sign-in flow needs that handle:

If you're just identifying the user for telemetry purposes, the sync identify is the right call — it sets the local identity instantly and your next track already carries the new developerUserId; the cdcust_ lands a few hundred ms later without anyone waiting.

let result = try await cd?.identifyAndWait(userId: user.id, email: user.email)
// result.crossdeckCustomerId is the cdcust_ — POST it to your backend now
if let cdcust = result?.crossdeckCustomerId {
    try await myBackend.attachCustomerHandle(userId: user.id, crossdeckCustomerId: cdcust)
}

cd.reset() / cd.resetSync() — call on sign-out

Critical for shared-device privacy. Sign-out without reset leaves the prior user's developerUserId, entitlement cache, super-properties, and breadcrumbs on disk — the next sign-in would coalesce events from two different humans into the same Crossdeck record. reset wipes all five.

reset() is async because the SDK durably clears every per-user entitlement slot before it returns. Three safe call shapes — pick the one that fits your call site:

// 1. From a sync call site (button handler, AuthService method,
//    non-async closure) — the escape hatch. resetSync() flips the
//    entitlement tombstone immediately so isEntitled() returns false
//    across the wipe window, then drains the rest on a detached Task.
cd?.resetSync()

// 2. From a sync call site when you specifically want to wait for
//    the full drain — wrap in a Task.
Task { await cd?.reset() }

// 3. From an async context (an async func, .task modifier on a
//    SwiftUI View, an async closure) — call it directly.
await cd?.reset()

reset regenerates the anonymousId so the next anonymous session is fully unlinked from the prior identified user. It does not server-side erase the prior user's data — that's the separate cd.forget() async call (GDPR right-to-be-forgotten, POSTs /identity/forget). Both run safely without a current identity; forget calls reset internally on success or failure.

iOS-specific auth provider examples

The shape is identical regardless of auth provider — pass your provider's stable user ID as userId and the email if you have it:

// Sign In with Apple
import AuthenticationServices

func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization auth: ASAuthorization) {
    guard let credential = auth.credential as? ASAuthorizationAppleIDCredential else { return }
    cd?.identify(
        userId: credential.user,
        email: credential.email,
        traits: [
            "name": [credential.fullName?.givenName, credential.fullName?.familyName].compactMap { $0 }.joined(separator: " ")
        ]
    )
}
// Firebase Auth iOS
import FirebaseAuth

Auth.auth().addStateDidChangeListener { _, user in
    guard let user else { cd?.resetSync(); return }
    cd?.identify(
        userId: user.uid,
        email: user.email,
        traits: ["displayName": user.displayName ?? ""]
    )
}
// Auth0 iOS
import Auth0

Auth0.authentication().userInfo(withAccessToken: token).start { result in
    if case .success(let profile) = result {
        cd?.identify(
            userId: profile.sub,
            email: profile.email,
            traits: ["name": profile.name ?? ""]
        )
    }
}

Super properties — per-install enrichment

Register key/value pairs once; every subsequent track() auto-includes them on the event's properties bag. Persisted across launches in UserDefaults.

cd.registerSuperProperty("theme", "dark")
cd.registerSuperPropertyOnce("first_install_version", "2.3.1")
cd.unregisterSuperProperty("theme")

registerSuperPropertyOnce only writes if the key isn't already present — use it for values that should reflect first-install state and never get overwritten (e.g. install version, install date).

Events & analytics

cd.track(_:properties:)

Synchronous from the caller's perspective — the event is validated, scrubbed, enriched, and enqueued on a background actor; the network round-trip happens in batches. Throws CrossdeckError for malformed input (missing event name, invalid property shape) before the event ever touches the queue.

cd.track("checkout_completed", properties: [
    "amount_usd": 29,
    "plan": "pro",
])

Properties are validated at the SDK boundary before the event enters the queue. One bad property can never crash the serialiser at flush time:

Enrichment order: super-properties → device info (platform, os_version, locale, timezone, app_bundle_id, app_version, sdk_name, sdk_version) → caller-supplied properties. The caller's bag wins on key collision.

The durable queue

The event queue is the analytics workhorse — used by track() and by every error report:

cd.flush() — async drain

Returns when the in-flight batch resolves — success or failure. On failure, events stay queued for the next retry; the awaited call does not throw. Use before app-shutdown to drain explicitly.

await cd.flush()

Permanent failure observability — onPermanentFailure

Closure invoked when a batch fails permanently (4xx that won't change on retry, or retry budget exhausted). Your last chance to observe events that will never deliver — typical use is logging them to your own observability stack and surfacing a diagnostic banner for invalid_request_error cases.

let options = CrossdeckOptions(
    appId: "app_ios_xxx",
    publicKey: "cd_pub_live_…",
    environment: .production,
    onPermanentFailure: { events, error in
        Logger().error("Crossdeck batch dropped: \(error.code) — \(events.count) events")
    }
)

Screen views

Screen views are how the dashboard's Pages tab tells you which screens your users actually use — the iOS equivalent of "/dashboard · 63 views" on a web app's Pages tab. The SDK fires page.viewed on every screen-enter; the dashboard groups by the screen name so you can see in one glance which features get traffic and which sit unused.

UIKit auto-tracks. SwiftUI needs one line per screen.

UIKit hosts — anywhere your app's screens are UIViewController subclasses — fire page.viewed automatically via a viewDidAppear swizzle. Class name becomes the screen property; a non-empty title on the controller becomes the title. Nothing to wire.

Pure-SwiftUI hosts are the tricky case. SwiftUI deliberately hides destination names from the runtime — every screen is backed by a generic UIHostingController<Content>, which is on the swizzle's denylist (otherwise every push/pop fires the same useless host name). The result: a pure-SwiftUI app emits zero page.viewed events without help.

Industry-standard fix — and the same shape Mixpanel, Amplitude, and PostHog ship: one .crossdeckScreen("Name") modifier per user-visible screen. It fires page.viewed with { screen, title } on .onAppear; the Pages backend groups on screen when no url/path is present, so your iOS app populates Pages identically to a web app.

Where to tag — five patterns cover every SwiftUI app

Tag the destination view, not the container that navigates to it. Tagging a container fires on every redraw or push-pop.

1. NavigationStack with typed destinations:

struct ContentView: View {
    var body: some View {
        NavigationStack {
            HomeView().crossdeckScreen("Home")
                .navigationDestination(for: ImagePrompt.self) { prompt in
                    CreateImageView(prompt: prompt)
                        .crossdeckScreen("Create Image")
                }
        }
    }
}

2. NavigationLink(destination:):

NavigationLink(destination: SettingsView().crossdeckScreen("Settings")) {
    Label("Settings", systemImage: "gear")
}

3. .sheet and .fullScreenCover content:

.sheet(isPresented: $showPaywall) {
    PaywallView().crossdeckScreen("Paywall")
}

.fullScreenCover(item: $editingAsset) { asset in
    AssetEditor(asset: asset).crossdeckScreen("Asset Editor")
}

4. TabView — tag each tab's root view, not the TabView itself:

TabView {
    LibraryView().crossdeckScreen("Library")
        .tabItem { Label("Library", systemImage: "photo.stack") }
    CreateView().crossdeckScreen("Create")
        .tabItem { Label("Create", systemImage: "sparkles") }
    AccountView().crossdeckScreen("Account")
        .tabItem { Label("Account", systemImage: "person") }
}

5. With extra properties — when you want the screen view to carry context the dashboard can break down by (e.g. plan tier, A/B variant). Properties flow through the same cd.track validation as any other event:

PaywallView()
    .crossdeckScreen("Paywall", properties: ["variant": experiment])

Don't tag these

Tagging the wrong layer either double-fires (visible to you as inflated views on Pages) or fires the wrong name. The four common mistakes:

Naming conventions

The screen name becomes the grouping key on the dashboard. Two rules:

Opt-out

The modifier is a thin wrapper around cd.track("page.viewed", …) — when autoTrack: .off is set in CrossdeckOptions, the underlying call still fires (your code asked for it explicitly; the SDK respects the contract). For strict-consent flows that want zero page.viewed events, remove the modifier from the view tree or guard it on a consent flag your app already owns.

Tap labels

Tap labels are how the dashboard's per-person journey and Pages → Top Actions tell you which CTA the user pressed — "Clicked 'Create Image'" instead of the useless "Clicked an element."

UIKit auto-tracks. SwiftUI buttons on iOS 16+ need one line per CTA.

UIKit controls (UIButton, UIControl subclasses) fire element.clicked automatically with the button's title / accessibility label resolved via the UIControl.sendAction swizzle. Nothing to wire.

SwiftUI buttons on iOS 13–15 are also caught — the SDK's UIWindow.sendEvent hit-test walks up 16 ancestors and down 6 descendants looking for an accessibilityLabel or UILabel.text. Most older-pipeline SwiftUI buttons render through UILabel primitives the descendant search finds.

SwiftUI buttons on iOS 16+ genuinely don't expose the label. Apple changed the SwiftUI rendering pipeline — Button("Create Image")'s text is drawn straight to a CALayer via Metal, and never lives on any UILabel.text or UIView.accessibilityLabel the runtime can read. The accessibility merge happens at the SwiftUI virtual-view layer above UIKit; UIView.accessibilityLabel stays nil on the rendered hierarchy. This is an Apple-imposed limit, not a CrossDeck regression — every analytics SDK hits the same wall.

Industry-standard fix — what Mixpanel / Amplitude / PostHog ship: one .crossdeckTap("Name") modifier per important CTA. Same shape as .crossdeckScreen. The wrapped view's own action still runs (.simultaneousGesture attaches the tap-capture gesture alongside the button's own).

Where to use it

// Button — wrapped action still fires.
Button { generateImage() } label: { Text("Create Image") }
    .crossdeckTap("Create Image")

// Image / Text / custom card — anywhere a tappable surface lives.
Image(systemName: "sparkles")
    .onTapGesture { generateImage() }
    .crossdeckTap("Generate sparkle")

// With extra properties — A/B variant, plan tier, prompt class.
Button { showPaywall() } label: { Text("Subscribe") }
    .crossdeckTap("Subscribe", properties: ["plan": "pro_monthly"])

What to tag, what to skip

Opt-out

The modifier is a thin wrapper around cd.track("element.clicked", …) — autoTrack settings don't gate it (your code asked for it explicitly). For strict-consent flows that want zero element.clicked events, remove the modifier from the view tree or guard it on a consent flag your app already owns.

Entitlements

Entitlements are how Crossdeck answers "does this customer get this feature right now?" — without exposing your subscription rail's internal product IDs to the client. The Swift SDK gives you two ways to consult the cache:

cd.isEntitled(_:)

Synchronous, cache-only. Returns true if the cached set for the currently-identified developerUserId contains the entitlement key; false otherwise. Safe to call from a SwiftUI view body or a UIKit tap handler — it never blocks on network.

if cd.isEntitled("pro") {
    showProFeature()
} else {
    showUpgradeSheet()
}

Customer-scoped reads. The cache is keyed on (developerUserId, entitlements). If the cached snapshot was for a different customer (e.g. after a sign-out / sign-in flow that the entitlement fetch hasn't yet refreshed), isEntitled(...) returns false — never silently leaks the prior user's entitlements.

cd.entitlementsForCurrentCustomer()

Returns the full Set<String> of entitlement keys for the currently-identified customer, or nil if no customer is identified or the cache has nothing for them yet. Use when you need to render an entitlement list (e.g. a settings screen showing the user's plan features).

guard let ents = cd.entitlementsForCurrentCustomer() else {
    // Not identified, or cache cold
    return
}
List(ents.sorted(), id: \.self) { Text($0) }
Cache freshness model.

v1.0.1 ships the full server-side surface: getEntitlements() async / GET /entitlements hydrates the cache; syncPurchases(rail: .apple, signedTransactionInfo:...) async forwards StoreKit 2 JWS evidence to /purchases/sync for verification + entitlement projection; onEntitlementsChange(...) subscribes to cache mutations. On 5xx / network failure the cache stays authoritative (markRefreshFailed records the failure timestamp without invalidating the last-known-good state — a paying customer never falls back to free during a Crossdeck outage). Google Play / cross-rail sync ships in v1.1 alongside the React Native + Android Play Billing surface; v1.0.1 throws rail_not_supported for any non-Apple rail.

Verify your install

The contract: after try Crossdeck.start(options: ...) returns, the dashboard's iOS / macOS · Swift SDK row flips LIVE within ~30 seconds. The signal is the boot heartbeat — a GET on /sdk/heartbeat that fires automatically from the synchronous start path. The backend reads appId + environment from the API key, plus the Crossdeck-Sdk-Version header the SDK ships on every request, and writes a record to projects/{p}.sdkHeartbeats.swift.{env} that the dashboard polls every few seconds.

What you should see, in order:

  1. Within 200ms of start — the heartbeat request lands. projects/{p}.appHeartbeats.{appId} + projects/{p}.sdkHeartbeats.swift.{env} both write.
  2. Within ~5s of dashboard refresh — the "Swift SDK" row on Dashboard → SDKs flips green.
  3. Within 5s of your first cd.track(...) call — the event appears in Dashboard → Activity → Realtime.

If the row stays dark for more than 60 seconds, check the troubleshooting section below for publicKey + environment alignment first — a misconfigured prefix is the most common cause.

Error capture

Two roads into the SDK's error pipeline:

  1. Uncaught NSException capture (opt-in via captureUncaughtExceptions: true). Installs a global handler that snapshots the exception, attaches breadcrumbs, runs your beforeSendError hook, and ships the event before the process dies.
  2. Manual captureError(_:handled:) — call from a do/catch block to ship a handled error. Same enrichment, same pipeline, same idempotency.

Uncaught exception capture

let cd = try! Crossdeck.start(options: CrossdeckOptions(
    appId: "app_ios_xxx",
    publicKey: "cd_pub_live_…",
    environment: .production,
    captureUncaughtExceptions: true
))

NSSetUncaughtExceptionHandler is a process-wide C hook. Crashlytics, Sentry, Bugsnag, and Firebase Crashlytics all want it. The Swift SDK chains: on install we capture the prior handler (whoever registered last) and invoke them after our own snapshot. Crash reporters that came before Crossdeck still receive the crash; Crashlytics dashboards stay populated. Apple does not provide a clean uninstall API for NSSetUncaughtExceptionHandler — if you stop capture via cd.stop(), our chain wrapper stays in place but the per-Crossdeck routing is removed.

Manual capture

do {
    try performRiskyOperation()
} catch {
    cd.captureError(error, handled: true)
}

Handled errors flow through the same path as uncaught ones — same fingerprinting (top-5-frame hash), same breadcrumb attachment, same beforeSend hook. The handled flag is preserved on the wire so the dashboard distinguishes them.

Breadcrumbs — context before the crash

50-entry ring buffer of timestamped events leading up to an error. Capacity configurable via breadcrumbCapacity. Every track() call adds a breadcrumb automatically; you can add custom ones from your UI handlers:

cd.addBreadcrumb(Breadcrumb(
    category: .ui,
    level: .info,
    message: "tapped subscribe button",
    data: ["plan": "annual"]
))

When an error fires, the current breadcrumb buffer is attached as error.breadcrumbs on the resulting event. The buffer is process-scoped — survives across screens, dropped on reset().

Fingerprinting — group identical errors

Errors are auto-fingerprinted from the top 5 stack frames (module:symbol joined). Two crashes from the same code path collapse to one issue in the dashboard. Stack symbols are parsed from Thread.callStackSymbols — addresses (ASLR-randomised per launch) are stripped so the fingerprint is stable across runs.

The SDK does NOT call swift_demangle at capture time (it's not on the public platform surface and pulling it via dlsym is brittle). Mangled symbols round-trip fine; the dashboard demangles server-side where the Swift toolchain is available.

beforeSendError — the redaction last line

Final filter before an error event leaves the device. Return nil to drop the error entirely (e.g. a known-noisy framework exception your team has decided to ignore). Return a mutated CapturedError to scrub PII the auto-scrubber missed (rare — the recursive PII scrubber catches emails and cards in every field by default).

let options = CrossdeckOptions(
    appId: "app_ios_xxx",
    publicKey: "cd_pub_live_…",
    environment: .production,
    captureUncaughtExceptions: true,
    beforeSendError: { event in
        // Drop a known-noisy framework exception
        if event.message.contains("NSNotificationCenter is deprecated") {
            return nil
        }
        return event
    }
)

Self-request skip — no feedback loops

The error-capture pipeline does NOT report HTTP failures against the Crossdeck ingest endpoint itself. Without this guard, a transient ingest failure would generate an $error event, which would itself fail to send, which would generate another error, ad infinitum. The check is a case-insensitive hostname compare against the configured endpoint's host.

App lifecycle

On iOS / iPadOS / Mac Catalyst, the SDK observes UIApplication notifications automatically. You do not need to wire anything yourself.

NotificationSDK behaviour
willResignActivePersist entire buffer + pending batch to UserDefaults immediately. Cheap; runs on the main thread but only writes <50 KB typical.
didEnterBackgroundBest-effort flush() using iOS's background-execution budget (typically a few seconds). Whatever fits ships; the rest survives in the durable buffer for next launch.
Force-quit while suspendedNothing happens at runtime (the app is gone) — but the durable buffer persisted at willResignActive rehydrates on next launch and re-sends with the original idempotency keys.

On macOS / tvOS / watchOS the equivalent observers are not installed (no UIApplication). The durable buffer still persists on every enqueue, and you can call cd.flush() from any NSApplicationWillTerminate observer if you want explicit drain.

Privacy & consent

Two channels of consent, tracked independently:

Both channels default GRANT — no explicit setup required. Wire setConsent(...) for an opt-out flow (EU cookie banner, COPPA age gate):

// User declines in your consent UI:
cd.setConsent(ConsentState(analytics: false, errors: false))

// User accepts — restore full access:
cd.setConsent(ConsentState(analytics: true, errors: true))

PII scrubber

On by default. Two patterns are scrubbed before any event leaves the device, recursively across nested dictionaries and arrays:

The tokens (<email> / <card>) are the platform-wide vocabulary — identical to the Web, Node, React Native, and backend allow-list. The scrubber walks reference-type containers (NSDictionary / NSArray) FIRST so object-identity-based cycle detection catches loops before falling back to depth-cap.

The scrub runs BEFORE the event is enqueued, so the persisted queue never holds raw PII. A crash dump of disk state is safe to share with support.

cd.setScrubPII(false) // disables — use only when consumer has explicit consent to ship raw values

App Store privacy manifest (PrivacyInfo.xcprivacy)

iOS 17+ App Store review rejects any binary that calls a required-reason API without a privacy manifest. The Swift SDK uses UserDefaults for queue persistence and anonymousId storage — a required-reason API since Xcode 15. @cross-deck/swift does not yet bundle its own PrivacyInfo.xcprivacy. Until it does, add the following declaration to your app target's PrivacyInfo.xcprivacy (create one via Xcode → File → New → App Privacy if your project doesn't have one):

<key>NSPrivacyAccessedAPITypes</key>
<array>
    <dict>
        <key>NSPrivacyAccessedAPIType</key>
        <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
        <key>NSPrivacyAccessedAPITypeReasons</key>
        <array>
            <string>CA92.1</string>
        </array>
    </dict>
</array>

Reason CA92.1 covers same-app UserDefaults access. If your target already declares NSPrivacyAccessedAPICategoryUserDefaults, add CA92.1 to the existing reasons array — one entry per category, multiple reasons. Crossdeck will ship a bundled manifest in a future minor release; once it does, this manual declaration is only needed for your own UserDefaults use. Full reason-code catalogue: developer.apple.com → Describing use of required reason API.

Configuration reference

Every field on CrossdeckOptions:

OptionTypeDefaultNotes
appIdStringRequired. Crossdeck App ID from the dashboard (e.g. app_ios_xxx).
publicKeyStringRequired. Publishable key starting with cd_pub_ (cd_pub_live_… or cd_pub_test_…).
environmentEnvironmentRequired. .production or .sandbox. Must match the publicKey prefix; mismatch throws env_mismatch at start.
baseUrlURL?https://api.cross-deck.com/v1Override for self-hosted setups or the local emulator.
urlSessionURLSession?nil (SDK builds one)Pass a custom session for proxy / App Group transport / test mocking.
storageStorage?UserDefaultsStorage()Pass MemoryStorage() for ephemeral or test use.
initialConsentConsentState(analytics: true, errors: true)Default-GRANT both channels (matches Web/Node/RN). Wire setConsent(...) for an opt-out flow (cookie banner / EU age gate).
scrubPIIBooltrueRecursive PII scrub on nested properties.
queueConfig.batchSizeInt20Events per HTTP POST.
queueConfig.flushIntervalMsInt5000Time-triggered flush cadence.
queueConfig.maxBufferSizeInt1000Hard cap. Overflow drops OLDEST.
queueConfig.retry.baseMsInt1000Initial backoff for retryable failures.
queueConfig.retry.maxMsInt30000Backoff cap (overridden by Retry-After if larger).
queueConfig.retry.maxAttemptsInt5After exhaustion, batch routes to onPermanentFailure.
breadcrumbCapacityInt50Ring-buffer depth.
captureUncaughtExceptionsBoolfalseOpt in to chain into NSSetUncaughtExceptionHandler.
beforeSendError(CapturedError) -> CapturedError?nilFilter / mutate / drop before ship.
onPermanentFailure(events, error) -> VoidnilObserve never-delivered batches.
debugLoggerDebugLoggernoopDebugLoggerPass defaultDebugLogger() for os.Logger routing during development.

API reference

Lifecycle

Identity

Events

Entitlements

Apple in-app purchase: bank-grade attribution

If your iOS / macOS / tvOS app makes StoreKit purchases, every purchase site needs one extra line on the purchase options:

let token: UUID = Crossdeck.appAccountTokenForCurrentIdentity()
let result = try await product.purchase(options: [
    .appAccountToken(token)
])

That line closes Shape 2 (identity-key mismatch) on the Apple rail — the silent-subscription-orphan bug that grows linearly with auto-tracked purchases when the developer's identify() value changes mid-stream (anonymous → logged in, account merge, SSO upgrade). Apple's transaction records are permanent, so a token that goes stale never recovers. The helper guarantees the token is one stable random UUID per install, decoupled from every other identifier in your app.

What the helper does

Anonymous-user / pre-identify purchases

The helper is safe to call from an anonymous session — common when an app shows a paywall before sign-up. On that first anonymous purchase:

Server-side resolution

When Apple's ASSN V2 webhook arrives later carrying the token, the backend looks the binding up first and resolves the customer via the recorded mapping — not via the older implicit assumption that appAccountToken == developerUserId. Customers who slipped through with a mismatched token (older SDK install, manual syncPurchases without the helper, etc.) surface in Settings → Identity → Conflicts as an Apple unbound token row for operator review.

Do not roll your own UUID, do not derive from your user ID, do not skip the helper.

Apple's transaction record is permanent. A token that goes stale never recovers — every renewal in that chain carries the wrong token forever, and the subscription orphans silently. The v1.4.x line shipped a derivation-from-developerUserId path that caused exactly this trap; v1.5.0 replaces it with the helper. The deprecated AppAccountTokenDerivation.derive(developerUserId:) symbol is still in the module to preserve the cross-SDK test oracle, but the auto-track listener and cd.syncPurchases(...) no longer call it.

Errors

Consent

Diagnostics & debugging

Pass defaultDebugLogger() for human-readable signal routing to Apple's unified logging system at info level under the com.crossdeck.sdk subsystem:

let options = CrossdeckOptions(
    appId: "app_ios_xxx",
    publicKey: "cd_pub_test_…",
    environment: .sandbox,
    debugLogger: defaultDebugLogger()
)

View in Console.app or via log stream:

log stream --predicate 'subsystem == "com.crossdeck.sdk"' --level info

Signal vocabulary

Every internal SDK log goes through a structured DebugSignal enum so a consumer can grep for specific events:

SignalFires when
sdk.configuredClient successfully started + ready to accept track/identify. Carries appId, environment, sdkVersion.
sdk.first_event_sentFirst event of this process landed at the ingest endpoint. One-shot per process lifetime — dashboard onboarding checklist keys off this signal.
sdk.invalid_keypublicKey doesn't start with cd_pub_ (or matches no known prefix). Always loud — also surfaced as an error.
sdk.no_identitytrack() fired without a known developerUserId AND no stored anonymousId (degenerate — usually storage failed).
sdk.entitlement_cache_usedisEntitled(...) answered from the local cache without a network round-trip.
sdk.purchase_evidence_sentSuccessful /purchases/sync POST.
sdk.environment_mismatchConfigured environment doesn't match the publicKey prefix. Surfaced at start; SDK refuses to construct.
sdk.sensitive_property_warningA property key looks like PII (email, password, token, secret, card, phone). Warning-level — event ships, consumer reviews.
sdk.property_coercedA property value was coerced during sanitisation (Date → ISO, NaN → null, circular → "[circular]", truncated, etc.).
sdk.queue_persistedQueue state successfully written to UserDefaults. Emitted on every enqueue, every successful flush, and every app-background transition.
sdk.queue_restoredQueue state successfully rehydrated from UserDefaults on start.
sdk.flush_retry_scheduledFlush hit a retryable failure (5xx / 408 / 429 / network) and is scheduled for retry. Payload: attempt, delay_ms.
sdk.flush_permanent_failureQueue dropped a batch — permanent 4xx OR retry budget exhausted. Always loud; routed to onPermanentFailure when set.
sdk.consent_changedConsent state changed via setConsent(...).
sdk.consent_deniedtrack() or $error dropped because the relevant consent channel is off. Payload includes channel.
sdk.consent_dnt_appliedDo-Not-Track detected (web parity — N/A on iOS but emitted for cross-SDK consistency).
sdk.pii_scrubbedPII scrubber replaced an email or card on the wire.

Queue stats

let stats = await cd.stats()
print("buffered=\(stats.buffered) pending=\(stats.pending) attempts=\(stats.attemptsForPending)")

Use for in-app diagnostics screens or to gate a "Send feedback" flow on "queue is fully drained."

Troubleshooting

Xcode shows "Status: v1.0.0" in the package picker (or an old README)

My package is set to Branch: main and I can't change it

Build fails with "no such module 'Crossdeck'"

Events not appearing in the dashboard

Stale simulator install — ⌘B vs ⌘R

Xcode's ⌘B (Product → Build) compiles into DerivedData but does not install the new binary onto the simulator. ⌘R (Product → Run) compiles, installs, and launches. If you bumped @cross-deck/swift, added a .crossdeckScreen or .crossdeckTap modifier, and only hit ⌘B to confirm the compile, the simulator is still running the old binary — your new modifiers aren't in the running process and the dashboard sees no page.viewed events.

Symptoms: Pages tab shows 0 pages even after adding modifiers; per-person journey shows session events but no Viewed "..." lines. Auto-tracked sessions still fire (they use the SDK's process singleton, no SwiftUI environment plumbing) which is why the journey shows some activity but not the screens you decorated.

Fix: force-quit Clippi in the simulator (swipe up + flick), then ⌘R from Xcode. Watch for the fresh launch sequence (icon fade-in, splash) rather than a process resume. If you're chasing a stubborn build cache, also run Product → Clean Build Folder (⇧⌘K) and quit Xcode entirely.

"Clicked an element" rows in the per-person journey

Expected — these are taps the auto-track captured but couldn't resolve a label for. Two causes:

The presence of Clicked an element rows alongside named ones is the correct state — it preserves the engagement signal (someone tapped something, the session is alive) without forcing you to label every tappable pixel.

How long until I see events on the dashboard?

Typical end-to-end is 3–8 seconds from cd.track(...) returning to the event surfacing on Activity / People journey / Pages. The path:

  1. SDK batchestrack() appends to the in-memory queue; a batch flushes at batchSize (default 20) or every flushIntervalMs (default 5 000ms), whichever fires first.
  2. HTTP POST — one batch goes over the wire with a per-batch Idempotency-Key. Typically <500ms on Wi-Fi.
  3. Backend projector — resolves identity (anonymousId → cdcust if known), normalises the event, writes to Firestore.
  4. Dashboard onSnapshot listener — fans the new Firestore docs out to the open browser tab in real time.

Force a quicker round-trip during testing with await cd.flush() — drains the current batch immediately rather than waiting for the timer. Useful in dogfood walkthroughs where you want to refresh the dashboard the instant you finish a session.

queue.flush_permanent_failure in logs

isEntitled() returns false after a successful purchase

Strict-concurrency warnings in my app

Versioning & changelog

The Swift SDK follows SemVer. Breaking changes only ship in a major-version bump. Full release notes live in the public repo's CHANGELOG.md; every released version is tagged on the public repo so SwiftPM consumers can pin to a specific tag or use .upToNextMajor resolution. Current tags: v1.0.0, v1.0.1, v1.0.2, v1.0.3, v1.1.0, v1.2.0, v1.4.4, v1.4.5, v1.4.6, v1.4.7, v1.4.8, v1.4.9, v1.5.0 (latest).

Latest: v1.4.9 — 2026-05-27

View.crossdeckTap("Name") — explicit tap-label bolt-on for SwiftUI buttons whose label the auto-track can't reach on iOS 16+. Apple's Metal text rendering pipeline draws Button text straight to a CALayer with no UILabel intermediary, so the walk-up + descendant search added in v1.4.7 finds nothing on modern SwiftUI. Same shape as .crossdeckScreen — one line per important CTA, the button's own action still runs via .simultaneousGesture. See the new Tap labels section for the pattern matrix.

v1.4.8 — 2026-05-27

Rename enum Environmentenum CrossdeckEnvironment. Structural fix for the SwiftUI symbol collision that v1.4.4's typealias only papered over for consumer code — the SDK author's own @Environment(\.crossdeck) inside CrossdeckScreenModifier still resolved to the module-scoped enum and failed with 'Environment' cannot be used as an attribute. Renaming the source enum eliminates the trap structurally: there is no short form in scope, so @Environment(...) resolves to SwiftUI's wrapper unambiguously inside the SDK module AND for every consumer. Breaking — code using Environment.production / .sandbox must use CrossdeckEnvironment.production / .sandbox. Pre-launch, all shipping install snippets already use the qualified form.

v1.4.7 — 2026-05-26

SwiftUI tap-label auto-capture. Closes the "Clicked an element" gap that buried real CTAs on the dashboard. The UIWindow.sendEvent walk-up extended from 4 ancestors to 16 — SwiftUI's button-hosting tree puts the merged accessibility label 8–12 hops above the touched Text/Image. Plus a descendant-search fallback that descends up to 6 levels into the touched view's subtree for a UILabel or accessibility-labeled view (SwiftUI's accessibility-merge model commonly puts the label on a sibling or descendant). Same PII substring filter applied to descendant-found text so a password field's visible text never lands on the wire.

v1.4.6 — 2026-05-26

SwiftUI hosting-controller denylist. Fixes a leak where the auto-track viewDidAppear swizzle was emitting framework class names (PresentationHostingController, NavigationStackHostingController, UIKitNavigationController) as page.viewed events — those buried the developer's real screens with meaningless internal names. The substring denylist now catches *HostingController across all SwiftUI host variants. Pairs with backend filtering (isFrameworkHostName) so existing already-shipped events stop polluting the dashboard without waiting for the consumer to rebuild.

v1.4.5 — 2026-05-26

SwiftUI screen tracking. Pure-SwiftUI apps now populate the Pages dashboard with one line per screen — .crossdeckScreen("Name") on each user-visible destination. See Screen views for the five-pattern matrix (NavigationStack, NavigationLink, .sheet, .fullScreenCover, TabView).

v1.2.0 — 2026-05-25

Full bank-grade parity with the Web/Node/RN SDKs. v1.1.0 closed the ergonomics gap (non-throwing API); v1.2.0 closes every remaining gap a serious customer would notice — auto-tracking, App Store privacy manifest, performance vitals, mobile lifecycle, and ambient signal modules.

v1.1.0 — 2026-05-25

Fire-and-forget API ergonomics — matches Mixpanel / Amplitude / Sentry / Firebase Analytics iOS conventions. Dogfood feedback flagged that requiring try? at every analytics call site was hostile in Swift even though Web/Node/RN's track() throws.

v1.0.3 — 2026-05-25

Critical compile-fix release. v1.0.0–v1.0.2 declared iOS(.v13) in Package.swift but defaultDebugLogger() used Apple's modern Logger API (iOS 14+). Apps with deployment target below iOS 14 failed to compile.

v1.0.2 — 2026-05-25

Dogfood pass on the v1.0.1 surface. One additive API change closes the biggest first-time-Swift-dev friction point; everything else is documentation polish that ships on cross-deck.com.

v1.0.1 — 2026-05-25

KPMG-grade audit pass. Closes every contract gap and privacy concern an external bank-grade reviewer would flag against v1.0.0. Public API is additive only — every v1.0.0 caller still compiles.

v1.0.0 — 2026-05-24

Initial release. Bank-grade event ingestion (pending-batch slot, 4xx hard stop, Retry-After honoured, durable rehydration). Identity actor with anonymousId persistence + reset regeneration. Customer-scoped entitlement reads. PII scrubber with <email> / <card> tokens, recursive walk, NSDictionary cycle detection via object identity. NSException + manual captureError. Strict concurrency enabled; every shared-mutable type behind a Swift actor. Zero runtime dependencies.