| 1 | import AuthenticationServices |
| 2 | import CryptoKit |
| 3 | import LChatCore |
| 4 | import SwiftUI |
| 5 | import 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 |
| 27 | final 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). |
| 506 | private 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. |
| 517 | struct 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. |
| 896 | private 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 | |