| index 763cb00..26b19fb 100644 |
| --- a/ios/LChat/Views/SignInWithEleven.swift |
| +++ b/ios/LChat/Views/SignInWithEleven.swift |
| @@ -4,14 +4,21 @@ import LChatCore |
| import SwiftUI |
| import UIKit |
| |
| -// 🤖 "Sign in with Eleven" — the first-run hero path. A passkey to |
| -// home.elevenmessenger.com restores your saved server list onto this device |
| -// (the web's backup store, now reachable natively). The ceremony + crypto live |
| -// in LChatCore (HomeAuth / 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). |
| +// 🤖 "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. |
| @@ -20,17 +27,35 @@ import UIKit |
| final class HomeSignInModel: ObservableObject { |
| enum Phase: Equatable { |
| case idle |
| - case working // the passkey sheet is up |
| + 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 } |
| + 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 |
| @@ -57,7 +82,6 @@ final class HomeSignInModel: ObservableObject { |
| 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) |
| - sessionExists = HomeSessionStore.load() != nil |
| } 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 |
| @@ -82,6 +106,18 @@ final class HomeSignInModel: ObservableObject { |
| 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 |
| + UserDefaults.standard.set(method.rawValue, forKey: Self.lastSignInKey) |
| + } |
| + |
| // (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.) |
| @@ -119,10 +155,8 @@ final class HomeSignInModel: ObservableObject { |
| phase = .failed("Couldn't save your spaces — try again.") |
| return |
| } |
| - HomeSessionStore.save(HomeSession(token: result.token, |
| - credentialID: result.credentialID, |
| - dek: sealed.dek)) |
| - sessionExists = true |
| + 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 |
| @@ -132,6 +166,119 @@ final class HomeSignInModel: ObservableObject { |
| } |
| } |
| |
| + // ---- 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<ASAuthorization, Error>) 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) |
| + 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)) |
| + } |
| + } |
| + |
| + /// 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, |
| @@ -153,9 +300,8 @@ final class HomeSignInModel: ObservableObject { |
| ifMatch: "0") != nil else { |
| return .failed("Couldn't save your spaces — try again.") |
| } |
| - HomeSessionStore.save(HomeSession(token: result.token, |
| - credentialID: result.credentialID, |
| - dek: sealed.dek)) |
| + 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, |
| @@ -166,9 +312,8 @@ final class HomeSignInModel: ObservableObject { |
| // 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. |
| - HomeSessionStore.save(HomeSession(token: result.token, |
| - credentialID: result.credentialID, |
| - dek: opened.dek)) |
| + adopt(HomeSession(token: result.token, credentialID: result.credentialID, |
| + dek: opened.dek), method: .passkey) |
| // Removals made on the home page win over stale device copies — apply |
| // their tombstones before anything flows back up. |
| app.dropTombstoned(opened.contents.removed) |
| @@ -231,12 +376,24 @@ final class HomeSignInModel: ObservableObject { |
| } |
| } |
| |
| -// The first-run screen: "Sign in with Eleven" is the hero; joining a specific |
| -// chat by invite link (paste / QR) is demoted to a quiet secondary action. |
| +// 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 |
| - /// Opens the existing invite-link / QR add sheet. |
| + @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? |
| + @FocusState private var focus: EmailStep? |
| |
| var body: some View { |
| VStack(spacing: 0) { |
| @@ -275,34 +432,233 @@ struct SignInFirstRun: View { |
| .foregroundStyle(.secondary) |
| } |
| .padding(.bottom, 48) |
| + } else if let step = emailStep { |
| + emailDoor(step) |
| } else { |
| - VStack(spacing: 12) { |
| - Button(action: { Task { await model.signIn(into: app) } }) { |
| - HStack(spacing: 8) { |
| - if model.phase == .working { ProgressView().controlSize(.small) } |
| - Text(model.phase == .working ? "Signing in…" : "Sign in") |
| - .fontWeight(.semibold) |
| + 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 = "" |
| + } |
| + } |
| + } |
| + |
| + // ---- the doors ---------------------------------------------------------- |
| + |
| + /// Apple, email, passkey — 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] |
| + 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 |
| + } |
| + |
| + private var doors: some View { |
| + VStack(spacing: 12) { |
| + ForEach(doorOrder, id: \.self) { method in |
| + door(method, leading: method == doorOrder.first) |
| + } |
| + |
| + // The home page's privacy pitch, word for word. |
| + Text("No signup required. We don't store any personal details. We can't spam what we don't have.") |
| + .font(.footnote) |
| + .foregroundStyle(.tertiary) |
| + .multilineTextAlignment(.center) |
| + } |
| + .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. |
| + SignInWithAppleButton(.continue) { request in |
| + tapped = .apple |
| + model.prepareApple(request) |
| + } onCompletion: { result in |
| + Task { await model.handleApple(result) } |
| + } |
| + .signInWithAppleButtonStyle(scheme == .dark ? .white : .black) |
| + .frame(height: 50) |
| + .disabled(model.phase.busy) |
| + case .email: |
| + Button { |
| + tapped = .email |
| + emailStep = .address |
| + focus = .address |
| + } label: { |
| + Text("Continue with email") |
| + .fontWeight(.semibold) |
| + .frame(maxWidth: .infinity) |
| + } |
| + .buttonStyle(.bordered) |
| + .controlSize(.large) |
| + .disabled(model.phase.busy) |
| + case .passkey: |
| + if leading { |
| + // Promoted: the hero form (last-used passkey, or a sealed |
| + // anchor sign-in pointing at the door that opens the backup). |
| + Button { |
| + tapped = .passkey |
| + Task { await model.signIn(into: app) } |
| + } label: { |
| + HStack(spacing: 8) { |
| + if model.phase == .working && tapped == .passkey { |
| + ProgressView().controlSize(.small) |
| } |
| - .frame(maxWidth: .infinity) |
| + Text(passkeyLeadLabel) |
| + .fontWeight(.semibold) |
| } |
| - .buttonStyle(.borderedProminent) |
| - .controlSize(.large) |
| - .disabled(model.phase.busy) |
| + .frame(maxWidth: .infinity) |
| + } |
| + .buttonStyle(.borderedProminent) |
| + .controlSize(.large) |
| + .disabled(model.phase.busy) |
| + } else { |
| + // Demoted: the quiet text button under the anchor doors. |
| + Button("Sign in with a passkey") { |
| + tapped = .passkey |
| + Task { await model.signIn(into: app) } |
| + } |
| + .buttonStyle(.plain) |
| + .font(.subheadline.weight(.medium)) |
| + .foregroundStyle(.secondary) |
| + .padding(.vertical, 4) |
| + .disabled(model.phase.busy) |
| + } |
| + } |
| + } |
| + |
| + private var passkeyLeadLabel: String { |
| + if model.phase == .working && tapped == .passkey { return "Signing in…" } |
| + if model.phase == .sealed { return "Unlock your spaces with your passkey" } |
| + return "Sign in with a passkey" |
| + } |
| + |
| + // ---- the email door ----------------------------------------------------- |
| |
| - // The home page's privacy pitch, word for word. |
| - Text("No signup required. We don't store any personal details. We can't spam what we don't have.") |
| - .font(.footnote) |
| - .foregroundStyle(.tertiary) |
| - .multilineTextAlignment(.center) |
| + @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) } |
| + Text("Send code").fontWeight(.semibold) |
| + } |
| + .frame(maxWidth: .infinity) |
| + } |
| + .buttonStyle(.borderedProminent) |
| + .controlSize(.large) |
| + .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) } |
| + Text("Sign in").fontWeight(.semibold) |
| + } |
| + .frame(maxWidth: .infinity) |
| } |
| - .padding(.horizontal, 32) |
| - .padding(.bottom, 8) |
| + .buttonStyle(.borderedProminent) |
| + .controlSize(.large) |
| + .disabled(model.phase.busy || code.count != 6) |
| } |
| } |
| - .padding() |
| + .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): |
| @@ -314,10 +670,18 @@ struct SignInFirstRun: View { |
| case .backedUp(let n): |
| Label("Saved \(n) space\(n == 1 ? "" : "s") to Eleven.", systemImage: "checkmark.circle.fill") |
| .font(.callout).foregroundStyle(.green) |
| + case .sealed: |
| + // The identity-vs-key split, said plainly: the anchor signed you |
| + // in; the passkey (promoted below) is what opens your spaces. |
| + Text("Signed in — your spaces are locked with your passkey. Unlock them below.") |
| + .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) |
| + 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, .working, .syncing: |
| + case .idle, .syncing: |
| EmptyView() |
| } |
| } |