eleven / messenger Public

mac: Sign in with Apple + email-code doors on the Mac first-run screen (#1133)

* native: home base becomes resolvable — Debug builds can point at the dev home

LChatCore gains HomeBase: production (https://home.elevenmessenger.com) by
default, overridable ONLY in Debug builds — LCHAT_HOME_BASE in the process
environment first, then a home_base_override string in standard UserDefaults
(per-bundle-id on Mac dev alts). Release compiles the override out.

HomeClient's default base, HomeAuth's default rpID, the Mac browser-handoff
/restore link and the sidebar's Manage Spaces link all resolve through it.
HomeSessionStore scopes its Keychain account by the resolved base (suffix on
non-production only, so existing prod sessions keep their account name) — a
dev-home session can never be replayed against production accounts or vice
versa. Debug entitlements gain the dev clone's webcredentials association;
tools/dev + CLAUDE.md document the alt one-liner.

Unblocks PR #1054's testing and native sign-in device testing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: Sign in with Apple + email-code doors on the first-run screen

The native half of the #1112/#1091 identity anchors. LChatCore gains
HomeAnchorAuth (siwa/email client calls over HomeClient's plumbing, the
verbatim-nonce mint, identity-token extraction) and a DEK-less anchor
HomeSession (authenticates, never decrypts — sync parks until a passkey
upgrades it in place). The first-run screen becomes the doors: Apple leads
(SignInWithAppleButton, no scopes, nonce-bound verify), email beneath as a
serial card step (address → 6-digit code, web spin floors, the no-spam
note), the passkey demoted to a quiet text button — last-used promoted via
the home_last_signin UserDefaults marker, written by the one adopt() seam
every door funnels through. An anchor landing on a sealed backup promotes
the passkey to "Unlock your spaces with your passkey". SiwA entitlement
added for both build flavours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: Sign in with Apple + email-code doors on the Mac first-run screen

The parity half of the iOS doors (#1132), built on its LChatCore pieces
(HomeAnchorAuth, AnchorFinish, the DEK-less HomeSession). Developer ID
Mac apps cannot hold the native SiwA entitlement (#1089), so the Apple
door rides Apple's web flow in an ASWebAuthenticationSession: authorize
(Services ID com.elevenmessenger.signin, state "mac.<random hex>") →
home's /siwa/return trampoline → elevenmessenger://home-siwa, intercepted
in-process; the id_token from the fragment goes to the one verifier with
an empty nonce (the web flow carries no nonce binding). Non-ephemeral, so
shared Safari cookies give signed-in-to-Apple members a one-click confirm.

The email door mirrors iOS serially in the first-run surface: address →
Send code (~900ms floor) → 6-digit code → verify (~1.2s floor), Return
advances, back affordances, AX labels, the no-spam note.

Every door funnels through the one adopt() seam (Keychain write, the
published flags, the shared home_last_signin marker), so the last-used
door leads next time; a sealed anchor landing promotes the passkey door
to "unlock", and Settings presents the DEK-less state with the same
framing plus an unlock affordance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell db6a134352b1f9c92875c16cb19c0dc3bb26ce1b parent 3e8700f
1 file changed, +510 −43
  • ios/LChatMac/MacHomeSignIn.swift +510 −43
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift
index 2d86cc8..1e25183 100644
--- a/ios/LChatMac/MacHomeSignIn.swift
+++ b/ios/LChatMac/MacHomeSignIn.swift
@@ -8,7 +8,11 @@ import SwiftUI
// 🤖 "Sign in with Eleven" on the Mac — the Settings-pane counterpart of the
// iOS drawer flow (HomeSignInModel): a passkey to home.elevenmessenger.com
// restores the saved server list onto this Mac and starts the star/order sync
-// (MacAppState.syncHome, dormant until a session exists).
+// (MacAppState.syncHome, dormant until a session exists). The identity
+// anchors (Sign in with Apple, email codes — issues #1089/#1091/#1112) are
+// doors into the same model: they authenticate (a DEK-less HomeSession) but
+// can never decrypt, so the vault stays sealed until the passkey path
+// upgrades the session in place.
//
// ACTIVATION: the ceremony needs (1) macOS 15 (PRF — runtime-gated below, the
// target stays 14) and (2) the webcredentials associated domain, which needs a
@@ -22,17 +26,50 @@ import SwiftUI
final class MacHomeSignIn: ObservableObject {
enum Phase: Equatable {
case idle
- case working // the passkey sheet is up
+ case working // a sign-in sheet / verify round trip is in flight
case browser // handed to the default browser; awaiting the callback
case syncing // authenticated; fetching + restoring the backup
case restored(Int) // servers added to this Mac
case emptyBackup // signed in; account holds no servers yet
+ case sealed // anchor sign-in landed; the backup opens only under a passkey
case failed(String)
var busy: Bool { self == .working || self == .syncing }
+ /// A landed sign-in through any door — what folds the email door away.
+ var signedIn: Bool {
+ switch self {
+ case .restored, .emptyBackup, .sealed: return true
+ default: return false
+ }
+ }
+ }
+
+ /// Which door signed in — the #1112 last-used marker, stored under the
+ /// web's key name so the rule reads across platforms (iOS mirrors it the
+ /// same way): the last-used method leads the doors next time.
+ enum SignInMethod: String { case passkey, apple, email }
+ static let lastSignInKey = "home_last_signin"
+ static var lastSignIn: SignInMethod? {
+ UserDefaults.standard.string(forKey: lastSignInKey).flatMap(SignInMethod.init(rawValue:))
}
+
@Published private(set) var phase: Phase = .idle
@Published private(set) var sessionExists = HomeSessionStore.load() != nil
+ /// True when the stored session is an anchor's (Apple / email): it
+ /// authenticates every home call, but no passkey PRF was in play, so the
+ /// vault stays sealed — surfaces present "unlock" instead of "synced".
+ @Published private(set) var sessionSealed = HomeSessionStore.load().map { $0.dek == nil } ?? false
+
+ /// Session adoption — the ONE seam every door funnels a finished sign-in
+ /// through (passkey ceremony finish, browser handoff, Apple verify, email
+ /// verify): the same Keychain write, the published flags every surface
+ /// watches, and the last-used marker the first-run doors re-order by.
+ private func adopt(_ session: HomeSession, method: SignInMethod) {
+ HomeSessionStore.save(session)
+ sessionExists = true
+ sessionSealed = session.dek == nil
+ UserDefaults.standard.set(method.rawValue, forKey: Self.lastSignInKey)
+ }
func signIn(into app: MacAppState) async {
// The pre-macOS-15 path can't run the native ceremony at all — the
@@ -63,8 +100,7 @@ final class MacHomeSignIn: ObservableObject {
return
}
phase = .syncing // the sheet is down; the blob fetch + restore runs
- phase = try await restore(result, into: app)
- sessionExists = HomeSessionStore.load() != nil
+ phase = try await restore(result, into: app) // adopts the session on success
} catch is CryptoKitError {
// The ceremony RAN but the PRF-derived key didn't open the vault —
// in practice 1Password's native provider, whose PRF doesn't match
@@ -89,6 +125,7 @@ final class MacHomeSignIn: ObservableObject {
}
AppReset.wipeAll()
sessionExists = false
+ sessionSealed = false
phase = .idle
let cfg = NSWorkspace.OpenConfiguration()
cfg.createsNewApplicationInstance = true
@@ -101,7 +138,9 @@ final class MacHomeSignIn: ObservableObject {
/// hero, the browser handoff) may have signed in or out since this one was
/// created — Settings' copy went stale and hid its Sign Out button.
func refreshSession() {
- sessionExists = HomeSessionStore.load() != nil
+ let session = HomeSessionStore.load()
+ sessionExists = session != nil
+ sessionSealed = session.map { $0.dek == nil } ?? false
}
// ---- browser-assisted sign-in ------------------------------------------
@@ -172,8 +211,7 @@ final class MacHomeSignIn: ObservableObject {
return
}
let contents = try HomeVault.open(fetch.bytes, dek: dek)
- HomeSessionStore.save(HomeSession(token: h.token, credentialID: h.credId, dek: dek))
- sessionExists = true
+ adopt(HomeSession(token: h.token, credentialID: h.credId, dek: dek), method: .passkey)
let added = app.applyVault(contents.servers, removed: contents.removed)
app.syncHome()
phase = added > 0 ? .restored(added) : .emptyBackup
@@ -182,6 +220,158 @@ final class MacHomeSignIn: ObservableObject {
}
}
+ // ---- the Apple door (issues #1089/#1112; server: home/siwa.go) ---------
+ // Developer ID Mac apps cannot carry the native Sign in with Apple
+ // entitlement (issue #1089), so the Mac door rides Apple's WEB flow in an
+ // ASWebAuthenticationSession: authorize → home's /siwa/return trampoline
+ // form_post → elevenmessenger://home-siwa#state=…&id_token=…, which the
+ // session intercepts in-process (no Launch Services round trip — alts with
+ // suffixed schemes work the same). Shared Safari cookies (non-ephemeral)
+ // mean a signed-in-to-Apple member sees a one-click confirm sheet.
+
+ /// Kept alive for the flow's duration; ASWebAuthenticationSession is not
+ /// retained by the system while running.
+ private var webAuthSession: ASWebAuthenticationSession?
+ private let webAuthPresenter = WebAuthPresenter()
+
+ func signInWithApple() async {
+ // The state ties Apple's return leg to THIS attempt. Deliberately not
+ // "web."-prefixed — that routes the trampoline back to the home page;
+ // everything else bounces to the custom scheme. The web flow carries
+ // no nonce binding, so verify posts an empty nonce (server-optional).
+ let state = "mac." + HomeAnchorAuth.mintNonce()
+ var comps = URLComponents(string: "https://appleid.apple.com/auth/authorize")!
+ comps.queryItems = [
+ .init(name: "client_id", value: "com.elevenmessenger.signin"), // the Services ID (home.js uses the same)
+ .init(name: "redirect_uri",
+ value: HomeBase.url.appending(path: "/siwa/return").absoluteString),
+ .init(name: "response_type", value: "code id_token"),
+ .init(name: "response_mode", value: "form_post"),
+ .init(name: "state", value: state),
+ ]
+ phase = .working
+ defer { webAuthSession = nil }
+ do {
+ let callback = try await runWebAuth(url: comps.url!)
+ guard let idToken = Self.siwaCallbackToken(callback, state: state) else {
+ phase = .failed("Apple didn't return a usable sign-in — try again.")
+ return
+ }
+ let fin = try await HomeClient().siwaVerify(identityToken: idToken, nonce: "")
+ phase = await finishAnchor(fin, method: .apple)
+ } catch let e as ASWebAuthenticationSessionError where e.code == .canceledLogin {
+ phase = .idle // closing Apple's window is a choice, not an error
+ } catch {
+ phase = .failed(Self.anchorMessage(error))
+ }
+ }
+
+ private func runWebAuth(url: URL) async throws -> URL {
+ try await withCheckedThrowingContinuation { cont in
+ let session = ASWebAuthenticationSession(
+ url: url, callbackURLScheme: "elevenmessenger"
+ ) { url, err in
+ if let url {
+ cont.resume(returning: url)
+ } else {
+ cont.resume(throwing: err ?? ASWebAuthenticationSessionError(.canceledLogin))
+ }
+ }
+ session.presentationContextProvider = webAuthPresenter
+ // Share Safari's cookie jar: a member already signed in to Apple
+ // gets the one-tap confirm instead of a full password round.
+ session.prefersEphemeralWebBrowserSession = false
+ webAuthSession = session
+ if !session.start() {
+ cont.resume(throwing: ASWebAuthenticationSessionError(.presentationContextInvalid))
+ }
+ }
+ }
+
+ /// The trampoline's callback (elevenmessenger://home-siwa#state=…&
+ /// id_token=…): verify OUR state came back, hand out the identity token.
+ static func siwaCallbackToken(_ url: URL, state: String) -> String? {
+ guard url.host == "home-siwa" else { return nil }
+ // The fields ride the fragment (kept out of request logs); parse it
+ // with query semantics so each value is percent-decoded exactly once.
+ var frag = URLComponents()
+ frag.percentEncodedQuery = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .percentEncodedFragment
+ let items = frag.queryItems ?? []
+ guard items.first(where: { $0.name == "state" })?.value == state,
+ let token = items.first(where: { $0.name == "id_token" })?.value,
+ !token.isEmpty else { return nil }
+ return token
+ }
+
+ // ---- the email door (issue #1091; server: home/email.go) ---------------
+
+ /// Step one: send the code. True advances the door to the code step. The
+ /// spin floor mirrors the web's (~900ms): an instant send reads as a
+ /// glitch — and the server's 200 is the same known-or-not, so success
+ /// only ever means "sent".
+ func requestEmailCode(email: String) async -> Bool {
+ phase = .working
+ let floor = Task { try? await Task.sleep(for: .seconds(0.9)) }
+ do {
+ try await HomeClient().emailRequest(email: email)
+ await floor.value
+ phase = .idle
+ return true
+ } catch {
+ floor.cancel() // errors show immediately, like the web
+ phase = .failed(Self.anchorMessage(error))
+ return false
+ }
+ }
+
+ /// Step two: redeem the code and adopt the session — the same seam as
+ /// every other door. The floor is a touch longer than send's (the web's
+ /// 1.2s): a landing that swaps in instantly never registers.
+ func verifyEmailCode(email: String, code: String) async {
+ phase = .working
+ let floor = Task { try? await Task.sleep(for: .seconds(1.2)) }
+ do {
+ let fin = try await HomeClient().emailVerify(email: email, code: code)
+ await floor.value
+ phase = await finishAnchor(fin, method: .email)
+ } catch {
+ floor.cancel()
+ phase = .failed(Self.anchorMessage(error))
+ }
+ }
+
+ /// An anchor's landing: adopt the token (no DEK — an Apple/email sign-in
+ /// proves who you are but can never decrypt; the backup's key lives
+ /// behind a passkey's PRF), then report what the account holds: fresh or
+ /// backup-less → the empty pitch; an existing sealed backup → point at
+ /// the passkey door, which is what actually opens it.
+ private func finishAnchor(_ fin: HomeClient.AnchorFinish, method: SignInMethod) async -> Phase {
+ adopt(HomeSession(token: fin.token), method: method)
+ if fin.created == true { return .emptyBackup }
+ do {
+ return try await HomeClient().fetchBlob(token: fin.token) == nil ? .emptyBackup : .sealed
+ } catch {
+ // Signed in either way — the peek only picks the pitch.
+ return .failed("Signed in, but Eleven couldn't be reached to check your spaces — try again in a moment.")
+ }
+ }
+
+ /// Anchor errors surface the server's own member-facing copy ("that code
+ /// didn't work") when there is one; transport failures get the standing
+ /// network line.
+ private static func anchorMessage(_ error: Error) -> String {
+ if case HomeClientError.http(let code, let body) = error {
+ let text = body.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !text.isEmpty, text.count <= 120 { return text }
+ return "Eleven turned that down (\(code)). Try again in a moment."
+ }
+ if error is HomeClientError {
+ return "Couldn't reach Eleven. Check your connection and try again."
+ }
+ return error.localizedDescription
+ }
+
/// Vault-wins merge, the Mac shape: upsert SavedInstances (identity + star),
/// make sure each restored server has a rail entry (SavedChat), and only
/// then push — so the write-back can't wipe vault state (the iOS lesson).
@@ -208,9 +398,8 @@ final class MacHomeSignIn: ObservableObject {
ifMatch: "0") != nil else {
return .failed("Couldn't save your spaces — try again.")
}
- HomeSessionStore.save(HomeSession(token: result.token,
- credentialID: result.credentialID,
- dek: sealed.dek))
+ adopt(HomeSession(token: result.token, credentialID: result.credentialID,
+ dek: sealed.dek), method: .passkey)
return servers.isEmpty ? .emptyBackup : .restored(0)
}
guard let opened = try HomeVault.open(fetch.bytes,
@@ -218,15 +407,22 @@ final class MacHomeSignIn: ObservableObject {
prf: result.prf) else {
return .failed("That sign-in doesn't unlock these spaces.")
}
- HomeSessionStore.save(HomeSession(token: result.token,
- credentialID: result.credentialID,
- dek: opened.dek))
+ adopt(HomeSession(token: result.token, credentialID: result.credentialID,
+ dek: opened.dek), method: .passkey)
let added = app.applyVault(opened.contents.servers, removed: opened.contents.removed)
app.syncHome()
return added > 0 ? .restored(added) : .emptyBackup
}
}
+// The ASWebAuthenticationSession's window anchor — the key window, same
+// choice the passkey ceremony makes.
+private final class WebAuthPresenter: NSObject, ASWebAuthenticationPresentationContextProviding {
+ func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
+ NSApp.keyWindow ?? NSApp.windows.first ?? ASPresentationAnchor()
+ }
+}
+
// The sign-in state + Sign Out rows (MacSettingsView mounts them at the
// BOTTOM of the combined Profile section — Paul, 2026-07-15): one line of
// state and one action, matching the pane's quiet register. Rows, not a
@@ -255,8 +451,25 @@ struct HomeSignInSection: View {
Text("\(n) space\(n == 1 ? "" : "s") restored.")
.font(.footnote).foregroundStyle(.secondary)
}
- Text("Your space list syncs with Eleven — stars and order follow you between devices.")
- .font(.footnote).foregroundStyle(.secondary)
+ if model.sessionSealed {
+ // An anchor session (Apple / email): signed in, but the
+ // backup opens only under a passkey — #1132's .sealed
+ // framing, the Settings register.
+ LabeledContent("Spaces locked") {
+ if model.phase.busy {
+ ProgressView().controlSize(.small)
+ } else {
+ Button("Unlock with Your Passkey…") {
+ Task { await model.signIn(into: app) }
+ }
+ }
+ }
+ Text("You're signed in with Apple or email — your saved spaces stay locked until you unlock them with your passkey.")
+ .font(.footnote).foregroundStyle(.secondary)
+ } else {
+ Text("Your space list syncs with Eleven — stars and order follow you between devices.")
+ .font(.footnote).foregroundStyle(.secondary)
+ }
} else {
LabeledContent("Not signed in") {
if model.phase.busy {
@@ -287,14 +500,27 @@ struct HomeSignInSection: View {
}
}
-// The Mac's first-run hero — iOS's SignInFirstRun in the Mac idiom, shown by
-// RootView while the rail is empty. Sign in restores the backup and the rail
-// fills in place (applyVault selects the first space); the invite-link path
-// opens the same Add sheet as the + button. The ceremony is macOS 15-only
-// (PRF), so below that the screen offers just the invite-link path.
+// The Mac's first-run doors — iOS's SignInFirstRun in the Mac idiom, shown by
+// RootView while the rail is empty. Three ways in, all against the same home
+// server: Continue with Apple (the lead), an emailed 6-digit code, and the
+// existing passkey path (native ceremony with automatic browser failover)
+// demoted to a quiet text button — with the last-used door promoted (#1112).
+// Sign in restores the backup and the rail fills in place; an anchor sign-in
+// against a sealed backup lands signed in with the passkey door promoted to
+// "unlock" (the identity-vs-key split, exactly as on iOS/web).
struct MacFirstRun: View {
@EnvironmentObject var app: MacAppState
@ObservedObject var model: MacHomeSignIn
+ @Environment(\.colorScheme) private var scheme
+
+ private enum EmailStep { case address, code }
+ @State private var emailStep: EmailStep?
+ @State private var email = ""
+ @State private var code = ""
+ // Which door the last click opened — only so the promoted passkey button
+ // doesn't spin for an Apple verify that happens to share phase == .working.
+ @State private var tapped: MacHomeSignIn.SignInMethod?
+ @FocusState private var focus: EmailStep?
var body: some View {
VStack(spacing: 10) {
@@ -312,7 +538,7 @@ struct MacFirstRun: View {
// Status folds in under the pitch, occupying layout space (the
// inline-feedback rule — nothing floats).
statusLine
- .frame(minHeight: 22)
+ .frame(maxWidth: 380, minHeight: 22)
.padding(.top, 10)
Spacer()
@@ -323,44 +549,252 @@ struct MacFirstRun: View {
Text("Syncing your spaces…").foregroundStyle(.secondary)
}
.padding(.bottom, 56)
+ } else if let step = emailStep {
+ emailDoor(step)
} else {
- VStack(spacing: 10) {
- // 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")
+ doors
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ // A landed sign-in (any door) folds the email door away; the doors
+ // return beneath the status — sealed promotes the passkey to unlock.
+ .onChange(of: model.phase) { _, p in
+ if p.signedIn {
+ emailStep = nil
+ email = ""
+ code = ""
+ }
+ }
+ }
+
+ // ---- the doors ---------------------------------------------------------
+
+ /// Apple, email, passkey — the last-used method promoted to lead. After
+ /// a sealed anchor sign-in the passkey leads regardless: it is the one
+ /// door that actually opens the backup.
+ private var doorOrder: [MacHomeSignIn.SignInMethod] {
+ var order: [MacHomeSignIn.SignInMethod] = [.apple, .email, .passkey]
+ let lead: MacHomeSignIn.SignInMethod? =
+ model.phase == .sealed ? .passkey : MacHomeSignIn.lastSignIn
+ if let lead, let i = order.firstIndex(of: lead) {
+ order.remove(at: i)
+ order.insert(lead, at: 0)
+ }
+ return order
+ }
+
+ private var doors: some View {
+ VStack(spacing: 10) {
+ ForEach(doorOrder, id: \.self) { method in
+ door(method, leading: method == doorOrder.first)
+ }
+
+ // 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(.top, 2)
+ }
+ .frame(width: 280)
+ .padding(.bottom, 48)
+ }
+
+ @ViewBuilder private func door(_ method: MacHomeSignIn.SignInMethod, leading: Bool) -> some View {
+ switch method {
+ case .apple:
+ // Apple's branded control (HIG, glyph) over OUR web-session
+ // flow — see MacHomeSignIn.signInWithApple for why not the native
+ // sheet. Recreated on scheme flips (.id) — the style is init-only.
+ AppleDoorButton(style: scheme == .dark ? .white : .black) {
+ tapped = .apple
+ Task { await model.signInWithApple() }
+ }
+ .id(scheme)
+ .frame(height: 32)
+ .disabled(model.phase.busy)
+ case .email:
+ Button {
+ tapped = .email
+ emailStep = .address
+ } label: {
+ Text("Continue with email")
+ .fontWeight(.medium)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.large)
+ .disabled(model.phase.busy)
+ case .passkey:
+ if leading {
+ // Promoted: the hero form (last-used passkey, or a sealed
+ // anchor sign-in pointing at the door that opens the backup).
+ Button {
+ tapped = .passkey
+ Task { await model.signIn(into: app) }
+ } label: {
+ HStack(spacing: 8) {
+ if model.phase == .working && tapped == .passkey {
+ ProgressView().controlSize(.small)
+ }
+ Text(passkeyLeadLabel)
.fontWeight(.semibold)
- .frame(width: 200)
}
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
- .disabled(model.phase.busy)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .disabled(model.phase.busy)
+ } else {
+ // Demoted: the quiet text path under the anchor doors. The
+ // model still routes it — native ceremony where it can run,
+ // automatic browser failover everywhere else (no entitlement,
+ // 1Password's PRF mismatch, pre-15).
+ Button("Sign in with a passkey") {
+ tapped = .passkey
+ Task { await model.signIn(into: app) }
+ }
+ .buttonStyle(.plain)
+ .font(.subheadline.weight(.medium))
+ .foregroundStyle(.secondary)
+ .padding(.vertical, 2)
+ .disabled(model.phase.busy)
+ }
+ }
+ }
+
+ private var passkeyLeadLabel: String {
+ if model.phase == .working && tapped == .passkey { return "Signing in…" }
+ if model.phase == .sealed { return "Unlock your spaces with your passkey" }
+ return "Sign in with a passkey"
+ }
- // 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)
+ // ---- the email door ----------------------------------------------------
+
+ @ViewBuilder private func emailDoor(_ step: EmailStep) -> some View {
+ VStack(spacing: 12) {
+ HStack {
+ // Serial steps: code backs to the address, address backs to
+ // the doors — a typo'd address is one click away either way.
+ Button {
+ if step == .code {
+ emailStep = .address
+ code = ""
+ focus = .address
+ } else {
+ emailStep = nil
+ }
+ } label: {
+ Label("Back", systemImage: "chevron.backward")
+ .font(.subheadline)
}
- .padding(.bottom, 48)
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ .accessibilityLabel(step == .code ? "Back to your email address" : "Back to sign-in")
+ Spacer()
+ }
+ Text("Sign in with email")
+ .font(.headline)
+
+ if step == .address {
+ TextField("you@example.com", text: $email)
+ .textFieldStyle(.roundedBorder)
+ .textContentType(.emailAddress)
+ .autocorrectionDisabled()
+ .submitLabel(.send)
+ .focused($focus, equals: .address)
+ .onSubmit { if emailValid { sendCode() } } // Return advances
+ .accessibilityLabel("Your email address")
+ Button(action: sendCode) {
+ HStack(spacing: 8) {
+ if model.phase == .working { ProgressView().controlSize(.small) }
+ Text("Send code").fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .disabled(model.phase.busy || !emailValid)
+ // The web's note, word for word — under the address field.
+ Text("We never keep your email address. No spam, ever.")
+ .font(.footnote)
+ .foregroundStyle(.tertiary)
+ .multilineTextAlignment(.center)
+ } else {
+ Text("Check your email for a 6-digit code.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ TextField("6-digit code", text: $code)
+ .textFieldStyle(.roundedBorder)
+ .textContentType(.oneTimeCode)
+ .multilineTextAlignment(.center)
+ .font(.title3.monospacedDigit())
+ .focused($focus, equals: .code)
+ .onSubmit { if code.count == 6 { verifyCode() } } // Return advances
+ .onChange(of: code) { _, v in
+ // Digits only, six of them — mirrors the web's input.
+ let digits = String(v.filter(\.isNumber).prefix(6))
+ if digits != v { code = digits }
+ }
+ .accessibilityLabel("The 6-digit code from your email")
+ Button(action: verifyCode) {
+ HStack(spacing: 8) {
+ if model.phase == .working { ProgressView().controlSize(.small) }
+ Text("Sign in").fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .disabled(model.phase.busy || code.count != 6)
}
}
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .frame(width: 280)
+ .padding(.bottom, 48)
+ .onAppear { focus = step }
+ }
+
+ // The server's own bar (email.go): non-empty, has an @, fits an address.
+ private var emailValid: Bool {
+ let addr = email.trimmingCharacters(in: .whitespacesAndNewlines)
+ return !addr.isEmpty && addr.contains("@") && addr.count <= 254
+ }
+
+ private func sendCode() {
+ let addr = email.trimmingCharacters(in: .whitespacesAndNewlines)
+ Task {
+ if await model.requestEmailCode(email: addr) {
+ emailStep = .code
+ focus = .code
+ }
+ }
+ }
+
+ private func verifyCode() {
+ let addr = email.trimmingCharacters(in: .whitespacesAndNewlines)
+ Task { await model.verifyEmailCode(email: addr, code: code) }
}
+ // ---- status ------------------------------------------------------------
+
@ViewBuilder private var statusLine: some View {
switch model.phase {
case .failed(let msg):
Label(msg, systemImage: "exclamationmark.triangle")
.font(.footnote).foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
case .emptyBackup:
Text("Signed in — nothing saved on this account yet. Join a space in your browser first.")
.font(.callout).foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ case .sealed:
+ // The identity-vs-key split, said plainly: the anchor signed you
+ // in; the passkey (promoted below) is what opens your spaces.
+ Text("Signed in — your spaces are locked with your passkey. Unlock them below.")
+ .font(.callout).foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
case .restored(let n):
// Visible only in the beat before the rail fills and RootView
// swaps this screen out.
@@ -369,8 +803,41 @@ struct MacFirstRun: View {
case .browser:
Text("Finishing sign-in in your browser…")
.font(.callout).foregroundStyle(.secondary)
- case .idle, .working, .syncing:
+ case .working:
+ Text("Confirming it’s you…")
+ .font(.callout).foregroundStyle(.secondary)
+ case .idle, .syncing:
EmptyView()
}
}
}
+
+// Apple's own branded control (AppKit's ASAuthorizationAppleIDButton — the
+// HIG face, glyph and all) wired to OUR action: the SwiftUI
+// SignInWithAppleButton drives the native request, which needs an entitlement
+// Developer ID apps can't hold, so the face and the web-session behavior are
+// joined here instead.
+private struct AppleDoorButton: NSViewRepresentable {
+ let style: ASAuthorizationAppleIDButton.Style
+ let action: () -> Void
+
+ func makeCoordinator() -> Coordinator { Coordinator(action: action) }
+
+ func makeNSView(context: Context) -> ASAuthorizationAppleIDButton {
+ let button = ASAuthorizationAppleIDButton(authorizationButtonType: .continue,
+ authorizationButtonStyle: style)
+ button.target = context.coordinator
+ button.action = #selector(Coordinator.tap)
+ return button
+ }
+
+ func updateNSView(_ button: ASAuthorizationAppleIDButton, context: Context) {
+ button.isEnabled = context.environment.isEnabled
+ }
+
+ final class Coordinator: NSObject {
+ let action: () -> Void
+ init(action: @escaping () -> Void) { self.action = action }
+ @objc func tap() { action() }
+ }
+}