import AuthenticationServices import CryptoKit import LChatCore import SwiftUI import UIKit // πŸ€– "Sign in with Eleven" β€” the first-run doors. Three ways in, all against // the same home server: Sign in with Apple (the iOS lead), an emailed 6-digit // code, and the passkey ceremony (demoted to a quiet text button; the // last-used door is promoted, the web's #1112 rule). The ceremony + crypto // live in LChatCore (HomeAuth / HomeAnchorAuth / HomeVault / HomeCrypto); // this file is the iOS presentation plus the orchestration that turns a // restored vault into SavedInstances. The app still targets iOS 17 β€” the PRF // ceremony (iOS 18) is runtime-gated, so iOS 17 members keep the app and // simply don't get this feature (the affordances hide below 18). // // The identity-vs-key split holds natively exactly as on the web: an Apple or // email sign-in proves who you are and yields a session token, but only a // passkey's PRF can derive the key that opens the backup β€” an anchor sign-in // against an account with a sealed backup lands signed in with the passkey // door promoted to "unlock". // // The PRF output never leaves the device: HomeAuth returns it, HomeVault derives // the unwrap key from it in memory, and it's dropped when this model deallocates. @MainActor final class HomeSignInModel: ObservableObject { enum Phase: Equatable { case idle case working // a sign-in sheet / verify round trip is in flight case syncing // authenticated; fetching + restoring the backup case restored(Int) // servers added to this device case emptyBackup // signed in; the account holds no servers yet case backedUp(Int) // N servers sealed into a (new or empty) backup case sealed // anchor sign-in landed; the backup opens only under a passkey case failed(String) // No "noPasskey" case: the API can't distinguish "no passkey existed" // from a cancelled sheet (both surface as .canceled), so the guidance // lives as static idle copy on the first-run screen instead. var busy: Bool { self == .working || self == .syncing } /// A landed sign-in through any door β€” what folds the email door away. var signedIn: Bool { switch self { case .restored, .emptyBackup, .backedUp, .sealed: return true default: return false } } } /// Which door signed in β€” the #1112 last-used marker, mirrored natively /// under the web's key name so the rule reads across: the last-used /// method leads the doors next time (a future "Sign-in options" surface /// reads the same marker). enum SignInMethod: String { case passkey, apple, email, google, microsoft, github } static let lastSignInKey = "home_last_signin" static var lastSignIn: SignInMethod? { UserDefaults.standard.string(forKey: lastSignInKey).flatMap(SignInMethod.init(rawValue:)) } @Published private(set) var phase: Phase = .idle /// Whether a home session exists on this device β€” @Published so EVERY /// surface (first-run, space switcher) reacts to a sign-in made on any of them. /// A stale one-shot copy here once left "Back up this device" visible /// after sign-in, a path to accidentally minting a second account. @Published private(set) var sessionExists = HomeSessionStore.load() != nil /// True when the stored session is an anchor's (Apple / email): signed /// in, but no DEK β€” the vault stays sealed until a passkey unlocks it. /// SESSION state, deliberately not phase: the sealed landing must hold /// steady through a ceremony's transient phases (.working after the tap, /// .idle after a cancelled sheet) instead of bouncing to the doors. @Published private(set) var sessionSealed = HomeSessionStore.load().map { $0.dek == nil } ?? false init() {} /// Run the discoverable passkey ceremony and restore whatever servers the /// backup holds onto this device (adding them to `app`). func signIn(into app: AppState) async { guard let anchor = Self.keyWindow() else { phase = .failed("Couldn't find a window to sign in from.") return } guard #available(iOS 18.0, *) else { phase = .failed("Sign in with Eleven needs iOS 18.") return } phase = .working do { let result = try await HomeAuth(anchor: anchor).signIn() phase = .syncing // the sheet is down; the blob fetch + restore runs phase = try await restore(result, into: app) } catch let e as ASAuthorizationError { // The user backed out of the system sheet β€” quietly return to the // first-run choices rather than showing an error. Everything else // surfaces VERBATIM (code included): "failed silently" cost us a // debugging round on the very first device run. phase = e.code == .canceled ? .idle : .failed("\(e.localizedDescription) (AS \(e.code.rawValue))") } catch is CryptoKitError { // The ceremony ran but the PRF-derived key didn't open the vault β€” // in practice a third-party provider (1Password) answered with a // different passkey, or returned a PRF the DEK wasn't wrapped for. phase = .failed("That sign-in couldn't unlock your spaces. If you're offered a choice, pick the Apple Passwords option (Face ID).") } catch { phase = .failed((error as? HomeClientError).map(Self.message) ?? error.localizedDescription) } } /// Re-read the Keychain session (the dev reset wipes it out from under the /// model) and drop any terminal status back to the idle pitch. func refreshSession() { let session = HomeSessionStore.load() sessionExists = session != nil sessionSealed = session.map { $0.dek == nil } ?? false phase = .idle } /// The wrong-door escape on the first-run screen: drop ONLY the home /// session β€” nothing has been restored to this device β€” and bring the /// doors back for a sign-in that landed on the wrong account. func switchAccount() { HomeSessionStore.clear() sessionExists = false sessionSealed = false phase = .idle } /// Session adoption β€” the ONE seam every door funnels a finished sign-in /// through (passkey ceremony finish, Apple verify, email verify): the /// same Keychain write, the published flag every surface watches, and /// the last-used marker the first-run doors re-order by. The ceremony /// proof is only ever used transiently by its caller and never stored, /// matching the web. private func adopt(_ session: HomeSession, method: SignInMethod) { HomeSessionStore.save(session) sessionExists = true sessionSealed = session.dek == nil UserDefaults.standard.set(method.rawValue, forKey: Self.lastSignInKey) // The DEK-sync write-gate (#1089, flag-gated): a DEK-holding session // on a SiwA-anchored account adds/refreshes the synchronizable item. Task { await DEKSync.writeIfEligible() } } // (No partial sign-out exists: with a passkey there's nothing to be half // signed-out OF β€” Sign out is the full local reset, owned by RootView's // onSignOut so nav state folds with it. The passkey itself is untouched.) /// Mint a NEW Eleven passkey and seal this device's servers into a fresh /// backup. Deliberately a separate act from Sign in (home has no account /// discovery β€” registering always creates a new anonymous account, so it /// must never ride the sign-in button; the web dropped exactly that /// fallback in #323). The PRF output is used in-memory and dropped. func backUp(into app: AppState) async { // Registering ALWAYS creates a new anonymous account β€” with a session // already on this device that would orphan the real backup (all future // syncs would go to the fresh empty one). Refuse outright. guard !sessionExists, HomeSessionStore.load() == nil else { phase = .failed("This device is already signed in to Eleven.") return } guard let anchor = Self.keyWindow() else { phase = .failed("Couldn't find a window to sign in from.") return } guard #available(iOS 18.0, *) else { phase = .failed("Backing up to Eleven needs iOS 18.") return } phase = .working do { let result = try await HomeAuth(anchor: anchor).register() let contents = HomeVault.Contents(v: 1, servers: Self.vaultServers(from: app.instances)) let sealed = try HomeVault.sealFresh(contents, credentialID: result.credentialID, prf: result.prf) // If-Match "0" claims "no blob yet" β€” a fresh account always has none. guard try await HomeClient().putBlob(token: result.token, bytes: sealed.bytes, ifMatch: "0") != nil else { phase = .failed("Couldn't save your spaces β€” try again.") return } adopt(HomeSession(token: result.token, credentialID: result.credentialID, dek: sealed.dek), method: .passkey) phase = .backedUp(app.instances.count) } catch let e as ASAuthorizationError { phase = e.code == .canceled ? .idle : .failed("\(e.localizedDescription) (AS \(e.code.rawValue))") } catch { phase = .failed((error as? HomeClientError).map(Self.message) ?? error.localizedDescription) } } // ---- the Apple door (issue #1112; server: home/siwa.go) ----------------- /// The nonce minted for the sheet currently up β€” the SAME string rides /// the Apple request and the verify body (home compares the identity /// token's nonce claim against it verbatim). private var appleNonce: String? /// SignInWithAppleButton's request half. NO scopes β€” we want no name and /// no email (the account stays anonymous; home only needs the token's /// subject) β€” plus the fresh nonce binding this sheet to our verify. func prepareApple(_ request: ASAuthorizationAppleIDRequest) { let nonce = HomeAnchorAuth.mintNonce() appleNonce = nonce request.nonce = nonce phase = .working } /// SignInWithAppleButton's completion half: verify the identity token /// against home, then adopt the session through the same seam as a /// passkey ceremony finish. func handleApple(_ result: Result, into app: AppState) async { let nonce = appleNonce appleNonce = nil switch result { case .success(let auth): guard let nonce, let cred = auth.credential as? ASAuthorizationAppleIDCredential, let idToken = HomeAnchorAuth.identityToken(from: cred) else { phase = .failed("Apple didn't return a usable sign-in β€” try again.") return } do { let fin = try await HomeClient().siwaVerify(identityToken: idToken, nonce: nonce) // The DEK-sync use-gate (#1089, flag-gated): the assertion // that just verified is exactly the "fresh SiwA assertion" // the synced item's release requires. When the iCloud-synced // DEK is here AND belongs to this account's vault, this // Apple sign-in opens the spaces outright β€” no passkey. Any // failure falls through to the sealed/empty landing as today. if let released = await DEKSync.release(token: fin.token) { phase = .syncing adopt(released.session, method: .apple) phase = mergeVault(released.contents, into: app) } else { phase = await finishAnchor(fin, method: .apple) } } catch { phase = .failed(Self.anchorMessage(error)) } case .failure(let error): // Backing out of Apple's sheet is a choice, not an error β€” the // same quiet return as the passkey path's cancel. if let e = error as? ASAuthorizationError, e.code == .canceled { phase = .idle } else { phase = .failed(error.localizedDescription) } } } // ---- the email door (issue #1091; server: home/email.go) ---------------- /// Step one: send the code. True advances the door to the code step. The /// spin floor mirrors the web's (~900ms): an instant send reads as a /// glitch β€” and the server's 200 is the same known-or-not, so success /// only ever means "sent". func requestEmailCode(email: String) async -> Bool { phase = .working let floor = Task { try? await Task.sleep(for: .seconds(0.9)) } do { try await HomeClient().emailRequest(email: email) await floor.value phase = .idle return true } catch { floor.cancel() // errors show immediately, like the web phase = .failed(Self.anchorMessage(error)) return false } } /// Step two: redeem the code and adopt the session β€” the same seam as /// every other door. The floor is a touch longer than send's (the web's /// 1.2s): a landing that swaps in instantly never registers. func verifyEmailCode(email: String, code: String) async { phase = .working let floor = Task { try? await Task.sleep(for: .seconds(1.2)) } do { let fin = try await HomeClient().emailVerify(email: email, code: code) await floor.value phase = await finishAnchor(fin, method: .email) } catch { floor.cancel() phase = .failed(Self.anchorMessage(error)) } } // ---- the web-flow doors: Google / Microsoft / GitHub (home/oidc.go) ----- /// Which of the web-flow doors this home server has lit, plus GitHub's /// per-instance client id β€” fetched once when the doors show. Nil (not /// yet fetched / older server) just means those doors don't render. @Published private(set) var oidcDoors: HomeClient.SigninDoors? /// The in-flight browser session β€” retained on the model, or the system /// tears it down mid-flow. private var webAuth: ASWebAuthenticationSession? private let webAuthPresenter = WebAuthPresenter() func refreshDoors() async { oidcDoors = await HomeClient().signinDoors() } /// One browser round trip: authorize URL β†’ home's /oidc/return /// trampoline β†’ elevenmessenger://home-oidc, handed back in-process by /// the session (no Launch Services, no onOpenURL) β†’ verify β†’ the same /// anchor landing as Apple and email. The state must not be /// "web."-prefixed β€” that routes the trampoline to the web page. func signInWithOidc(_ door: OIDCDoor, into app: AppState) async { phase = .working let state = "ios." + HomeAnchorAuth.mintNonce() let nonce = HomeAnchorAuth.mintNonce() guard let url = HomeOIDCAuth.authorizeURL(door, base: HomeBase.url, state: state, nonce: nonce, githubClientID: oidcDoors?.githubClientID) else { phase = .failed("\(door.display) sign-in isn't available right now.") return } do { let cb = try await runWebAuth(url: url) guard let parsed = HomeOIDCAuth.callback(cb, state: state) else { phase = .failed("That sign-in didn't complete β€” try again.") return } let fin: HomeClient.AnchorFinish if door == .github, let code = parsed.code { fin = try await HomeClient().githubVerify(code: code) } else if let idToken = parsed.idToken { fin = try await HomeClient().oidcVerify(provider: door.rawValue, identityToken: idToken, nonce: nonce) } else { phase = .failed("That sign-in didn't complete β€” try again.") return } phase = await finishAnchor(fin, method: SignInMethod(rawValue: door.rawValue) ?? .email) } catch let e as ASWebAuthenticationSessionError where e.code == .canceledLogin { // Closing the browser sheet is a choice, not an error β€” the same // quiet return as Apple's cancel. phase = .idle } catch { phase = .failed(Self.anchorMessage(error)) } } private func runWebAuth(url: URL) async throws -> URL { defer { webAuth = nil } return try await withCheckedThrowingContinuation { cont in let session = ASWebAuthenticationSession( url: url, callbackURLScheme: HomeOIDCAuth.callbackScheme) { cb, err in if let cb { cont.resume(returning: cb) } else { cont.resume(throwing: err ?? HomeClientError.network) } } // Share the system browser's cookies: an already-signed-in // Google/Microsoft/GitHub user gets a one-tap consent β€” the same // choice the Mac SiwA web flow makes. session.prefersEphemeralWebBrowserSession = false session.presentationContextProvider = webAuthPresenter webAuth = session // A failed start never calls the completion handler β€” resuming // here keeps the continuation (and the whole doors screen, which // gates on phase.busy) from wedging forever. Same guard as the // Mac's runWebAuth. if !session.start() { cont.resume(throwing: ASWebAuthenticationSessionError(.presentationContextInvalid)) } } } /// An anchor's landing: adopt the token (no DEK β€” an Apple/email sign-in /// proves who you are but can never decrypt; the backup's key lives /// behind a passkey's PRF), then report what the account holds: fresh or /// backup-less β†’ the empty pitch; an existing sealed backup β†’ point at /// the passkey door, which is what actually opens it. private func finishAnchor(_ fin: HomeClient.AnchorFinish, method: SignInMethod) async -> Phase { adopt(HomeSession(token: fin.token), method: method) if fin.created == true { return .emptyBackup } do { return try await HomeClient().fetchBlob(token: fin.token) == nil ? .emptyBackup : .sealed } catch { // Signed in either way β€” the peek only picks the pitch. return .failed("Signed in, but Eleven couldn't be reached to check your spaces β€” try again in a moment.") } } /// Anchor errors surface the server's own member-facing copy ("that code /// didn't work") when there is one; transport failures get the standing /// network line. private static func anchorMessage(_ error: Error) -> String { if case HomeClientError.http(_, let body) = error { let text = body.trimmingCharacters(in: .whitespacesAndNewlines) if !text.isEmpty, text.count <= 120 { return text } } if let e = error as? HomeClientError { return message(e) } return error.localizedDescription } /// Fetch + open the backup and merge it into this device β€” the VAULT wins /// on sign-in (its stars, names and order apply to servers the device /// already has; missing ones are added). Only then does the device push, /// so the write-back can't wipe state other devices stored β€” the exact /// data-loss the first cut had. Returns the resulting phase. @available(iOS 18.0, *) private func restore(_ result: HomeAuth.Result, into app: AppState) async throws -> Phase { guard let fetch = try await HomeClient().fetchBlob(token: result.token) else { // Authenticated, but the account holds no blob (a backUp whose PUT // failed after register, or a web-deleted backup). With the PRF in // hand this is recoverable RIGHT NOW: seal the device's servers // into a first envelope for this passkey. Bailing here was a // permanent dead-end β€” no session saved, and routine sync can't // create a blob (it never holds the PRF). let contents = HomeVault.Contents(v: 1, servers: Self.vaultServers(from: app.instances)) let sealed = try HomeVault.sealFresh(contents, credentialID: result.credentialID, prf: result.prf) guard try await HomeClient().putBlob(token: result.token, bytes: sealed.bytes, ifMatch: "0") != nil else { return .failed("Couldn't save your spaces β€” try again.") } adopt(HomeSession(token: result.token, credentialID: result.credentialID, dek: sealed.dek), method: .passkey) return app.instances.isEmpty ? .emptyBackup : .backedUp(app.instances.count) } guard let opened = try HomeVault.open(fetch.bytes, credentialID: result.credentialID, prf: result.prf) else { return .failed("That sign-in doesn't unlock these spaces.") } // Keep the session (link token + DEK, Keychain) so stars/reorders sync // to home from now on without another Face ID. The PRF output itself is // NOT stored β€” the DEK is all sync needs. adopt(HomeSession(token: result.token, credentialID: result.credentialID, dek: opened.dek), method: .passkey) return mergeVault(opened.contents, into: app) } /// An OPENED vault landing on this device β€” the vault-wins merge every /// unlocked sign-in shares (the passkey restore above, and the Apple door /// when the synced DEK releases). Caller adopts the session first. private func mergeVault(_ contents: HomeVault.Contents, into app: AppState) -> Phase { // Removals made on the home page win over stale device copies β€” apply // their tombstones before anything flows back up. app.dropTombstoned(contents.removed) var added = 0 for server in contents.servers { // array order is the master order // A credential-less entry (a space saved by URL alone on the home // page) can't be signed into from here β€” skip it; it stays in the // vault untouched. guard let creds = server.credentials, !app.instances.contains(where: { $0.origin == server.url }) else { continue } app.add(SavedInstance( origin: server.url, deviceToken: creds.token, screenName: creds.name ?? server.name ?? "", userPub: creds.userPub, userPriv: creds.userPriv, groupName: server.name ?? "", starred: server.isStarred ? true : nil, archived: server.isArchived ? true : nil )) added += 1 } // Vault wins for servers on BOTH sides: its stars/names/order apply to // the device before anything flows back. app.applyVault(contents.servers.map { (url: $0.url, starred: $0.isStarred, archived: $0.isArchived, name: $0.name) }) // Device-only servers (joined before signing in) flow back up, so home // matches the device from the very first sign-in. HomeSync.pushSoon(instances: app.instances) return added > 0 ? .restored(added) : .emptyBackup } /// The device's servers as vault entries (used for a first backup). static func vaultServers(from instances: [SavedInstance]) -> [HomeVault.Server] { instances.map { inst in HomeVault.Server( url: inst.origin, name: inst.groupName.isEmpty ? nil : inst.groupName, starred: inst.isStarred ? true : nil, archived: inst.isArchived ? true : nil, credentials: .init(token: inst.deviceToken, userPub: inst.userPub, userPriv: inst.userPriv, name: inst.screenName)) } } private static func message(_ e: HomeClientError) -> String { switch e { case .network: return "Couldn't reach Eleven. Check your connection and try again." case .http(let code, _): return "Eleven turned that down (\(code)). Try again in a moment." } } // The key window backs the ASAuthorizationController's presentation anchor. fileprivate static func keyWindow() -> UIWindow? { UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .flatMap(\.windows) .first { $0.isKeyWindow } } } // The web-auth sheet's presentation anchor β€” the app's first ASWebAuthentication- // Session (the Mac has its own in MacHomeSignIn). private final class WebAuthPresenter: NSObject, ASWebAuthenticationPresentationContextProviding { func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { HomeSignInModel.keyWindow() ?? ASPresentationAnchor() } } // The first-run screen: the doors. Apple leads on iOS (the platform default), // email beneath it, and the passkey path demoted to a quiet text button β€” with // the last-used method promoted to lead (the web's #1112 rule, mirrored // simply). The email door is a card step of its own, the web's screen-swap // pattern: address β†’ code β†’ session, serial, with a back affordance. struct SignInFirstRun: View { @EnvironmentObject var app: AppState @ObservedObject var model: HomeSignInModel @Environment(\.colorScheme) private var scheme private enum EmailStep { case address, code } @State private var emailStep: EmailStep? @State private var email = "" @State private var code = "" // Which door the last tap opened β€” only so the promoted passkey button // doesn't spin for an Apple verify that happens to share phase == .working. @State private var tapped: HomeSignInModel.SignInMethod? // One door at a time: the demoted doors unfold behind this. @State private var showAllDoors = false @FocusState private var focus: EmailStep? var body: some View { VStack(spacing: 0) { Spacer() VStack(spacing: 16) { Image("ElevenMark") // the real Eleven brand mark (purple "11" tile) .resizable() .frame(width: 76, height: 76) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .accessibilityHidden(true) Text("Welcome to Eleven") .font(.title2.weight(.semibold)) // The tagline is the welcome pitch β€” the sealed landing is an // unlock moment, not a welcome, and earns no marketing words // (Paul, 2026-07-19: the button says everything this state // needs). if !model.sessionSealed { Text("Secure group chat") .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 8) } } // Status / result folds in under the pitch, occupying layout space // (no floating toast β€” the project's inline-feedback rule). statusLine .frame(minHeight: 22) .padding(.top, 14) Spacer() if model.phase == .syncing { // Authenticated; the backup is restoring. RootView carries the // user into their first space the moment instances land, so // this shows only for the fetch's beat. VStack(spacing: 12) { ProgressView() Text("Syncing your spaces…") .font(.callout) .foregroundStyle(.secondary) } .padding(.bottom, 48) } else if let step = emailStep { emailDoor(step) } else if model.phase.signedIn || model.sessionSealed { landed } else { doors } } .padding() // A landed sign-in (any door) folds the email door away; the doors // return beneath the status β€” sealed promotes the passkey to unlock. .onChange(of: model.phase) { _, p in if p.signedIn { emailStep = nil email = "" code = "" } } // Which web-flow doors this home server has lit (and GitHub's // client id) β€” fetched once per screen; until it lands the three // fixed doors stand alone, same as an older server. .task { await model.refreshDoors() } } // ---- the doors ---------------------------------------------------------- /// Apple, email, passkey β€” plus whichever web-flow doors the home server /// has lit (Google/Microsoft/GitHub, in the web's fixed brand order) β€” /// with the last-used method promoted to lead. After a sealed anchor /// sign-in the passkey leads regardless: it is the one door that /// actually opens the backup. private var doorOrder: [HomeSignInModel.SignInMethod] { var order: [HomeSignInModel.SignInMethod] = [.apple, .email, .passkey] if model.oidcDoors?.githubClientID?.isEmpty == false { order.append(.github) } if model.oidcDoors?.microsoft == true { order.append(.microsoft) } if model.oidcDoors?.google == true { order.append(.google) } let lead: HomeSignInModel.SignInMethod? = model.phase == .sealed ? .passkey : HomeSignInModel.lastSignIn if let lead, let i = order.firstIndex(of: lead) { order.remove(at: i) order.insert(lead, at: 0) } return order } // A landed sign-in folds the doors away entirely β€” signed in means no // sign-in options. Sealed keeps exactly one control, the passkey, // because it is the unlock rather than an alternative; the quiet escape // below it covers the wrong-account landing either way. private var landed: some View { VStack(spacing: 12) { if model.sessionSealed { door(.passkey, leading: true) } Button("Sign in a different way") { model.switchAccount() } .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) .disabled(model.phase.busy) // never mid-ceremony } .padding(.horizontal, 32) .padding(.bottom, 8) } private var doors: some View { // Exactly ONE door at a time β€” last-used when known, else the // platform default β€” with the rest behind "Other ways to sign in" // (Paul 2026-07-19, the web card's rule, mirrored). VStack(spacing: 12) { ForEach(showAllDoors ? doorOrder : Array(doorOrder.prefix(1)), id: \.self) { method in door(method, leading: method == doorOrder.first) } if !showAllDoors { Button("Other ways to sign in") { showAllDoors = true } .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) } } .padding(.horizontal, 32) .padding(.bottom, 8) } @ViewBuilder private func door(_ method: HomeSignInModel.SignInMethod, leading: Bool) -> some View { switch method { case .apple: // Apple's own button (HIG); the model owns the nonce + verify so // the session-adoption seam is shared with every other door. // While its trip is in flight Apple's control can't host a // spinner (the web's pressed door spins β€” home.js's .loading), // so a same-geometry stand-in covers it; the real button stays // in the hierarchy, hidden, or the in-flight authorization // would be torn down with the view. let appleBusy = model.phase.busy && tapped == .apple SignInWithAppleButton(.continue) { request in tapped = .apple model.prepareApple(request) } onCompletion: { result in Task { await model.handleApple(result, into: app) } } .signInWithAppleButtonStyle(scheme == .dark ? .white : .black) .frame(height: DoorButtonStyle.height) .disabled(model.phase.busy) .opacity(appleBusy ? 0 : 1) .overlay { if appleBusy { HStack(spacing: 8) { ProgressView() .tint(scheme == .dark ? .black : .white) Text("Signing in…").fontWeight(.semibold) } .frame(maxWidth: .infinity, minHeight: DoorButtonStyle.height) .foregroundStyle(scheme == .dark ? Color.black : .white) .background(scheme == .dark ? Color.white : .black, in: RoundedRectangle(cornerRadius: DoorButtonStyle.radius, style: .continuous)) } } case .email: Button { tapped = .email emailStep = .address focus = .address } label: { Text("Continue with email") } .buttonStyle(DoorButtonStyle(prominent: leading)) .disabled(model.phase.busy) case .passkey: // Promoted or listed, the passkey door is the same row shape β€” // only the fill marks the lead (no mixed button types in the // list; the Apple control fixes the geometry). Button { tapped = .passkey Task { await model.signIn(into: app) } } label: { HStack(spacing: 8) { if model.phase == .working && tapped == .passkey { ProgressView() .controlSize(.small) .tint(leading ? .white : nil) } else if leading && model.sessionSealed { // The unlock moment gets the passkey glyph β€” the icon // carries what the trimmed copy no longer spells out. Image(systemName: "person.badge.key.fill") } Text(leading ? passkeyLeadLabel : "Sign in with a passkey") } } .buttonStyle(DoorButtonStyle(prominent: leading)) .disabled(model.phase.busy) case .google, .microsoft, .github: // The web-flow doors: a browser sheet, not a native one β€” same // row geometry, the provider's mark carrying the brand. let door = OIDCDoor(rawValue: method.rawValue) ?? .google Button { tapped = method Task { await model.signInWithOidc(door, into: app) } } label: { HStack(spacing: 8) { if model.phase == .working && tapped == method { ProgressView() .controlSize(.small) .tint(leading ? .white : nil) } else { Image("Door\(door.display)") .resizable() .scaledToFit() .frame(width: 18, height: 18) } Text("Continue with \(door.display)") } } .buttonStyle(DoorButtonStyle(prominent: leading)) .disabled(model.phase.busy) } } private var passkeyLeadLabel: String { if model.phase == .working && tapped == .passkey { return "Signing in…" } if model.sessionSealed { return "Unlock with your passkey" } return "Sign in with a passkey" } // ---- the email door ----------------------------------------------------- @ViewBuilder private func emailDoor(_ step: EmailStep) -> some View { VStack(spacing: 12) { HStack { // Serial steps: code backs to the address, address backs to // the doors β€” a typo'd address is one tap away either way. Button { if step == .code { emailStep = .address code = "" focus = .address } else { emailStep = nil } } label: { Label("Back", systemImage: "chevron.backward") .font(.subheadline) } .accessibilityLabel(step == .code ? "Back to your email address" : "Back to sign-in") Spacer() } Text("Sign in with email") .font(.headline) if step == .address { TextField("you@example.com", text: $email) .textFieldStyle(.roundedBorder) .keyboardType(.emailAddress) .textContentType(.emailAddress) .textInputAutocapitalization(.never) .autocorrectionDisabled() .submitLabel(.send) .focused($focus, equals: .address) .onSubmit { if emailValid { sendCode() } } .accessibilityLabel("Your email address") Button(action: sendCode) { HStack(spacing: 8) { if model.phase == .working { ProgressView().controlSize(.small).tint(.white) } Text("Send code") } } .buttonStyle(DoorButtonStyle(prominent: true)) .disabled(model.phase.busy || !emailValid) // The web's note, word for word β€” under the address field. Text("We never keep your email address. No spam, ever.") .font(.footnote) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) } else { Text("Check your email for a 6-digit code.") .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) TextField("6-digit code", text: $code) .textFieldStyle(.roundedBorder) .keyboardType(.numberPad) .textContentType(.oneTimeCode) .multilineTextAlignment(.center) .font(.title3.monospacedDigit()) .focused($focus, equals: .code) .onChange(of: code) { _, v in // Digits only, six of them β€” mirrors the web's input. let digits = String(v.filter(\.isNumber).prefix(6)) if digits != v { code = digits } } .accessibilityLabel("The 6-digit code from your email") Button(action: verifyCode) { HStack(spacing: 8) { if model.phase == .working { ProgressView().controlSize(.small).tint(.white) } Text("Sign in") } } .buttonStyle(DoorButtonStyle(prominent: true)) .disabled(model.phase.busy || code.count != 6) } } .padding(.horizontal, 32) .padding(.bottom, 8) .onAppear { focus = step } } // The server's own bar (email.go): non-empty, has an @, fits an address. private var emailValid: Bool { let addr = email.trimmingCharacters(in: .whitespacesAndNewlines) return !addr.isEmpty && addr.contains("@") && addr.count <= 254 } private func sendCode() { let addr = email.trimmingCharacters(in: .whitespacesAndNewlines) Task { if await model.requestEmailCode(email: addr) { emailStep = .code focus = .code } } } private func verifyCode() { let addr = email.trimmingCharacters(in: .whitespacesAndNewlines) Task { await model.verifyEmailCode(email: addr, code: code) } } // ---- status ------------------------------------------------------------- @ViewBuilder private var statusLine: some View { switch model.phase { case .restored(let n): Label("\(n) space\(n == 1 ? "" : "s") restored.", systemImage: "checkmark.circle.fill") .font(.callout).foregroundStyle(.green) case .emptyBackup: Text("Signed in β€” nothing saved on this account yet. Join a space in your browser first.") .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) case .backedUp(let n): Label("Saved \(n) space\(n == 1 ? "" : "s") to Eleven.", systemImage: "checkmark.circle.fill") .font(.callout).foregroundStyle(.green) case .working: Text("Confirming it’s you…") .font(.callout).foregroundStyle(.secondary) case .failed(let msg): Label(msg, systemImage: "exclamationmark.triangle") .font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center) case .idle, .syncing, .sealed: // Sealed deliberately says NOTHING here: "signed in but locked" // reads as noise when no spaces are visible (Paul: if you can't // see your spaces, you're not signed in). The unlock button β€” a // passkey icon and five words β€” is the whole story. EmptyView() } } } // πŸ€– One geometry for every door (Paul, 2026-07-19): the Apple control fixes // the shape β€” full-width, 50pt, low-radius continuous corners β€” and no other // button type may sit beside it in the list. Prominent = the promoted door // (or a step's main action); plain = a demoted row. The pressed door hosts // its own spinner (the web's .loading rule), so busy state lives in the // button, not beside it. private struct DoorButtonStyle: ButtonStyle { var prominent = false @Environment(\.isEnabled) private var enabled static let radius: CGFloat = 12 static let height: CGFloat = 50 func makeBody(configuration: Configuration) -> some View { configuration.label .fontWeight(.semibold) .frame(maxWidth: .infinity, minHeight: Self.height) .foregroundStyle(prominent ? Color.white : Color.accentColor) .background(prominent ? Color.accentColor : Color(.secondarySystemFill), in: RoundedRectangle(cornerRadius: Self.radius, style: .continuous)) .opacity((configuration.isPressed ? 0.75 : 1) * (enabled ? 1 : 0.55)) } }