eleven / messenger Public

mac: first-run Sign in hero, syncing state, dev reset, last-space launch (#427)

* mac: first-run Sign in hero, syncing state, dev reset, last-space launch

Parity with the iOS sign-in experience just merged (#412):
- Empty rail now shows the Welcome hero (mark, 'Group chat made by
  dreams', Sign in, the home privacy pitch, invite link secondary)
  instead of 'No chat selected'. Restore fills the rail in place and
  applyVault's selection carries you straight in.
- MacHomeSignIn gains the .syncing phase ('Syncing your spaces…') in
  both the hero and the Settings section.
- Settings gains a Debug-only 'Reset app…' — DevReset is now
  platform-aware: the Mac is NOT sandboxed, so it removes only paths
  that are provably ours (Application Support/lchat, the app's WebKit
  store, its caches) instead of the container-wide iOS wipe.
- Launch opens the space last open on this Mac (recent[0]) rather than
  always the top of the rail.

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

* ios+mac: dev reset no longer 'crashes' — iOS folds to first-run in place, Mac relaunches

exit(0)/terminate right after the wipe read as a crash on both
platforms. iOS: the drawer's reset now hands RootView a closure that
wipes, drops the in-memory mirrors (AppState.devReset, session refresh,
nav to nil) and closes the drawer — the running app lands on the
first-run hero. Mac: openApplication(createsNewApplicationInstance)
then terminate — a visible fresh relaunch instead of a vanish.

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

* ios+mac: first-run copy — 'The group chat of dreams', tightened privacy pitch

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

* ios+mac: restore 'We can't spam what we don't have' in the first-run pitch

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

* mac+ios: plain webcredentials association on Mac Debug; friendly wrong-passkey error

macOS's ?mode=developer path never engaged (swcd showed no record even
with developer-mode on, lsregister forced, and its cache reset) — the
plain association works: Apple's CDN serves home's AASA and the app now
runs from /Applications. CryptoKitError from restore now maps to a
message that names the actual fix (pick the Apple Passwords passkey,
not a third-party provider whose PRF doesn't match).

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

* mac,home: browser-assisted sign-in (1Password PRF workaround) + plain-words UI copy

1Password's native macOS provider returns a PRF that doesn't match its
own browser extension's (the context every backup is wrapped in), so
the working ceremony runs in the BROWSER: the app generates an
ephemeral P-256 keypair + nonce and opens /restore with {app:1, nonce,
ephPub}; the page unlocks the vault and hands the session (link token +
blob key) back over elevenmessenger://home-session, ECIES-wrapped to
the ephemeral key — nothing plaintext in any URL, the return scheme
hardcoded, the private key never leaving the app process. Works on
macOS 14 too (no local PRF needed).

Copy sweep per Paul: no 'passkey'/'backup' anywhere in UI — people are
just signing in. Technical terms stay in code comments.

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

* mac: one root window — Window scene instead of WindowGroup

URL callbacks (the browser sign-in handoff) and reopen events were
spawning a second copy of the whole UI. Feature windows can be their
own scenes later.

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

* home: no-cache on every page — browsers kept pre-deploy HTML

/restore (and / and /enroll) had no Cache-Control at all, so browsers
cached them heuristically and showed stale pages after a deploy —
exactly the old restore page rejecting the app handoff. Static already
went through the noCache wrapper; the pages now match.

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 45ca27c50af93dc29d19fed1fcb106f9496c2123 parent 5c52916
15 files changed, +412 −45
  • home/main.go +3 −0
  • home/web/pages/restore.html +2 −2
  • home/web/static/restore.js +40 −12
  • ios/LChat/LChatApp.swift +22 −0
  • ios/LChat/Views/InstanceSidebar.swift +6 −7
  • ios/LChat/Views/SignInWithEleven.swift +22 −9
  • ios/LChatCore/Sources/LChatCore/Crypto.swift +20 −0
  • ios/LChatCore/Sources/LChatCore/DevReset.swift +21 −0
  • ios/LChatMac/Assets.xcassets/ElevenMark.imageset/Contents.json +12 −0
  • ios/LChatMac/Assets.xcassets/ElevenMark.imageset/ElevenMark.png binary
  • ios/LChatMac/LChatMac-Dev.entitlements +8 −7
  • ios/LChatMac/LChatMacApp.swift +16 −2
  • ios/LChatMac/MacHomeSignIn.swift +205 −5
  • ios/LChatMac/MacSettingsView.swift +26 −0
  • ios/LChatMac/RootView.swift +9 −1
diff --git a/home/main.go b/home/main.go
index 0bcc3ce..627ee94 100644
--- a/home/main.go
+++ b/home/main.go
@@ -225,6 +225,7 @@ func (s *Server) servePage(w http.ResponseWriter, r *http.Request) {
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; connect-src 'self'; "+
"object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
+ w.Header().Set("Cache-Control", "no-cache") // pages must never outlive a deploy
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(s.page)
}
@@ -249,6 +250,7 @@ func (s *Server) serveEnroll(w http.ResponseWriter, r *http.Request) {
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; connect-src 'self'; "+
"object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
+ w.Header().Set("Cache-Control", "no-cache") // pages must never outlive a deploy
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(s.enrollPage)
}
@@ -259,6 +261,7 @@ func (s *Server) serveRestore(w http.ResponseWriter, r *http.Request) {
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; connect-src 'self'; "+
"object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
+ w.Header().Set("Cache-Control", "no-cache") // pages must never outlive a deploy
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(s.restorePage)
}
diff --git a/home/web/pages/restore.html b/home/web/pages/restore.html
index b9fb52d..96d4481 100644
--- a/home/web/pages/restore.html
+++ b/home/web/pages/restore.html
@@ -26,8 +26,8 @@
</div>
<h1>Signing in…</h1>
- <p class="sub">Use your passkey — your face or fingerprint. Not even this
- server can see inside your backup.</p>
+ <p class="sub">Your face or fingerprint is the key. Not even this
+ server can see inside your spaces.</p>
<!-- The user already chose "Sign in" on their chat's page, so the ceremony
starts on load — this page is a loading screen, not a second decision.
diff --git a/home/web/static/restore.js b/home/web/static/restore.js
index 02fe954..38f288a 100644
--- a/home/web/static/restore.js
+++ b/home/web/static/restore.js
@@ -12,7 +12,7 @@
// sends us a ret we don't have in the backup gets nothing back.
import {
- b64urlToBytes, bytesToB64url, kekFromPRF, unwrapDEK, unpackBlob, openBlob, wrapToPublicKey,
+ b64urlToBytes, bytesToB64url, kekFromPRF, unwrapDEK, exportDEK, unpackBlob, openBlob, wrapToPublicKey,
} from "/static/crypto.js";
import { beginLogin, runLogin, fetchBlob } from "/static/backup-core.js";
@@ -28,6 +28,11 @@ function readPayload() {
if (!raw) return null;
try {
const p = JSON.parse(new TextDecoder().decode(b64urlToBytes(raw)));
+ // Native-app handoff: the app generated {nonce, ephPub} and opened this
+ // page because its own passkey provider can't do PRF (e.g. 1Password's
+ // native macOS provider). No ret URL — the return target is the FIXED
+ // custom scheme in restore(), never caller-supplied.
+ if (p && p.app === 1 && typeof p.nonce === "string" && typeof p.ephPub === "string") return p;
if (!p || typeof p.ret !== "string" || typeof p.nonce !== "string" || typeof p.ephPub !== "string") return null;
new URL(p.ret); // must parse — it's the redirect target
return p;
@@ -40,7 +45,7 @@ const payload = readPayload();
// Drop the fragment from the address bar as soon as we've read it.
try { history.replaceState(null, "", location.pathname); } catch {}
-const retOrigin = payload ? new URL(payload.ret).origin : null;
+const retOrigin = payload && payload.ret ? new URL(payload.ret).origin : null;
// Ceremony options are pre-fetched (Safari activation — see the enrol page).
let loginBegin = null;
@@ -50,16 +55,16 @@ function prefetch() {
async function restore() {
$("restore").disabled = true;
- setStatus("Waiting for your passkey…", "busy");
+ setStatus("Confirming it’s you…", "busy");
const { token, credId, prf } = await runLogin(loginBegin);
if (!prf) {
- setStatus("Signed in, but this browser didn't return a PRF result, so the backup can't be unlocked here.", "error");
+ setStatus("Signed in, but this browser can't unlock your spaces here. Try another browser.", "error");
$("restore").disabled = false;
return;
}
const got = await fetchBlob(token);
if (!got) {
- setStatus("No backup found for this passkey.", "error");
+ setStatus("Nothing saved for this sign-in yet.", "error");
$("restore").disabled = false;
return;
}
@@ -67,11 +72,29 @@ async function restore() {
const { keys } = unpackBlob(got.bytes);
const wrapped = keys[credId];
if (!wrapped) {
- setStatus("This passkey isn't one of the backup's keys.", "error");
+ setStatus("That sign-in can't unlock these spaces.", "error");
$("restore").disabled = false;
return;
}
const dek = await unwrapDEK(wrapped, kek);
+
+ // App handoff: hand the SESSION (link token + blob key) to the native app,
+ // ECIES-wrapped to the ephemeral public key it generated — nothing is ever
+ // plaintext in the return URL, and only the initiating process holds the
+ // matching private key. The custom scheme is hardcoded, so a crafted page
+ // payload can never redirect the session anywhere else.
+ if (payload.app === 1) {
+ const dekB64 = await exportDEK(dek);
+ const session = JSON.stringify({ v: 1, token, credId, dek: dekB64 });
+ const sealed = await wrapToPublicKey(bytesToB64url(new TextEncoder().encode(session)), payload.ephPub);
+ const back = JSON.stringify({ nonce: payload.nonce, wrapped: sealed });
+ setStatus("Unlocked. Handing over to the Eleven app…", "ok");
+ $("actions").classList.add("hidden");
+ $("badge").classList.add("sealed");
+ setTimeout(() => location.replace("elevenmessenger://home-session#" +
+ bytesToB64url(new TextEncoder().encode(back))), 600);
+ return;
+ }
const { obj } = await openBlob(got.bytes, dek);
// Only the entry whose origin matches the requester (ret) is ever returned.
@@ -80,7 +103,7 @@ async function restore() {
try { return new URL(s.url).origin === retOrigin; } catch { return false; }
});
if (!entry || !entry.credentials) {
- setStatus(`Your backup doesn't include ${host}. Open the chat from an invite instead.`, "error");
+ setStatus(`Your Eleven account doesn't include ${host}. Open the chat from an invite instead.`, "error");
$("restore").disabled = false;
return;
}
@@ -101,7 +124,7 @@ async function restore() {
const onError = (err) => {
if (err && err.name === "NotAllowedError") {
- setStatus("Passkey prompt was cancelled or blocked. If you didn't cancel, try again.", "error");
+ setStatus("Sign-in was cancelled or blocked. If you didn't cancel, try again.", "error");
} else {
setStatus((err && err.message) || String(err), "error");
}
@@ -114,17 +137,22 @@ const onError = (err) => {
// ---- boot ---------------------------------------------------------------------
+// The tile names what's being signed in: the requesting chat's host, or the
+// native app for an app handoff (which carries no ret URL — reading .ret
+// unguarded here threw before the ceremony could start).
+const entryName = payload && payload.ret ? new URL(payload.ret).host : "the Eleven app";
+
if (!payload) {
- setStatus("This page was opened without a valid request. Go back to your chat and choose “Restore with a passkey”.", "error");
+ setStatus("This page was opened without a valid request. Go back to your chat and tap Sign in.", "error");
$("actions").classList.add("hidden");
$("entry").classList.add("hidden"); // no entry to show — a placeholder tile would mislead
} else if (!window.PublicKeyCredential) {
// The payload is valid, so the tile can still name the chat under the error.
- $("entry-host").textContent = new URL(payload.ret).host;
- setStatus("This browser doesn't support passkeys.", "error");
+ $("entry-host").textContent = entryName;
+ setStatus("This browser can't sign you in — try another browser.", "error");
$("actions").classList.add("hidden");
} else {
- $("entry-host").textContent = new URL(payload.ret).host;
+ $("entry-host").textContent = entryName;
$("restore").addEventListener("click", () => restore().catch(onError));
// The user already clicked "Sign in" on the instance — run the ceremony
// immediately; this page is a loading screen. If the browser refuses a
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index 579646e..c7c968a 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -35,6 +35,16 @@ final class AppState: ObservableObject {
init() { instances = InstancesStore.load() }
+ #if DEBUG
+ /// Dev reset (the drawer's "Reset app"): DevReset has already wiped the
+ /// stores — drop the in-memory mirror so the UI folds back to first-run
+ /// without relaunching (nothing here re-persists).
+ func devReset() {
+ instances = []
+ serverAvatars = [:]
+ }
+ #endif
+
/// Fetch each server's photo for the switcher. /api/group is public and reports
/// whether a photo is set, so this works with no key and only fetches the bytes
/// when one exists (and only when we don't already have it). Cheap + idempotent —
@@ -367,6 +377,18 @@ struct RootView: View {
onAdd: {
showServers = false
showAdd = true
+ },
+ onReset: {
+ // Dev reset: wipe the stores, then fold the RUNNING app
+ // back to first-run in place — an exit(0) here read as a
+ // crash. Order matters: wipe first, then drop the
+ // in-memory mirrors so nothing re-persists.
+ DevReset.wipeAll()
+ app.devReset()
+ signIn.refreshSession()
+ path = []
+ nav.selectInstance(nil)
+ showServers = false
}
)
.offset(x: (progress - 1) * drawerWidth)
diff --git a/ios/LChat/Views/InstanceSidebar.swift b/ios/LChat/Views/InstanceSidebar.swift
index 8352d5c..4c9f417 100644
--- a/ios/LChat/Views/InstanceSidebar.swift
+++ b/ios/LChat/Views/InstanceSidebar.swift
@@ -13,6 +13,8 @@ struct ServerDrawer: View {
@ObservedObject var signIn: HomeSignInModel
var onSelect: (SavedInstance) -> Void
var onAdd: () -> Void
+ // The dev reset, owned by RootView (it must also fold nav/session state).
+ var onReset: () -> Void = {}
// "More" starts expanded if the active space is one of the collapsed ones,
// so you never lose sight of where you are.
@State private var showMore = false
@@ -117,7 +119,7 @@ struct ServerDrawer: View {
Button {
Task { await signIn.backUp(into: app) }
} label: {
- Text("New to Eleven? Back up this device")
+ Text("New to Eleven? Save your spaces")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -155,10 +157,7 @@ struct ServerDrawer: View {
.buttonStyle(.plain)
.confirmationDialog("Erase every server, key, and message from this device?",
isPresented: $confirmReset, titleVisibility: .visible) {
- Button("Erase Everything", role: .destructive) {
- DevReset.wipeAll()
- exit(0)
- }
+ Button("Erase Everything", role: .destructive, action: onReset)
}
#endif
}
@@ -173,8 +172,8 @@ struct ServerDrawer: View {
private var statusLine: String? {
switch signIn.phase {
case .failed(let msg): return msg
- case .emptyBackup: return "Signed in — no spaces backed up yet."
- case .backedUp(let n): return "Backed up \(n) space\(n == 1 ? "" : "s") to Eleven."
+ case .emptyBackup: return "Signed in — no spaces saved yet."
+ case .backedUp(let n): return "Saved \(n) space\(n == 1 ? "" : "s") to Eleven."
case .restored(let n): return "\(n) space\(n == 1 ? "" : "s") restored."
case .syncing: return "Syncing your spaces…"
case .idle, .working: return nil
diff --git a/ios/LChat/Views/SignInWithEleven.swift b/ios/LChat/Views/SignInWithEleven.swift
index 433b1a1..188bd05 100644
--- a/ios/LChat/Views/SignInWithEleven.swift
+++ b/ios/LChat/Views/SignInWithEleven.swift
@@ -1,4 +1,5 @@
import AuthenticationServices
+import CryptoKit
import LChatCore
import SwiftUI
import UIKit
@@ -44,7 +45,7 @@ final class HomeSignInModel: ObservableObject {
/// backup holds onto this device (adding them to `app`).
func signIn(into app: AppState) async {
guard let anchor = Self.keyWindow() else {
- phase = .failed("Couldn't find a window to present the passkey sheet.")
+ phase = .failed("Couldn't find a window to sign in from.")
return
}
guard #available(iOS 18.0, *) else {
@@ -64,11 +65,23 @@ final class HomeSignInModel: ObservableObject {
// debugging round on the very first device run.
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 (Face ID).")
} catch {
phase = .failed((error as? HomeClientError).map(Self.message) ?? error.localizedDescription)
}
}
+ /// Re-read the Keychain session (the dev reset wipes it out from under the
+ /// model) and drop any terminal status back to the idle pitch.
+ func refreshSession() {
+ sessionExists = HomeSessionStore.load() != nil
+ phase = .idle
+ }
+
/// Forget the home session — the device keeps its servers, it just stops
/// syncing them until the next sign-in. (The passkey itself is untouched.)
func signOut() {
@@ -91,7 +104,7 @@ final class HomeSignInModel: ObservableObject {
return
}
guard let anchor = Self.keyWindow() else {
- phase = .failed("Couldn't find a window to present the passkey sheet.")
+ phase = .failed("Couldn't find a window to sign in from.")
return
}
guard #available(iOS 18.0, *) else {
@@ -107,7 +120,7 @@ final class HomeSignInModel: ObservableObject {
// If-Match "0" claims "no blob yet" — a fresh account always has none.
guard try await HomeClient().putBlob(token: result.token, bytes: sealed.bytes,
ifMatch: "0") != nil else {
- phase = .failed("Couldn't store the backup — try again.")
+ phase = .failed("Couldn't save your spaces — try again.")
return
}
HomeSessionStore.save(HomeSession(token: result.token,
@@ -142,7 +155,7 @@ final class HomeSignInModel: ObservableObject {
prf: result.prf)
guard try await HomeClient().putBlob(token: result.token, bytes: sealed.bytes,
ifMatch: "0") != nil else {
- return .failed("Couldn't store a backup — try again.")
+ return .failed("Couldn't save your spaces — try again.")
}
HomeSessionStore.save(HomeSession(token: result.token,
credentialID: result.credentialID,
@@ -152,7 +165,7 @@ final class HomeSignInModel: ObservableObject {
guard let opened = try HomeVault.open(fetch.bytes,
credentialID: result.credentialID,
prf: result.prf) else {
- return .failed("This passkey isn't part of your Eleven backup.")
+ return .failed("That sign-in doesn't unlock these spaces.")
}
// Keep the session (link token + DEK, Keychain) so stars/reorders sync
// to home from now on without another Face ID. The PRF output itself is
@@ -236,7 +249,7 @@ struct SignInFirstRun: View {
.accessibilityHidden(true)
Text("Welcome to Eleven")
.font(.title2.weight(.semibold))
- Text("Group chat made by dreams")
+ Text("The group chat of dreams")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -277,7 +290,7 @@ struct SignInFirstRun: View {
.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.")
+ 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)
@@ -300,10 +313,10 @@ struct SignInFirstRun: View {
Label("\(n) space\(n == 1 ? "" : "s") restored.", systemImage: "checkmark.circle.fill")
.font(.callout).foregroundStyle(.green)
case .emptyBackup:
- Text("Signed in — no spaces backed up yet. Add one with an invite link.")
+ Text("Signed in — no spaces saved yet. Add one with an invite link.")
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
case .backedUp(let n):
- Label("Backed up \(n) space\(n == 1 ? "" : "s") to Eleven.", systemImage: "checkmark.circle.fill")
+ Label("Saved \(n) space\(n == 1 ? "" : "s") to Eleven.", systemImage: "checkmark.circle.fill")
.font(.callout).foregroundStyle(.green)
case .failed(let msg):
Label(msg, systemImage: "exclamationmark.triangle")
diff --git a/ios/LChatCore/Sources/LChatCore/Crypto.swift b/ios/LChatCore/Sources/LChatCore/Crypto.swift
index 0a27c3f..a296b3e 100644
--- a/ios/LChatCore/Sources/LChatCore/Crypto.swift
+++ b/ios/LChatCore/Sources/LChatCore/Crypto.swift
@@ -68,6 +68,26 @@ public enum ECIES {
}
}
+ /// Arbitrary-length variant of `unwrap`, taking a live private key: the
+ /// browser sign-in handoff wraps a JSON session (home's `wrapToPublicKey`,
+ /// same layout + info string), not a 32-byte thread key.
+ public static func unwrapBytes(wrappedB64: String, priv: P256.KeyAgreement.PrivateKey) throws -> Data {
+ guard let blob = Data(base64urlEncoded: wrappedB64), blob.count > 65 + 12 else {
+ throw CryptoError.badWrap
+ }
+ let ephPub = blob.prefix(65)
+ let sealed = blob.suffix(from: blob.startIndex + 65) // iv || ciphertext || tag
+ do {
+ let eph = try P256.KeyAgreement.PublicKey(x963Representation: ephPub)
+ let shared = try priv.sharedSecretFromKeyAgreement(with: eph)
+ let aes = shared.hkdfDerivedSymmetricKey(
+ using: SHA256.self, salt: Data(), sharedInfo: info, outputByteCount: 32)
+ return try AES.GCM.open(AES.GCM.SealedBox(combined: sealed), using: aes)
+ } catch {
+ throw CryptoError.badWrap
+ }
+ }
+
/// Unwraps a wrapped key blob (base64url) with a user private key (base64url
/// PKCS#8), returning the 32-byte raw AES key.
public static func unwrap(wrappedB64: String, privPkcs8B64: String) throws -> Data {
diff --git a/ios/LChatCore/Sources/LChatCore/DevReset.swift b/ios/LChatCore/Sources/LChatCore/DevReset.swift
index aa3ca42..5c0c99f 100644
--- a/ios/LChatCore/Sources/LChatCore/DevReset.swift
+++ b/ios/LChatCore/Sources/LChatCore/DevReset.swift
@@ -30,6 +30,26 @@ public enum DevReset {
UserDefaults.standard.removePersistentDomain(forName: bundleID)
}
let fm = FileManager.default
+ #if os(macOS)
+ // NOT sandboxed on the Mac: the search paths are the user's REAL
+ // Library folders, so only paths that are provably OURS may go — the
+ // app's data dir (MessageStore/MacChatsStore both live in
+ // "Application Support/lchat"), its WebKit store (the webviews'
+ // localStorage identities), and its caches. Never the whole folder.
+ for base in fm.urls(for: .applicationSupportDirectory, in: .userDomainMask) {
+ try? fm.removeItem(at: base.appendingPathComponent("lchat"))
+ }
+ if let bundleID = Bundle.main.bundleIdentifier {
+ for base in fm.urls(for: .cachesDirectory, in: .userDomainMask) {
+ try? fm.removeItem(at: base.appendingPathComponent(bundleID))
+ }
+ for base in fm.urls(for: .libraryDirectory, in: .userDomainMask) {
+ try? fm.removeItem(at: base.appendingPathComponent("WebKit/\(bundleID)"))
+ try? fm.removeItem(at: base.appendingPathComponent("HTTPStorages/\(bundleID)"))
+ }
+ }
+ #else
+ // Sandboxed: the search paths are the app's own container — wipe wholesale.
for dir: FileManager.SearchPathDirectory in [.applicationSupportDirectory,
.cachesDirectory, .documentDirectory] {
for url in fm.urls(for: dir, in: .userDomainMask) {
@@ -40,5 +60,6 @@ public enum DevReset {
}
}
try? fm.removeItem(at: fm.temporaryDirectory)
+ #endif
}
}
diff --git a/ios/LChatMac/Assets.xcassets/ElevenMark.imageset/Contents.json b/ios/LChatMac/Assets.xcassets/ElevenMark.imageset/Contents.json
new file mode 100644
index 0000000..a4c616b
--- /dev/null
+++ b/ios/LChatMac/Assets.xcassets/ElevenMark.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "ElevenMark.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/LChatMac/Assets.xcassets/ElevenMark.imageset/ElevenMark.png b/ios/LChatMac/Assets.xcassets/ElevenMark.imageset/ElevenMark.png
new file mode 100644
index 0000000..bca266f
Binary files /dev/null and b/ios/LChatMac/Assets.xcassets/ElevenMark.imageset/ElevenMark.png differ
diff --git a/ios/LChatMac/LChatMac-Dev.entitlements b/ios/LChatMac/LChatMac-Dev.entitlements
index fff4028..1896b87 100644
--- a/ios/LChatMac/LChatMac-Dev.entitlements
+++ b/ios/LChatMac/LChatMac-Dev.entitlements
@@ -2,15 +2,16 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
- <!-- STAGED, NOT ACTIVE: flip the LChatMac Debug CODE_SIGN_ENTITLEMENTS in
- project.yml to this file once this Mac is registered in team J5NDF5PCD8
- (scratchpad/register-mac.sh) — the webcredentials association needs a
- real Development profile; under ad-hoc signing the build breaks.
- ?mode=developer mirrors the iOS Debug entitlement (no Apple-CDN wait);
- it must never ship in a release build. -->
+ <!-- Debug-only (active via project.yml's LChatMac Debug
+ CODE_SIGN_ENTITLEMENTS; needs a real Development profile — the Mac is
+ registered in team J5NDF5PCD8). The PLAIN association, deliberately:
+ macOS's ?mode=developer path never engaged (swcd ignored the app even
+ with developer-mode on, lsregister forced, and its cache reset — AS
+ 1004 throughout), while the normal fetch works because home's AASA is
+ public. iOS Debug keeps its ?mode=developer entitlement. -->
<key>com.apple.developer.associated-domains</key>
<array>
- <string>webcredentials:home.elevenmessenger.com?mode=developer</string>
+ <string>webcredentials:home.elevenmessenger.com</string>
</array>
</dict>
</plist>
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index a25cea1..2d3aa66 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -40,7 +40,11 @@ struct LChatMacApp: App {
}
var body: some Scene {
- WindowGroup {
+ // Window, NOT WindowGroup: the root surface is one window, full stop —
+ // WindowGroup let `open`/URL callbacks and reopen events spawn extra
+ // copies of the whole app UI. Windows for breaking specific features
+ // out (a lightbox, an app) can be their own scenes later.
+ Window("Eleven", id: "main") {
RootView()
.environmentObject(app)
.frame(minWidth: 820, minHeight: 560)
@@ -51,6 +55,12 @@ struct LChatMacApp: App {
// https invite arriving here still works). add(url:) de-dupes
// and selects, so re-opening an existing chat just jumps to it.
.onOpenURL { url in
+ // The browser sign-in handoff returns here
+ // (elevenmessenger://home-session#…) — see MacHomeSignIn.
+ if MacHomeSignIn.handleCallback(url, into: app) {
+ NSApp.activate(ignoringOtherApps: true)
+ return
+ }
guard let raw = inviteLink(fromOpenedURL: url),
isValidInvite(raw) else { return }
app.add(url: raw.trimmingCharacters(in: .whitespacesAndNewlines))
@@ -240,7 +250,11 @@ final class MacAppState: ObservableObject {
chats = MacChatsStore.load()
recent = MacChatsStore.loadRecent()
instances = InstancesStore.load()
- selection = chats.first?.id // didSet doesn't fire during init
+ // Launch into the space last open on this Mac (recent[0] — selection's
+ // didSet keeps it current), falling back to the top of the rail.
+ let chatIDs = Set(chats.map(\.id))
+ selection = recent.first { chatIDs.contains($0) } ?? chats.first?.id
+ // didSet doesn't fire during init
}
// Store (or refresh) the native identity for an origin, extracted from the
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift
index 27e598a..031672f 100644
--- a/ios/LChatMac/MacHomeSignIn.swift
+++ b/ios/LChatMac/MacHomeSignIn.swift
@@ -1,6 +1,8 @@
import AppKit
import AuthenticationServices
+import CryptoKit
import LChatCore
+import Security
import SwiftUI
// 🤖 "Sign in with Eleven" on the Mac — the Settings-pane counterpart of the
@@ -19,10 +21,15 @@ import SwiftUI
@MainActor
final class MacHomeSignIn: ObservableObject {
enum Phase: Equatable {
- case idle, working
+ case idle
+ case working // the passkey sheet is up
+ 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 failed(String)
+
+ var busy: Bool { self == .working || self == .syncing }
}
@Published private(set) var phase: Phase = .idle
@Published private(set) var sessionExists = HomeSessionStore.load() != nil
@@ -33,17 +40,23 @@ final class MacHomeSignIn: ObservableObject {
return
}
guard let anchor = NSApp.keyWindow ?? NSApp.windows.first else {
- phase = .failed("Couldn't find a window to present the passkey sheet.")
+ phase = .failed("Couldn't find a window to sign in from.")
return
}
phase = .working
do {
let result = try await HomeAuth(anchor: anchor).signIn()
+ 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.")
} catch {
phase = .failed(error.localizedDescription)
}
@@ -55,6 +68,84 @@ final class MacHomeSignIn: ObservableObject {
phase = .idle
}
+ // ---- browser-assisted sign-in ------------------------------------------
+ // 1Password's native macOS provider returns a PRF that doesn't match its
+ // own browser extension's (the context backups are wrapped in), so the
+ // ceremony that WORKS runs in the browser: we generate an ephemeral
+ // keypair + nonce, open /restore with {app:1, nonce, ephPub}, and the page
+ // sends the session back over elevenmessenger://home-session, ECIES-wrapped
+ // to the ephemeral key — which never leaves this process.
+
+ private static var pendingHandoff:
+ (model: MacHomeSignIn, priv: P256.KeyAgreement.PrivateKey, nonce: String)?
+
+ func signInViaBrowser() {
+ let priv = P256.KeyAgreement.PrivateKey()
+ var nonceBytes = [UInt8](repeating: 0, count: 16)
+ _ = SecRandomCopyBytes(kSecRandomDefault, nonceBytes.count, &nonceBytes)
+ let nonce = Data(nonceBytes).base64urlEncodedString()
+ Self.pendingHandoff = (self, priv, nonce)
+ let payload: [String: Any] = [
+ "app": 1, "nonce": nonce,
+ "ephPub": Data(priv.publicKey.x963Representation).base64urlEncodedString(),
+ ]
+ guard let json = try? JSONSerialization.data(withJSONObject: payload),
+ let url = URL(string: "https://home.elevenmessenger.com/restore#"
+ + json.base64urlEncodedString()) else { return }
+ phase = .browser
+ NSWorkspace.shared.open(url)
+ }
+
+ /// Routes the custom-scheme callback (LChatMacApp.onOpenURL). Returns
+ /// whether the URL was a handoff callback (claimed even when nothing is
+ /// pending, so it never falls through to the invite-link parser).
+ static func handleCallback(_ url: URL, into app: MacAppState) -> Bool {
+ guard url.host == "home-session" else { return false }
+ guard let (model, priv, nonce) = pendingHandoff else { return true }
+ pendingHandoff = nil
+ Task { await model.completeBrowserSignIn(url, priv: priv, nonce: nonce, into: app) }
+ return true
+ }
+
+ private func completeBrowserSignIn(_ url: URL, priv: P256.KeyAgreement.PrivateKey,
+ nonce: String, into app: MacAppState) async {
+ phase = .syncing
+ struct Handoff: Decodable {
+ let v: Int
+ let token: String
+ let credId: String
+ let dek: String
+ }
+ do {
+ guard let frag = url.fragment?.removingPercentEncoding ?? url.fragment,
+ let backData = Data(base64urlEncoded: frag),
+ let back = try? JSONSerialization.jsonObject(with: backData) as? [String: String],
+ back["nonce"] == nonce, let wrapped = back["wrapped"] else {
+ phase = .failed("The browser handoff didn't match this app's request — try again.")
+ return
+ }
+ let session = try ECIES.unwrapBytes(wrappedB64: wrapped, priv: priv)
+ let h = try JSONDecoder().decode(Handoff.self, from: session)
+ guard let dekRaw = Data(base64urlEncoded: h.dek), dekRaw.count == 32 else {
+ phase = .failed("The browser sent an unusable session — try again.")
+ return
+ }
+ let dek = SymmetricKey(data: dekRaw)
+ guard let fetch = try await HomeClient().fetchBlob(token: h.token) else {
+ phase = .failed("Signed in — nothing saved on this account yet.")
+ return
+ }
+ let contents = try HomeVault.open(fetch.bytes, dek: dek)
+ HomeSessionStore.save(HomeSession(token: h.token, credentialID: h.credId, dek: dek))
+ sessionExists = true
+ let added = app.applyVault(contents.servers)
+ app.syncHome()
+ phase = added > 0 ? .restored(added) : .emptyBackup
+ } catch {
+ phase = .failed(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).
@@ -78,7 +169,7 @@ final class MacHomeSignIn: ObservableObject {
prf: result.prf)
guard try await HomeClient().putBlob(token: result.token, bytes: sealed.bytes,
ifMatch: "0") != nil else {
- return .failed("Couldn't store a backup — try again.")
+ return .failed("Couldn't save your spaces — try again.")
}
HomeSessionStore.save(HomeSession(token: result.token,
credentialID: result.credentialID,
@@ -88,7 +179,7 @@ final class MacHomeSignIn: ObservableObject {
guard let opened = try HomeVault.open(fetch.bytes,
credentialID: result.credentialID,
prf: result.prf) else {
- return .failed("This passkey isn't part of your Eleven backup.")
+ return .failed("That sign-in doesn't unlock these spaces.")
}
HomeSessionStore.save(HomeSession(token: result.token,
credentialID: result.credentialID,
@@ -119,7 +210,7 @@ struct HomeSignInSection: View {
.font(.footnote).foregroundStyle(.secondary)
} else {
LabeledContent("Not signed in") {
- if model.phase == .working {
+ if model.phase.busy {
ProgressView().controlSize(.small)
} else {
Button("Sign In with Eleven…") {
@@ -127,6 +218,15 @@ struct HomeSignInSection: View {
}
}
}
+ Button("Use your browser to sign in") { model.signInViaBrowser() }
+ .buttonStyle(.link)
+ .disabled(model.phase.busy)
+ if model.phase == .syncing {
+ Text("Syncing your spaces…").font(.footnote).foregroundStyle(.secondary)
+ }
+ if model.phase == .browser {
+ Text("Finishing sign-in in your browser…").font(.footnote).foregroundStyle(.secondary)
+ }
if case .failed(let msg) = model.phase {
Text(msg).font(.footnote).foregroundStyle(.secondary)
}
@@ -134,3 +234,103 @@ 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.
+struct MacFirstRun: View {
+ @EnvironmentObject var app: MacAppState
+ @ObservedObject var model: MacHomeSignIn
+
+ var body: some View {
+ VStack(spacing: 10) {
+ Spacer()
+ Image("ElevenMark")
+ .resizable()
+ .frame(width: 76, height: 76)
+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+ .accessibilityHidden(true)
+ Text("Welcome to Eleven")
+ .font(.title2.weight(.semibold))
+ Text("The group chat of dreams")
+ .foregroundStyle(.secondary)
+
+ // Status folds in under the pitch, occupying layout space (the
+ // inline-feedback rule — nothing floats).
+ statusLine
+ .frame(minHeight: 22)
+ .padding(.top, 10)
+
+ Spacer()
+
+ if model.phase == .syncing {
+ VStack(spacing: 10) {
+ ProgressView()
+ Text("Syncing your spaces…").foregroundStyle(.secondary)
+ }
+ .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)
+ }
+ // 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)
+
+ Button("Add with an invite link") { app.showAdd = true }
+ .buttonStyle(.link)
+ .padding(.top, 2)
+ .disabled(model.phase.busy)
+ }
+ .padding(.bottom, 48)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ @ViewBuilder private var statusLine: some View {
+ switch model.phase {
+ case .failed(let msg):
+ Label(msg, systemImage: "exclamationmark.triangle")
+ .font(.footnote).foregroundStyle(.secondary)
+ case .emptyBackup:
+ Text("Signed in — no spaces saved yet. Add one with an invite link.")
+ .font(.callout).foregroundStyle(.secondary)
+ case .restored(let n):
+ // Visible only in the beat before the rail fills and RootView
+ // swaps this screen out.
+ Label("\(n) space\(n == 1 ? "" : "s") restored.", systemImage: "checkmark.circle.fill")
+ .font(.callout).foregroundStyle(.green)
+ case .browser:
+ Text("Finishing sign-in in your browser…")
+ .font(.callout).foregroundStyle(.secondary)
+ case .idle, .working, .syncing:
+ EmptyView()
+ }
+ }
+}
diff --git a/ios/LChatMac/MacSettingsView.swift b/ios/LChatMac/MacSettingsView.swift
index e80b58c..a9f876b 100644
--- a/ios/LChatMac/MacSettingsView.swift
+++ b/ios/LChatMac/MacSettingsView.swift
@@ -29,6 +29,7 @@ struct MacSettingsView: View {
}
@StateObject private var homeSignIn = MacHomeSignIn()
+ @State private var confirmReset = false
var body: some View {
Form {
@@ -85,6 +86,31 @@ struct MacSettingsView: View {
Text(awayNote).font(.footnote).foregroundStyle(.secondary)
}
}
+ #if DEBUG
+ // Dev builds only: a true first-run needs the Keychain wiped —
+ // identities survive reinstalls, so trashing the app isn't a reset.
+ Section("Development") {
+ Button("Reset app…", role: .destructive) { confirmReset = true }
+ .confirmationDialog(
+ "Erase every server, key, and message from this Mac?",
+ isPresented: $confirmReset, titleVisibility: .visible
+ ) {
+ Button("Erase Everything", role: .destructive) {
+ DevReset.wipeAll()
+ // Relaunch fresh rather than bare-terminate — a
+ // sudden quit reads as a crash. The new instance
+ // boots against the wiped stores (first-run).
+ let cfg = NSWorkspace.OpenConfiguration()
+ cfg.createsNewApplicationInstance = true
+ NSWorkspace.shared.openApplication(
+ at: Bundle.main.bundleURL, configuration: cfg
+ ) { _, _ in
+ DispatchQueue.main.async { NSApp.terminate(nil) }
+ }
+ }
+ }
+ }
+ #endif
}
.formStyle(.grouped)
// No frame here — the Settings window's single fixed size lives on the
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift
index bb3963c..1f4ee80 100644
--- a/ios/LChatMac/RootView.swift
+++ b/ios/LChatMac/RootView.swift
@@ -13,6 +13,10 @@ struct RootView: View {
// (just hidden) so switching back is instant — no reload / loader.
@State private var activated: Set<SavedChat.ID> = []
+ // Drives the first-run "Sign in" ceremony (empty rail only). Settings owns
+ // its own MacHomeSignIn; both read the same Keychain session.
+ @StateObject private var firstRunSignIn = MacHomeSignIn()
+
var body: some View {
HStack(spacing: 0) {
Sidebar()
@@ -84,7 +88,11 @@ struct RootView: View {
.allowsHitTesting(chat.id == app.selection)
}
}
- if app.selected == nil {
+ if app.chats.isEmpty {
+ // Fresh Mac: lead with Sign in (restore your spaces), the
+ // invite link as the secondary path — iOS's first-run screen.
+ MacFirstRun(model: firstRunSignIn)
+ } else if app.selected == nil {
ContentUnavailableView(
"No chat selected",
systemImage: "bubble.left.and.bubble.right",