eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import AppKit
2import AuthenticationServices
3import CryptoKit
4import LChatCore
5import Security
6import 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
26final 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.
513private 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.
524struct 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).
604struct 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.
990private 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.
1019private 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