eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import AuthenticationServices
2import CryptoKit
3import LChatCore
4import SwiftUI
5import UIKit
6
7// πŸ€– "Sign in with Eleven" β€” the first-run doors. Three ways in, all against
8// the same home server: Sign in with Apple (the iOS lead), an emailed 6-digit
9// code, and the passkey ceremony (demoted to a quiet text button; the
10// last-used door is promoted, the web's #1112 rule). The ceremony + crypto
11// live in LChatCore (HomeAuth / HomeAnchorAuth / HomeVault / HomeCrypto);
12// this file is the iOS presentation plus the orchestration that turns a
13// restored vault into SavedInstances. The app still targets iOS 17 β€” the PRF
14// ceremony (iOS 18) is runtime-gated, so iOS 17 members keep the app and
15// simply don't get this feature (the affordances hide below 18).
16//
17// The identity-vs-key split holds natively exactly as on the web: an Apple or
18// email sign-in proves who you are and yields a session token, but only a
19// passkey's PRF can derive the key that opens the backup β€” an anchor sign-in
20// against an account with a sealed backup lands signed in with the passkey
21// door promoted to "unlock".
22//
23// The PRF output never leaves the device: HomeAuth returns it, HomeVault derives
24// the unwrap key from it in memory, and it's dropped when this model deallocates.
25
26@MainActor
27final class HomeSignInModel: ObservableObject {
28 enum Phase: Equatable {
29 case idle
30 case working // a sign-in sheet / verify round trip is in flight
31 case syncing // authenticated; fetching + restoring the backup
32 case restored(Int) // servers added to this device
33 case emptyBackup // signed in; the account holds no servers yet
34 case backedUp(Int) // N servers sealed into a (new or empty) backup
35 case sealed // anchor sign-in landed; the backup opens only under a passkey
36 case failed(String)
37 // No "noPasskey" case: the API can't distinguish "no passkey existed"
38 // from a cancelled sheet (both surface as .canceled), so the guidance
39 // lives as static idle copy on the first-run screen instead.
40
41 var busy: Bool { self == .working || self == .syncing }
42 /// A landed sign-in through any door β€” what folds the email door away.
43 var signedIn: Bool {
44 switch self {
45 case .restored, .emptyBackup, .backedUp, .sealed: return true
46 default: return false
47 }
48 }
49 }
50
51 /// Which door signed in β€” the #1112 last-used marker, mirrored natively
52 /// under the web's key name so the rule reads across: the last-used
53 /// method leads the doors next time (a future "Sign-in options" surface
54 /// reads the same marker).
55 enum SignInMethod: String { case passkey, apple, email, google, microsoft, github }
56 static let lastSignInKey = "home_last_signin"
57 static var lastSignIn: SignInMethod? {
58 UserDefaults.standard.string(forKey: lastSignInKey).flatMap(SignInMethod.init(rawValue:))
59 }
60 @Published private(set) var phase: Phase = .idle
61 /// Whether a home session exists on this device β€” @Published so EVERY
62 /// surface (first-run, space switcher) reacts to a sign-in made on any of them.
63 /// A stale one-shot copy here once left "Back up this device" visible
64 /// after sign-in, a path to accidentally minting a second account.
65 @Published private(set) var sessionExists = HomeSessionStore.load() != nil
66 /// True when the stored session is an anchor's (Apple / email): signed
67 /// in, but no DEK β€” the vault stays sealed until a passkey unlocks it.
68 /// SESSION state, deliberately not phase: the sealed landing must hold
69 /// steady through a ceremony's transient phases (.working after the tap,
70 /// .idle after a cancelled sheet) instead of bouncing to the doors.
71 @Published private(set) var sessionSealed = HomeSessionStore.load().map { $0.dek == nil } ?? false
72
73 init() {}
74
75 /// Run the discoverable passkey ceremony and restore whatever servers the
76 /// backup holds onto this device (adding them to `app`).
77 func signIn(into app: AppState) async {
78 guard let anchor = Self.keyWindow() else {
79 phase = .failed("Couldn't find a window to sign in from.")
80 return
81 }
82 guard #available(iOS 18.0, *) else {
83 phase = .failed("Sign in with Eleven needs iOS 18.")
84 return
85 }
86 phase = .working
87 do {
88 let result = try await HomeAuth(anchor: anchor).signIn()
89 phase = .syncing // the sheet is down; the blob fetch + restore runs
90 phase = try await restore(result, into: app)
91 } catch let e as ASAuthorizationError {
92 // The user backed out of the system sheet β€” quietly return to the
93 // first-run choices rather than showing an error. Everything else
94 // surfaces VERBATIM (code included): "failed silently" cost us a
95 // debugging round on the very first device run.
96 phase = e.code == .canceled ? .idle
97 : .failed("\(e.localizedDescription) (AS \(e.code.rawValue))")
98 } catch is CryptoKitError {
99 // The ceremony ran but the PRF-derived key didn't open the vault β€”
100 // in practice a third-party provider (1Password) answered with a
101 // different passkey, or returned a PRF the DEK wasn't wrapped for.
102 phase = .failed("That sign-in couldn't unlock your spaces. If you're offered a choice, pick the Apple Passwords option (Face ID).")
103 } catch {
104 phase = .failed((error as? HomeClientError).map(Self.message) ?? error.localizedDescription)
105 }
106 }
107
108 /// Re-read the Keychain session (the dev reset wipes it out from under the
109 /// model) and drop any terminal status back to the idle pitch.
110 func refreshSession() {
111 let session = HomeSessionStore.load()
112 sessionExists = session != nil
113 sessionSealed = session.map { $0.dek == nil } ?? false
114 phase = .idle
115 }
116
117 /// The wrong-door escape on the first-run screen: drop ONLY the home
118 /// session β€” nothing has been restored to this device β€” and bring the
119 /// doors back for a sign-in that landed on the wrong account.
120 func switchAccount() {
121 HomeSessionStore.clear()
122 sessionExists = false
123 sessionSealed = false
124 phase = .idle
125 }
126
127 /// Session adoption β€” the ONE seam every door funnels a finished sign-in
128 /// through (passkey ceremony finish, Apple verify, email verify): the
129 /// same Keychain write, the published flag every surface watches, and
130 /// the last-used marker the first-run doors re-order by. The ceremony
131 /// proof is only ever used transiently by its caller and never stored,
132 /// matching the web.
133 private func adopt(_ session: HomeSession, method: SignInMethod) {
134 HomeSessionStore.save(session)
135 sessionExists = true
136 sessionSealed = session.dek == nil
137 UserDefaults.standard.set(method.rawValue, forKey: Self.lastSignInKey)
138 // The DEK-sync write-gate (#1089, flag-gated): a DEK-holding session
139 // on a SiwA-anchored account adds/refreshes the synchronizable item.
140 Task { await DEKSync.writeIfEligible() }
141 }
142
143 // (No partial sign-out exists: with a passkey there's nothing to be half
144 // signed-out OF β€” Sign out is the full local reset, owned by RootView's
145 // onSignOut so nav state folds with it. The passkey itself is untouched.)
146
147 /// Mint a NEW Eleven passkey and seal this device's servers into a fresh
148 /// backup. Deliberately a separate act from Sign in (home has no account
149 /// discovery β€” registering always creates a new anonymous account, so it
150 /// must never ride the sign-in button; the web dropped exactly that
151 /// fallback in #323). The PRF output is used in-memory and dropped.
152 func backUp(into app: AppState) async {
153 // Registering ALWAYS creates a new anonymous account β€” with a session
154 // already on this device that would orphan the real backup (all future
155 // syncs would go to the fresh empty one). Refuse outright.
156 guard !sessionExists, HomeSessionStore.load() == nil else {
157 phase = .failed("This device is already signed in to Eleven.")
158 return
159 }
160 guard let anchor = Self.keyWindow() else {
161 phase = .failed("Couldn't find a window to sign in from.")
162 return
163 }
164 guard #available(iOS 18.0, *) else {
165 phase = .failed("Backing up to Eleven needs iOS 18.")
166 return
167 }
168 phase = .working
169 do {
170 let result = try await HomeAuth(anchor: anchor).register()
171 let contents = HomeVault.Contents(v: 1, servers: Self.vaultServers(from: app.instances))
172 let sealed = try HomeVault.sealFresh(contents, credentialID: result.credentialID,
173 prf: result.prf)
174 // If-Match "0" claims "no blob yet" β€” a fresh account always has none.
175 guard try await HomeClient().putBlob(token: result.token, bytes: sealed.bytes,
176 ifMatch: "0") != nil else {
177 phase = .failed("Couldn't save your spaces β€” try again.")
178 return
179 }
180 adopt(HomeSession(token: result.token, credentialID: result.credentialID,
181 dek: sealed.dek), method: .passkey)
182 phase = .backedUp(app.instances.count)
183 } catch let e as ASAuthorizationError {
184 phase = e.code == .canceled ? .idle
185 : .failed("\(e.localizedDescription) (AS \(e.code.rawValue))")
186 } catch {
187 phase = .failed((error as? HomeClientError).map(Self.message) ?? error.localizedDescription)
188 }
189 }
190
191 // ---- the Apple door (issue #1112; server: home/siwa.go) -----------------
192
193 /// The nonce minted for the sheet currently up β€” the SAME string rides
194 /// the Apple request and the verify body (home compares the identity
195 /// token's nonce claim against it verbatim).
196 private var appleNonce: String?
197
198 /// SignInWithAppleButton's request half. NO scopes β€” we want no name and
199 /// no email (the account stays anonymous; home only needs the token's
200 /// subject) β€” plus the fresh nonce binding this sheet to our verify.
201 func prepareApple(_ request: ASAuthorizationAppleIDRequest) {
202 let nonce = HomeAnchorAuth.mintNonce()
203 appleNonce = nonce
204 request.nonce = nonce
205 phase = .working
206 }
207
208 /// SignInWithAppleButton's completion half: verify the identity token
209 /// against home, then adopt the session through the same seam as a
210 /// passkey ceremony finish.
211 func handleApple(_ result: Result<ASAuthorization, Error>, into app: AppState) async {
212 let nonce = appleNonce
213 appleNonce = nil
214 switch result {
215 case .success(let auth):
216 guard let nonce,
217 let cred = auth.credential as? ASAuthorizationAppleIDCredential,
218 let idToken = HomeAnchorAuth.identityToken(from: cred) else {
219 phase = .failed("Apple didn't return a usable sign-in β€” try again.")
220 return
221 }
222 do {
223 let fin = try await HomeClient().siwaVerify(identityToken: idToken, nonce: nonce)
224 // The DEK-sync use-gate (#1089, flag-gated): the assertion
225 // that just verified is exactly the "fresh SiwA assertion"
226 // the synced item's release requires. When the iCloud-synced
227 // DEK is here AND belongs to this account's vault, this
228 // Apple sign-in opens the spaces outright β€” no passkey. Any
229 // failure falls through to the sealed/empty landing as today.
230 if let released = await DEKSync.release(token: fin.token) {
231 phase = .syncing
232 adopt(released.session, method: .apple)
233 phase = mergeVault(released.contents, into: app)
234 } else {
235 phase = await finishAnchor(fin, method: .apple)
236 }
237 } catch {
238 phase = .failed(Self.anchorMessage(error))
239 }
240 case .failure(let error):
241 // Backing out of Apple's sheet is a choice, not an error β€” the
242 // same quiet return as the passkey path's cancel.
243 if let e = error as? ASAuthorizationError, e.code == .canceled {
244 phase = .idle
245 } else {
246 phase = .failed(error.localizedDescription)
247 }
248 }
249 }
250
251 // ---- the email door (issue #1091; server: home/email.go) ----------------
252
253 /// Step one: send the code. True advances the door to the code step. The
254 /// spin floor mirrors the web's (~900ms): an instant send reads as a
255 /// glitch β€” and the server's 200 is the same known-or-not, so success
256 /// only ever means "sent".
257 func requestEmailCode(email: String) async -> Bool {
258 phase = .working
259 let floor = Task { try? await Task.sleep(for: .seconds(0.9)) }
260 do {
261 try await HomeClient().emailRequest(email: email)
262 await floor.value
263 phase = .idle
264 return true
265 } catch {
266 floor.cancel() // errors show immediately, like the web
267 phase = .failed(Self.anchorMessage(error))
268 return false
269 }
270 }
271
272 /// Step two: redeem the code and adopt the session β€” the same seam as
273 /// every other door. The floor is a touch longer than send's (the web's
274 /// 1.2s): a landing that swaps in instantly never registers.
275 func verifyEmailCode(email: String, code: String) async {
276 phase = .working
277 let floor = Task { try? await Task.sleep(for: .seconds(1.2)) }
278 do {
279 let fin = try await HomeClient().emailVerify(email: email, code: code)
280 await floor.value
281 phase = await finishAnchor(fin, method: .email)
282 } catch {
283 floor.cancel()
284 phase = .failed(Self.anchorMessage(error))
285 }
286 }
287
288 // ---- the web-flow doors: Google / Microsoft / GitHub (home/oidc.go) -----
289
290 /// Which of the web-flow doors this home server has lit, plus GitHub's
291 /// per-instance client id β€” fetched once when the doors show. Nil (not
292 /// yet fetched / older server) just means those doors don't render.
293 @Published private(set) var oidcDoors: HomeClient.SigninDoors?
294
295 /// The in-flight browser session β€” retained on the model, or the system
296 /// tears it down mid-flow.
297 private var webAuth: ASWebAuthenticationSession?
298 private let webAuthPresenter = WebAuthPresenter()
299
300 func refreshDoors() async {
301 oidcDoors = await HomeClient().signinDoors()
302 }
303
304 /// One browser round trip: authorize URL β†’ home's /oidc/return
305 /// trampoline β†’ elevenmessenger://home-oidc, handed back in-process by
306 /// the session (no Launch Services, no onOpenURL) β†’ verify β†’ the same
307 /// anchor landing as Apple and email. The state must not be
308 /// "web."-prefixed β€” that routes the trampoline to the web page.
309 func signInWithOidc(_ door: OIDCDoor, into app: AppState) async {
310 phase = .working
311 let state = "ios." + HomeAnchorAuth.mintNonce()
312 let nonce = HomeAnchorAuth.mintNonce()
313 guard let url = HomeOIDCAuth.authorizeURL(door, base: HomeBase.url, state: state,
314 nonce: nonce,
315 githubClientID: oidcDoors?.githubClientID) else {
316 phase = .failed("\(door.display) sign-in isn't available right now.")
317 return
318 }
319 do {
320 let cb = try await runWebAuth(url: url)
321 guard let parsed = HomeOIDCAuth.callback(cb, state: state) else {
322 phase = .failed("That sign-in didn't complete β€” try again.")
323 return
324 }
325 let fin: HomeClient.AnchorFinish
326 if door == .github, let code = parsed.code {
327 fin = try await HomeClient().githubVerify(code: code)
328 } else if let idToken = parsed.idToken {
329 fin = try await HomeClient().oidcVerify(provider: door.rawValue,
330 identityToken: idToken, nonce: nonce)
331 } else {
332 phase = .failed("That sign-in didn't complete β€” try again.")
333 return
334 }
335 phase = await finishAnchor(fin, method: SignInMethod(rawValue: door.rawValue) ?? .email)
336 } catch let e as ASWebAuthenticationSessionError where e.code == .canceledLogin {
337 // Closing the browser sheet is a choice, not an error β€” the same
338 // quiet return as Apple's cancel.
339 phase = .idle
340 } catch {
341 phase = .failed(Self.anchorMessage(error))
342 }
343 }
344
345 private func runWebAuth(url: URL) async throws -> URL {
346 defer { webAuth = nil }
347 return try await withCheckedThrowingContinuation { cont in
348 let session = ASWebAuthenticationSession(
349 url: url, callbackURLScheme: HomeOIDCAuth.callbackScheme) { cb, err in
350 if let cb {
351 cont.resume(returning: cb)
352 } else {
353 cont.resume(throwing: err ?? HomeClientError.network)
354 }
355 }
356 // Share the system browser's cookies: an already-signed-in
357 // Google/Microsoft/GitHub user gets a one-tap consent β€” the same
358 // choice the Mac SiwA web flow makes.
359 session.prefersEphemeralWebBrowserSession = false
360 session.presentationContextProvider = webAuthPresenter
361 webAuth = session
362 // A failed start never calls the completion handler β€” resuming
363 // here keeps the continuation (and the whole doors screen, which
364 // gates on phase.busy) from wedging forever. Same guard as the
365 // Mac's runWebAuth.
366 if !session.start() {
367 cont.resume(throwing: ASWebAuthenticationSessionError(.presentationContextInvalid))
368 }
369 }
370 }
371
372 /// An anchor's landing: adopt the token (no DEK β€” an Apple/email sign-in
373 /// proves who you are but can never decrypt; the backup's key lives
374 /// behind a passkey's PRF), then report what the account holds: fresh or
375 /// backup-less β†’ the empty pitch; an existing sealed backup β†’ point at
376 /// the passkey door, which is what actually opens it.
377 private func finishAnchor(_ fin: HomeClient.AnchorFinish, method: SignInMethod) async -> Phase {
378 adopt(HomeSession(token: fin.token), method: method)
379 if fin.created == true { return .emptyBackup }
380 do {
381 return try await HomeClient().fetchBlob(token: fin.token) == nil ? .emptyBackup : .sealed
382 } catch {
383 // Signed in either way β€” the peek only picks the pitch.
384 return .failed("Signed in, but Eleven couldn't be reached to check your spaces β€” try again in a moment.")
385 }
386 }
387
388 /// Anchor errors surface the server's own member-facing copy ("that code
389 /// didn't work") when there is one; transport failures get the standing
390 /// network line.
391 private static func anchorMessage(_ error: Error) -> String {
392 if case HomeClientError.http(_, let body) = error {
393 let text = body.trimmingCharacters(in: .whitespacesAndNewlines)
394 if !text.isEmpty, text.count <= 120 { return text }
395 }
396 if let e = error as? HomeClientError { return message(e) }
397 return error.localizedDescription
398 }
399
400 /// Fetch + open the backup and merge it into this device β€” the VAULT wins
401 /// on sign-in (its stars, names and order apply to servers the device
402 /// already has; missing ones are added). Only then does the device push,
403 /// so the write-back can't wipe state other devices stored β€” the exact
404 /// data-loss the first cut had. Returns the resulting phase.
405 @available(iOS 18.0, *)
406 private func restore(_ result: HomeAuth.Result, into app: AppState) async throws -> Phase {
407 guard let fetch = try await HomeClient().fetchBlob(token: result.token) else {
408 // Authenticated, but the account holds no blob (a backUp whose PUT
409 // failed after register, or a web-deleted backup). With the PRF in
410 // hand this is recoverable RIGHT NOW: seal the device's servers
411 // into a first envelope for this passkey. Bailing here was a
412 // permanent dead-end β€” no session saved, and routine sync can't
413 // create a blob (it never holds the PRF).
414 let contents = HomeVault.Contents(v: 1, servers: Self.vaultServers(from: app.instances))
415 let sealed = try HomeVault.sealFresh(contents, credentialID: result.credentialID,
416 prf: result.prf)
417 guard try await HomeClient().putBlob(token: result.token, bytes: sealed.bytes,
418 ifMatch: "0") != nil else {
419 return .failed("Couldn't save your spaces β€” try again.")
420 }
421 adopt(HomeSession(token: result.token, credentialID: result.credentialID,
422 dek: sealed.dek), method: .passkey)
423 return app.instances.isEmpty ? .emptyBackup : .backedUp(app.instances.count)
424 }
425 guard let opened = try HomeVault.open(fetch.bytes,
426 credentialID: result.credentialID,
427 prf: result.prf) else {
428 return .failed("That sign-in doesn't unlock these spaces.")
429 }
430 // Keep the session (link token + DEK, Keychain) so stars/reorders sync
431 // to home from now on without another Face ID. The PRF output itself is
432 // NOT stored β€” the DEK is all sync needs.
433 adopt(HomeSession(token: result.token, credentialID: result.credentialID,
434 dek: opened.dek), method: .passkey)
435 return mergeVault(opened.contents, into: app)
436 }
437
438 /// An OPENED vault landing on this device β€” the vault-wins merge every
439 /// unlocked sign-in shares (the passkey restore above, and the Apple door
440 /// when the synced DEK releases). Caller adopts the session first.
441 private func mergeVault(_ contents: HomeVault.Contents, into app: AppState) -> Phase {
442 // Removals made on the home page win over stale device copies β€” apply
443 // their tombstones before anything flows back up.
444 app.dropTombstoned(contents.removed)
445 var added = 0
446 for server in contents.servers { // array order is the master order
447 // A credential-less entry (a space saved by URL alone on the home
448 // page) can't be signed into from here β€” skip it; it stays in the
449 // vault untouched.
450 guard let creds = server.credentials,
451 !app.instances.contains(where: { $0.origin == server.url }) else { continue }
452 app.add(SavedInstance(
453 origin: server.url,
454 deviceToken: creds.token,
455 screenName: creds.name ?? server.name ?? "",
456 userPub: creds.userPub,
457 userPriv: creds.userPriv,
458 groupName: server.name ?? "",
459 starred: server.isStarred ? true : nil,
460 archived: server.isArchived ? true : nil
461 ))
462 added += 1
463 }
464 // Vault wins for servers on BOTH sides: its stars/names/order apply to
465 // the device before anything flows back.
466 app.applyVault(contents.servers.map {
467 (url: $0.url, starred: $0.isStarred, archived: $0.isArchived, name: $0.name)
468 })
469 // Device-only servers (joined before signing in) flow back up, so home
470 // matches the device from the very first sign-in.
471 HomeSync.pushSoon(instances: app.instances)
472 return added > 0 ? .restored(added) : .emptyBackup
473 }
474
475 /// The device's servers as vault entries (used for a first backup).
476 static func vaultServers(from instances: [SavedInstance]) -> [HomeVault.Server] {
477 instances.map { inst in
478 HomeVault.Server(
479 url: inst.origin,
480 name: inst.groupName.isEmpty ? nil : inst.groupName,
481 starred: inst.isStarred ? true : nil,
482 archived: inst.isArchived ? true : nil,
483 credentials: .init(token: inst.deviceToken, userPub: inst.userPub,
484 userPriv: inst.userPriv, name: inst.screenName))
485 }
486 }
487
488 private static func message(_ e: HomeClientError) -> String {
489 switch e {
490 case .network: return "Couldn't reach Eleven. Check your connection and try again."
491 case .http(let code, _): return "Eleven turned that down (\(code)). Try again in a moment."
492 }
493 }
494
495 // The key window backs the ASAuthorizationController's presentation anchor.
496 fileprivate static func keyWindow() -> UIWindow? {
497 UIApplication.shared.connectedScenes
498 .compactMap { $0 as? UIWindowScene }
499 .flatMap(\.windows)
500 .first { $0.isKeyWindow }
501 }
502}
503
504// The web-auth sheet's presentation anchor β€” the app's first ASWebAuthentication-
505// Session (the Mac has its own in MacHomeSignIn).
506private final class WebAuthPresenter: NSObject, ASWebAuthenticationPresentationContextProviding {
507 func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
508 HomeSignInModel.keyWindow() ?? ASPresentationAnchor()
509 }
510}
511
512// The first-run screen: the doors. Apple leads on iOS (the platform default),
513// email beneath it, and the passkey path demoted to a quiet text button β€” with
514// the last-used method promoted to lead (the web's #1112 rule, mirrored
515// simply). The email door is a card step of its own, the web's screen-swap
516// pattern: address β†’ code β†’ session, serial, with a back affordance.
517struct SignInFirstRun: View {
518 @EnvironmentObject var app: AppState
519 @ObservedObject var model: HomeSignInModel
520 @Environment(\.colorScheme) private var scheme
521
522 private enum EmailStep { case address, code }
523 @State private var emailStep: EmailStep?
524 @State private var email = ""
525 @State private var code = ""
526 // Which door the last tap opened β€” only so the promoted passkey button
527 // doesn't spin for an Apple verify that happens to share phase == .working.
528 @State private var tapped: HomeSignInModel.SignInMethod?
529 // One door at a time: the demoted doors unfold behind this.
530 @State private var showAllDoors = false
531 @FocusState private var focus: EmailStep?
532
533 var body: some View {
534 VStack(spacing: 0) {
535 Spacer()
536 VStack(spacing: 16) {
537 Image("ElevenMark") // the real Eleven brand mark (purple "11" tile)
538 .resizable()
539 .frame(width: 76, height: 76)
540 .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
541 .accessibilityHidden(true)
542 Text("Welcome to Eleven")
543 .font(.title2.weight(.semibold))
544 // The tagline is the welcome pitch β€” the sealed landing is an
545 // unlock moment, not a welcome, and earns no marketing words
546 // (Paul, 2026-07-19: the button says everything this state
547 // needs).
548 if !model.sessionSealed {
549 Text("Secure group chat")
550 .font(.callout)
551 .foregroundStyle(.secondary)
552 .multilineTextAlignment(.center)
553 .padding(.horizontal, 8)
554 }
555 }
556
557 // Status / result folds in under the pitch, occupying layout space
558 // (no floating toast β€” the project's inline-feedback rule).
559 statusLine
560 .frame(minHeight: 22)
561 .padding(.top, 14)
562
563 Spacer()
564
565 if model.phase == .syncing {
566 // Authenticated; the backup is restoring. RootView carries the
567 // user into their first space the moment instances land, so
568 // this shows only for the fetch's beat.
569 VStack(spacing: 12) {
570 ProgressView()
571 Text("Syncing your spaces…")
572 .font(.callout)
573 .foregroundStyle(.secondary)
574 }
575 .padding(.bottom, 48)
576 } else if let step = emailStep {
577 emailDoor(step)
578 } else if model.phase.signedIn || model.sessionSealed {
579 landed
580 } else {
581 doors
582 }
583 }
584 .padding()
585 // A landed sign-in (any door) folds the email door away; the doors
586 // return beneath the status β€” sealed promotes the passkey to unlock.
587 .onChange(of: model.phase) { _, p in
588 if p.signedIn {
589 emailStep = nil
590 email = ""
591 code = ""
592 }
593 }
594 // Which web-flow doors this home server has lit (and GitHub's
595 // client id) β€” fetched once per screen; until it lands the three
596 // fixed doors stand alone, same as an older server.
597 .task { await model.refreshDoors() }
598 }
599
600 // ---- the doors ----------------------------------------------------------
601
602 /// Apple, email, passkey β€” plus whichever web-flow doors the home server
603 /// has lit (Google/Microsoft/GitHub, in the web's fixed brand order) β€”
604 /// with the last-used method promoted to lead. After a sealed anchor
605 /// sign-in the passkey leads regardless: it is the one door that
606 /// actually opens the backup.
607 private var doorOrder: [HomeSignInModel.SignInMethod] {
608 var order: [HomeSignInModel.SignInMethod] = [.apple, .email, .passkey]
609 if model.oidcDoors?.githubClientID?.isEmpty == false { order.append(.github) }
610 if model.oidcDoors?.microsoft == true { order.append(.microsoft) }
611 if model.oidcDoors?.google == true { order.append(.google) }
612 let lead: HomeSignInModel.SignInMethod? =
613 model.phase == .sealed ? .passkey : HomeSignInModel.lastSignIn
614 if let lead, let i = order.firstIndex(of: lead) {
615 order.remove(at: i)
616 order.insert(lead, at: 0)
617 }
618 return order
619 }
620
621 // A landed sign-in folds the doors away entirely β€” signed in means no
622 // sign-in options. Sealed keeps exactly one control, the passkey,
623 // because it is the unlock rather than an alternative; the quiet escape
624 // below it covers the wrong-account landing either way.
625 private var landed: some View {
626 VStack(spacing: 12) {
627 if model.sessionSealed {
628 door(.passkey, leading: true)
629 }
630 Button("Sign in a different way") { model.switchAccount() }
631 .font(.subheadline.weight(.medium))
632 .foregroundStyle(.secondary)
633 .disabled(model.phase.busy) // never mid-ceremony
634 }
635 .padding(.horizontal, 32)
636 .padding(.bottom, 8)
637 }
638
639 private var doors: some View {
640 // Exactly ONE door at a time β€” last-used when known, else the
641 // platform default β€” with the rest behind "Other ways to sign in"
642 // (Paul 2026-07-19, the web card's rule, mirrored).
643 VStack(spacing: 12) {
644 ForEach(showAllDoors ? doorOrder : Array(doorOrder.prefix(1)), id: \.self) { method in
645 door(method, leading: method == doorOrder.first)
646 }
647 if !showAllDoors {
648 Button("Other ways to sign in") { showAllDoors = true }
649 .font(.subheadline.weight(.medium))
650 .foregroundStyle(.secondary)
651 }
652 }
653 .padding(.horizontal, 32)
654 .padding(.bottom, 8)
655 }
656
657 @ViewBuilder private func door(_ method: HomeSignInModel.SignInMethod, leading: Bool) -> some View {
658 switch method {
659 case .apple:
660 // Apple's own button (HIG); the model owns the nonce + verify so
661 // the session-adoption seam is shared with every other door.
662 // While its trip is in flight Apple's control can't host a
663 // spinner (the web's pressed door spins β€” home.js's .loading),
664 // so a same-geometry stand-in covers it; the real button stays
665 // in the hierarchy, hidden, or the in-flight authorization
666 // would be torn down with the view.
667 let appleBusy = model.phase.busy && tapped == .apple
668 SignInWithAppleButton(.continue) { request in
669 tapped = .apple
670 model.prepareApple(request)
671 } onCompletion: { result in
672 Task { await model.handleApple(result, into: app) }
673 }
674 .signInWithAppleButtonStyle(scheme == .dark ? .white : .black)
675 .frame(height: DoorButtonStyle.height)
676 .disabled(model.phase.busy)
677 .opacity(appleBusy ? 0 : 1)
678 .overlay {
679 if appleBusy {
680 HStack(spacing: 8) {
681 ProgressView()
682 .tint(scheme == .dark ? .black : .white)
683 Text("Signing in…").fontWeight(.semibold)
684 }
685 .frame(maxWidth: .infinity, minHeight: DoorButtonStyle.height)
686 .foregroundStyle(scheme == .dark ? Color.black : .white)
687 .background(scheme == .dark ? Color.white : .black,
688 in: RoundedRectangle(cornerRadius: DoorButtonStyle.radius,
689 style: .continuous))
690 }
691 }
692 case .email:
693 Button {
694 tapped = .email
695 emailStep = .address
696 focus = .address
697 } label: {
698 Text("Continue with email")
699 }
700 .buttonStyle(DoorButtonStyle(prominent: leading))
701 .disabled(model.phase.busy)
702 case .passkey:
703 // Promoted or listed, the passkey door is the same row shape β€”
704 // only the fill marks the lead (no mixed button types in the
705 // list; the Apple control fixes the geometry).
706 Button {
707 tapped = .passkey
708 Task { await model.signIn(into: app) }
709 } label: {
710 HStack(spacing: 8) {
711 if model.phase == .working && tapped == .passkey {
712 ProgressView()
713 .controlSize(.small)
714 .tint(leading ? .white : nil)
715 } else if leading && model.sessionSealed {
716 // The unlock moment gets the passkey glyph β€” the icon
717 // carries what the trimmed copy no longer spells out.
718 Image(systemName: "person.badge.key.fill")
719 }
720 Text(leading ? passkeyLeadLabel : "Sign in with a passkey")
721 }
722 }
723 .buttonStyle(DoorButtonStyle(prominent: leading))
724 .disabled(model.phase.busy)
725 case .google, .microsoft, .github:
726 // The web-flow doors: a browser sheet, not a native one β€” same
727 // row geometry, the provider's mark carrying the brand.
728 let door = OIDCDoor(rawValue: method.rawValue) ?? .google
729 Button {
730 tapped = method
731 Task { await model.signInWithOidc(door, into: app) }
732 } label: {
733 HStack(spacing: 8) {
734 if model.phase == .working && tapped == method {
735 ProgressView()
736 .controlSize(.small)
737 .tint(leading ? .white : nil)
738 } else {
739 Image("Door\(door.display)")
740 .resizable()
741 .scaledToFit()
742 .frame(width: 18, height: 18)
743 }
744 Text("Continue with \(door.display)")
745 }
746 }
747 .buttonStyle(DoorButtonStyle(prominent: leading))
748 .disabled(model.phase.busy)
749 }
750 }
751
752 private var passkeyLeadLabel: String {
753 if model.phase == .working && tapped == .passkey { return "Signing in…" }
754 if model.sessionSealed { return "Unlock with your passkey" }
755 return "Sign in with a passkey"
756 }
757
758 // ---- the email door -----------------------------------------------------
759
760 @ViewBuilder private func emailDoor(_ step: EmailStep) -> some View {
761 VStack(spacing: 12) {
762 HStack {
763 // Serial steps: code backs to the address, address backs to
764 // the doors β€” a typo'd address is one tap away either way.
765 Button {
766 if step == .code {
767 emailStep = .address
768 code = ""
769 focus = .address
770 } else {
771 emailStep = nil
772 }
773 } label: {
774 Label("Back", systemImage: "chevron.backward")
775 .font(.subheadline)
776 }
777 .accessibilityLabel(step == .code ? "Back to your email address" : "Back to sign-in")
778 Spacer()
779 }
780 Text("Sign in with email")
781 .font(.headline)
782
783 if step == .address {
784 TextField("you@example.com", text: $email)
785 .textFieldStyle(.roundedBorder)
786 .keyboardType(.emailAddress)
787 .textContentType(.emailAddress)
788 .textInputAutocapitalization(.never)
789 .autocorrectionDisabled()
790 .submitLabel(.send)
791 .focused($focus, equals: .address)
792 .onSubmit { if emailValid { sendCode() } }
793 .accessibilityLabel("Your email address")
794 Button(action: sendCode) {
795 HStack(spacing: 8) {
796 if model.phase == .working { ProgressView().controlSize(.small).tint(.white) }
797 Text("Send code")
798 }
799 }
800 .buttonStyle(DoorButtonStyle(prominent: true))
801 .disabled(model.phase.busy || !emailValid)
802 // The web's note, word for word β€” under the address field.
803 Text("We never keep your email address. No spam, ever.")
804 .font(.footnote)
805 .foregroundStyle(.tertiary)
806 .multilineTextAlignment(.center)
807 } else {
808 Text("Check your email for a 6-digit code.")
809 .font(.callout)
810 .foregroundStyle(.secondary)
811 .multilineTextAlignment(.center)
812 TextField("6-digit code", text: $code)
813 .textFieldStyle(.roundedBorder)
814 .keyboardType(.numberPad)
815 .textContentType(.oneTimeCode)
816 .multilineTextAlignment(.center)
817 .font(.title3.monospacedDigit())
818 .focused($focus, equals: .code)
819 .onChange(of: code) { _, v in
820 // Digits only, six of them β€” mirrors the web's input.
821 let digits = String(v.filter(\.isNumber).prefix(6))
822 if digits != v { code = digits }
823 }
824 .accessibilityLabel("The 6-digit code from your email")
825 Button(action: verifyCode) {
826 HStack(spacing: 8) {
827 if model.phase == .working { ProgressView().controlSize(.small).tint(.white) }
828 Text("Sign in")
829 }
830 }
831 .buttonStyle(DoorButtonStyle(prominent: true))
832 .disabled(model.phase.busy || code.count != 6)
833 }
834 }
835 .padding(.horizontal, 32)
836 .padding(.bottom, 8)
837 .onAppear { focus = step }
838 }
839
840 // The server's own bar (email.go): non-empty, has an @, fits an address.
841 private var emailValid: Bool {
842 let addr = email.trimmingCharacters(in: .whitespacesAndNewlines)
843 return !addr.isEmpty && addr.contains("@") && addr.count <= 254
844 }
845
846 private func sendCode() {
847 let addr = email.trimmingCharacters(in: .whitespacesAndNewlines)
848 Task {
849 if await model.requestEmailCode(email: addr) {
850 emailStep = .code
851 focus = .code
852 }
853 }
854 }
855
856 private func verifyCode() {
857 let addr = email.trimmingCharacters(in: .whitespacesAndNewlines)
858 Task { await model.verifyEmailCode(email: addr, code: code) }
859 }
860
861 // ---- status -------------------------------------------------------------
862
863 @ViewBuilder private var statusLine: some View {
864 switch model.phase {
865 case .restored(let n):
866 Label("\(n) space\(n == 1 ? "" : "s") restored.", systemImage: "checkmark.circle.fill")
867 .font(.callout).foregroundStyle(.green)
868 case .emptyBackup:
869 Text("Signed in β€” nothing saved on this account yet. Join a space in your browser first.")
870 .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
871 case .backedUp(let n):
872 Label("Saved \(n) space\(n == 1 ? "" : "s") to Eleven.", systemImage: "checkmark.circle.fill")
873 .font(.callout).foregroundStyle(.green)
874 case .working:
875 Text("Confirming it’s you…")
876 .font(.callout).foregroundStyle(.secondary)
877 case .failed(let msg):
878 Label(msg, systemImage: "exclamationmark.triangle")
879 .font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center)
880 case .idle, .syncing, .sealed:
881 // Sealed deliberately says NOTHING here: "signed in but locked"
882 // reads as noise when no spaces are visible (Paul: if you can't
883 // see your spaces, you're not signed in). The unlock button β€” a
884 // passkey icon and five words β€” is the whole story.
885 EmptyView()
886 }
887 }
888}
889
890// πŸ€– One geometry for every door (Paul, 2026-07-19): the Apple control fixes
891// the shape β€” full-width, 50pt, low-radius continuous corners β€” and no other
892// button type may sit beside it in the list. Prominent = the promoted door
893// (or a step's main action); plain = a demoted row. The pressed door hosts
894// its own spinner (the web's .loading rule), so busy state lives in the
895// button, not beside it.
896private struct DoorButtonStyle: ButtonStyle {
897 var prominent = false
898 @Environment(\.isEnabled) private var enabled
899
900 static let radius: CGFloat = 12
901 static let height: CGFloat = 50
902
903 func makeBody(configuration: Configuration) -> some View {
904 configuration.label
905 .fontWeight(.semibold)
906 .frame(maxWidth: .infinity, minHeight: Self.height)
907 .foregroundStyle(prominent ? Color.white : Color.accentColor)
908 .background(prominent ? Color.accentColor : Color(.secondarySystemFill),
909 in: RoundedRectangle(cornerRadius: Self.radius, style: .continuous))
910 .opacity((configuration.isPressed ? 0.75 : 1) * (enabled ? 1 : 0.55))
911 }
912}
913