import AppKit import AuthenticationServices import CryptoKit import LChatCore import Security 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). 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 // real Development profile β€” flip CODE_SIGN_ENTITLEMENTS to // LChatMac-Dev.entitlements in project.yml once this Mac is registered in the // team. Until then the ceremony fails with "not associated with domain". // // The PRF output never leaves this process; only the link token + blob DEK // persist (Keychain, HomeSessionStore) β€” same posture as iOS. @MainActor final class MacHomeSignIn: ObservableObject { enum Phase: Equatable { case idle 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, google, microsoft, github } static let lastSignInKey = "home_last_signin" static var lastSignIn: SignInMethod? { UserDefaults.standard.string(forKey: lastSignInKey).flatMap(SignInMethod.init(rawValue:)) } @Published private(set) var phase: Phase = .idle @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) // The DEK-sync write-gate (#1089, flag-gated): a DEK-holding session // on a SiwA-anchored account adds/refreshes the synchronizable item. Task { await DEKSync.writeIfEligible() } } func signIn(into app: MacAppState) async { // The pre-macOS-15 path can't run the native ceremony at all β€” the // browser can (its own WebAuthn), so go straight there. guard #available(macOS 15.0, *) else { signInViaBrowser() return } guard let anchor = NSApp.keyWindow ?? NSApp.windows.first else { phase = .failed("Couldn't find a window to sign in from.") return } phase = .working do { let result: HomeAuth.Result do { result = try await HomeAuth(anchor: anchor).signIn() } catch let e as ASAuthorizationError where e.code == .canceled { phase = .idle // the member closed the sheet β€” never bounce them return } catch { // The native ceremony itself failed β€” no associated-domain // entitlement (every Developer ID build today: AS 1004), no // provider, whatever. The browser ceremony is the route that // works, so take it instead of explaining a fallback link // nobody clicks. signInViaBrowser() return } phase = .syncing // the sheet is down; the blob fetch + restore runs 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 // its own browser extension's (what the backup is wrapped in). The // browser ceremony is exactly the one that works: failover. signInViaBrowser() } catch { // Post-ceremony failures that aren't a key mismatch (the blob // fetch died, etc.) β€” the browser wouldn't fare better; say so. phase = .failed(error.localizedDescription) } } /// Sign out = clear this Mac: there's no half-signed-out state with a /// passkey β€” the spaces live in the member's Eleven account and the next /// sign-in restores them. A last sync push first (so seconds-old changes /// reach the account), then the full local wipe, then a fresh relaunch /// (the running UI is saturated with the wiped state). func signOut(from app: MacAppState) async { if let session = HomeSessionStore.load() { _ = await HomeSync.push(instances: app.instancesInRailOrder(), session: session) } AppReset.wipeAll() sessionExists = false sessionSealed = false phase = .idle let cfg = NSWorkspace.OpenConfiguration() cfg.createsNewApplicationInstance = true NSWorkspace.shared.openApplication(at: Bundle.main.bundleURL, configuration: cfg) { _, _ in DispatchQueue.main.async { NSApp.terminate(nil) } } } /// The wrong-door escape on the first-run screen: drop ONLY the home /// session β€” nothing has been restored to this Mac yet β€” and bring the /// doors back. signOut(from:) is the full local wipe + relaunch, wildly /// oversized for a sign-in that just landed on the wrong account. func switchAccount() { HomeSessionStore.clear() sessionExists = false sessionSealed = false phase = .idle } /// Re-read the Keychain session: another surface's model (the first-run /// 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() { let session = HomeSessionStore.load() sessionExists = session != nil sessionSealed = session.map { $0.dek == nil } ?? false } // ---- browser-assisted sign-in ------------------------------------------ // 1Password's native macOS provider returns a PRF that doesn't match its // own browser extension's (the context backups are wrapped in), so the // ceremony that WORKS runs in the browser: we generate an ephemeral // keypair + nonce, open /restore with {app:1, nonce, ephPub}, and the page // sends the session back over elevenmessenger://home-session, ECIES-wrapped // to the ephemeral key β€” which never leaves this process. private static var pendingHandoff: (model: MacHomeSignIn, priv: P256.KeyAgreement.PrivateKey, nonce: String)? /// The custom scheme THIS build registered (Info.plist; dev alts suffix /// it β€” elevenmessenger.alt-). Named in the handoff payload so the /// return lands HERE: Launch Services routes a scheme to one app, and /// without this an alt's own sign-in return went to the release app, /// which claimed and dropped it. static var urlScheme: String { (Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]])? .first.flatMap { ($0["CFBundleURLSchemes"] as? [String])?.first } ?? "elevenmessenger" } func signInViaBrowser() { let priv = P256.KeyAgreement.PrivateKey() var nonceBytes = [UInt8](repeating: 0, count: 16) _ = SecRandomCopyBytes(kSecRandomDefault, nonceBytes.count, &nonceBytes) let nonce = Data(nonceBytes).base64urlEncodedString() Self.pendingHandoff = (self, priv, nonce) let payload: [String: Any] = [ "app": 1, "nonce": nonce, "ephPub": Data(priv.publicKey.x963Representation).base64urlEncodedString(), "scheme": Self.urlScheme, ] guard let json = try? JSONSerialization.data(withJSONObject: payload), let url = URL(string: HomeBase.url.appending(path: "/restore").absoluteString + "#" + json.base64urlEncodedString()) else { return } phase = .browser NSWorkspace.shared.open(url) } /// Routes the custom-scheme callback (LChatMacApp.onOpenURL). Returns /// whether the URL was a handoff callback (claimed even when nothing is /// pending, so it never falls through to the invite-link parser). static func handleCallback(_ url: URL, into app: MacAppState) -> Bool { guard url.host == "home-session" else { return false } guard let (model, priv, nonce) = pendingHandoff else { return true } pendingHandoff = nil Task { await model.completeBrowserSignIn(url, priv: priv, nonce: nonce, into: app) } return true } private func completeBrowserSignIn(_ url: URL, priv: P256.KeyAgreement.PrivateKey, nonce: String, into app: MacAppState) async { phase = .syncing struct Handoff: Decodable { let v: Int let token: String let credId: String let dek: String } do { guard let frag = url.fragment?.removingPercentEncoding ?? url.fragment, let backData = Data(base64urlEncoded: frag), let back = try? JSONSerialization.jsonObject(with: backData) as? [String: String], back["nonce"] == nonce, let wrapped = back["wrapped"] else { phase = .failed("The browser handoff didn't match this app's request β€” try again.") return } let session = try ECIES.unwrapBytes(wrappedB64: wrapped, priv: priv) let h = try JSONDecoder().decode(Handoff.self, from: session) guard let dekRaw = Data(base64urlEncoded: h.dek), dekRaw.count == 32 else { phase = .failed("The browser sent an unusable session β€” try again.") return } let dek = SymmetricKey(data: dekRaw) guard let fetch = try await HomeClient().fetchBlob(token: h.token) else { phase = .failed("Signed in β€” nothing saved on this account yet.") return } let contents = try HomeVault.open(fetch.bytes, dek: dek) 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 } catch { phase = .failed(error.localizedDescription) } } // ---- 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(into app: MacAppState) 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: "") // The DEK-sync use-gate (#1089, flag-gated): the web-session // assertion that just verified is the fresh SiwA assertion the // synced item's release requires β€” same rule as iOS's native // sheet. When the iCloud-synced DEK is here AND belongs to this // account's vault, this Apple sign-in opens the spaces outright; // any failure falls through to the sealed/empty landing as today. if let released = await DEKSync.release(token: fin.token) { phase = .syncing adopt(released.session, method: .apple) let added = app.applyVault(released.contents.servers, removed: released.contents.removed) app.syncHome() phase = added > 0 ? .restored(added) : .emptyBackup } else { 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 web-flow doors: Google / Microsoft / GitHub (home/oidc.go) ---- /// Which of the web-flow doors this home server has lit, plus GitHub's /// per-instance client id β€” fetched once when the doors show. Nil (not /// yet fetched / older server) just means those doors don't render. @Published private(set) var oidcDoors: HomeClient.SigninDoors? func refreshDoors() async { oidcDoors = await HomeClient().signinDoors() } /// One browser round trip, the SiwA web flow's exact shape: authorize /// URL -> home's /oidc/return trampoline -> elevenmessenger://home-oidc, /// intercepted in-process by the session -> verify -> the same anchor /// landing as Apple and email. Google/Microsoft carry a nonce the verify /// round-trips; GitHub is the code exchange (the server holds the /// secret). func signInWithOidc(_ door: OIDCDoor, into app: MacAppState) async { let state = "mac." + HomeAnchorAuth.mintNonce() let nonce = HomeAnchorAuth.mintNonce() guard let url = HomeOIDCAuth.authorizeURL(door, base: HomeBase.url, state: state, nonce: nonce, githubClientID: oidcDoors?.githubClientID) else { phase = .failed("\(door.display) sign-in isn't available right now.") return } phase = .working defer { webAuthSession = nil } do { let callback = try await runWebAuth(url: url) guard let parsed = HomeOIDCAuth.callback(callback, state: state) else { phase = .failed("That sign-in didn't complete β€” try again.") return } let fin: HomeClient.AnchorFinish if door == .github, let code = parsed.code { fin = try await HomeClient().githubVerify(code: code) } else if let idToken = parsed.idToken { fin = try await HomeClient().oidcVerify(provider: door.rawValue, identityToken: idToken, nonce: nonce) } else { phase = .failed("That sign-in didn't complete β€” try again.") return } phase = await finishAnchor(fin, method: SignInMethod(rawValue: door.rawValue) ?? .email) } catch let e as ASWebAuthenticationSessionError where e.code == .canceledLogin { phase = .idle // closing the provider's window is a choice, not an error } catch { phase = .failed(Self.anchorMessage(error)) } } // ---- 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). @available(macOS 15.0, *) private func restore(_ result: HomeAuth.Result, into app: MacAppState) async throws -> Phase { guard let fetch = try await HomeClient().fetchBlob(token: result.token) else { // Blob-less account: recoverable right now with the PRF in hand // (mirrors the iOS path β€” bailing was a permanent dead-end). Seal // the Mac's servers straight into the first envelope β€” an empty // seal + async push raced and misreported the result. let servers = app.instancesInRailOrder().map { inst in HomeVault.Server( url: inst.origin, name: inst.groupName.isEmpty ? nil : inst.groupName, starred: inst.isStarred ? true : nil, archived: inst.isArchived ? true : nil, credentials: .init(token: inst.deviceToken, userPub: inst.userPub, userPriv: inst.userPriv, name: inst.screenName)) } let sealed = try HomeVault.sealFresh(HomeVault.Contents(v: 1, servers: servers), credentialID: result.credentialID, prf: result.prf) guard try await HomeClient().putBlob(token: result.token, bytes: sealed.bytes, ifMatch: "0") != nil else { return .failed("Couldn't save your spaces β€” try again.") } adopt(HomeSession(token: result.token, credentialID: result.credentialID, dek: sealed.dek), method: .passkey) return servers.isEmpty ? .emptyBackup : .restored(0) } guard let opened = try HomeVault.open(fetch.bytes, credentialID: result.credentialID, prf: result.prf) else { return .failed("That sign-in doesn't unlock these spaces.") } 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 // Section β€” the caller owns the section so identity and account read as one // piece. struct HomeSignInSection: View { @EnvironmentObject var app: MacAppState @ObservedObject var model: MacHomeSignIn @State private var confirmSignOut = false var body: some View { Group { if model.sessionExists { LabeledContent("Signed in") { Button("Sign Out…") { confirmSignOut = true } } .confirmationDialog( "Sign out? Your spaces, messages and keys are removed from this Mac. Sign back in to get them back.", isPresented: $confirmSignOut, titleVisibility: .visible ) { Button("Sign Out & Clear This Mac", role: .destructive) { Task { await model.signOut(from: app) } } } if case .restored(let n) = model.phase, n > 0 { Text("\(n) space\(n == 1 ? "" : "s") restored.") .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 { ProgressView().controlSize(.small) } else { Button("Sign In with Eleven…") { Task { await model.signIn(into: app) } } } } Button("Use your browser to sign in") { model.signInViaBrowser() } .buttonStyle(.link) .disabled(model.phase.busy) if model.phase == .syncing { Text("Syncing your spaces…").font(.footnote).foregroundStyle(.secondary) } if model.phase == .browser { Text("Finishing sign-in in your browser…").font(.footnote).foregroundStyle(.secondary) } if case .failed(let msg) = model.phase { Text(msg).font(.footnote).foregroundStyle(.secondary) } } } // Signing in happens on OTHER surfaces too β€” re-check whenever the // pane shows, so this section never claims "Not signed in" stale. .onAppear { model.refreshSession() } } } // 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? // One door at a time: the demoted doors unfold behind this. @State private var showAllDoors = false @FocusState private var focus: EmailStep? var body: some View { VStack(spacing: 10) { Spacer() Image("ElevenMark") .resizable() .frame(width: 76, height: 76) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .accessibilityHidden(true) Text("Welcome to Eleven") .font(.title2.weight(.semibold)) // The tagline is the welcome pitch β€” the sealed landing is an // unlock moment, not a welcome, and earns no marketing words // (Paul, 2026-07-19: the button says everything this state needs). if !model.sessionSealed { Text("Secure group chat") .foregroundStyle(.secondary) } // Status folds in under the pitch, occupying layout space (the // inline-feedback rule β€” nothing floats). statusLine .frame(maxWidth: 380, minHeight: 22) .padding(.top, 10) Spacer() if model.phase == .syncing { VStack(spacing: 10) { ProgressView() Text("Syncing your spaces…").foregroundStyle(.secondary) } .padding(.bottom, 56) } else if let step = emailStep { emailDoor(step) } else if model.phase.signedIn || model.sessionSealed { landed } else { doors } } .frame(maxWidth: .infinity, maxHeight: .infinity) // A landed sign-in (any door) folds the email door away; the doors // fold too (signed in = no sign-in options) β€” sealed keeps only the // passkey, promoted as the unlock. .onChange(of: model.phase) { _, p in if p.signedIn { emailStep = nil email = "" code = "" } } } // ---- the doors --------------------------------------------------------- /// Apple, email, passkey β€” plus whichever web-flow doors the home server /// has lit (Google/Microsoft/GitHub, in the web's fixed brand order) β€” /// 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] if model.oidcDoors?.githubClientID?.isEmpty == false { order.append(.github) } if model.oidcDoors?.microsoft == true { order.append(.microsoft) } if model.oidcDoors?.google == true { order.append(.google) } let lead: 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 } // A landed sign-in folds the doors away entirely β€” signed in means no // sign-in options. Sealed keeps exactly one control, the passkey, // because it is the unlock rather than an alternative; the quiet escape // below it covers the wrong-account landing either way. private var landed: some View { VStack(spacing: 10) { if model.sessionSealed { door(.passkey, leading: true) } Button("Sign in a different way") { model.switchAccount() } .buttonStyle(.plain) .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) .padding(.vertical, 2) .disabled(model.phase.busy) // never mid-ceremony } .frame(width: 280) .padding(.bottom, 48) } private var doors: some View { // Exactly ONE door at a time β€” last-used when known, else the // platform default β€” with the rest behind "Other ways to sign in" // (Paul 2026-07-19, the web card's rule, mirrored). VStack(spacing: 10) { ForEach(showAllDoors ? doorOrder : Array(doorOrder.prefix(1)), id: \.self) { method in door(method, leading: method == doorOrder.first) } if !showAllDoors { Button("Other ways to sign in") { showAllDoors = true } .buttonStyle(.plain) .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) .padding(.vertical, 2) } } .frame(width: 280) .padding(.bottom, 48) // Which web-flow doors this home server has lit (and GitHub's client // id) β€” fetched when the doors show; until it lands the three fixed // doors stand alone, same as an older server. .task { await model.refreshDoors() } } @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. // While its trip is in flight the control can't host a spinner // (the web's pressed door spins), so a same-geometry stand-in // covers it; the real button stays in the hierarchy, hidden. let appleBusy = model.phase.busy && tapped == .apple AppleDoorButton(style: scheme == .dark ? .white : .black) { tapped = .apple Task { await model.signInWithApple(into: app) } } .id(scheme) .frame(height: MacDoorButtonStyle.height) .disabled(model.phase.busy) .opacity(appleBusy ? 0 : 1) .overlay { if appleBusy { HStack(spacing: 6) { ProgressView() .controlSize(.small) .tint(scheme == .dark ? .black : .white) Text("Signing in…").fontWeight(.medium) } .frame(maxWidth: .infinity, minHeight: MacDoorButtonStyle.height) .foregroundStyle(scheme == .dark ? Color.black : .white) .background(scheme == .dark ? Color.white : .black, in: RoundedRectangle(cornerRadius: MacDoorButtonStyle.radius, style: .continuous)) } } case .email: Button { tapped = .email emailStep = .address } label: { Text("Continue with email") } .buttonStyle(MacDoorButtonStyle(prominent: leading)) .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: 6) { if model.phase == .working && tapped == .passkey { ProgressView().controlSize(.small).tint(.white) } else if model.sessionSealed { // The unlock moment gets the passkey glyph β€” the // icon carries what the trimmed copy no longer // spells out. Image(systemName: "person.badge.key.fill") } Text(passkeyLeadLabel) } } .buttonStyle(MacDoorButtonStyle(prominent: true)) .disabled(model.phase.busy) } else { // Demoted, but the same row shape β€” no mixed button types in // the list; the Apple control fixes the geometry. 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(MacDoorButtonStyle()) .disabled(model.phase.busy) } case .google, .microsoft, .github: // The web-flow doors: a browser sheet over the same session seam // β€” same row geometry, the provider's mark carrying the brand. let door = OIDCDoor(rawValue: method.rawValue) ?? .google Button { tapped = method Task { await model.signInWithOidc(door, into: app) } } label: { HStack(spacing: 6) { if model.phase == .working && tapped == method { ProgressView().controlSize(.small) .tint(leading ? .white : nil) } else { Image("Door\(door.display)") .resizable() .scaledToFit() .frame(width: 14, height: 14) } Text("Continue with \(door.display)") } } .buttonStyle(MacDoorButtonStyle(prominent: leading)) .disabled(model.phase.busy) } } private var passkeyLeadLabel: String { if model.phase == .working && tapped == .passkey { return "Signing in…" } if model.sessionSealed { return "Unlock with your passkey" } return "Sign in with a passkey" } // ---- the email door ---------------------------------------------------- @ViewBuilder private func emailDoor(_ step: EmailStep) -> some View { VStack(spacing: 12) { HStack { // Serial steps: code backs to the address, address backs to // the doors β€” a typo'd address is one click away either way. Button { if step == .code { emailStep = .address code = "" focus = .address } else { emailStep = nil } } label: { Label("Back", systemImage: "chevron.backward") .font(.subheadline) } .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: 6) { if model.phase == .working { ProgressView().controlSize(.small).tint(.white) } Text("Send code") } } .buttonStyle(MacDoorButtonStyle(prominent: true)) .disabled(model.phase.busy || !emailValid) // The web's note, word for word β€” under the address field. Text("We never keep your email address. No spam, ever.") .font(.footnote) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) } else { Text("Check your email for a 6-digit code.") .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) TextField("6-digit code", text: $code) .textFieldStyle(.roundedBorder) .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: 6) { if model.phase == .working { ProgressView().controlSize(.small).tint(.white) } Text("Sign in") } } .buttonStyle(MacDoorButtonStyle(prominent: true)) .disabled(model.phase.busy || code.count != 6) } } .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: // Deliberately NOTHING: "signed in but locked" reads as noise // when no spaces are visible (Paul: if you can't see your // spaces, you're not signed in). The unlock button β€” a passkey // icon and five words β€” is the whole story. EmptyView() case .restored(let n): // Visible only in the beat before the rail fills and RootView // swaps this screen out. Label("\(n) space\(n == 1 ? "" : "s") restored.", systemImage: "checkmark.circle.fill") .font(.callout).foregroundStyle(.green) case .browser: Text("Finishing sign-in in your browser…") .font(.callout).foregroundStyle(.secondary) 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() } } } // πŸ€– One geometry for every door β€” the Mac sizing of the iOS DoorButtonStyle // (Paul, 2026-07-19: the Apple control fixes the shape; no mixed button // types in the list). Prominent = the promoted door / a step's main action. // The pressed door hosts its own spinner, so busy state lives in the button. private struct MacDoorButtonStyle: ButtonStyle { var prominent = false @Environment(\.isEnabled) private var enabled static let radius: CGFloat = 8 static let height: CGFloat = 32 func makeBody(configuration: Configuration) -> some View { configuration.label .fontWeight(.medium) .frame(maxWidth: .infinity, minHeight: Self.height) .foregroundStyle(prominent ? Color.white : Color.accentColor) .background(prominent ? Color.accentColor : Color.primary.opacity(0.07), in: RoundedRectangle(cornerRadius: Self.radius, style: .continuous)) .opacity((configuration.isPressed ? 0.75 : 1) * (enabled ? 1 : 0.55)) } }