Skip to content

SwiftUI App Layer

MeroKitUI ports @calimero-network/mero-react to SwiftUI. Where mero-react has a provider, hooks and components, MeroKitUI has one @MainActor MeroClient, ObservableObject view models, and views that read both from the environment. Everything targets iOS 15 / macOS 12.

mero-react MeroKitUI
MeroProvider + useMero() MeroClient (.environmentObject) + MeroProviderConfig
useAsyncResource / useAsyncMutation AsyncResource<Value> / AsyncMutation
read hooks (useGroupInfo, useNamespaces, …) client.groupInfo(_:), client.namespaces(), …
write hooks (useCreateContext, useAddGroupMembers, …) ContextActions, GroupActions, …
useEphemeral EphemeralPresence<State>
useMigrationStatus, useAppVersion, … MigrationStatusModel, AppVersionModel, …
primeInvitationCapture, onInvitation, … InvitationCapture
useInviteRedemption + InvitationDialog InviteRedemption + InvitationDialog
useJoinSync + JoinSyncBanner JoinSync + JoinSyncBanner
ConnectButton, LoginModal, CalimeroLogo, theme ConnectButton, LoginView, CalimeroLogo, MeroTheme
storage/* (localStorage) MeroAppStorage (UserDefaults)
@main
struct MyApp: App {
@StateObject private var client = MeroClient(
tokenStore: { KeychainTokenStore() },
config: MeroProviderConfig(mode: .multiContext, packageName: "com.example.notes"))
init() { primeInvitationCapture(appKey: "notes", slug: ["com.example.notes"]) }
var body: some Scene {
WindowGroup {
MeroRootView(callbackScheme: "notes").environmentObject(client)
}
}
}

MeroClient holds what useMero() returns:

  • isAuthenticated, isLoading, isOnline, nodeURL — isOnline follows the node’s SSE stream. A revoked token family, or a 403 permission_denied (a token minted without context:subscribe), logs out; a 401 asks /auth/validate, which recovers an expired access token through the transport’s refresh. An account session is reported online.
  • applicationId, contextId, contextIdentity — from the SSO callback on a node. An account’s application is derived: from the registry when packageName is set, else from its contexts when they name exactly one. selectContext(_:identity:) opens another context.
  • transport (mero-react’s mero) — a MeroTransportClient: rpc is the same call on a node and through a relay.
  • admin — a NodeAdmin on a node, an AccountAdmin for an account; the same calls and results on both. readAdmin is the raw AdminApi behind it.
  • isDelegated, can — ask what is allowed, not which transport runs: a node can do everything; an account creates contexts and invites, founds a namespace only when the app names its package, and never upgrades.
  • app — the package, version and registry the client was given.
  • An account: signInWithCloud(callbackScheme:) (the wallet in the system sheet), or connectWithAccount(_:) with a DelegatedSession you hold. A relay whose node key is not known yet is learned first.
  • A node: connectToNode(_:callbackScheme:) opens the node’s login page asking for permissions(for: mode); the page returns to <scheme>://auth#access_token=…&node_url=….

logout() clears everything: the tokens, the account session (the device key stays), MeroAppStorage, and every published id.

Reads are AsyncResources bound to the client: they re-read when the session changes, reset to their initial value when the input changes, and never let a slow, superseded answer land. Keep one in a @StateObject:

struct MembersView: View {
@StateObject private var members: AsyncResource<[GroupMember]>
init(client: MeroClient, groupId: String) {
_members = StateObject(wrappedValue: client.groupMembers(groupId))
}
var body: some View {
List(members.data, id: \.identity) { Text($0.name ?? $0.identity) }
.overlay { if members.isLoading { ProgressView() } }
.refreshable { await members.refetch() }
}
}

Writes are AsyncMutation families sharing isLoading, error and failure (a ClassifiedError): ContextActions (create, private, delete, join subgroup, resync), JoinActions (joinContext, joinNamespace, redeem — pass throwOnError: true to branch on a refusal), GroupActions, MetadataActions, NamespaceActions, UpgradeActions, BlobActions. A call resolves nil, sending nothing, with no client.

The rest: ContextExecutor (contract calls; an account with no relay is told so), EventSubscription, ContextDiscovery, GroupCapabilities, EphemeralPresence (a replay is authoritative, your own echo is filtered, setPresence is trailing-edge throttled, ageOf counts from the node’s age), MigrationStatusModel (re-read on every migration event, polled every 5 s while the stream is down), AppVersionModel (isStale against your build), LatestVersionModel, GroupAppVersionModel (pendingApply), MyAuthoredMigrationModel, and BlobFileModel (bytes and a temporary file, removed with the model).

struct Root: View {
@EnvironmentObject private var client: MeroClient
@StateObject private var redemption = InviteRedemption(parse: NotesInvite.parse, enabled: false)
var body: some View {
InvitationGate(callbackScheme: "notes", parse: NotesInvite.parse, appName: "Notes") {
Workspace()
}
.overlay(InvitationDialog(redemption: redemption, noun: "notebook"))
.onOpenURL { InvitationCapture.shared.handle($0) }
.onChange(of: client.isAuthenticated) { signedIn in
if signedIn { redemption.enable() }
}
}
}
  1. Capture. Every link reaches InvitationCapture — a cold open and a warm one alike arrive at onOpenURL (MeroRootView forwards them for you). It is persisted until acked, deduplicated by content, replayed to a handler that registers late, and dropped only on an outcome. A join link for another app’s package is acked and never offered; one that keeps failing stops auto-joining after maxAutoJoinAttempts launches.
  2. Logged out? InvitationGate says “You’re invited to join …” with Log in to join and Not now (which acks it), instead of the landing page.
  3. Redeem. InviteRedemption asks first by default (awaitingConfirmation), then joins (joining), and reports joined, alreadyMember (a join that landed although its request failed) or failed with a reason describeInviteFailure puts in plain words. It acks the invitation on the outcome, never on display. Use JoinInvitation’s redeemer(for:) for the standard join on either connection: a node joins the namespace and its context; an account bootstraps through an admitter the invitation names (DelegatedBootstrap).
  4. Wait for the data. A join returns before the namespace’s data arrives, and an empty page looks like an empty workspace. The join marks the namespace just-joined; InvitationDialog shows Syncing … until the page reading the data says it has settled:
@StateObject private var gate = JoinSync(namespaceId: workspaceId)
// once the first read returns, empty or not:
gate.update(namespaceId: workspaceId, settled: true)
// meanwhile:
JoinSyncBanner(show: gate.isSyncing, what: "notes", onDismiss: gate.dismiss)

The gate gives up after 30 s with nothing in flight, and at 120 s regardless.

To share an invitation: buildInviteLink(slug: "com.example.notes", token: token) → https://links.calimero.network/com.example.notes/join?invitation=….

  • LoginView(callbackScheme:) — a Cloud tab (AccountSignInPanel) and a Node tab (NodeLoginPanel: the local nodes discovery found, or a URL, checked against admin-api/is-authed). tabs: [.cloud] keeps Cloud alone.
  • ConnectButton(callbackScheme:) — Connect opens LoginView; connected, a menu with the account, the node and Log out; Reconnecting… while the stream is down.
  • MigrationPendingBanner(contextId:), MigrationAdminPanel(namespaceId:).
  • CalimeroLogo(size:color:) and CalimeroLogoShape.

Every view draws from the environment’s MeroTheme — mero-react’s tokens, with defaultMeroTheme (accent #A5FF11):

MeroRootView(callbackScheme: "notes")
.meroTheme(defaultMeroTheme.with { $0.radius = 12 })

Every word LoginView shows, its logo and the logo’s tile come from LoginBranding; each field defaults to the stock copy, so set only what you rebrand. The glow behind the hero is MeroTheme.showsGlow.

LoginView(
callbackScheme: "notes",
branding: LoginBranding(
cloudTitle: "Welcome to Notes",
cloudButton: "Continue",
cloudHint: nil, // hide the line under the button
manualNodeLink: "Use my own server",
useCloudLink: "Back to sign-in",
node: NodeLoginStrings(connectButton: "Connect"),
logo: Image("NotesMark"),
logoTileCornerRadius: 38, // a round tile
logoTileBackground: .white,
logoTileShadow: false))

The view models take an injectable sleep and clock where they wait (EphemeralPresence, JoinSync, ContextDiscovery, MigrationStatusModel), InvitationCapture(defaults:) and MeroAppStorage(defaults:) take a UserDefaults suite, and MeroClient takes a URLSession, a WebAuthenticating and an AuthLoginStateStore, so an app’s flows run under MockURLProtocol from MeroKitTestSupport with no simulator.