mac: Sign in with Apple + email-code doors on the Mac first-run screen (#1133)
* native: home base becomes resolvable — Debug builds can point at the dev home LChatCore gains HomeBase: production (https://home.elevenmessenger.com) by default, overridable ONLY in Debug builds — LCHAT_HOME_BASE in the process environment first, then a home_base_override string in standard UserDefaults (per-bundle-id on Mac dev alts). Release compiles the override out. HomeClient's default base, HomeAuth's default rpID, the Mac browser-handoff /restore link and the sidebar's Manage Spaces link all resolve through it. HomeSessionStore scopes its Keychain account by the resolved base (suffix on non-production only, so existing prod sessions keep their account name) — a dev-home session can never be replayed against production accounts or vice versa. Debug entitlements gain the dev clone's webcredentials association; tools/dev + CLAUDE.md document the alt one-liner. Unblocks PR #1054's testing and native sign-in device testing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: Sign in with Apple + email-code doors on the first-run screen The native half of the #1112/#1091 identity anchors. LChatCore gains HomeAnchorAuth (siwa/email client calls over HomeClient's plumbing, the verbatim-nonce mint, identity-token extraction) and a DEK-less anchor HomeSession (authenticates, never decrypts — sync parks until a passkey upgrades it in place). The first-run screen becomes the doors: Apple leads (SignInWithAppleButton, no scopes, nonce-bound verify), email beneath as a serial card step (address → 6-digit code, web spin floors, the no-spam note), the passkey demoted to a quiet text button — last-used promoted via the home_last_signin UserDefaults marker, written by the one adopt() seam every door funnels through. An anchor landing on a sealed backup promotes the passkey to "Unlock your spaces with your passkey". SiwA entitlement added for both build flavours. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac: Sign in with Apple + email-code doors on the Mac first-run screen The parity half of the iOS doors (#1132), built on its LChatCore pieces (HomeAnchorAuth, AnchorFinish, the DEK-less HomeSession). Developer ID Mac apps cannot hold the native SiwA entitlement (#1089), so the Apple door rides Apple's web flow in an ASWebAuthenticationSession: authorize (Services ID com.elevenmessenger.signin, state "mac.<random hex>") → home's /siwa/return trampoline → elevenmessenger://home-siwa, intercepted in-process; the id_token from the fragment goes to the one verifier with an empty nonce (the web flow carries no nonce binding). Non-ephemeral, so shared Safari cookies give signed-in-to-Apple members a one-click confirm. The email door mirrors iOS serially in the first-run surface: address → Send code (~900ms floor) → 6-digit code → verify (~1.2s floor), Return advances, back affordances, AX labels, the no-spam note. Every door funnels through the one adopt() seam (Keychain write, the published flags, the shared home_last_signin marker), so the last-used door leads next time; a sealed anchor landing promotes the passkey door to "unlock", and Settings presents the DEK-less state with the same framing plus an unlock affordance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+510
−43
ios/LChatMac/MacHomeSignIn.swift+510 −43
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift| index 2d86cc8..1e25183 100644 |
| --- a/ios/LChatMac/MacHomeSignIn.swift |
| +++ b/ios/LChatMac/MacHomeSignIn.swift |
| @@ -8,7 +8,11 @@ import SwiftUI |
| // 🤖 "Sign in with Eleven" on the Mac — the Settings-pane counterpart of the |
| // iOS drawer flow (HomeSignInModel): a passkey to home.elevenmessenger.com |
| // restores the saved server list onto this Mac and starts the star/order sync |
| -// (MacAppState.syncHome, dormant until a session exists). |
| +// (MacAppState.syncHome, dormant until a session exists). The identity |
| +// anchors (Sign in with Apple, email codes — issues #1089/#1091/#1112) are |
| +// doors into the same model: they authenticate (a DEK-less HomeSession) but |
| +// can never decrypt, so the vault stays sealed until the passkey path |
| +// upgrades the session in place. |
| // |
| // ACTIVATION: the ceremony needs (1) macOS 15 (PRF — runtime-gated below, the |
| // target stays 14) and (2) the webcredentials associated domain, which needs a |
| @@ -22,17 +26,50 @@ import SwiftUI |
| final class MacHomeSignIn: 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 browser // handed to the default browser; awaiting the callback |
| case syncing // authenticated; fetching + restoring the backup |
| case restored(Int) // servers added to this Mac |
| case emptyBackup // signed in; account holds no servers yet |
| + case sealed // anchor sign-in landed; the backup opens only under a passkey |
| case failed(String) |
| 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, .sealed: return true |
| + default: return false |
| + } |
| + } |
| + } |
| + |
| + /// Which door signed in — the #1112 last-used marker, stored under the |
| + /// web's key name so the rule reads across platforms (iOS mirrors it the |
| + /// same way): the last-used method leads the doors next time. |
| + 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 |
| @Published private(set) var sessionExists = HomeSessionStore.load() != nil |
| + /// True when the stored session is an anchor's (Apple / email): it |
| + /// authenticates every home call, but no passkey PRF was in play, so the |
| + /// vault stays sealed — surfaces present "unlock" instead of "synced". |
| + @Published private(set) var sessionSealed = HomeSessionStore.load().map { $0.dek == nil } ?? false |
| + |
| + /// Session adoption — the ONE seam every door funnels a finished sign-in |
| + /// through (passkey ceremony finish, browser handoff, Apple verify, email |
| + /// verify): the same Keychain write, the published flags every surface |
| + /// watches, and the last-used marker the first-run doors re-order by. |
| + private func adopt(_ session: HomeSession, method: SignInMethod) { |
| + HomeSessionStore.save(session) |
| + sessionExists = true |
| + sessionSealed = session.dek == nil |
| + UserDefaults.standard.set(method.rawValue, forKey: Self.lastSignInKey) |
| + } |
| func signIn(into app: MacAppState) async { |
| // The pre-macOS-15 path can't run the native ceremony at all — the |
| @@ -63,8 +100,7 @@ final class MacHomeSignIn: ObservableObject { |
| return |
| } |
| phase = .syncing // the sheet is down; the blob fetch + restore runs |
| - phase = try await restore(result, into: app) |
| - sessionExists = HomeSessionStore.load() != nil |
| + phase = try await restore(result, into: app) // adopts the session on success |
| } catch is CryptoKitError { |
| // The ceremony RAN but the PRF-derived key didn't open the vault — |
| // in practice 1Password's native provider, whose PRF doesn't match |
| @@ -89,6 +125,7 @@ final class MacHomeSignIn: ObservableObject { |
| } |
| AppReset.wipeAll() |
| sessionExists = false |
| + sessionSealed = false |
| phase = .idle |
| let cfg = NSWorkspace.OpenConfiguration() |
| cfg.createsNewApplicationInstance = true |
| @@ -101,7 +138,9 @@ final class MacHomeSignIn: ObservableObject { |
| /// hero, the browser handoff) may have signed in or out since this one was |
| /// created — Settings' copy went stale and hid its Sign Out button. |
| func refreshSession() { |
| - sessionExists = HomeSessionStore.load() != nil |
| + let session = HomeSessionStore.load() |
| + sessionExists = session != nil |
| + sessionSealed = session.map { $0.dek == nil } ?? false |
| } |
| // ---- browser-assisted sign-in ------------------------------------------ |
| @@ -172,8 +211,7 @@ final class MacHomeSignIn: ObservableObject { |
| return |
| } |
| let contents = try HomeVault.open(fetch.bytes, dek: dek) |
| - HomeSessionStore.save(HomeSession(token: h.token, credentialID: h.credId, dek: dek)) |
| - sessionExists = true |
| + adopt(HomeSession(token: h.token, credentialID: h.credId, dek: dek), method: .passkey) |
| let added = app.applyVault(contents.servers, removed: contents.removed) |
| app.syncHome() |
| phase = added > 0 ? .restored(added) : .emptyBackup |
| @@ -182,6 +220,158 @@ final class MacHomeSignIn: ObservableObject { |
| } |
| } |
| + // ---- the Apple door (issues #1089/#1112; server: home/siwa.go) --------- |
| + // Developer ID Mac apps cannot carry the native Sign in with Apple |
| + // entitlement (issue #1089), so the Mac door rides Apple's WEB flow in an |
| + // ASWebAuthenticationSession: authorize → home's /siwa/return trampoline |
| + // form_post → elevenmessenger://home-siwa#state=…&id_token=…, which the |
| + // session intercepts in-process (no Launch Services round trip — alts with |
| + // suffixed schemes work the same). Shared Safari cookies (non-ephemeral) |
| + // mean a signed-in-to-Apple member sees a one-click confirm sheet. |
| + |
| + /// Kept alive for the flow's duration; ASWebAuthenticationSession is not |
| + /// retained by the system while running. |
| + private var webAuthSession: ASWebAuthenticationSession? |
| + private let webAuthPresenter = WebAuthPresenter() |
| + |
| + func signInWithApple() async { |
| + // The state ties Apple's return leg to THIS attempt. Deliberately not |
| + // "web."-prefixed — that routes the trampoline back to the home page; |
| + // everything else bounces to the custom scheme. The web flow carries |
| + // no nonce binding, so verify posts an empty nonce (server-optional). |
| + let state = "mac." + HomeAnchorAuth.mintNonce() |
| + var comps = URLComponents(string: "https://appleid.apple.com/auth/authorize")! |
| + comps.queryItems = [ |
| + .init(name: "client_id", value: "com.elevenmessenger.signin"), // the Services ID (home.js uses the same) |
| + .init(name: "redirect_uri", |
| + value: HomeBase.url.appending(path: "/siwa/return").absoluteString), |
| + .init(name: "response_type", value: "code id_token"), |
| + .init(name: "response_mode", value: "form_post"), |
| + .init(name: "state", value: state), |
| + ] |
| + phase = .working |
| + defer { webAuthSession = nil } |
| + do { |
| + let callback = try await runWebAuth(url: comps.url!) |
| + guard let idToken = Self.siwaCallbackToken(callback, state: state) else { |
| + phase = .failed("Apple didn't return a usable sign-in — try again.") |
| + return |
| + } |
| + let fin = try await HomeClient().siwaVerify(identityToken: idToken, nonce: "") |
| + phase = await finishAnchor(fin, method: .apple) |
| + } catch let e as ASWebAuthenticationSessionError where e.code == .canceledLogin { |
| + phase = .idle // closing Apple's window is a choice, not an error |
| + } catch { |
| + phase = .failed(Self.anchorMessage(error)) |
| + } |
| + } |
| + |
| + private func runWebAuth(url: URL) async throws -> URL { |
| + try await withCheckedThrowingContinuation { cont in |
| + let session = ASWebAuthenticationSession( |
| + url: url, callbackURLScheme: "elevenmessenger" |
| + ) { url, err in |
| + if let url { |
| + cont.resume(returning: url) |
| + } else { |
| + cont.resume(throwing: err ?? ASWebAuthenticationSessionError(.canceledLogin)) |
| + } |
| + } |
| + session.presentationContextProvider = webAuthPresenter |
| + // Share Safari's cookie jar: a member already signed in to Apple |
| + // gets the one-tap confirm instead of a full password round. |
| + session.prefersEphemeralWebBrowserSession = false |
| + webAuthSession = session |
| + if !session.start() { |
| + cont.resume(throwing: ASWebAuthenticationSessionError(.presentationContextInvalid)) |
| + } |
| + } |
| + } |
| + |
| + /// The trampoline's callback (elevenmessenger://home-siwa#state=…& |
| + /// id_token=…): verify OUR state came back, hand out the identity token. |
| + static func siwaCallbackToken(_ url: URL, state: String) -> String? { |
| + guard url.host == "home-siwa" else { return nil } |
| + // The fields ride the fragment (kept out of request logs); parse it |
| + // with query semantics so each value is percent-decoded exactly once. |
| + var frag = URLComponents() |
| + frag.percentEncodedQuery = URLComponents(url: url, resolvingAgainstBaseURL: false)? |
| + .percentEncodedFragment |
| + let items = frag.queryItems ?? [] |
| + guard items.first(where: { $0.name == "state" })?.value == state, |
| + let token = items.first(where: { $0.name == "id_token" })?.value, |
| + !token.isEmpty else { return nil } |
| + return token |
| + } |
| + |
| + // ---- 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 code, let body) = error { |
| + let text = body.trimmingCharacters(in: .whitespacesAndNewlines) |
| + if !text.isEmpty, text.count <= 120 { return text } |
| + return "Eleven turned that down (\(code)). Try again in a moment." |
| + } |
| + if error is HomeClientError { |
| + return "Couldn't reach Eleven. Check your connection and try again." |
| + } |
| + return error.localizedDescription |
| + } |
| + |
| /// Vault-wins merge, the Mac shape: upsert SavedInstances (identity + star), |
| /// make sure each restored server has a rail entry (SavedChat), and only |
| /// then push — so the write-back can't wipe vault state (the iOS lesson). |
| @@ -208,9 +398,8 @@ final class MacHomeSignIn: 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 servers.isEmpty ? .emptyBackup : .restored(0) |
| } |
| guard let opened = try HomeVault.open(fetch.bytes, |
| @@ -218,15 +407,22 @@ final class MacHomeSignIn: ObservableObject { |
| prf: result.prf) else { |
| return .failed("That sign-in doesn't unlock these spaces.") |
| } |
| - 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) |
| let added = app.applyVault(opened.contents.servers, removed: opened.contents.removed) |
| app.syncHome() |
| return added > 0 ? .restored(added) : .emptyBackup |
| } |
| } |
| +// The ASWebAuthenticationSession's window anchor — the key window, same |
| +// choice the passkey ceremony makes. |
| +private final class WebAuthPresenter: NSObject, ASWebAuthenticationPresentationContextProviding { |
| + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { |
| + NSApp.keyWindow ?? NSApp.windows.first ?? ASPresentationAnchor() |
| + } |
| +} |
| + |
| // The sign-in state + Sign Out rows (MacSettingsView mounts them at the |
| // BOTTOM of the combined Profile section — Paul, 2026-07-15): one line of |
| // state and one action, matching the pane's quiet register. Rows, not a |
| @@ -255,8 +451,25 @@ struct HomeSignInSection: View { |
| Text("\(n) space\(n == 1 ? "" : "s") restored.") |
| .font(.footnote).foregroundStyle(.secondary) |
| } |
| - Text("Your space list syncs with Eleven — stars and order follow you between devices.") |
| - .font(.footnote).foregroundStyle(.secondary) |
| + if model.sessionSealed { |
| + // An anchor session (Apple / email): signed in, but the |
| + // backup opens only under a passkey — #1132's .sealed |
| + // framing, the Settings register. |
| + LabeledContent("Spaces locked") { |
| + if model.phase.busy { |
| + ProgressView().controlSize(.small) |
| + } else { |
| + Button("Unlock with Your Passkey…") { |
| + Task { await model.signIn(into: app) } |
| + } |
| + } |
| + } |
| + Text("You're signed in with Apple or email — your saved spaces stay locked until you unlock them with your passkey.") |
| + .font(.footnote).foregroundStyle(.secondary) |
| + } else { |
| + Text("Your space list syncs with Eleven — stars and order follow you between devices.") |
| + .font(.footnote).foregroundStyle(.secondary) |
| + } |
| } else { |
| LabeledContent("Not signed in") { |
| if model.phase.busy { |
| @@ -287,14 +500,27 @@ struct HomeSignInSection: View { |
| } |
| } |
| -// The Mac's first-run hero — iOS's SignInFirstRun in the Mac idiom, shown by |
| -// RootView while the rail is empty. Sign in restores the backup and the rail |
| -// fills in place (applyVault selects the first space); the invite-link path |
| -// opens the same Add sheet as the + button. The ceremony is macOS 15-only |
| -// (PRF), so below that the screen offers just the invite-link path. |
| +// The Mac's first-run doors — iOS's SignInFirstRun in the Mac idiom, shown by |
| +// RootView while the rail is empty. Three ways in, all against the same home |
| +// server: Continue with Apple (the lead), an emailed 6-digit code, and the |
| +// existing passkey path (native ceremony with automatic browser failover) |
| +// demoted to a quiet text button — with the last-used door promoted (#1112). |
| +// Sign in restores the backup and the rail fills in place; an anchor sign-in |
| +// against a sealed backup lands signed in with the passkey door promoted to |
| +// "unlock" (the identity-vs-key split, exactly as on iOS/web). |
| struct MacFirstRun: View { |
| @EnvironmentObject var app: MacAppState |
| @ObservedObject var model: MacHomeSignIn |
| + @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 click opened — only so the promoted passkey button |
| + // doesn't spin for an Apple verify that happens to share phase == .working. |
| + @State private var tapped: MacHomeSignIn.SignInMethod? |
| + @FocusState private var focus: EmailStep? |
| var body: some View { |
| VStack(spacing: 10) { |
| @@ -312,7 +538,7 @@ struct MacFirstRun: View { |
| // Status folds in under the pitch, occupying layout space (the |
| // inline-feedback rule — nothing floats). |
| statusLine |
| - .frame(minHeight: 22) |
| + .frame(maxWidth: 380, minHeight: 22) |
| .padding(.top, 10) |
| Spacer() |
| @@ -323,44 +549,252 @@ struct MacFirstRun: View { |
| Text("Syncing your spaces…").foregroundStyle(.secondary) |
| } |
| .padding(.bottom, 56) |
| + } else if let step = emailStep { |
| + emailDoor(step) |
| } else { |
| - VStack(spacing: 10) { |
| - // One button, always: the model routes it — native ceremony |
| - // where it can run, automatic browser failover everywhere |
| - // else (no entitlement, 1Password's PRF mismatch, pre-15). |
| - // The old "Use your browser" link is gone; nobody clicked it. |
| - Button { |
| - Task { await model.signIn(into: app) } |
| - } label: { |
| - Text(model.phase.busy ? "Signing in…" : "Sign in") |
| + doors |
| + } |
| + } |
| + .frame(maxWidth: .infinity, maxHeight: .infinity) |
| + // 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 — 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: [MacHomeSignIn.SignInMethod] { |
| + var order: [MacHomeSignIn.SignInMethod] = [.apple, .email, .passkey] |
| + let lead: MacHomeSignIn.SignInMethod? = |
| + model.phase == .sealed ? .passkey : MacHomeSignIn.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: 10) { |
| + 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) |
| + .frame(maxWidth: 300) |
| + .padding(.top, 2) |
| + } |
| + .frame(width: 280) |
| + .padding(.bottom, 48) |
| + } |
| + |
| + @ViewBuilder private func door(_ method: MacHomeSignIn.SignInMethod, leading: Bool) -> some View { |
| + switch method { |
| + case .apple: |
| + // Apple's branded control (HIG, glyph) over OUR web-session |
| + // flow — see MacHomeSignIn.signInWithApple for why not the native |
| + // sheet. Recreated on scheme flips (.id) — the style is init-only. |
| + AppleDoorButton(style: scheme == .dark ? .white : .black) { |
| + tapped = .apple |
| + Task { await model.signInWithApple() } |
| + } |
| + .id(scheme) |
| + .frame(height: 32) |
| + .disabled(model.phase.busy) |
| + case .email: |
| + Button { |
| + tapped = .email |
| + emailStep = .address |
| + } label: { |
| + Text("Continue with email") |
| + .fontWeight(.medium) |
| + .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) |
| + } |
| + Text(passkeyLeadLabel) |
| .fontWeight(.semibold) |
| - .frame(width: 200) |
| } |
| - .buttonStyle(.borderedProminent) |
| - .controlSize(.large) |
| - .disabled(model.phase.busy) |
| + .frame(maxWidth: .infinity) |
| + } |
| + .buttonStyle(.borderedProminent) |
| + .controlSize(.large) |
| + .disabled(model.phase.busy) |
| + } else { |
| + // Demoted: the quiet text path under the anchor doors. The |
| + // model still routes it — native ceremony where it can run, |
| + // automatic browser failover everywhere else (no entitlement, |
| + // 1Password's PRF mismatch, pre-15). |
| + Button("Sign in with a passkey") { |
| + tapped = .passkey |
| + Task { await model.signIn(into: app) } |
| + } |
| + .buttonStyle(.plain) |
| + .font(.subheadline.weight(.medium)) |
| + .foregroundStyle(.secondary) |
| + .padding(.vertical, 2) |
| + .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 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) |
| - .frame(maxWidth: 300) |
| + // ---- the email door ---------------------------------------------------- |
| + |
| + @ViewBuilder private func emailDoor(_ step: EmailStep) -> some View { |
| + VStack(spacing: 12) { |
| + HStack { |
| + // Serial steps: code backs to the address, address backs to |
| + // the doors — a typo'd address is one click away either way. |
| + Button { |
| + if step == .code { |
| + emailStep = .address |
| + code = "" |
| + focus = .address |
| + } else { |
| + emailStep = nil |
| + } |
| + } label: { |
| + Label("Back", systemImage: "chevron.backward") |
| + .font(.subheadline) |
| } |
| - .padding(.bottom, 48) |
| + .buttonStyle(.plain) |
| + .foregroundStyle(.secondary) |
| + .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) |
| + .textContentType(.emailAddress) |
| + .autocorrectionDisabled() |
| + .submitLabel(.send) |
| + .focused($focus, equals: .address) |
| + .onSubmit { if emailValid { sendCode() } } // Return advances |
| + .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) |
| + .textContentType(.oneTimeCode) |
| + .multilineTextAlignment(.center) |
| + .font(.title3.monospacedDigit()) |
| + .focused($focus, equals: .code) |
| + .onSubmit { if code.count == 6 { verifyCode() } } // Return advances |
| + .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) |
| + } |
| + .buttonStyle(.borderedProminent) |
| + .controlSize(.large) |
| + .disabled(model.phase.busy || code.count != 6) |
| } |
| } |
| - .frame(maxWidth: .infinity, maxHeight: .infinity) |
| + .frame(width: 280) |
| + .padding(.bottom, 48) |
| + .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 .failed(let msg): |
| Label(msg, systemImage: "exclamationmark.triangle") |
| .font(.footnote).foregroundStyle(.secondary) |
| + .multilineTextAlignment(.center) |
| case .emptyBackup: |
| Text("Signed in — nothing saved on this account yet. Join a space in your browser first.") |
| .font(.callout).foregroundStyle(.secondary) |
| + .multilineTextAlignment(.center) |
| + case .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 .restored(let n): |
| // Visible only in the beat before the rail fills and RootView |
| // swaps this screen out. |
| @@ -369,8 +803,41 @@ struct MacFirstRun: View { |
| case .browser: |
| Text("Finishing sign-in in your browser…") |
| .font(.callout).foregroundStyle(.secondary) |
| - case .idle, .working, .syncing: |
| + case .working: |
| + Text("Confirming it’s you…") |
| + .font(.callout).foregroundStyle(.secondary) |
| + case .idle, .syncing: |
| EmptyView() |
| } |
| } |
| } |
| + |
| +// Apple's own branded control (AppKit's ASAuthorizationAppleIDButton — the |
| +// HIG face, glyph and all) wired to OUR action: the SwiftUI |
| +// SignInWithAppleButton drives the native request, which needs an entitlement |
| +// Developer ID apps can't hold, so the face and the web-session behavior are |
| +// joined here instead. |
| +private struct AppleDoorButton: NSViewRepresentable { |
| + let style: ASAuthorizationAppleIDButton.Style |
| + let action: () -> Void |
| + |
| + func makeCoordinator() -> Coordinator { Coordinator(action: action) } |
| + |
| + func makeNSView(context: Context) -> ASAuthorizationAppleIDButton { |
| + let button = ASAuthorizationAppleIDButton(authorizationButtonType: .continue, |
| + authorizationButtonStyle: style) |
| + button.target = context.coordinator |
| + button.action = #selector(Coordinator.tap) |
| + return button |
| + } |
| + |
| + func updateNSView(_ button: ASAuthorizationAppleIDButton, context: Context) { |
| + button.isEnabled = context.environment.isEnabled |
| + } |
| + |
| + final class Coordinator: NSObject { |
| + let action: () -> Void |
| + init(action: @escaping () -> Void) { self.action = action } |
| + @objc func tap() { action() } |
| + } |
| +} |