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) |
Provider state
Section titled “Provider state”@mainstruct 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—isOnlinefollows the node’s SSE stream. A revoked token family, or a403 permission_denied(a token minted withoutcontext:subscribe), logs out; a401asks/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 whenpackageNameis set, else from its contexts when they name exactly one.selectContext(_:identity:)opens another context.transport(mero-react’smero) — aMeroTransportClient:rpcis the same call on a node and through a relay.admin— aNodeAdminon a node, anAccountAdminfor an account; the same calls and results on both.readAdminis the rawAdminApibehind 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.
Connecting
Section titled “Connecting”- An account:
signInWithCloud(callbackScheme:)(the wallet in the system sheet), orconnectWithAccount(_:)with aDelegatedSessionyou hold. A relay whose node key is not known yet is learned first. - A node:
connectToNode(_:callbackScheme:)opens the node’s login page asking forpermissions(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.
Hooks as view models
Section titled “Hooks as view models”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).
Invitations, from link to synced data
Section titled “Invitations, from link to synced data”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() } } }}- Capture. Every link reaches
InvitationCapture— a cold open and a warm one alike arrive atonOpenURL(MeroRootViewforwards them for you). It is persisted until acked, deduplicated by content, replayed to a handler that registers late, and dropped only on an outcome. Ajoinlink for another app’s package is acked and never offered; one that keeps failing stops auto-joining aftermaxAutoJoinAttemptslaunches. - Logged out?
InvitationGatesays “You’re invited to join …” with Log in to join and Not now (which acks it), instead of the landing page. - Redeem.
InviteRedemptionasks first by default (awaitingConfirmation), then joins (joining), and reportsjoined,alreadyMember(a join that landed although its request failed) orfailedwith a reasondescribeInviteFailureputs in plain words. It acks the invitation on the outcome, never on display. UseJoinInvitation’sredeemer(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). - 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;
InvitationDialogshows 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=….
Views and theme
Section titled “Views and theme”LoginView(callbackScheme:)— a Cloud tab (AccountSignInPanel) and a Node tab (NodeLoginPanel: the local nodes discovery found, or a URL, checked againstadmin-api/is-authed).tabs: [.cloud]keeps Cloud alone.ConnectButton(callbackScheme:)— Connect opensLoginView; connected, a menu with the account, the node and Log out; Reconnecting… while the stream is down.MigrationPendingBanner(contextId:),MigrationAdminPanel(namespaceId:).CalimeroLogo(size:color:)andCalimeroLogoShape.
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))Testing your app
Section titled “Testing your app”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.