import LChatCore import SwiftUI import UIKit @main struct LChatApp: App { @UIApplicationDelegateAdaptor(PushManager.self) private var push @StateObject private var app = AppState() init() { // Reading a chat clears its delivered notifications (PR #260) — wired // here because the shared ThreadStore can't depend on the app target. ThreadStore.onMarkRead = { PushManager.clearDelivered(thread: $0) } // Sockets heartbeat "active" while the app is frontmost (presence + // push suppression) — wired per platform because LChatCore can't see UIKit. ChatSocket.appIsActive = { UIApplication.shared.applicationState == .active } // Start the row-height cache's disk load now, off-main — merged long // before the first chat opens, so first opens skip the measure pass // that cost seconds on device (see FeedHeightCache). FeedHeightCache.warm() } var body: some Scene { WindowGroup { RootView() .environmentObject(app) } } } // An invite link that arrived from outside the app (the join page's scheme // or a universal link), validated and parked for its confirming tap. // Identifiable so the confirm sheet presents item-based — a second link // landing while the sheet is up swaps the content in place (newest wins, // like the Mac receiver). struct PendingAddLink: Identifiable, Equatable { let link: String var id: String { link } } // Owns the persisted list of instances (workspaces). Each instance carries its own // keypair; the list lives in the Keychain (InstancesStore). final class AppState: ObservableObject { @Published var instances: [SavedInstance] // The web→app handoff's pre-fill (RootView's onOpenURL stashes the // validated link; AddFromLinkConfirm consumes it). @Published var pendingAddLink: PendingAddLink? // Per-instance server photos (id -> image) shown in the server switcher. Public // branding served in the clear at /api/server-avatar (not an E2E blob), so it's // fetched directly here rather than through BlobImageLoader. @Published var serverAvatars: [String: UIImage] = [:] // Sealed-avatar latch: instance ids whose sealed photo we already tried to // resolve this launch (the resolver is a 600k-iteration PBKDF2 — once). private var sealedAvatarAttempted: Set = [] init() { instances = InstancesStore.load() #if DEBUG // The sim rig's door (LChatUITests, 2026-07-21): a dev-server join // link in the environment seeds the space natively — no accounts // flow, no hands. Debug-only, and only into an empty install. if instances.isEmpty, let link = ProcessInfo.processInfo.environment["LCHAT_DEV_JOIN"], !link.isEmpty { Task { @MainActor in guard self.instances.isEmpty, let inst = try? await SpaceClaim.claim( setupLink: link, screenName: "Sim") else { return } self.instances.append(inst) InstancesStore.save(self.instances) } } #endif } /// Sign-out's second half: AppReset has already wiped the stores — drop /// the in-memory mirror so the UI folds back to first-run without /// relaunching (nothing here re-persists). func eraseAll() { instances = [] serverAvatars = [:] sealedAvatarAttempted = [] } /// Fetch each server's photo for the switcher. /api/group is public and reports /// whether a photo is set, so this works with no key and only fetches the bytes /// when one exists (and only when we don't already have it). Cheap + idempotent — /// safe to call whenever the switcher appears or the instance set changes. @MainActor func loadServerAvatars() async { for inst in instances { guard let base = inst.baseURL else { continue } let api = LChatAPI(baseURL: base, token: inst.deviceToken) let info = await api.serverInfo() if info.hasAvatar { if serverAvatars[inst.id] == nil, let data = await api.serverAvatar(), let image = UIImage(data: data) { serverAvatars[inst.id] = image } } else if info.sealed { // Sealed branding: the photo (if any) is ciphertext — we're a // member with keys, so decrypt it (SealedBranding.swift). One // attempt per launch per instance: the resolver costs a 600k // PBKDF2, and a photo-less sealed instance would otherwise // re-pay it on every task refire (the image cache never fills). if serverAvatars[inst.id] == nil, !sealedAvatarAttempted.contains(inst.id) { sealedAvatarAttempted.insert(inst.id) if let opened = await SealedBrandingResolver.open(api: api, userPrivPkcs8B64: inst.userPriv), let data = await api.serverAvatarSealed(key: opened.key), let image = UIImage(data: data) { serverAvatars[inst.id] = image } } } else if serverAvatars[inst.id] != nil { serverAvatars[inst.id] = nil // photo removed on the server — drop back to initials } } } /// Add a new instance, or replace one with the same id (re-join). Both are /// deliberate acts, so the entry is stamped addedAt=now (unless the caller /// set it) — that's what lets it beat an older home-page removal tombstone. func add(_ inst: SavedInstance) { var inst = inst if inst.addedAt == nil { inst.addedAt = Date().timeIntervalSince1970 * 1000 } if let i = instances.firstIndex(where: { $0.id == inst.id }) { instances[i] = inst } else { instances.append(inst) } InstancesStore.save(instances) HomeSync.pushSoon(instances: instances) } func remove(_ inst: SavedInstance) { instances.removeAll { $0.id == inst.id } InstancesStore.save(instances) ThreadListSnapshot.clear(origin: inst.origin) // no keys linger for a space we left // The cached branding key goes too — it opens the (publicly served) // sealed avatar ciphertext, so it must not outlive membership here. if let base = inst.baseURL { SealedBrandingResolver.clearCache(origin: base.absoluteString) } // Sync never deletes from the vault — the removed space just stops being // device-ordered and tails as a vault-only entry (see HomeSync). HomeSync.pushSoon(instances: instances) } /// Reorder the server list (drag in the switcher). The array order IS the /// display order, so persisting it is all there is to it. func move(from source: IndexSet, to destination: Int) { instances.move(fromOffsets: source, toOffset: destination) InstancesStore.save(instances) HomeSync.pushSoon(instances: instances) } /// Sign-in merge — the vault wins: apply its stars + names to servers this /// device already has, and its array order (device-only servers tail, in /// their existing relative order). One save, no push — the caller pushes /// once after the whole merge, so the write-back reflects the merged state. func applyVault(_ entries: [(url: String, starred: Bool, archived: Bool, name: String?)]) { for e in entries { guard let i = instances.firstIndex(where: { $0.origin == e.url }) else { continue } instances[i].starred = e.starred ? true : nil instances[i].archived = e.archived ? true : nil if let n = e.name, !n.isEmpty { instances[i].groupName = n } } var rest = instances var out: [SavedInstance] = [] for url in entries.map(\.url) { if let i = rest.firstIndex(where: { $0.origin == url }) { out.append(rest.remove(at: i)) } } instances = out + rest InstancesStore.save(instances) } /// Reorder within ONE star group (the switcher's favourites and the More /// sheet's sections each drag independently — home's independent lists). Offsets /// are relative to that group's own array; the other group is untouched. func moveInGroup(starred: Bool, from source: IndexSet, to destination: Int) { var group = instances.filter { $0.isStarred == starred } let other = instances.filter { $0.isStarred != starred } group.move(fromOffsets: source, toOffset: destination) // Normalised starred-first, matching toggleStar's boundary invariant. instances = starred ? group + other : other + group InstancesStore.save(instances) HomeSync.pushSoon(instances: instances) } /// Star or unstar a space. Starring sends it to the END of the starred group; /// unstarring sends it to the TOP of the rest — matching the home vault's /// toggleStar so the array order stays in lockstep (the vault write-back, /// when signed in, is layered on top of this). func toggleStar(_ inst: SavedInstance) { guard let target = instances.first(where: { $0.id == inst.id }) else { return } var updated = target updated.starred = target.isStarred ? nil : true let starred = instances.filter { $0.id != inst.id && $0.isStarred } let others = instances.filter { $0.id != inst.id && !$0.isStarred } instances = starred + [updated] + others InstancesStore.save(instances) HomeSync.pushSoon(instances: instances) } /// Merge a PULLED vault into this device (the foreground sync): removals /// made on the home page apply first (tombstones drop our stale copies), /// spaces signed into elsewhere are added (their credentials ride the /// vault), then the vault's stars/archive/names/order apply — the same /// vault-wins rule as sign-in. No push back: the device state now equals /// the vault. func merge(vault contents: HomeVault.Contents) { dropTombstoned(contents.removed) for s in contents.servers { guard let creds = s.credentials, !instances.contains(where: { $0.origin == s.url }), // An entry AND a tombstone (a stale client re-pushed after the // removal): adding it would stamp addedAt=now, and the next // push would read that as a deliberate re-add and clear the // tombstone vault-wide. Let the vault heal instead. HomeVault.tombstonedAt(contents.removed, url: s.url) == 0 else { continue } instances.append(SavedInstance( origin: s.url, deviceToken: creds.token, screenName: creds.name ?? s.name ?? "", userPub: creds.userPub, userPriv: creds.userPriv, groupName: s.name ?? "", starred: s.isStarred ? true : nil, archived: s.isArchived ? true : nil, addedAt: Date().timeIntervalSince1970 * 1000)) } applyVault(contents.servers.map { (url: $0.url, starred: $0.isStarred, archived: $0.isArchived, name: $0.name) }) } /// Apply home-page removals to this device: drop any instance whose vault /// tombstone is newer than when the device gained it. In-memory + persist /// only — deliberately no push (push independently skips tombstoned /// entries, so there's nothing to write back). func dropTombstoned(_ removed: [HomeVault.Tombstone]?) { guard let removed, !removed.isEmpty else { return } let before = instances.count instances.removeAll { HomeVault.tombstonedAt(removed, url: $0.origin) > ($0.addedAt ?? 0) } if instances.count != before { InstancesStore.save(instances) } } /// Archive or unarchive a space: hidden from the switcher's lists, /// grouped under Archived. Repositions with the home vault's setArchived /// semantics so the array order stays in lockstep: archiving drops the star /// and lands at the TOP of the archived group; unarchiving lands at the /// BOTTOM of the active list. Both are the active/archived boundary — one /// insert. func toggleArchive(_ inst: SavedInstance) { guard let target = instances.first(where: { $0.id == inst.id }) else { return } var updated = target let on = !target.isArchived updated.archived = on ? true : nil if on { updated.starred = nil } // leaving the rail let starred = instances.filter { $0.id != inst.id && $0.isStarred && !$0.isArchived } let others = instances.filter { $0.id != inst.id && !$0.isStarred && !$0.isArchived } let archived = instances.filter { $0.id != inst.id && $0.isArchived } instances = starred + others + [updated] + archived InstancesStore.save(instances) HomeSync.pushSoon(instances: instances) } /// Persist resolved fields discovered after first load (our user id, group name). func note(id: String, myUserId: String? = nil, groupName: String? = nil) { guard let i = instances.firstIndex(where: { $0.id == id }) else { return } var changed = false if let uid = myUserId, !uid.isEmpty, instances[i].myUserId != uid { instances[i].myUserId = uid; changed = true } if let name = groupName, !name.isEmpty, instances[i].groupName != name { instances[i].groupName = name; changed = true } if changed { InstancesStore.save(instances) HomeSync.pushSoon(instances: instances) // names feed the vault entries } } } // Holds the live navigation: which instance (server) is open and its ThreadStore. // One ThreadStore at a time, swapped when the server changes, so the chat list and // the feed observe the same store. @MainActor final class NavModel: ObservableObject { @Published var instanceID: String? @Published var store: ThreadStore? // One live store per visited space, reused on re-entry — its rows are // already folded, so going back into a space never shows loading again // this session (the Keychain snapshot covers the cross-launch case). private var stores: [String: ThreadStore] = [:] /// `remember: false` selects a space as CONTEXT only (the home screen's /// avatar / Edit profile need one) without stamping it as where you live — /// only a space you actually open becomes the next launch's landing. func selectInstance(_ inst: SavedInstance?, remember: Bool = true) { guard inst?.id != instanceID else { return } instanceID = inst?.id if let inst { // Reuse unless the credentials changed underneath (a re-join // replaces the SavedInstance in place with a fresh token/keypair). if let cached = stores[inst.id], cached.instance.deviceToken == inst.deviceToken { store = cached } else { let fresh = ThreadStore(instance: inst) stores[inst.id] = fresh store = fresh } } else { store = nil stores = [:] // sign-out: nothing decrypted survives in memory } // Remembered across launches: reopening the app (or restoring a // backup) lands in the space you were last in. if remember, let id = inst?.id { UserDefaults.standard.set(id, forKey: "lastInstanceID") } } } // What the NavigationStack pushes over the spaces home screen: the selected // space's chat list, then chats within it. enum Route: Hashable { case chats case thread(String) case spaceApp(String) // a space-wide app's full-pane surface (docs/native-apps.md phase 4) } // The shell, the Mimestream shape: the stack's ROOT is the spaces home screen // (SpacesHomeView); the active space's chat list is PUSHED on top, so "back" // from your chats is how you switch space. Day to day you never see home — // launch lands straight in the space you were last in. struct RootView: View { @EnvironmentObject var app: AppState @EnvironmentObject private var push: PushManager @Environment(\.scenePhase) private var scenePhase @StateObject private var nav = NavModel() // Drives the first-run "Sign in with Eleven" ceremony + restore state. @StateObject private var signIn = HomeSignInModel() @State private var path: [Route] = [] // home → .chats → .thread(id)… // The space you actually LIVE in — stamped by opening one, mirrored from // lastInstanceID. nav.instanceID can additionally hold a context-only // selection (home's avatar needs one); this drives home's "Current" group. @State private var currentSpaceID: String? // Sign out clears the device — home's menu row only *requests* it; this // destructive confirmation gates the actual wipe. @State private var confirmSignOut = false // Create-a-space (the goes-to-eleven flow): whether home offers it (GET // /api/features), and the in-flight model while the instrument is up // (item-based presentation — no present-before-content race). @State private var spacesOffered = false @State private var createModel: CreateSpaceModel? var body: some View { NavigationStack(path: $path) { Group { if app.instances.isEmpty { noServers } else { SpacesHomeView(nav: nav, signIn: signIn, currentID: currentSpaceID, spacesOffered: spacesOffered, onSelect: select, onSignOut: { confirmSignOut = true }, onCreate: { createModel = makeCreateModel() }) } } .navigationDestination(for: Route.self) { route in // The store is always live when something is pushed (selection // precedes the push); the fallbacks just keep a race from // rendering nothing. switch route { case .chats: if let store = nav.store { ThreadListView(store: store, path: $path) } else { ProgressView() } case .thread(let id): if let store = nav.store { ThreadDestination(store: store, id: id, path: $path) } else { ProgressView() } case .spaceApp(let app): if let store = nav.store { SpaceAppScreen(store: store, app: app) } else { ProgressView() } } } } .confirmationDialog( "Sign out? Your spaces, messages and keys are removed from this \(DeviceName.current). Sign back in to get them back.", isPresented: $confirmSignOut, titleVisibility: .visible ) { Button("Sign Out & Clear This \(DeviceName.current)", role: .destructive, action: signOut) } // Load server photos once at launch (and when the server set changes) so both // the toolbar space button and the switcher show them without opening anything. .task(id: Set(app.instances.map(\.id))) { await app.loadServerAvatars() } .onAppear { Perf.noteFirstFrame() if nav.instanceID == nil { // The space you were last in opens straight to its chats; a // device that doesn't KNOW where you live (fresh sign-in, // never opened a space) lands on the spaces home screen — // with the head of the list selected as context (remember: // false: only a space you open becomes the landing). if let known = knownLastInstance { nav.selectInstance(known) currentSpaceID = known.id // Restored state, not a navigation — landing in your space // must not play a push animation over the home screen. The // hydrate races the first frame so last session's rows are // up before (or with) the view's own load. Task { await nav.store?.hydrate() } var t = Transaction() t.disablesAnimations = true withTransaction(t) { path = [.chats] } } else { nav.selectInstance(fallbackInstance, remember: false) } } // Someone with chats wants to hear about them (no-op without the // push entitlement; asks permission the first time). if !app.instances.isEmpty { push.enablePush() } // A cold-launch notification tap is already waiting here (delivered // before this view could subscribe) — route it now. routeNotificationTap() pullHome() routeChangelogTap() // The DEK-sync migrate-by-addition moment (#1089, flag-gated): // an existing signed-in install adds the synchronizable DEK item // on its first unlocked launch — write-gated on the account // holding a SiwA anchor; a no-op for everyone else. Task { await DEKSync.writeIfEligible() } } // Sign-in restore populates instances while the first-run screen is // up — the root folds to the spaces home screen (the restored list), // with the head of it selected as context. Deliberately NOT pushed // into a space: a fresh sign-in doesn't know where you live yet. .onChange(of: app.instances.map(\.id)) { _, _ in guard nav.instanceID == nil, let inst = fallbackInstance else { return } nav.selectInstance(inst, remember: false) push.enablePush() } // A tap that arrives while we're already up: the published id changes and // this fires. (onAppear covers the cold-launch case where it was set first.) .onChange(of: push.pendingThreadID) { _, _ in routeNotificationTap() } .onChange(of: push.pendingChangelogTap) { _, _ in routeChangelogTap() } // App Studio's Open, forwarded by an app pane's webview bridge // (WebBridge {openAppThread}): open — or create — the app's personal // thread and push it, like a notification tap. The pane itself must // never navigate, or the app renders inside App Studio's screen. .onReceive(NotificationCenter.default.publisher(for: .lchatOpenAppThread)) { note in guard let store = nav.store, let name = note.userInfo?["app"] as? String, !name.isEmpty else { return } let title = note.userInfo?["title"] as? String ?? name Task { if let id = await store.openOrCreateAppThread(app: name, title: title), path.last != .thread(id) { path = [.chats, .thread(id)] } } } // Coming back to the app re-fetches the active server's chats, so a chat // started with us while we were away (thread creation sends no push) is // there when we look. The `.task(id:)` in ThreadListView only fires on first // appear / server switch, so foregrounding otherwise never refreshed. .onChange(of: scenePhase) { _, phase in refreshInbox(when: phase == .active) // Foregrounding also pulls the vault: a star/archive made on the // home page (or another device) lands here without a re-sign-in. if phase == .active { pullHome() } // Leaving for the background arms the next Background App Refresh // wake, so chats keep syncing into the local store while we sleep. if phase == .background, !app.instances.isEmpty { BackgroundRefresh.schedule() } // …and releases the shared store's sqlite connection: a lock on an // app-group file held across suspension is the 0xdead10cc kill // (MessageStore.suspend). Queued behind any in-flight op; the next // op reopens lazily. if phase == .background { Task { await MessageStore.shared.suspend() } } } // A push landing while we're open (see PushManager.inboxPing) refreshes live. .onChange(of: push.inboxPing) { _, _ in refreshInbox(when: true) } // Whether the switcher offers "Create a new space" — home must offer it // and the member must already have a space (the web's canCreateSpace // gate; the server enforces the same with 403 not_eligible). .task(id: "\(app.instances.isEmpty)-\(signIn.sessionExists)") { guard signIn.sessionExists, !app.instances.isEmpty else { return } spacesOffered = await HomeClient().features() } .fullScreenCover(item: $createModel) { model in CreateSpaceSheet(model: model) } // The web→app handoff receiver (the Mac onOpenURL's iOS sibling): the // join page's "Open in the app" scheme (elevenmessenger://add?url=…, // the secret riding encoded inside the query value — it never reaches // any server) and universal links on covered instance domains both // land here. Validated, then PRE-FILLED into the confirm card rather // than joined — one confirming tap for any URL that arrived from // outside. Non-invite URLs (e.g. message permalinks) fall through. .onOpenURL { url in guard let link = inviteLink(fromOpenedURL: url), parseJoinLink(link) != nil else { return } app.pendingAddLink = PendingAddLink(link: link) } .sheet(item: $app.pendingAddLink) { pending in AddFromLinkConfirm(link: pending.link) { inst in app.pendingAddLink = nil select(inst) } } // A features() fetch that failed once must not hide the affordance for // the whole session — re-check whenever you come back to the home // screen (the only place the + lives). .onChange(of: path) { _, p in guard p.isEmpty, !spacesOffered, signIn.sessionExists, !app.instances.isEmpty else { return } Task { spacesOffered = await HomeClient().features() } } } // The goes-to-eleven create flow. The WHOLE pipeline (provision → claim → // setup_complete) runs inside `create`, under "Building your space" — the // instance is persisted (and vault-pushed) before success is ever shown, // so quitting at the confetti can't strand a half-born space. private func makeCreateModel() -> CreateSpaceModel { CreateSpaceModel( create: { [weak app] in // Carry the name you already go by (your most recent space). let screenName = await MainActor.run { app?.instances.first(where: { !$0.isArchived })?.screenName ?? app?.instances.first?.screenName ?? "" } let inst = try await SpaceCreationFlow.run(screenName: screenName) await MainActor.run { app?.add(inst) } return inst }, finish: { inst, _ in select(inst) createModel = nil }, cancel: { createModel = nil }) } // The space this device KNOWS you were last in — the launch-straight-to- // chats case. nil when there's no record (fresh sign-in): home shows. private var knownLastInstance: SavedInstance? { let last = UserDefaults.standard.string(forKey: "lastInstanceID") return app.instances.first { $0.id == last } } // The context space while you're parked on home: the head of the list // (vault order — starred first), skipping archived. private var fallbackInstance: SavedInstance? { app.instances.first { !$0.isArchived } ?? app.instances.first } // The silent vault pull (no-op when signed out or rate-limited). private func pullHome() { Task { if let contents = await HomeSync.pull() { app.merge(vault: contents) } } } // Re-fetch the active server's thread list, unless one is already in flight (the // cold-launch `.task` load, a pull-to-refresh, or an overlapping trigger). private func refreshInbox(when: Bool) { guard when, let store = nav.store, !store.loading else { return } Task { await store.refresh() } } // A tapped notification lands in its chat: the shared key store knows which // instance the thread belongs to, so we switch servers if needed and push the // chat. Idempotent — clears the pending id so re-firing (incl. our own nil-out) // is a no-op. private func routeNotificationTap() { guard let threadID = push.pendingThreadID else { return } push.pendingThreadID = nil // The parked SharedThreadKeys entry is the fast path; a stale or // missing park falls back to the spaces' thread snapshots, then to // the only signed-in space (NotificationRoute) — a tap must never // silently strand the member on the chat list. let inst: SavedInstance switch NotificationRoute.resolve(threadID: threadID, instances: app.instances) { case .parkedKey(let i), .snapshot(let i), .onlySpace(let i): inst = i case .unresolved: // Multi-space and the thread is in none of their records — no // safe guess exists. Leave the member where the launch landed // them, and say why in the log (the one place field reports of // "the tap did nothing" can be diagnosed from). NSLog("notification tap: thread not resolvable to a space, dropped") return } if nav.instanceID != inst.id { nav.selectInstance(inst) } currentSpaceID = inst.id if path.last != .thread(threadID) { path = [.chats, .thread(threadID)] } } // A tapped "What's new" banner (the release-notes push, announce.go). The // payload carries no thread id — the Admin thread's name rides encrypted, so // the server can't address it — so resolve locally: open the CURRENT space's // Admin app-thread if it has one, else just land in the app. The banner // title names the space, so a multi-space admin can see which one updated. private func routeChangelogTap() { guard push.pendingChangelogTap else { return } push.pendingChangelogTap = false guard let admin = nav.store?.threads.first(where: { $0.appId == "admin" }) else { return } if path.last != .thread(admin.id) { path = [.chats, .thread(admin.id)] } } // Opening a space is an ordinary navigation: the chat list slides in with // its chrome, and shows the loading scene itself if its messages aren't // there yet (ThreadListView's first-load overlay). private func select(_ inst: SavedInstance) { if inst.id != nav.instanceID { // First join included: ask for notification permission the moment // there's a chat worth hearing about, and (re)register this instance. push.enablePush() push.subscribeNewInstance(inst) nav.selectInstance(inst) } else { // Already selected as context — but a context selection deliberately // didn't stamp "last" (and selectInstance no-ops on a same-id // re-call), so opening it stamps here. UserDefaults.standard.set(inst.id, forKey: "lastInstanceID") } // Rows first, then the slide: hydrate is a few-ms Keychain read (a // no-op on a store that's already live), so the chat list is visible // DURING the push animation instead of materialising after it. let store = nav.store Task { await store?.hydrate() if path != [.chats] { path = [.chats] } } // The Current row is a shortcut — flipping it mid-slide repaints home // under the outgoing view. Hold the flip until the push has fully // covered it (the row is correct by the time home is next seen). Task { try? await Task.sleep(for: .seconds(0.75)) currentSpaceID = inst.id } } // Sign out = clear this device (the spaces live in the member's Eleven // account; signing back in restores them). A last sync push first, so even // seconds-old changes are in the account before the wipe; then wipe, drop // the in-memory mirrors, and fold to first-run in place. The destructive // confirmation already happened (RootView's dialog). private func signOut() { Task { if let session = HomeSessionStore.load() { _ = await HomeSync.push(instances: app.instances, session: session) } // Stop the pushes before the wipe kills the tokens that authorise // stopping them — otherwise every space keeps pushing sealed // payloads at this phone forever (the NSE can't decrypt or drop // them once the keys are gone). await push.unsubscribeAll() AppReset.wipeAll() app.eraseAll() signIn.refreshSession() path = [] currentSpaceID = nil nav.selectInstance(nil) } } // First run: the sign-in doors — Apple leading, email beneath, the // passkey demoted (last-used promoted; see SignInFirstRun). Still no // paste-a-link (Paul's call — joining lives on the web; a native join // flow comes later). The screen is iOS 18-gated with the passkey // ceremony (PRF), so iOS 17 says so instead. @ViewBuilder private var noServers: some View { if #available(iOS 18.0, *) { SignInFirstRun(model: signIn) } else { VStack(spacing: 14) { Image(systemName: "person.badge.key") .font(.system(size: 44)).foregroundStyle(.secondary) Text("Sign in needs iOS 18").font(.headline) Text("Update your \(DeviceName.current) to sign in to Eleven — or use your space in the browser meanwhile.") .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) } .padding() } } }