eleven / messenger Public

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>
Paul Campbell 5c59497c52098f831759773b10a7c3b46d41de38 parent 4f5d9df
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)
}