| 1 | import AppKit |
| 2 | import AuthenticationServices |
| 3 | import CryptoKit |
| 4 | import LChatCore |
| 5 | import Security |
| 6 | import SwiftUI |
| 7 | |
| 8 | // π€ "Sign in with Eleven" on the Mac β the Settings-pane counterpart of the |
| 9 | // iOS drawer flow (HomeSignInModel): a passkey to home.elevenmessenger.com |
| 10 | // restores the saved server list onto this Mac and starts the star/order sync |
| 11 | // (MacAppState.syncHome, dormant until a session exists). The identity |
| 12 | // anchors (Sign in with Apple, email codes β issues #1089/#1091/#1112) are |
| 13 | // doors into the same model: they authenticate (a DEK-less HomeSession) but |
| 14 | // can never decrypt, so the vault stays sealed until the passkey path |
| 15 | // upgrades the session in place. |
| 16 | // |
| 17 | // ACTIVATION: the ceremony needs (1) macOS 15 (PRF β runtime-gated below, the |
| 18 | // target stays 14) and (2) the webcredentials associated domain, which needs a |
| 19 | // real Development profile β flip CODE_SIGN_ENTITLEMENTS to |
| 20 | // LChatMac-Dev.entitlements in project.yml once this Mac is registered in the |
| 21 | // team. Until then the ceremony fails with "not associated with domain". |
| 22 | // |
| 23 | // The PRF output never leaves this process; only the link token + blob DEK |
| 24 | // persist (Keychain, HomeSessionStore) β same posture as iOS. |
| 25 | @MainActor |
| 26 | final class MacHomeSignIn: ObservableObject { |
| 27 | enum Phase: Equatable { |
| 28 | case idle |
| 29 | case working // a sign-in sheet / verify round trip is in flight |
| 30 | case browser // handed to the default browser; awaiting the callback |
| 31 | case syncing // authenticated; fetching + restoring the backup |
| 32 | case restored(Int) // servers added to this Mac |
| 33 | case emptyBackup // signed in; account holds no servers yet |
| 34 | case sealed // anchor sign-in landed; the backup opens only under a passkey |
| 35 | case failed(String) |
| 36 | |
| 37 | var busy: Bool { self == .working || self == .syncing } |
| 38 | /// A landed sign-in through any door β what folds the email door away. |
| 39 | var signedIn: Bool { |
| 40 | switch self { |
| 41 | case .restored, .emptyBackup, .sealed: return true |
| 42 | default: return false |
| 43 | } |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | /// Which door signed in β the #1112 last-used marker, stored under the |
| 48 | /// web's key name so the rule reads across platforms (iOS mirrors it the |
| 49 | /// same way): the last-used method leads the doors next time. |
| 50 | enum SignInMethod: String { case passkey, apple, email, google, microsoft, github } |
| 51 | static let lastSignInKey = "home_last_signin" |
| 52 | static var lastSignIn: SignInMethod? { |
| 53 | UserDefaults.standard.string(forKey: lastSignInKey).flatMap(SignInMethod.init(rawValue:)) |
| 54 | } |
| 55 | |
| 56 | @Published private(set) var phase: Phase = .idle |
| 57 | @Published private(set) var sessionExists = HomeSessionStore.load() != nil |
| 58 | /// True when the stored session is an anchor's (Apple / email): it |
| 59 | /// authenticates every home call, but no passkey PRF was in play, so the |
| 60 | /// vault stays sealed β surfaces present "unlock" instead of "synced". |
| 61 | @Published private(set) var sessionSealed = HomeSessionStore.load().map { $0.dek == nil } ?? false |
| 62 | |
| 63 | /// Session adoption β the ONE seam every door funnels a finished sign-in |
| 64 | /// through (passkey ceremony finish, browser handoff, Apple verify, email |
| 65 | /// verify): the same Keychain write, the published flags every surface |
| 66 | /// watches, and the last-used marker the first-run doors re-order by. |
| 67 | private func adopt(_ session: HomeSession, method: SignInMethod) { |
| 68 | HomeSessionStore.save(session) |
| 69 | sessionExists = true |
| 70 | sessionSealed = session.dek == nil |
| 71 | UserDefaults.standard.set(method.rawValue, forKey: Self.lastSignInKey) |
| 72 | // The DEK-sync write-gate (#1089, flag-gated): a DEK-holding session |
| 73 | // on a SiwA-anchored account adds/refreshes the synchronizable item. |
| 74 | Task { await DEKSync.writeIfEligible() } |
| 75 | } |
| 76 | |
| 77 | func signIn(into app: MacAppState) async { |
| 78 | // The pre-macOS-15 path can't run the native ceremony at all β the |
| 79 | // browser can (its own WebAuthn), so go straight there. |
| 80 | guard #available(macOS 15.0, *) else { |
| 81 | signInViaBrowser() |
| 82 | return |
| 83 | } |
| 84 | guard let anchor = NSApp.keyWindow ?? NSApp.windows.first else { |
| 85 | phase = .failed("Couldn't find a window to sign in from.") |
| 86 | return |
| 87 | } |
| 88 | phase = .working |
| 89 | do { |
| 90 | let result: HomeAuth.Result |
| 91 | do { |
| 92 | result = try await HomeAuth(anchor: anchor).signIn() |
| 93 | } catch let e as ASAuthorizationError where e.code == .canceled { |
| 94 | phase = .idle // the member closed the sheet β never bounce them |
| 95 | return |
| 96 | } catch { |
| 97 | // The native ceremony itself failed β no associated-domain |
| 98 | // entitlement (every Developer ID build today: AS 1004), no |
| 99 | // provider, whatever. The browser ceremony is the route that |
| 100 | // works, so take it instead of explaining a fallback link |
| 101 | // nobody clicks. |
| 102 | signInViaBrowser() |
| 103 | return |
| 104 | } |
| 105 | phase = .syncing // the sheet is down; the blob fetch + restore runs |
| 106 | phase = try await restore(result, into: app) // adopts the session on success |
| 107 | } catch is CryptoKitError { |
| 108 | // The ceremony RAN but the PRF-derived key didn't open the vault β |
| 109 | // in practice 1Password's native provider, whose PRF doesn't match |
| 110 | // its own browser extension's (what the backup is wrapped in). The |
| 111 | // browser ceremony is exactly the one that works: failover. |
| 112 | signInViaBrowser() |
| 113 | } catch { |
| 114 | // Post-ceremony failures that aren't a key mismatch (the blob |
| 115 | // fetch died, etc.) β the browser wouldn't fare better; say so. |
| 116 | phase = .failed(error.localizedDescription) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | /// Sign out = clear this Mac: there's no half-signed-out state with a |
| 121 | /// passkey β the spaces live in the member's Eleven account and the next |
| 122 | /// sign-in restores them. A last sync push first (so seconds-old changes |
| 123 | /// reach the account), then the full local wipe, then a fresh relaunch |
| 124 | /// (the running UI is saturated with the wiped state). |
| 125 | func signOut(from app: MacAppState) async { |
| 126 | if let session = HomeSessionStore.load() { |
| 127 | _ = await HomeSync.push(instances: app.instancesInRailOrder(), session: session) |
| 128 | } |
| 129 | AppReset.wipeAll() |
| 130 | sessionExists = false |
| 131 | sessionSealed = false |
| 132 | phase = .idle |
| 133 | let cfg = NSWorkspace.OpenConfiguration() |
| 134 | cfg.createsNewApplicationInstance = true |
| 135 | NSWorkspace.shared.openApplication(at: Bundle.main.bundleURL, configuration: cfg) { _, _ in |
| 136 | DispatchQueue.main.async { NSApp.terminate(nil) } |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | /// The wrong-door escape on the first-run screen: drop ONLY the home |
| 141 | /// session β nothing has been restored to this Mac yet β and bring the |
| 142 | /// doors back. signOut(from:) is the full local wipe + relaunch, wildly |
| 143 | /// oversized for a sign-in that just landed on the wrong account. |
| 144 | func switchAccount() { |
| 145 | HomeSessionStore.clear() |
| 146 | sessionExists = false |
| 147 | sessionSealed = false |
| 148 | phase = .idle |
| 149 | } |
| 150 | |
| 151 | /// Re-read the Keychain session: another surface's model (the first-run |
| 152 | /// hero, the browser handoff) may have signed in or out since this one was |
| 153 | /// created β Settings' copy went stale and hid its Sign Out button. |
| 154 | func refreshSession() { |
| 155 | let session = HomeSessionStore.load() |
| 156 | sessionExists = session != nil |
| 157 | sessionSealed = session.map { $0.dek == nil } ?? false |
| 158 | } |
| 159 | |
| 160 | // ---- browser-assisted sign-in ------------------------------------------ |
| 161 | // 1Password's native macOS provider returns a PRF that doesn't match its |
| 162 | // own browser extension's (the context backups are wrapped in), so the |
| 163 | // ceremony that WORKS runs in the browser: we generate an ephemeral |
| 164 | // keypair + nonce, open /restore with {app:1, nonce, ephPub}, and the page |
| 165 | // sends the session back over elevenmessenger://home-session, ECIES-wrapped |
| 166 | // to the ephemeral key β which never leaves this process. |
| 167 | |
| 168 | private static var pendingHandoff: |
| 169 | (model: MacHomeSignIn, priv: P256.KeyAgreement.PrivateKey, nonce: String)? |
| 170 | |
| 171 | /// The custom scheme THIS build registered (Info.plist; dev alts suffix |
| 172 | /// it β elevenmessenger.alt-<slug>). Named in the handoff payload so the |
| 173 | /// return lands HERE: Launch Services routes a scheme to one app, and |
| 174 | /// without this an alt's own sign-in return went to the release app, |
| 175 | /// which claimed and dropped it. |
| 176 | static var urlScheme: String { |
| 177 | (Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]])? |
| 178 | .first.flatMap { ($0["CFBundleURLSchemes"] as? [String])?.first } |
| 179 | ?? "elevenmessenger" |
| 180 | } |
| 181 | |
| 182 | func signInViaBrowser() { |
| 183 | let priv = P256.KeyAgreement.PrivateKey() |
| 184 | var nonceBytes = [UInt8](repeating: 0, count: 16) |
| 185 | _ = SecRandomCopyBytes(kSecRandomDefault, nonceBytes.count, &nonceBytes) |
| 186 | let nonce = Data(nonceBytes).base64urlEncodedString() |
| 187 | Self.pendingHandoff = (self, priv, nonce) |
| 188 | let payload: [String: Any] = [ |
| 189 | "app": 1, "nonce": nonce, |
| 190 | "ephPub": Data(priv.publicKey.x963Representation).base64urlEncodedString(), |
| 191 | "scheme": Self.urlScheme, |
| 192 | ] |
| 193 | guard let json = try? JSONSerialization.data(withJSONObject: payload), |
| 194 | let url = URL(string: HomeBase.url.appending(path: "/restore").absoluteString |
| 195 | + "#" + json.base64urlEncodedString()) else { return } |
| 196 | phase = .browser |
| 197 | NSWorkspace.shared.open(url) |
| 198 | } |
| 199 | |
| 200 | /// Routes the custom-scheme callback (LChatMacApp.onOpenURL). Returns |
| 201 | /// whether the URL was a handoff callback (claimed even when nothing is |
| 202 | /// pending, so it never falls through to the invite-link parser). |
| 203 | static func handleCallback(_ url: URL, into app: MacAppState) -> Bool { |
| 204 | guard url.host == "home-session" else { return false } |
| 205 | guard let (model, priv, nonce) = pendingHandoff else { return true } |
| 206 | pendingHandoff = nil |
| 207 | Task { await model.completeBrowserSignIn(url, priv: priv, nonce: nonce, into: app) } |
| 208 | return true |
| 209 | } |
| 210 | |
| 211 | private func completeBrowserSignIn(_ url: URL, priv: P256.KeyAgreement.PrivateKey, |
| 212 | nonce: String, into app: MacAppState) async { |
| 213 | phase = .syncing |
| 214 | struct Handoff: Decodable { |
| 215 | let v: Int |
| 216 | let token: String |
| 217 | let credId: String |
| 218 | let dek: String |
| 219 | } |
| 220 | do { |
| 221 | guard let frag = url.fragment?.removingPercentEncoding ?? url.fragment, |
| 222 | let backData = Data(base64urlEncoded: frag), |
| 223 | let back = try? JSONSerialization.jsonObject(with: backData) as? [String: String], |
| 224 | back["nonce"] == nonce, let wrapped = back["wrapped"] else { |
| 225 | phase = .failed("The browser handoff didn't match this app's request β try again.") |
| 226 | return |
| 227 | } |
| 228 | let session = try ECIES.unwrapBytes(wrappedB64: wrapped, priv: priv) |
| 229 | let h = try JSONDecoder().decode(Handoff.self, from: session) |
| 230 | guard let dekRaw = Data(base64urlEncoded: h.dek), dekRaw.count == 32 else { |
| 231 | phase = .failed("The browser sent an unusable session β try again.") |
| 232 | return |
| 233 | } |
| 234 | let dek = SymmetricKey(data: dekRaw) |
| 235 | guard let fetch = try await HomeClient().fetchBlob(token: h.token) else { |
| 236 | phase = .failed("Signed in β nothing saved on this account yet.") |
| 237 | return |
| 238 | } |
| 239 | let contents = try HomeVault.open(fetch.bytes, dek: dek) |
| 240 | adopt(HomeSession(token: h.token, credentialID: h.credId, dek: dek), method: .passkey) |
| 241 | let added = app.applyVault(contents.servers, removed: contents.removed) |
| 242 | app.syncHome() |
| 243 | phase = added > 0 ? .restored(added) : .emptyBackup |
| 244 | } catch { |
| 245 | phase = .failed(error.localizedDescription) |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | // ---- the Apple door (issues #1089/#1112; server: home/siwa.go) --------- |
| 250 | // Developer ID Mac apps cannot carry the native Sign in with Apple |
| 251 | // entitlement (issue #1089), so the Mac door rides Apple's WEB flow in an |
| 252 | // ASWebAuthenticationSession: authorize β home's /siwa/return trampoline |
| 253 | // form_post β elevenmessenger://home-siwa#state=β¦&id_token=β¦, which the |
| 254 | // session intercepts in-process (no Launch Services round trip β alts with |
| 255 | // suffixed schemes work the same). Shared Safari cookies (non-ephemeral) |
| 256 | // mean a signed-in-to-Apple member sees a one-click confirm sheet. |
| 257 | |
| 258 | /// Kept alive for the flow's duration; ASWebAuthenticationSession is not |
| 259 | /// retained by the system while running. |
| 260 | private var webAuthSession: ASWebAuthenticationSession? |
| 261 | private let webAuthPresenter = WebAuthPresenter() |
| 262 | |
| 263 | func signInWithApple(into app: MacAppState) async { |
| 264 | // The state ties Apple's return leg to THIS attempt. Deliberately not |
| 265 | // "web."-prefixed β that routes the trampoline back to the home page; |
| 266 | // everything else bounces to the custom scheme. The web flow carries |
| 267 | // no nonce binding, so verify posts an empty nonce (server-optional). |
| 268 | let state = "mac." + HomeAnchorAuth.mintNonce() |
| 269 | var comps = URLComponents(string: "https://appleid.apple.com/auth/authorize")! |
| 270 | comps.queryItems = [ |
| 271 | .init(name: "client_id", value: "com.elevenmessenger.signin"), // the Services ID (home.js uses the same) |
| 272 | .init(name: "redirect_uri", |
| 273 | value: HomeBase.url.appending(path: "/siwa/return").absoluteString), |
| 274 | .init(name: "response_type", value: "code id_token"), |
| 275 | .init(name: "response_mode", value: "form_post"), |
| 276 | .init(name: "state", value: state), |
| 277 | ] |
| 278 | phase = .working |
| 279 | defer { webAuthSession = nil } |
| 280 | do { |
| 281 | let callback = try await runWebAuth(url: comps.url!) |
| 282 | guard let idToken = Self.siwaCallbackToken(callback, state: state) else { |
| 283 | phase = .failed("Apple didn't return a usable sign-in β try again.") |
| 284 | return |
| 285 | } |
| 286 | let fin = try await HomeClient().siwaVerify(identityToken: idToken, nonce: "") |
| 287 | // The DEK-sync use-gate (#1089, flag-gated): the web-session |
| 288 | // assertion that just verified is the fresh SiwA assertion the |
| 289 | // synced item's release requires β same rule as iOS's native |
| 290 | // sheet. When the iCloud-synced DEK is here AND belongs to this |
| 291 | // account's vault, this Apple sign-in opens the spaces outright; |
| 292 | // any failure falls through to the sealed/empty landing as today. |
| 293 | if let released = await DEKSync.release(token: fin.token) { |
| 294 | phase = .syncing |
| 295 | adopt(released.session, method: .apple) |
| 296 | let added = app.applyVault(released.contents.servers, |
| 297 | removed: released.contents.removed) |
| 298 | app.syncHome() |
| 299 | phase = added > 0 ? .restored(added) : .emptyBackup |
| 300 | } else { |
| 301 | phase = await finishAnchor(fin, method: .apple) |
| 302 | } |
| 303 | } catch let e as ASWebAuthenticationSessionError where e.code == .canceledLogin { |
| 304 | phase = .idle // closing Apple's window is a choice, not an error |
| 305 | } catch { |
| 306 | phase = .failed(Self.anchorMessage(error)) |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | private func runWebAuth(url: URL) async throws -> URL { |
| 311 | try await withCheckedThrowingContinuation { cont in |
| 312 | let session = ASWebAuthenticationSession( |
| 313 | url: url, callbackURLScheme: "elevenmessenger" |
| 314 | ) { url, err in |
| 315 | if let url { |
| 316 | cont.resume(returning: url) |
| 317 | } else { |
| 318 | cont.resume(throwing: err ?? ASWebAuthenticationSessionError(.canceledLogin)) |
| 319 | } |
| 320 | } |
| 321 | session.presentationContextProvider = webAuthPresenter |
| 322 | // Share Safari's cookie jar: a member already signed in to Apple |
| 323 | // gets the one-tap confirm instead of a full password round. |
| 324 | session.prefersEphemeralWebBrowserSession = false |
| 325 | webAuthSession = session |
| 326 | if !session.start() { |
| 327 | cont.resume(throwing: ASWebAuthenticationSessionError(.presentationContextInvalid)) |
| 328 | } |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | /// The trampoline's callback (elevenmessenger://home-siwa#state=β¦& |
| 333 | /// id_token=β¦): verify OUR state came back, hand out the identity token. |
| 334 | static func siwaCallbackToken(_ url: URL, state: String) -> String? { |
| 335 | guard url.host == "home-siwa" else { return nil } |
| 336 | // The fields ride the fragment (kept out of request logs); parse it |
| 337 | // with query semantics so each value is percent-decoded exactly once. |
| 338 | var frag = URLComponents() |
| 339 | frag.percentEncodedQuery = URLComponents(url: url, resolvingAgainstBaseURL: false)? |
| 340 | .percentEncodedFragment |
| 341 | let items = frag.queryItems ?? [] |
| 342 | guard items.first(where: { $0.name == "state" })?.value == state, |
| 343 | let token = items.first(where: { $0.name == "id_token" })?.value, |
| 344 | !token.isEmpty else { return nil } |
| 345 | return token |
| 346 | } |
| 347 | |
| 348 | // ---- the web-flow doors: Google / Microsoft / GitHub (home/oidc.go) ---- |
| 349 | |
| 350 | /// Which of the web-flow doors this home server has lit, plus GitHub's |
| 351 | /// per-instance client id β fetched once when the doors show. Nil (not |
| 352 | /// yet fetched / older server) just means those doors don't render. |
| 353 | @Published private(set) var oidcDoors: HomeClient.SigninDoors? |
| 354 | |
| 355 | func refreshDoors() async { |
| 356 | oidcDoors = await HomeClient().signinDoors() |
| 357 | } |
| 358 | |
| 359 | /// One browser round trip, the SiwA web flow's exact shape: authorize |
| 360 | /// URL -> home's /oidc/return trampoline -> elevenmessenger://home-oidc, |
| 361 | /// intercepted in-process by the session -> verify -> the same anchor |
| 362 | /// landing as Apple and email. Google/Microsoft carry a nonce the verify |
| 363 | /// round-trips; GitHub is the code exchange (the server holds the |
| 364 | /// secret). |
| 365 | func signInWithOidc(_ door: OIDCDoor, into app: MacAppState) async { |
| 366 | let state = "mac." + HomeAnchorAuth.mintNonce() |
| 367 | let nonce = HomeAnchorAuth.mintNonce() |
| 368 | guard let url = HomeOIDCAuth.authorizeURL(door, base: HomeBase.url, state: state, |
| 369 | nonce: nonce, |
| 370 | githubClientID: oidcDoors?.githubClientID) else { |
| 371 | phase = .failed("\(door.display) sign-in isn't available right now.") |
| 372 | return |
| 373 | } |
| 374 | phase = .working |
| 375 | defer { webAuthSession = nil } |
| 376 | do { |
| 377 | let callback = try await runWebAuth(url: url) |
| 378 | guard let parsed = HomeOIDCAuth.callback(callback, state: state) else { |
| 379 | phase = .failed("That sign-in didn't complete β try again.") |
| 380 | return |
| 381 | } |
| 382 | let fin: HomeClient.AnchorFinish |
| 383 | if door == .github, let code = parsed.code { |
| 384 | fin = try await HomeClient().githubVerify(code: code) |
| 385 | } else if let idToken = parsed.idToken { |
| 386 | fin = try await HomeClient().oidcVerify(provider: door.rawValue, |
| 387 | identityToken: idToken, nonce: nonce) |
| 388 | } else { |
| 389 | phase = .failed("That sign-in didn't complete β try again.") |
| 390 | return |
| 391 | } |
| 392 | phase = await finishAnchor(fin, method: SignInMethod(rawValue: door.rawValue) ?? .email) |
| 393 | } catch let e as ASWebAuthenticationSessionError where e.code == .canceledLogin { |
| 394 | phase = .idle // closing the provider's window is a choice, not an error |
| 395 | } catch { |
| 396 | phase = .failed(Self.anchorMessage(error)) |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | // ---- the email door (issue #1091; server: home/email.go) --------------- |
| 401 | |
| 402 | /// Step one: send the code. True advances the door to the code step. The |
| 403 | /// spin floor mirrors the web's (~900ms): an instant send reads as a |
| 404 | /// glitch β and the server's 200 is the same known-or-not, so success |
| 405 | /// only ever means "sent". |
| 406 | func requestEmailCode(email: String) async -> Bool { |
| 407 | phase = .working |
| 408 | let floor = Task { try? await Task.sleep(for: .seconds(0.9)) } |
| 409 | do { |
| 410 | try await HomeClient().emailRequest(email: email) |
| 411 | await floor.value |
| 412 | phase = .idle |
| 413 | return true |
| 414 | } catch { |
| 415 | floor.cancel() // errors show immediately, like the web |
| 416 | phase = .failed(Self.anchorMessage(error)) |
| 417 | return false |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | /// Step two: redeem the code and adopt the session β the same seam as |
| 422 | /// every other door. The floor is a touch longer than send's (the web's |
| 423 | /// 1.2s): a landing that swaps in instantly never registers. |
| 424 | func verifyEmailCode(email: String, code: String) async { |
| 425 | phase = .working |
| 426 | let floor = Task { try? await Task.sleep(for: .seconds(1.2)) } |
| 427 | do { |
| 428 | let fin = try await HomeClient().emailVerify(email: email, code: code) |
| 429 | await floor.value |
| 430 | phase = await finishAnchor(fin, method: .email) |
| 431 | } catch { |
| 432 | floor.cancel() |
| 433 | phase = .failed(Self.anchorMessage(error)) |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | /// An anchor's landing: adopt the token (no DEK β an Apple/email sign-in |
| 438 | /// proves who you are but can never decrypt; the backup's key lives |
| 439 | /// behind a passkey's PRF), then report what the account holds: fresh or |
| 440 | /// backup-less β the empty pitch; an existing sealed backup β point at |
| 441 | /// the passkey door, which is what actually opens it. |
| 442 | private func finishAnchor(_ fin: HomeClient.AnchorFinish, method: SignInMethod) async -> Phase { |
| 443 | adopt(HomeSession(token: fin.token), method: method) |
| 444 | if fin.created == true { return .emptyBackup } |
| 445 | do { |
| 446 | return try await HomeClient().fetchBlob(token: fin.token) == nil ? .emptyBackup : .sealed |
| 447 | } catch { |
| 448 | // Signed in either way β the peek only picks the pitch. |
| 449 | return .failed("Signed in, but Eleven couldn't be reached to check your spaces β try again in a moment.") |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | /// Anchor errors surface the server's own member-facing copy ("that code |
| 454 | /// didn't work") when there is one; transport failures get the standing |
| 455 | /// network line. |
| 456 | private static func anchorMessage(_ error: Error) -> String { |
| 457 | if case HomeClientError.http(let code, let body) = error { |
| 458 | let text = body.trimmingCharacters(in: .whitespacesAndNewlines) |
| 459 | if !text.isEmpty, text.count <= 120 { return text } |
| 460 | return "Eleven turned that down (\(code)). Try again in a moment." |
| 461 | } |
| 462 | if error is HomeClientError { |
| 463 | return "Couldn't reach Eleven. Check your connection and try again." |
| 464 | } |
| 465 | return error.localizedDescription |
| 466 | } |
| 467 | |
| 468 | /// Vault-wins merge, the Mac shape: upsert SavedInstances (identity + star), |
| 469 | /// make sure each restored server has a rail entry (SavedChat), and only |
| 470 | /// then push β so the write-back can't wipe vault state (the iOS lesson). |
| 471 | @available(macOS 15.0, *) |
| 472 | private func restore(_ result: HomeAuth.Result, into app: MacAppState) async throws -> Phase { |
| 473 | guard let fetch = try await HomeClient().fetchBlob(token: result.token) else { |
| 474 | // Blob-less account: recoverable right now with the PRF in hand |
| 475 | // (mirrors the iOS path β bailing was a permanent dead-end). Seal |
| 476 | // the Mac's servers straight into the first envelope β an empty |
| 477 | // seal + async push raced and misreported the result. |
| 478 | let servers = app.instancesInRailOrder().map { inst in |
| 479 | HomeVault.Server( |
| 480 | url: inst.origin, |
| 481 | name: inst.groupName.isEmpty ? nil : inst.groupName, |
| 482 | starred: inst.isStarred ? true : nil, |
| 483 | archived: inst.isArchived ? true : nil, |
| 484 | credentials: .init(token: inst.deviceToken, userPub: inst.userPub, |
| 485 | userPriv: inst.userPriv, name: inst.screenName)) |
| 486 | } |
| 487 | let sealed = try HomeVault.sealFresh(HomeVault.Contents(v: 1, servers: servers), |
| 488 | credentialID: result.credentialID, |
| 489 | prf: result.prf) |
| 490 | guard try await HomeClient().putBlob(token: result.token, bytes: sealed.bytes, |
| 491 | ifMatch: "0") != nil else { |
| 492 | return .failed("Couldn't save your spaces β try again.") |
| 493 | } |
| 494 | adopt(HomeSession(token: result.token, credentialID: result.credentialID, |
| 495 | dek: sealed.dek), method: .passkey) |
| 496 | return servers.isEmpty ? .emptyBackup : .restored(0) |
| 497 | } |
| 498 | guard let opened = try HomeVault.open(fetch.bytes, |
| 499 | credentialID: result.credentialID, |
| 500 | prf: result.prf) else { |
| 501 | return .failed("That sign-in doesn't unlock these spaces.") |
| 502 | } |
| 503 | adopt(HomeSession(token: result.token, credentialID: result.credentialID, |
| 504 | dek: opened.dek), method: .passkey) |
| 505 | let added = app.applyVault(opened.contents.servers, removed: opened.contents.removed) |
| 506 | app.syncHome() |
| 507 | return added > 0 ? .restored(added) : .emptyBackup |
| 508 | } |
| 509 | } |
| 510 | |
| 511 | // The ASWebAuthenticationSession's window anchor β the key window, same |
| 512 | // choice the passkey ceremony makes. |
| 513 | private final class WebAuthPresenter: NSObject, ASWebAuthenticationPresentationContextProviding { |
| 514 | func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { |
| 515 | NSApp.keyWindow ?? NSApp.windows.first ?? ASPresentationAnchor() |
| 516 | } |
| 517 | } |
| 518 | |
| 519 | // The sign-in state + Sign Out rows (MacSettingsView mounts them at the |
| 520 | // BOTTOM of the combined Profile section β Paul, 2026-07-15): one line of |
| 521 | // state and one action, matching the pane's quiet register. Rows, not a |
| 522 | // Section β the caller owns the section so identity and account read as one |
| 523 | // piece. |
| 524 | struct HomeSignInSection: View { |
| 525 | @EnvironmentObject var app: MacAppState |
| 526 | @ObservedObject var model: MacHomeSignIn |
| 527 | @State private var confirmSignOut = false |
| 528 | |
| 529 | var body: some View { |
| 530 | Group { |
| 531 | if model.sessionExists { |
| 532 | LabeledContent("Signed in") { |
| 533 | Button("Sign Outβ¦") { confirmSignOut = true } |
| 534 | } |
| 535 | .confirmationDialog( |
| 536 | "Sign out? Your spaces, messages and keys are removed from this Mac. Sign back in to get them back.", |
| 537 | isPresented: $confirmSignOut, titleVisibility: .visible |
| 538 | ) { |
| 539 | Button("Sign Out & Clear This Mac", role: .destructive) { |
| 540 | Task { await model.signOut(from: app) } |
| 541 | } |
| 542 | } |
| 543 | if case .restored(let n) = model.phase, n > 0 { |
| 544 | Text("\(n) space\(n == 1 ? "" : "s") restored.") |
| 545 | .font(.footnote).foregroundStyle(.secondary) |
| 546 | } |
| 547 | if model.sessionSealed { |
| 548 | // An anchor session (Apple / email): signed in, but the |
| 549 | // backup opens only under a passkey β #1132's .sealed |
| 550 | // framing, the Settings register. |
| 551 | LabeledContent("Spaces locked") { |
| 552 | if model.phase.busy { |
| 553 | ProgressView().controlSize(.small) |
| 554 | } else { |
| 555 | Button("Unlock with Your Passkeyβ¦") { |
| 556 | Task { await model.signIn(into: app) } |
| 557 | } |
| 558 | } |
| 559 | } |
| 560 | Text("You're signed in with Apple or email β your saved spaces stay locked until you unlock them with your passkey.") |
| 561 | .font(.footnote).foregroundStyle(.secondary) |
| 562 | } else { |
| 563 | Text("Your space list syncs with Eleven β stars and order follow you between devices.") |
| 564 | .font(.footnote).foregroundStyle(.secondary) |
| 565 | } |
| 566 | } else { |
| 567 | LabeledContent("Not signed in") { |
| 568 | if model.phase.busy { |
| 569 | ProgressView().controlSize(.small) |
| 570 | } else { |
| 571 | Button("Sign In with Elevenβ¦") { |
| 572 | Task { await model.signIn(into: app) } |
| 573 | } |
| 574 | } |
| 575 | } |
| 576 | Button("Use your browser to sign in") { model.signInViaBrowser() } |
| 577 | .buttonStyle(.link) |
| 578 | .disabled(model.phase.busy) |
| 579 | if model.phase == .syncing { |
| 580 | Text("Syncing your spacesβ¦").font(.footnote).foregroundStyle(.secondary) |
| 581 | } |
| 582 | if model.phase == .browser { |
| 583 | Text("Finishing sign-in in your browserβ¦").font(.footnote).foregroundStyle(.secondary) |
| 584 | } |
| 585 | if case .failed(let msg) = model.phase { |
| 586 | Text(msg).font(.footnote).foregroundStyle(.secondary) |
| 587 | } |
| 588 | } |
| 589 | } |
| 590 | // Signing in happens on OTHER surfaces too β re-check whenever the |
| 591 | // pane shows, so this section never claims "Not signed in" stale. |
| 592 | .onAppear { model.refreshSession() } |
| 593 | } |
| 594 | } |
| 595 | |
| 596 | // The Mac's first-run doors β iOS's SignInFirstRun in the Mac idiom, shown by |
| 597 | // RootView while the rail is empty. Three ways in, all against the same home |
| 598 | // server: Continue with Apple (the lead), an emailed 6-digit code, and the |
| 599 | // existing passkey path (native ceremony with automatic browser failover) |
| 600 | // demoted to a quiet text button β with the last-used door promoted (#1112). |
| 601 | // Sign in restores the backup and the rail fills in place; an anchor sign-in |
| 602 | // against a sealed backup lands signed in with the passkey door promoted to |
| 603 | // "unlock" (the identity-vs-key split, exactly as on iOS/web). |
| 604 | struct MacFirstRun: View { |
| 605 | @EnvironmentObject var app: MacAppState |
| 606 | @ObservedObject var model: MacHomeSignIn |
| 607 | @Environment(\.colorScheme) private var scheme |
| 608 | |
| 609 | private enum EmailStep { case address, code } |
| 610 | @State private var emailStep: EmailStep? |
| 611 | @State private var email = "" |
| 612 | @State private var code = "" |
| 613 | // Which door the last click opened β only so the promoted passkey button |
| 614 | // doesn't spin for an Apple verify that happens to share phase == .working. |
| 615 | @State private var tapped: MacHomeSignIn.SignInMethod? |
| 616 | // One door at a time: the demoted doors unfold behind this. |
| 617 | @State private var showAllDoors = false |
| 618 | @FocusState private var focus: EmailStep? |
| 619 | |
| 620 | var body: some View { |
| 621 | VStack(spacing: 10) { |
| 622 | Spacer() |
| 623 | Image("ElevenMark") |
| 624 | .resizable() |
| 625 | .frame(width: 76, height: 76) |
| 626 | .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| 627 | .accessibilityHidden(true) |
| 628 | Text("Welcome to Eleven") |
| 629 | .font(.title2.weight(.semibold)) |
| 630 | // The tagline is the welcome pitch β the sealed landing is an |
| 631 | // unlock moment, not a welcome, and earns no marketing words |
| 632 | // (Paul, 2026-07-19: the button says everything this state needs). |
| 633 | if !model.sessionSealed { |
| 634 | Text("Secure group chat") |
| 635 | .foregroundStyle(.secondary) |
| 636 | } |
| 637 | |
| 638 | // Status folds in under the pitch, occupying layout space (the |
| 639 | // inline-feedback rule β nothing floats). |
| 640 | statusLine |
| 641 | .frame(maxWidth: 380, minHeight: 22) |
| 642 | .padding(.top, 10) |
| 643 | |
| 644 | Spacer() |
| 645 | |
| 646 | if model.phase == .syncing { |
| 647 | VStack(spacing: 10) { |
| 648 | ProgressView() |
| 649 | Text("Syncing your spacesβ¦").foregroundStyle(.secondary) |
| 650 | } |
| 651 | .padding(.bottom, 56) |
| 652 | } else if let step = emailStep { |
| 653 | emailDoor(step) |
| 654 | } else if model.phase.signedIn || model.sessionSealed { |
| 655 | landed |
| 656 | } else { |
| 657 | doors |
| 658 | } |
| 659 | } |
| 660 | .frame(maxWidth: .infinity, maxHeight: .infinity) |
| 661 | // A landed sign-in (any door) folds the email door away; the doors |
| 662 | // fold too (signed in = no sign-in options) β sealed keeps only the |
| 663 | // passkey, promoted as the unlock. |
| 664 | .onChange(of: model.phase) { _, p in |
| 665 | if p.signedIn { |
| 666 | emailStep = nil |
| 667 | email = "" |
| 668 | code = "" |
| 669 | } |
| 670 | } |
| 671 | } |
| 672 | |
| 673 | // ---- the doors --------------------------------------------------------- |
| 674 | |
| 675 | /// Apple, email, passkey β plus whichever web-flow doors the home server |
| 676 | /// has lit (Google/Microsoft/GitHub, in the web's fixed brand order) β |
| 677 | /// the last-used method promoted to lead. After a sealed anchor sign-in |
| 678 | /// the passkey leads regardless: it is the one door that actually opens |
| 679 | /// the backup. |
| 680 | private var doorOrder: [MacHomeSignIn.SignInMethod] { |
| 681 | var order: [MacHomeSignIn.SignInMethod] = [.apple, .email, .passkey] |
| 682 | if model.oidcDoors?.githubClientID?.isEmpty == false { order.append(.github) } |
| 683 | if model.oidcDoors?.microsoft == true { order.append(.microsoft) } |
| 684 | if model.oidcDoors?.google == true { order.append(.google) } |
| 685 | let lead: MacHomeSignIn.SignInMethod? = |
| 686 | model.phase == .sealed ? .passkey : MacHomeSignIn.lastSignIn |
| 687 | if let lead, let i = order.firstIndex(of: lead) { |
| 688 | order.remove(at: i) |
| 689 | order.insert(lead, at: 0) |
| 690 | } |
| 691 | return order |
| 692 | } |
| 693 | |
| 694 | // A landed sign-in folds the doors away entirely β signed in means no |
| 695 | // sign-in options. Sealed keeps exactly one control, the passkey, |
| 696 | // because it is the unlock rather than an alternative; the quiet escape |
| 697 | // below it covers the wrong-account landing either way. |
| 698 | private var landed: some View { |
| 699 | VStack(spacing: 10) { |
| 700 | if model.sessionSealed { |
| 701 | door(.passkey, leading: true) |
| 702 | } |
| 703 | Button("Sign in a different way") { model.switchAccount() } |
| 704 | .buttonStyle(.plain) |
| 705 | .font(.subheadline.weight(.medium)) |
| 706 | .foregroundStyle(.secondary) |
| 707 | .padding(.vertical, 2) |
| 708 | .disabled(model.phase.busy) // never mid-ceremony |
| 709 | } |
| 710 | .frame(width: 280) |
| 711 | .padding(.bottom, 48) |
| 712 | } |
| 713 | |
| 714 | private var doors: some View { |
| 715 | // Exactly ONE door at a time β last-used when known, else the |
| 716 | // platform default β with the rest behind "Other ways to sign in" |
| 717 | // (Paul 2026-07-19, the web card's rule, mirrored). |
| 718 | VStack(spacing: 10) { |
| 719 | ForEach(showAllDoors ? doorOrder : Array(doorOrder.prefix(1)), id: \.self) { method in |
| 720 | door(method, leading: method == doorOrder.first) |
| 721 | } |
| 722 | if !showAllDoors { |
| 723 | Button("Other ways to sign in") { showAllDoors = true } |
| 724 | .buttonStyle(.plain) |
| 725 | .font(.subheadline.weight(.medium)) |
| 726 | .foregroundStyle(.secondary) |
| 727 | .padding(.vertical, 2) |
| 728 | } |
| 729 | } |
| 730 | .frame(width: 280) |
| 731 | .padding(.bottom, 48) |
| 732 | // Which web-flow doors this home server has lit (and GitHub's client |
| 733 | // id) β fetched when the doors show; until it lands the three fixed |
| 734 | // doors stand alone, same as an older server. |
| 735 | .task { await model.refreshDoors() } |
| 736 | } |
| 737 | |
| 738 | @ViewBuilder private func door(_ method: MacHomeSignIn.SignInMethod, leading: Bool) -> some View { |
| 739 | switch method { |
| 740 | case .apple: |
| 741 | // Apple's branded control (HIG, glyph) over OUR web-session |
| 742 | // flow β see MacHomeSignIn.signInWithApple for why not the native |
| 743 | // sheet. Recreated on scheme flips (.id) β the style is init-only. |
| 744 | // While its trip is in flight the control can't host a spinner |
| 745 | // (the web's pressed door spins), so a same-geometry stand-in |
| 746 | // covers it; the real button stays in the hierarchy, hidden. |
| 747 | let appleBusy = model.phase.busy && tapped == .apple |
| 748 | AppleDoorButton(style: scheme == .dark ? .white : .black) { |
| 749 | tapped = .apple |
| 750 | Task { await model.signInWithApple(into: app) } |
| 751 | } |
| 752 | .id(scheme) |
| 753 | .frame(height: MacDoorButtonStyle.height) |
| 754 | .disabled(model.phase.busy) |
| 755 | .opacity(appleBusy ? 0 : 1) |
| 756 | .overlay { |
| 757 | if appleBusy { |
| 758 | HStack(spacing: 6) { |
| 759 | ProgressView() |
| 760 | .controlSize(.small) |
| 761 | .tint(scheme == .dark ? .black : .white) |
| 762 | Text("Signing inβ¦").fontWeight(.medium) |
| 763 | } |
| 764 | .frame(maxWidth: .infinity, minHeight: MacDoorButtonStyle.height) |
| 765 | .foregroundStyle(scheme == .dark ? Color.black : .white) |
| 766 | .background(scheme == .dark ? Color.white : .black, |
| 767 | in: RoundedRectangle(cornerRadius: MacDoorButtonStyle.radius, |
| 768 | style: .continuous)) |
| 769 | } |
| 770 | } |
| 771 | case .email: |
| 772 | Button { |
| 773 | tapped = .email |
| 774 | emailStep = .address |
| 775 | } label: { |
| 776 | Text("Continue with email") |
| 777 | } |
| 778 | .buttonStyle(MacDoorButtonStyle(prominent: leading)) |
| 779 | .disabled(model.phase.busy) |
| 780 | case .passkey: |
| 781 | if leading { |
| 782 | // Promoted: the hero form (last-used passkey, or a sealed |
| 783 | // anchor sign-in pointing at the door that opens the backup). |
| 784 | Button { |
| 785 | tapped = .passkey |
| 786 | Task { await model.signIn(into: app) } |
| 787 | } label: { |
| 788 | HStack(spacing: 6) { |
| 789 | if model.phase == .working && tapped == .passkey { |
| 790 | ProgressView().controlSize(.small).tint(.white) |
| 791 | } else if model.sessionSealed { |
| 792 | // The unlock moment gets the passkey glyph β the |
| 793 | // icon carries what the trimmed copy no longer |
| 794 | // spells out. |
| 795 | Image(systemName: "person.badge.key.fill") |
| 796 | } |
| 797 | Text(passkeyLeadLabel) |
| 798 | } |
| 799 | } |
| 800 | .buttonStyle(MacDoorButtonStyle(prominent: true)) |
| 801 | .disabled(model.phase.busy) |
| 802 | } else { |
| 803 | // Demoted, but the same row shape β no mixed button types in |
| 804 | // the list; the Apple control fixes the geometry. The model |
| 805 | // still routes it β native ceremony where it can run, |
| 806 | // automatic browser failover everywhere else (no entitlement, |
| 807 | // 1Password's PRF mismatch, pre-15). |
| 808 | Button("Sign in with a passkey") { |
| 809 | tapped = .passkey |
| 810 | Task { await model.signIn(into: app) } |
| 811 | } |
| 812 | .buttonStyle(MacDoorButtonStyle()) |
| 813 | .disabled(model.phase.busy) |
| 814 | } |
| 815 | case .google, .microsoft, .github: |
| 816 | // The web-flow doors: a browser sheet over the same session seam |
| 817 | // β same row geometry, the provider's mark carrying the brand. |
| 818 | let door = OIDCDoor(rawValue: method.rawValue) ?? .google |
| 819 | Button { |
| 820 | tapped = method |
| 821 | Task { await model.signInWithOidc(door, into: app) } |
| 822 | } label: { |
| 823 | HStack(spacing: 6) { |
| 824 | if model.phase == .working && tapped == method { |
| 825 | ProgressView().controlSize(.small) |
| 826 | .tint(leading ? .white : nil) |
| 827 | } else { |
| 828 | Image("Door\(door.display)") |
| 829 | .resizable() |
| 830 | .scaledToFit() |
| 831 | .frame(width: 14, height: 14) |
| 832 | } |
| 833 | Text("Continue with \(door.display)") |
| 834 | } |
| 835 | } |
| 836 | .buttonStyle(MacDoorButtonStyle(prominent: leading)) |
| 837 | .disabled(model.phase.busy) |
| 838 | } |
| 839 | } |
| 840 | |
| 841 | private var passkeyLeadLabel: String { |
| 842 | if model.phase == .working && tapped == .passkey { return "Signing inβ¦" } |
| 843 | if model.sessionSealed { return "Unlock with your passkey" } |
| 844 | return "Sign in with a passkey" |
| 845 | } |
| 846 | |
| 847 | // ---- the email door ---------------------------------------------------- |
| 848 | |
| 849 | @ViewBuilder private func emailDoor(_ step: EmailStep) -> some View { |
| 850 | VStack(spacing: 12) { |
| 851 | HStack { |
| 852 | // Serial steps: code backs to the address, address backs to |
| 853 | // the doors β a typo'd address is one click away either way. |
| 854 | Button { |
| 855 | if step == .code { |
| 856 | emailStep = .address |
| 857 | code = "" |
| 858 | focus = .address |
| 859 | } else { |
| 860 | emailStep = nil |
| 861 | } |
| 862 | } label: { |
| 863 | Label("Back", systemImage: "chevron.backward") |
| 864 | .font(.subheadline) |
| 865 | } |
| 866 | .buttonStyle(.plain) |
| 867 | .foregroundStyle(.secondary) |
| 868 | .accessibilityLabel(step == .code ? "Back to your email address" : "Back to sign-in") |
| 869 | Spacer() |
| 870 | } |
| 871 | Text("Sign in with email") |
| 872 | .font(.headline) |
| 873 | |
| 874 | if step == .address { |
| 875 | TextField("you@example.com", text: $email) |
| 876 | .textFieldStyle(.roundedBorder) |
| 877 | .textContentType(.emailAddress) |
| 878 | .autocorrectionDisabled() |
| 879 | .submitLabel(.send) |
| 880 | .focused($focus, equals: .address) |
| 881 | .onSubmit { if emailValid { sendCode() } } // Return advances |
| 882 | .accessibilityLabel("Your email address") |
| 883 | Button(action: sendCode) { |
| 884 | HStack(spacing: 6) { |
| 885 | if model.phase == .working { ProgressView().controlSize(.small).tint(.white) } |
| 886 | Text("Send code") |
| 887 | } |
| 888 | } |
| 889 | .buttonStyle(MacDoorButtonStyle(prominent: true)) |
| 890 | .disabled(model.phase.busy || !emailValid) |
| 891 | // The web's note, word for word β under the address field. |
| 892 | Text("We never keep your email address. No spam, ever.") |
| 893 | .font(.footnote) |
| 894 | .foregroundStyle(.tertiary) |
| 895 | .multilineTextAlignment(.center) |
| 896 | } else { |
| 897 | Text("Check your email for a 6-digit code.") |
| 898 | .font(.callout) |
| 899 | .foregroundStyle(.secondary) |
| 900 | .multilineTextAlignment(.center) |
| 901 | TextField("6-digit code", text: $code) |
| 902 | .textFieldStyle(.roundedBorder) |
| 903 | .textContentType(.oneTimeCode) |
| 904 | .multilineTextAlignment(.center) |
| 905 | .font(.title3.monospacedDigit()) |
| 906 | .focused($focus, equals: .code) |
| 907 | .onSubmit { if code.count == 6 { verifyCode() } } // Return advances |
| 908 | .onChange(of: code) { _, v in |
| 909 | // Digits only, six of them β mirrors the web's input. |
| 910 | let digits = String(v.filter(\.isNumber).prefix(6)) |
| 911 | if digits != v { code = digits } |
| 912 | } |
| 913 | .accessibilityLabel("The 6-digit code from your email") |
| 914 | Button(action: verifyCode) { |
| 915 | HStack(spacing: 6) { |
| 916 | if model.phase == .working { ProgressView().controlSize(.small).tint(.white) } |
| 917 | Text("Sign in") |
| 918 | } |
| 919 | } |
| 920 | .buttonStyle(MacDoorButtonStyle(prominent: true)) |
| 921 | .disabled(model.phase.busy || code.count != 6) |
| 922 | } |
| 923 | } |
| 924 | .frame(width: 280) |
| 925 | .padding(.bottom, 48) |
| 926 | .onAppear { focus = step } |
| 927 | } |
| 928 | |
| 929 | // The server's own bar (email.go): non-empty, has an @, fits an address. |
| 930 | private var emailValid: Bool { |
| 931 | let addr = email.trimmingCharacters(in: .whitespacesAndNewlines) |
| 932 | return !addr.isEmpty && addr.contains("@") && addr.count <= 254 |
| 933 | } |
| 934 | |
| 935 | private func sendCode() { |
| 936 | let addr = email.trimmingCharacters(in: .whitespacesAndNewlines) |
| 937 | Task { |
| 938 | if await model.requestEmailCode(email: addr) { |
| 939 | emailStep = .code |
| 940 | focus = .code |
| 941 | } |
| 942 | } |
| 943 | } |
| 944 | |
| 945 | private func verifyCode() { |
| 946 | let addr = email.trimmingCharacters(in: .whitespacesAndNewlines) |
| 947 | Task { await model.verifyEmailCode(email: addr, code: code) } |
| 948 | } |
| 949 | |
| 950 | // ---- status ------------------------------------------------------------ |
| 951 | |
| 952 | @ViewBuilder private var statusLine: some View { |
| 953 | switch model.phase { |
| 954 | case .failed(let msg): |
| 955 | Label(msg, systemImage: "exclamationmark.triangle") |
| 956 | .font(.footnote).foregroundStyle(.secondary) |
| 957 | .multilineTextAlignment(.center) |
| 958 | case .emptyBackup: |
| 959 | Text("Signed in β nothing saved on this account yet. Join a space in your browser first.") |
| 960 | .font(.callout).foregroundStyle(.secondary) |
| 961 | .multilineTextAlignment(.center) |
| 962 | case .sealed: |
| 963 | // Deliberately NOTHING: "signed in but locked" reads as noise |
| 964 | // when no spaces are visible (Paul: if you can't see your |
| 965 | // spaces, you're not signed in). The unlock button β a passkey |
| 966 | // icon and five words β is the whole story. |
| 967 | EmptyView() |
| 968 | case .restored(let n): |
| 969 | // Visible only in the beat before the rail fills and RootView |
| 970 | // swaps this screen out. |
| 971 | Label("\(n) space\(n == 1 ? "" : "s") restored.", systemImage: "checkmark.circle.fill") |
| 972 | .font(.callout).foregroundStyle(.green) |
| 973 | case .browser: |
| 974 | Text("Finishing sign-in in your browserβ¦") |
| 975 | .font(.callout).foregroundStyle(.secondary) |
| 976 | case .working: |
| 977 | Text("Confirming itβs youβ¦") |
| 978 | .font(.callout).foregroundStyle(.secondary) |
| 979 | case .idle, .syncing: |
| 980 | EmptyView() |
| 981 | } |
| 982 | } |
| 983 | } |
| 984 | |
| 985 | // Apple's own branded control (AppKit's ASAuthorizationAppleIDButton β the |
| 986 | // HIG face, glyph and all) wired to OUR action: the SwiftUI |
| 987 | // SignInWithAppleButton drives the native request, which needs an entitlement |
| 988 | // Developer ID apps can't hold, so the face and the web-session behavior are |
| 989 | // joined here instead. |
| 990 | private struct AppleDoorButton: NSViewRepresentable { |
| 991 | let style: ASAuthorizationAppleIDButton.Style |
| 992 | let action: () -> Void |
| 993 | |
| 994 | func makeCoordinator() -> Coordinator { Coordinator(action: action) } |
| 995 | |
| 996 | func makeNSView(context: Context) -> ASAuthorizationAppleIDButton { |
| 997 | let button = ASAuthorizationAppleIDButton(authorizationButtonType: .continue, |
| 998 | authorizationButtonStyle: style) |
| 999 | button.target = context.coordinator |
| 1000 | button.action = #selector(Coordinator.tap) |
| 1001 | return button |
| 1002 | } |
| 1003 | |
| 1004 | func updateNSView(_ button: ASAuthorizationAppleIDButton, context: Context) { |
| 1005 | button.isEnabled = context.environment.isEnabled |
| 1006 | } |
| 1007 | |
| 1008 | final class Coordinator: NSObject { |
| 1009 | let action: () -> Void |
| 1010 | init(action: @escaping () -> Void) { self.action = action } |
| 1011 | @objc func tap() { action() } |
| 1012 | } |
| 1013 | } |
| 1014 | |
| 1015 | // π€ One geometry for every door β the Mac sizing of the iOS DoorButtonStyle |
| 1016 | // (Paul, 2026-07-19: the Apple control fixes the shape; no mixed button |
| 1017 | // types in the list). Prominent = the promoted door / a step's main action. |
| 1018 | // The pressed door hosts its own spinner, so busy state lives in the button. |
| 1019 | private struct MacDoorButtonStyle: ButtonStyle { |
| 1020 | var prominent = false |
| 1021 | @Environment(\.isEnabled) private var enabled |
| 1022 | |
| 1023 | static let radius: CGFloat = 8 |
| 1024 | static let height: CGFloat = 32 |
| 1025 | |
| 1026 | func makeBody(configuration: Configuration) -> some View { |
| 1027 | configuration.label |
| 1028 | .fontWeight(.medium) |
| 1029 | .frame(maxWidth: .infinity, minHeight: Self.height) |
| 1030 | .foregroundStyle(prominent ? Color.white : Color.accentColor) |
| 1031 | .background(prominent ? Color.accentColor : Color.primary.opacity(0.07), |
| 1032 | in: RoundedRectangle(cornerRadius: Self.radius, style: .continuous)) |
| 1033 | .opacity((configuration.isPressed ? 0.75 : 1) * (enabled ? 1 : 0.55)) |
| 1034 | } |
| 1035 | } |
| 1036 | |