mac: sign-in fails over to the browser automatically (#487)
If the native passkey ceremony can't run or can't unlock the vault, the browser ceremony starts on its own — the member never hunts for a fallback link. Failover cases: the ceremony itself failing (AS 1004 — no associated-domain entitlement, which is EVERY Developer ID build today — or no provider), a CryptoKit unwrap failure after it ran (1Password's native PRF not matching its extension's), and pre-macOS-15. The one case that stays put is the member cancelling the sheet. First-run drops the 'Use your browser to sign in' link (one button, routed by the model); Settings keeps its quiet copy as a deliberate escape hatch.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+44
−36
ios/LChatMac/MacHomeSignIn.swift+44 −36
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift| index 2d73171..531aa54 100644 |
| --- a/ios/LChatMac/MacHomeSignIn.swift |
| +++ b/ios/LChatMac/MacHomeSignIn.swift |
| @@ -35,8 +35,10 @@ final class MacHomeSignIn: ObservableObject { |
| @Published private(set) var sessionExists = HomeSessionStore.load() != nil |
| func signIn(into app: MacAppState) async { |
| + // The pre-macOS-15 path can't run the native ceremony at all — the |
| + // browser can (its own WebAuthn), so go straight there. |
| guard #available(macOS 15.0, *) else { |
| - phase = .failed("Sign in with Eleven needs macOS 15.") |
| + signInViaBrowser() |
| return |
| } |
| guard let anchor = NSApp.keyWindow ?? NSApp.windows.first else { |
| @@ -45,19 +47,33 @@ final class MacHomeSignIn: ObservableObject { |
| } |
| phase = .working |
| do { |
| - let result = try await HomeAuth(anchor: anchor).signIn() |
| + let result: HomeAuth.Result |
| + do { |
| + result = try await HomeAuth(anchor: anchor).signIn() |
| + } catch let e as ASAuthorizationError where e.code == .canceled { |
| + phase = .idle // the member closed the sheet — never bounce them |
| + return |
| + } catch { |
| + // The native ceremony itself failed — no associated-domain |
| + // entitlement (every Developer ID build today: AS 1004), no |
| + // provider, whatever. The browser ceremony is the route that |
| + // works, so take it instead of explaining a fallback link |
| + // nobody clicks. |
| + signInViaBrowser() |
| + return |
| + } |
| phase = .syncing // the sheet is down; the blob fetch + restore runs |
| phase = try await restore(result, into: app) |
| sessionExists = HomeSessionStore.load() != nil |
| - } catch let e as ASAuthorizationError { |
| - phase = e.code == .canceled ? .idle |
| - : .failed("\(e.localizedDescription) (AS \(e.code.rawValue))") |
| } catch is CryptoKitError { |
| - // The ceremony ran but the PRF-derived key didn't open the vault — |
| - // in practice a third-party provider (1Password) answered with a |
| - // different passkey, or returned a PRF the DEK wasn't wrapped for. |
| - phase = .failed("That sign-in couldn't unlock your spaces. If you're offered a choice, pick the Apple Passwords option (Touch ID) — or use your browser to sign in.") |
| + // The ceremony RAN but the PRF-derived key didn't open the vault — |
| + // in practice 1Password's native provider, whose PRF doesn't match |
| + // its own browser extension's (what the backup is wrapped in). The |
| + // browser ceremony is exactly the one that works: failover. |
| + signInViaBrowser() |
| } catch { |
| + // Post-ceremony failures that aren't a key mismatch (the blob |
| + // fetch died, etc.) — the browser wouldn't fare better; say so. |
| phase = .failed(error.localizedDescription) |
| } |
| } |
| @@ -306,35 +322,27 @@ struct MacFirstRun: View { |
| .padding(.bottom, 56) |
| } else { |
| VStack(spacing: 10) { |
| - if #available(macOS 15.0, *) { |
| - Button { |
| - Task { await model.signIn(into: app) } |
| - } label: { |
| - Text(model.phase.busy ? "Signing in…" : "Sign in") |
| - .fontWeight(.semibold) |
| - .frame(width: 200) |
| - } |
| - .buttonStyle(.borderedProminent) |
| - .controlSize(.large) |
| - .disabled(model.phase.busy) |
| - |
| - // The home page's privacy pitch, word for word. |
| - Text("No signup required. We don't store any personal details. We can't spam what we don't have.") |
| - .font(.footnote) |
| - .foregroundStyle(.tertiary) |
| - .multilineTextAlignment(.center) |
| - .frame(maxWidth: 300) |
| + // One button, always: the model routes it — native ceremony |
| + // where it can run, automatic browser failover everywhere |
| + // else (no entitlement, 1Password's PRF mismatch, pre-15). |
| + // The old "Use your browser" link is gone; nobody clicked it. |
| + Button { |
| + Task { await model.signIn(into: app) } |
| + } label: { |
| + Text(model.phase.busy ? "Signing in…" : "Sign in") |
| + .fontWeight(.semibold) |
| + .frame(width: 200) |
| } |
| - // The browser path needs no PRF from the app's own |
| - // ceremony, so it isn't macOS 15-gated — and it's THE |
| - // route for third-party providers (1Password) whose |
| - // native PRF doesn't match their extension's. |
| - Button("Use your browser to sign in") { model.signInViaBrowser() } |
| - .buttonStyle(.link) |
| - .font(.callout) |
| - .padding(.top, 2) |
| - .disabled(model.phase.busy) |
| + .buttonStyle(.borderedProminent) |
| + .controlSize(.large) |
| + .disabled(model.phase.busy) |
| + // The home page's privacy pitch, word for word. |
| + Text("No signup required. We don't store any personal details. We can't spam what we don't have.") |
| + .font(.footnote) |
| + .foregroundStyle(.tertiary) |
| + .multilineTextAlignment(.center) |
| + .frame(maxWidth: 300) |
| } |
| .padding(.bottom, 48) |
| } |