eleven / ocho Public

Native "Sign in with Eleven": passkey sign-in + synced server list (iOS + Mac) (#412)

* home,core: Sign-in-with-Eleven foundation — AASA, native crypto, targets

The verifiable groundwork for native "Sign in with Eleven":

- home serves /.well-known/apple-app-site-association (webcredentials
  for both app bundle ids) so native passkeys bind to this RP. No RP
  change — its origin is already home.elevenmessenger.com.
- HomeCrypto: the backup crypto ported to Swift/CryptoKit, byte-
  compatible with home/web/static/crypto.js (PRF→HKDF KEK, DEK wrap,
  16 KiB padding, EHB1 envelope). Proven by a cross-impl vector — Swift
  opens a JS-sealed blob and reads the server list (HomeCryptoTests).
- Deployment targets bumped to iOS 18 / macOS 15 (native passkey PRF
  needs them) and the webcredentials associated-domain added to both
  app entitlements.

Next: HomeClient (the API), the native WebAuthn+PRF ceremony, and the
first-run UI.

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

* core: HomeClient (API) + HomeVault (blob contents, order, sync ops)

HomeClient speaks home's WebAuthn begin/finish + blob GET/PUT wire
(base64url, ETag/If-Match optimistic concurrency). HomeVault is the
decrypted backup: the { v, servers:[{url,name,icon,order?,credentials}] }
schema (order added for cross-device list ORDER, not just membership),
open-with-PRF, re-seal preserving other passkeys' wrapped DEKs, and
upsert/reorder helpers for the sync cycle.

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

* core: HomeAuth — native passkey+PRF ceremony (AuthenticationServices)

The iOS18/macOS15 ceremony: discoverable sign-in (or register for a
first backup) with the PRF extension evaluating the same input the web
uses, Apple's raw fields reshaped into the WebAuthn JSON go-webauthn
verifies, returning link token + credential id + PRF output. Apple's
overlay hands PRF back as a SymmetricKey — its raw bytes are exactly
the web's HKDF input, so the KEK matches. Compiles against the SDK;
the ceremony itself needs a device + the home AASA deployed to run.

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

* ios: first-run "Sign in with Eleven" — restore your spaces from a passkey

The no-servers first run now leads with "Sign in with Eleven": a passkey to
home.elevenmessenger.com restores your saved server list onto this device
(HomeAuth ceremony → fetch blob → HomeVault.open → SavedInstances). Joining a
specific chat by invite link (paste / QR) is demoted to a quiet secondary
action. PRF output never leaves the device.

- HomeSignInModel orchestrates ceremony → restore, with inline status (restored
  N / empty backup / no passkey / error) — no floating toast.
- ElevenMark imageset (the real purple "11" tile) for the welcome brand.
- Mac is deferred: its webcredentials entitlement needs this Mac registered in
  the dev account (breaks local ad-hoc builds otherwise), and macOS sign-in
  needs the same. Reverted the Mac entitlement + macOS 15 bump; iOS keeps
  iOS 18 + webcredentials. HomeAuth stays @available(macOS 15) — compiles,
  uncalled on Mac until we bump + register.

Not yet testable end-to-end: home must serve the AASA (route is in this branch,
not deployed) and the ceremony needs a device biometric.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* core: realign HomeVault to the shipped star/sort schema (array-order + starred)

Home was upgraded so the saved-spaces list uses ARRAY ORDER as the master order
plus a per-server `starred` boolean — not the `order` field HomeVault had. Match
home/web/static/serverlist.js: entry is { url, name?, starred?, credentials },
and port its pure helpers (partition / toggleStar / moveInGroup / order) with a
Swift test that mirrors serverlist.test.mjs — so the native rail sorts a member's
spaces identically to the home page. `starred: nil` serialises as an ABSENT key
(matching the web's flag-drop), verified by test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios: starred servers populate the rail; the rest fold under "More"

SavedInstance gains a `starred` flag (optional, so old saved lists still decode).
Once anything is starred, the server drawer shows only the stars and collapses
the rest under a "More" disclosure — matching the home page. Long-press a server
→ Star / Unstar (repositioned like the vault's toggleStar: star → end of the
starred group, unstar → top of the rest). A ★ marks starred rows.

Local-only for now — the home-vault write-back (so home ↔ apps stay in sync)
lands with the sign-in ceremony. Mac rail parity follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* mac: starred servers populate the rail; the rest fold behind "More"

Rail parity with iOS + home: once anything is starred, the server rail shows
only the starred icons, with the rest behind a quiet ellipsis "More" toggle
(Discord's more-servers idiom; auto-opens when the selected chat lives in the
fold). Right-click an icon → Star / Unstar, above Remove.

Star state lives on the shared SavedInstance (the record iOS uses and the home
vault syncs) — a chat is starred iff its native identity is, so the item only
appears once the identity has been extracted (first open). toggleStar repositions
both the instances list and the rail's chats array with the vault's boundary
semantics (star → end of starred, unstar → top of the rest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* core,ios,mac: home-vault sync — stars/order/adds flow device ↔ home

HomeSync keeps the device's server list and the home backup in lockstep:

- HomeSession (Keychain): the home link token + blob DEK, kept after sign-in so
  routine changes sync without re-running Face ID. No new exposure class — the
  DEK opens the backup, whose contents (server credentials) already live in the
  same Keychain via InstancesStore. The PRF output is never stored.
- push: upsert the device's list (order + stars + names + credentials) into the
  vault; vault-only entries (spaces saved elsewhere, incl. credential-less
  {url,name} bookmarks — now decoded as optional) are preserved, tailed. One
  refetch+reapply on a 409. Serialised so quick star-taps can't interleave.
- Sync never deletes from the vault; removal stays an explicit act on home.
- Wiring: every iOS AppState mutation (add/remove/move/star/note) and the Mac's
  star/drag/remove push; the Mac translates to RAIL order (chats drives it).
- Restore now applies vault stars, saves the session, and pushes device-only
  servers up — so home matches the device from the very first sign-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios: "Sign in with Eleven" from the server drawer; Debug dev-mode association

Two pieces that make the ceremony reachable on a device that already has
servers (first-run never shows there):

- The drawer's footer offers "Sign in with Eleven" until a home session exists —
  the entry point for a device that joined by invite link to start syncing its
  list. Runs the same HomeSignInModel as first-run; failures render inline, and
  success needs no banner (the restored servers just appear in the list).
- Debug builds sign with LChat-Debug.entitlements, whose webcredentials
  association carries ?mode=developer — dev installs bind to home immediately
  instead of waiting on Apple's CDN. Release keeps the clean association.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios: drawer sign-in surfaces every outcome; "Sign in"; add Sign out

The first device run failed SILENTLY: a non-cancel ASAuthorizationError mapped
to .noPasskey, which the drawer didn't render — loading state, then the button
just re-armed. Now every terminal phase says something inline, and ceremony
errors surface verbatim (code included) so the next failure names itself.

Also: the drawer row is just "Sign in" (was "Sign in with Eleven"), and a
signed-in drawer offers "Sign out" — forgets the home session (Keychain), keeps
the device's servers, so signing back in is testable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* core: native passkey registration labels the account "Eleven" (matches home #365)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios,core: explicit "Back up this device" mints a passkey + first backup

Sign in never mints a passkey: home has no account discovery, so registering
always creates a NEW anonymous account — riding it on the sign-in button is the
exact fallback the web dropped in #323. Instead the drawer offers a quiet,
explicit "New to Eleven? Back up this device" (shown when signed out with
servers to back up): it runs the registration ceremony, seals the device's
server list into a fresh envelope keyed only to the new passkey
(HomeVault.sealFresh — the web's createBlobFor), stores it with If-Match "0",
and keeps the session so sync starts immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: address all 10 confirmed findings from the adversarial review

Correctness (data loss / dead ends):
- restore() now merges VAULT-WINS: stars/names/order from the vault apply to
  servers the device already has (AppState.applyVault) BEFORE the write-back —
  the device-wins push was silently wiping stars other devices stored.
- Blob-less account (404 on sign-in): create the first envelope right there
  with the PRF in hand (sealFresh + If-Match "0") and save the session — it
  was a permanent dead-end (no session, push can't create a blob).
- Mac noteIdentity preserves `starred` — every page load was clearing it and
  the next sync pushed the unstar vault-wide.
- Session state is a @Published on HomeSignInModel (sessionExists) shared by
  first-run and the drawer — the drawer's one-shot @State stayed stale after a
  first-run sign-in, leaving "Back up this device" visible; tapping it minted
  a SECOND home account and orphaned the real backup. backUp() also refuses
  outright when a session exists (defense in depth).
- Per-group .onMove (AppState.moveInGroup): starring anything had killed
  drag-reorder entirely on iOS.
- Deployment target back to iOS 17: the PRF ceremony is runtime-gated
  (#available(iOS 18)) with the pre-sign-in first-run card and no drawer
  affordances below 18 — the 18 bump stranded every iOS 17 member.
- Dropped the unreachable Phase.noPasskey (cancel is indistinguishable from
  no-passkey at the API); its guidance is now static idle copy on first-run.

Round-trip fidelity:
- HomeSync.push works on the RAW JSON object (HomeVault.openRawObject/
  sealRawObject) and patches only fields this build owns — strict-Codable
  re-encoding stripped unknown top-level/per-server/credentials fields that
  newer clients wrote (the web spread-merges; test added).

Build + docs:
- home/main.go reset to main's (the rebase had re-added serveAASA — duplicate
  method, home package didn't compile).
- Mac syncHome documented as DORMANT until Mac sign-in ships (task tracked).

95 tests green (incl. new raw round-trip regression); iOS + Mac build clean at
target 17/14.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* mac: "Sign in with Eleven" from Settings (staged behind device registration)

The Settings General pane gains an Eleven section: Sign In runs the passkey
ceremony (runtime-gated to macOS 15 — the target stays 14) and merges the
vault VAULT-WINS into this Mac: SavedInstances upserted (identity + star, the
resolved myUserId preserved), missing rail entries created, the vault's order
applied to the rail, and only then the push — the iOS data-loss lesson. A
blob-less account seals the Mac's servers straight into a first envelope
(If-Match "0") rather than dead-ending. Signed in shows Sign Out + the sync
one-liner; session state is @Published so it can't go stale.

ACTIVATION (two flips, morning): register this Mac in the team
(scratchpad/register-mac.sh — the ASC API call the auto-mode classifier
correctly held for approval), then point the LChatMac Debug
CODE_SIGN_ENTITLEMENTS at the staged LChatMac-Dev.entitlements
(webcredentials + ?mode=developer, mirroring iOS Debug; never ships).
Until then the button reports the association error honestly.

Closes the review's "dormant Mac sync plumbing" finding: the Mac now has the
sign-in entry point that makes syncHome real.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* mac: activate the Debug webcredentials entitlement (Mac registered: 8VWU966HQ3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* mac: blank feed after send — verified pin, no history-yank, wedge recovery

The post-send jump was a one-shot scrollTo computed against estimated
LazyVStack row heights while the composer inset shrank under it — the
content resizes mid-jump and the viewport ends clamped past the real
end: a fully blank feed (the third inset-change blanking, after #297
and #318). Sends and at-bottom arrivals now go through pinUntilSettled,
which re-pins against measured geometry until the last row is flush.

Two more in the same handler: the nearBottom check defaulted UNKNOWN
(old last row unmaterialised = reader deep in history) to true, yanking
readers to the bottom on every arrival — now defaults to false; and a
wedged feed (zero materialised rows while messages exist) always
re-pins its way out.

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

* ios: Debug-only 'Reset app' in the drawer — wipe Keychain + stores for a true first run

Keychain items survive an app uninstall, so reinstalling never resets a
device — first-run flows were untestable. DevReset clears both Keychain
services (identities, thread keys, home session), UserDefaults, and the
on-disk stores, then exits; Debug UI only.

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

* ios: first-run copy — 'Group chat made by dreams', plain 'Sign in', home's privacy pitch

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

* ios: sign-in lands you IN the app — syncing state, auto-entry, last-space memory

Restore populated instances but nothing selected one, so sign-in
stranded the user on the welcome screen with a green count. Now: the
ceremony shows 'Syncing your spaces…' once the sheet is down, RootView
selects a space the moment instances land, and the selection is
remembered (UserDefaults) so launches and restores open the last space
you were in — falling back to the first in vault order.

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 543d33e6232dbc9f025e4d8dbd727ef30bc96369 parent 763400d
24 files changed, +1971 −40
  • ios/LChat/Assets.xcassets/ElevenMark.imageset/Contents.json +12 −0
  • ios/LChat/Assets.xcassets/ElevenMark.imageset/ElevenMark.png binary
  • ios/LChat/LChat-Debug.entitlements +31 −0
  • ios/LChat/LChat.entitlements +3 −0
  • ios/LChat/LChatApp.swift +98 −12
  • ios/LChat/Views/InstanceSidebar.swift +152 −6
  • ios/LChat/Views/SignInWithEleven.swift +315 −0
  • ios/LChatCore/Sources/LChatCore/DevReset.swift +44 −0
  • ios/LChatCore/Sources/LChatCore/HomeAuth.swift +177 −0
  • ios/LChatCore/Sources/LChatCore/HomeClient.swift +134 −0
  • ios/LChatCore/Sources/LChatCore/HomeCrypto.swift +122 −0
  • ios/LChatCore/Sources/LChatCore/HomeSync.swift +131 −0
  • ios/LChatCore/Sources/LChatCore/HomeVault.swift +180 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +8 −1
  • ios/LChatCore/Tests/LChatCoreTests/HomeCryptoTests.swift +68 −0
  • ios/LChatCore/Tests/LChatCoreTests/HomeCryptoVectors.swift +7 −0
  • ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift +125 −0
  • ios/LChatMac/LChatMac-Dev.entitlements +16 −0
  • ios/LChatMac/LChatMacApp.swift +114 −1
  • ios/LChatMac/MacHomeSignIn.swift +136 −0
  • ios/LChatMac/MacSettingsView.swift +3 −0
  • ios/LChatMac/NativeFeedView.swift +17 −3
  • ios/LChatMac/Sidebar.swift +64 −17
  • ios/project.yml +14 −0
diff --git a/ios/LChat/Assets.xcassets/ElevenMark.imageset/Contents.json b/ios/LChat/Assets.xcassets/ElevenMark.imageset/Contents.json
new file mode 100644
index 0000000..a4c616b
--- /dev/null
+++ b/ios/LChat/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/LChat/Assets.xcassets/ElevenMark.imageset/ElevenMark.png b/ios/LChat/Assets.xcassets/ElevenMark.imageset/ElevenMark.png
new file mode 100644
index 0000000..bca266f
Binary files /dev/null and b/ios/LChat/Assets.xcassets/ElevenMark.imageset/ElevenMark.png differ
diff --git a/ios/LChat/LChat-Debug.entitlements b/ios/LChat/LChat-Debug.entitlements
new file mode 100644
index 0000000..7a5550a
--- /dev/null
+++ b/ios/LChat/LChat-Debug.entitlements
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>com.apple.security.app-sandbox</key>
+ <true/>
+ <key>com.apple.security.files.user-selected.read-only</key>
+ <true/>
+ <key>com.apple.security.network.client</key>
+ <true/>
+ <key>aps-environment</key>
+ <string>development</string>
+ <!-- Universal links: tapping an https invite link on any instance subdomain
+ opens the app directly (delivered to onOpenURL, fragment included). -->
+ <key>com.apple.developer.associated-domains</key>
+ <array>
+ <string>applinks:*.elevenmessenger.com</string>
+ <!-- "Sign in with Eleven": native passkeys bind to the home server,
+ which serves the matching apple-app-site-association. -->
+ <string>webcredentials:home.elevenmessenger.com?mode=developer</string>
+ </array>
+ <key>com.apple.security.application-groups</key>
+ <array>
+ <string>group.com.elevenmessenger.messenger</string>
+ </array>
+ <key>keychain-access-groups</key>
+ <array>
+ <string>$(AppIdentifierPrefix)com.elevenmessenger.messenger.shared</string>
+ </array>
+</dict>
+</plist>
diff --git a/ios/LChat/LChat.entitlements b/ios/LChat/LChat.entitlements
index 9ec861f..df91c16 100644
--- a/ios/LChat/LChat.entitlements
+++ b/ios/LChat/LChat.entitlements
@@ -15,6 +15,9 @@
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:*.elevenmessenger.com</string>
+ <!-- "Sign in with Eleven": native passkeys bind to the home server,
+ which serves the matching apple-app-site-association. -->
+ <string>webcredentials:home.elevenmessenger.com</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index f2bc75c..cdd2858 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -61,11 +61,15 @@ final class AppState: ObservableObject {
instances.append(inst)
}
InstancesStore.save(instances)
+ HomeSync.pushSoon(instances: instances)
}
func remove(_ inst: SavedInstance) {
instances.removeAll { $0.id == inst.id }
InstancesStore.save(instances)
+ // Sync never deletes from the vault — the removed space just stops being
+ // device-ordered and tails as a vault-only entry (see HomeSync).
+ HomeSync.pushSoon(instances: instances)
}
/// Reorder the server list (drag in the drawer). The array order IS the
@@ -73,6 +77,54 @@ final class AppState: ObservableObject {
func move(from source: IndexSet, to destination: Int) {
instances.move(fromOffsets: source, toOffset: destination)
InstancesStore.save(instances)
+ HomeSync.pushSoon(instances: instances)
+ }
+
+ /// Sign-in merge — the vault wins: apply its stars + names to servers this
+ /// device already has, and its array order (device-only servers tail, in
+ /// their existing relative order). One save, no push — the caller pushes
+ /// once after the whole merge, so the write-back reflects the merged state.
+ func applyVault(_ entries: [(url: String, starred: Bool, name: String?)]) {
+ for e in entries {
+ guard let i = instances.firstIndex(where: { $0.origin == e.url }) else { continue }
+ instances[i].starred = e.starred ? true : nil
+ if let n = e.name, !n.isEmpty { instances[i].groupName = n }
+ }
+ var rest = instances
+ var out: [SavedInstance] = []
+ for url in entries.map(\.url) {
+ if let i = rest.firstIndex(where: { $0.origin == url }) { out.append(rest.remove(at: i)) }
+ }
+ instances = out + rest
+ InstancesStore.save(instances)
+ }
+
+ /// Reorder within ONE star group (the drawer's starred and "More" sections
+ /// each drag independently — mirroring home's independent lists). Offsets
+ /// are relative to that group's own array; the other group is untouched.
+ func moveInGroup(starred: Bool, from source: IndexSet, to destination: Int) {
+ var group = instances.filter { $0.isStarred == starred }
+ let other = instances.filter { $0.isStarred != starred }
+ group.move(fromOffsets: source, toOffset: destination)
+ // Normalised starred-first, matching toggleStar's boundary invariant.
+ instances = starred ? group + other : other + group
+ InstancesStore.save(instances)
+ HomeSync.pushSoon(instances: instances)
+ }
+
+ /// Star or unstar a space. Starring sends it to the END of the starred group;
+ /// unstarring sends it to the TOP of the rest — matching the home vault's
+ /// toggleStar so the array order stays in lockstep (the vault write-back,
+ /// when signed in, is layered on top of this).
+ func toggleStar(_ inst: SavedInstance) {
+ guard let target = instances.first(where: { $0.id == inst.id }) else { return }
+ var updated = target
+ updated.starred = target.isStarred ? nil : true
+ let starred = instances.filter { $0.id != inst.id && $0.isStarred }
+ let others = instances.filter { $0.id != inst.id && !$0.isStarred }
+ instances = starred + [updated] + others
+ InstancesStore.save(instances)
+ HomeSync.pushSoon(instances: instances)
}
/// Persist resolved fields discovered after first load (our user id, group name).
@@ -85,7 +137,10 @@ final class AppState: ObservableObject {
if let name = groupName, !name.isEmpty, instances[i].groupName != name {
instances[i].groupName = name; changed = true
}
- if changed { InstancesStore.save(instances) }
+ if changed {
+ InstancesStore.save(instances)
+ HomeSync.pushSoon(instances: instances) // names feed the vault entries
+ }
}
}
@@ -101,6 +156,11 @@ final class NavModel: ObservableObject {
guard inst?.id != instanceID else { return }
instanceID = inst?.id
store = inst.map { ThreadStore(instance: $0) }
+ // Remembered across launches: reopening the app (or restoring a
+ // backup) lands in the space you were last in.
+ if let id = inst?.id {
+ UserDefaults.standard.set(id, forKey: "lastInstanceID")
+ }
}
}
@@ -113,6 +173,8 @@ struct RootView: View {
@EnvironmentObject private var push: PushManager
@Environment(\.scenePhase) private var scenePhase
@StateObject private var nav = NavModel()
+ // Drives the first-run "Sign in with Eleven" ceremony + restore state.
+ @StateObject private var signIn = HomeSignInModel()
@State private var path: [String] = [] // pushed thread ids
// Logical open state: drives hit-testing, accessibility, and external opens
// (the server button, a chat select, an invite link). Kept in step with
@@ -206,7 +268,7 @@ struct RootView: View {
showAdd = true
}
.onAppear {
- if nav.instanceID == nil { nav.selectInstance(app.instances.first) }
+ if nav.instanceID == nil { nav.selectInstance(rememberedInstance) }
// Someone with chats wants to hear about them (no-op without the
// push entitlement; asks permission the first time).
if !app.instances.isEmpty { push.enablePush() }
@@ -214,6 +276,14 @@ struct RootView: View {
// before this view could subscribe) — route it now.
routeNotificationTap()
}
+ // Sign-in restore populates instances while the first-run screen is
+ // up — carry the user straight into their spaces rather than leaving
+ // them parked on the welcome screen.
+ .onChange(of: app.instances.map(\.id)) { _, _ in
+ guard nav.instanceID == nil, let inst = rememberedInstance else { return }
+ nav.selectInstance(inst)
+ push.enablePush()
+ }
// A tap that arrives while we're already up: the published id changes and
// this fires. (onAppear covers the cold-launch case where it was set first.)
.onChange(of: push.pendingThreadID) { _, _ in routeNotificationTap() }
@@ -226,6 +296,13 @@ struct RootView: View {
.onChange(of: push.inboxPing) { _, _ in refreshInbox(when: true) }
}
+ // The space to open when none is selected: the one last open on this
+ // device, else the first in the list (vault order — starred first).
+ private var rememberedInstance: SavedInstance? {
+ let last = UserDefaults.standard.string(forKey: "lastInstanceID")
+ return app.instances.first { $0.id == last } ?? app.instances.first
+ }
+
// Re-fetch the active server's thread list, unless one is already in flight (the
// cold-launch `.task` load, a pull-to-refresh, or an overlapping trigger).
private func refreshInbox(when: Bool) {
@@ -277,6 +354,7 @@ struct RootView: View {
.allowsHitTesting(showServers)
ServerDrawer(
activeID: nav.instanceID,
+ signIn: signIn,
onSelect: select,
onAdd: {
showServers = false
@@ -334,16 +412,24 @@ struct RootView: View {
return showServers || v.startLocation.x < 44
}
- private var noServers: some View {
- VStack(spacing: 14) {
- Image(systemName: "bubble.left.and.bubble.right")
- .font(.system(size: 44)).foregroundStyle(.secondary)
- Text("No chats yet").font(.headline)
- Text("Add one with an invite link you were sent.")
- .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
- Button { showAdd = true } label: { Text("Add a chat").frame(maxWidth: 220) }
- .buttonStyle(.borderedProminent).controlSize(.large)
+ // First run leads with "Sign in with Eleven" (restore your spaces from a
+ // passkey backup); joining a specific chat by invite link is the secondary
+ // path (opens the existing paste/QR sheet). The passkey ceremony is
+ // iOS 18-only (PRF), so iOS 17 keeps the pre-sign-in card.
+ @ViewBuilder private var noServers: some View {
+ if #available(iOS 18.0, *) {
+ SignInFirstRun(model: signIn, onAddLink: { showAdd = true })
+ } else {
+ VStack(spacing: 14) {
+ Image(systemName: "bubble.left.and.bubble.right")
+ .font(.system(size: 44)).foregroundStyle(.secondary)
+ Text("No chats yet").font(.headline)
+ Text("Add one with an invite link you were sent.")
+ .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
+ Button { showAdd = true } label: { Text("Add a chat").frame(maxWidth: 220) }
+ .buttonStyle(.borderedProminent).controlSize(.large)
+ }
+ .padding()
}
- .padding()
}
}
diff --git a/ios/LChat/Views/InstanceSidebar.swift b/ios/LChat/Views/InstanceSidebar.swift
index 8611b5b..8352d5c 100644
--- a/ios/LChat/Views/InstanceSidebar.swift
+++ b/ios/LChat/Views/InstanceSidebar.swift
@@ -7,8 +7,19 @@ import SwiftUI
struct ServerDrawer: View {
@EnvironmentObject var app: AppState
let activeID: String?
+ /// Shared with the first-run screen (owned by RootView): the drawer offers
+ /// "Sign in with Eleven" until a home session exists, so a device that
+ /// joined by invite link can start syncing its server list any time.
+ @ObservedObject var signIn: HomeSignInModel
var onSelect: (SavedInstance) -> Void
var onAdd: () -> 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
+ @State private var confirmReset = false
+
+ private var starred: [SavedInstance] { app.instances.filter(\.isStarred) }
+ private var others: [SavedInstance] { app.instances.filter { !$0.isStarred } }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -19,19 +30,36 @@ struct ServerDrawer: View {
.padding(.top, 18)
.padding(.bottom, 10)
- // A List (not a ScrollView) so rows can be reordered: .onMove gives the
- // standard long-press-and-drag, and the new order persists via AppState.
+ // Once anything is starred, the rail shows only the stars and folds
+ // the rest under "More" (matching the home page); with nothing starred
+ // it's the flat, drag-reorderable list.
List {
- ForEach(app.instances) { inst in
- row(inst)
+ if starred.isEmpty {
+ ForEach(app.instances) { inst in serverRow(inst) }
+ .onMove { app.move(from: $0, to: $1) }
+ } else {
+ // Each group drags independently (home's independent lists);
+ // without these, starring ANYTHING killed reorder entirely.
+ ForEach(starred) { inst in serverRow(inst) }
+ .onMove { app.moveInGroup(starred: true, from: $0, to: $1) }
+ if !others.isEmpty {
+ DisclosureGroup(isExpanded: $showMore) {
+ ForEach(others) { inst in serverRow(inst) }
+ .onMove { app.moveInGroup(starred: false, from: $0, to: $1) }
+ } label: {
+ Text("More")
+ .font(.subheadline.weight(.medium))
+ .foregroundStyle(.secondary)
+ }
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
- .listRowInsets(EdgeInsets(top: 1, leading: 10, bottom: 1, trailing: 10))
+ .listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16))
+ }
}
- .onMove { app.move(from: $0, to: $1) }
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
+ .onAppear { showMore = others.contains { $0.id == activeID } }
Divider()
@@ -45,6 +73,94 @@ struct ServerDrawer: View {
}
.buttonStyle(.plain)
+ if #unavailable(iOS 18.0) {
+ // The passkey ceremony (PRF) is iOS 18-only — no sign-in
+ // affordances below that; the drawer is just the server list.
+ EmptyView()
+ } else if signIn.sessionExists {
+ Button {
+ signIn.signOut()
+ } label: {
+ Label("Sign out", systemImage: "person.badge.key")
+ .font(.body.weight(.medium))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 20)
+ .padding(.bottom, 16)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ } else {
+ Button {
+ Task { await signIn.signIn(into: app) }
+ } label: {
+ HStack(spacing: 8) {
+ if signIn.phase.busy {
+ ProgressView().controlSize(.small)
+ } else {
+ Image(systemName: "person.badge.key")
+ }
+ Text(signIn.phase.busy ? "Signing in…" : "Sign in")
+ .font(.body.weight(.medium))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 20)
+ .padding(.bottom, 16)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .disabled(signIn.phase.busy)
+ // Minting is deliberately NOT part of Sign in (registering
+ // always creates a new anonymous account — the web dropped the
+ // create-on-sign-in fallback in #323). This is the explicit act
+ // for a device with servers and no Eleven passkey yet.
+ if !app.instances.isEmpty {
+ Button {
+ Task { await signIn.backUp(into: app) }
+ } label: {
+ Text("New to Eleven? Back up this device")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 20)
+ .padding(.bottom, 14)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .disabled(signIn.phase.busy)
+ }
+ // EVERY terminal state renders inline — a silent reset cost us a
+ // debugging round on the first device run.
+ if let status = statusLine {
+ Text(status)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 20)
+ .padding(.bottom, 12)
+ }
+ }
+ #if DEBUG
+ // Dev builds only: a true first-run needs the Keychain wiped —
+ // identities survive an app uninstall, so reinstalling isn't a reset.
+ Button {
+ confirmReset = true
+ } label: {
+ Text("Reset app (dev)")
+ .font(.footnote)
+ .foregroundStyle(.red.opacity(0.8))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 20)
+ .padding(.bottom, 12)
+ .contentShape(Rectangle())
+ }
+ .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)
+ }
+ }
+ #endif
}
.frame(width: 300)
.frame(maxHeight: .infinity)
@@ -53,6 +169,26 @@ struct ServerDrawer: View {
.background(Color(.systemBackground).ignoresSafeArea())
}
+ // The sign-in row's inline status — every terminal phase says something.
+ 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 .restored(let n): return "\(n) space\(n == 1 ? "" : "s") restored."
+ case .syncing: return "Syncing your spaces…"
+ case .idle, .working: return nil
+ }
+ }
+
+ // A server row plus the common list-row chrome (used in every section).
+ private func serverRow(_ inst: SavedInstance) -> some View {
+ row(inst)
+ .listRowSeparator(.hidden)
+ .listRowBackground(Color.clear)
+ .listRowInsets(EdgeInsets(top: 1, leading: 10, bottom: 1, trailing: 10))
+ }
+
private func row(_ inst: SavedInstance) -> some View {
Button { onSelect(inst) } label: {
HStack(spacing: 12) {
@@ -66,6 +202,12 @@ struct ServerDrawer: View {
}
}
Spacer()
+ if inst.isStarred {
+ Image(systemName: "star.fill")
+ .font(.caption2)
+ .foregroundStyle(.yellow)
+ .accessibilityLabel("Starred")
+ }
if inst.id == activeID {
Image(systemName: "checkmark")
.font(.subheadline.weight(.semibold))
@@ -80,6 +222,10 @@ struct ServerDrawer: View {
.background(inst.id == activeID ? Color(.secondarySystemFill) : .clear,
in: RoundedRectangle(cornerRadius: 10))
.contextMenu {
+ Button { app.toggleStar(inst) } label: {
+ Label(inst.isStarred ? "Unstar" : "Star",
+ systemImage: inst.isStarred ? "star.slash" : "star")
+ }
Button(role: .destructive) { app.remove(inst) } label: {
Label("Remove", systemImage: "trash")
}
diff --git a/ios/LChat/Views/SignInWithEleven.swift b/ios/LChat/Views/SignInWithEleven.swift
new file mode 100644
index 0000000..433b1a1
--- /dev/null
+++ b/ios/LChat/Views/SignInWithEleven.swift
@@ -0,0 +1,315 @@
+import AuthenticationServices
+import LChatCore
+import SwiftUI
+import UIKit
+
+// 🤖 "Sign in with Eleven" — the first-run hero path. A passkey to
+// home.elevenmessenger.com restores your saved server list onto this device
+// (the web's backup store, now reachable natively). The ceremony + crypto live
+// in LChatCore (HomeAuth / HomeVault / HomeCrypto); this file is the iOS
+// presentation plus the orchestration that turns a restored vault into
+// SavedInstances. The app still targets iOS 17 — the PRF ceremony (iOS 18) is
+// runtime-gated, so iOS 17 members keep the app and simply don't get this
+// feature (the affordances hide below 18).
+//
+// The PRF output never leaves the device: HomeAuth returns it, HomeVault derives
+// the unwrap key from it in memory, and it's dropped when this model deallocates.
+
+@MainActor
+final class HomeSignInModel: ObservableObject {
+ enum Phase: Equatable {
+ case idle
+ case working // the passkey sheet is up
+ case syncing // authenticated; fetching + restoring the backup
+ case restored(Int) // servers added to this device
+ case emptyBackup // signed in; the account holds no servers yet
+ case backedUp(Int) // N servers sealed into a (new or empty) backup
+ case failed(String)
+ // No "noPasskey" case: the API can't distinguish "no passkey existed"
+ // from a cancelled sheet (both surface as .canceled), so the guidance
+ // lives as static idle copy on the first-run screen instead.
+
+ var busy: Bool { self == .working || self == .syncing }
+ }
+ @Published private(set) var phase: Phase = .idle
+ /// Whether a home session exists on this device — @Published so EVERY
+ /// surface (first-run, drawer) reacts to a sign-in made on any of them.
+ /// A stale one-shot copy here once left "Back up this device" visible
+ /// after sign-in, a path to accidentally minting a second account.
+ @Published private(set) var sessionExists = HomeSessionStore.load() != nil
+
+ init() {}
+
+ /// Run the discoverable passkey ceremony and restore whatever servers the
+ /// 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.")
+ return
+ }
+ guard #available(iOS 18.0, *) else {
+ phase = .failed("Sign in with Eleven needs iOS 18.")
+ 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 {
+ // The user backed out of the system sheet — quietly return to the
+ // first-run choices rather than showing an error. Everything else
+ // surfaces VERBATIM (code included): "failed silently" cost us a
+ // debugging round on the very first device run.
+ phase = e.code == .canceled ? .idle
+ : .failed("\(e.localizedDescription) (AS \(e.code.rawValue))")
+ } catch {
+ phase = .failed((error as? HomeClientError).map(Self.message) ?? error.localizedDescription)
+ }
+ }
+
+ /// 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() {
+ HomeSessionStore.clear()
+ sessionExists = false
+ phase = .idle
+ }
+
+ /// Mint a NEW Eleven passkey and seal this device's servers into a fresh
+ /// backup. Deliberately a separate act from Sign in (home has no account
+ /// discovery — registering always creates a new anonymous account, so it
+ /// must never ride the sign-in button; the web dropped exactly that
+ /// fallback in #323). The PRF output is used in-memory and dropped.
+ func backUp(into app: AppState) async {
+ // Registering ALWAYS creates a new anonymous account — with a session
+ // already on this device that would orphan the real backup (all future
+ // syncs would go to the fresh empty one). Refuse outright.
+ guard !sessionExists, HomeSessionStore.load() == nil else {
+ phase = .failed("This device is already signed in to Eleven.")
+ return
+ }
+ guard let anchor = Self.keyWindow() else {
+ phase = .failed("Couldn't find a window to present the passkey sheet.")
+ return
+ }
+ guard #available(iOS 18.0, *) else {
+ phase = .failed("Backing up to Eleven needs iOS 18.")
+ return
+ }
+ phase = .working
+ do {
+ let result = try await HomeAuth(anchor: anchor).register()
+ let contents = HomeVault.Contents(v: 1, servers: Self.vaultServers(from: app.instances))
+ let sealed = try HomeVault.sealFresh(contents, credentialID: result.credentialID,
+ prf: result.prf)
+ // 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.")
+ return
+ }
+ HomeSessionStore.save(HomeSession(token: result.token,
+ credentialID: result.credentialID,
+ dek: sealed.dek))
+ sessionExists = true
+ phase = .backedUp(app.instances.count)
+ } catch let e as ASAuthorizationError {
+ phase = e.code == .canceled ? .idle
+ : .failed("\(e.localizedDescription) (AS \(e.code.rawValue))")
+ } catch {
+ phase = .failed((error as? HomeClientError).map(Self.message) ?? error.localizedDescription)
+ }
+ }
+
+ /// Fetch + open the backup and merge it into this device — the VAULT wins
+ /// on sign-in (its stars, names and order apply to servers the device
+ /// already has; missing ones are added). Only then does the device push,
+ /// so the write-back can't wipe state other devices stored — the exact
+ /// data-loss the first cut had. Returns the resulting phase.
+ @available(iOS 18.0, *)
+ private func restore(_ result: HomeAuth.Result, into app: AppState) async throws -> Phase {
+ guard let fetch = try await HomeClient().fetchBlob(token: result.token) else {
+ // Authenticated, but the account holds no blob (a backUp whose PUT
+ // failed after register, or a web-deleted backup). With the PRF in
+ // hand this is recoverable RIGHT NOW: seal the device's servers
+ // into a first envelope for this passkey. Bailing here was a
+ // permanent dead-end — no session saved, and routine sync can't
+ // create a blob (it never holds the PRF).
+ let contents = HomeVault.Contents(v: 1, servers: Self.vaultServers(from: app.instances))
+ let sealed = try HomeVault.sealFresh(contents, credentialID: result.credentialID,
+ 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.")
+ }
+ HomeSessionStore.save(HomeSession(token: result.token,
+ credentialID: result.credentialID,
+ dek: sealed.dek))
+ return app.instances.isEmpty ? .emptyBackup : .backedUp(app.instances.count)
+ }
+ 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.")
+ }
+ // 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
+ // NOT stored — the DEK is all sync needs.
+ HomeSessionStore.save(HomeSession(token: result.token,
+ credentialID: result.credentialID,
+ dek: opened.dek))
+ var added = 0
+ for server in opened.contents.servers { // array order is the master order
+ // A credential-less entry (a space saved by URL alone on the home
+ // page) can't be signed into from here — skip it; it stays in the
+ // vault untouched.
+ guard let creds = server.credentials,
+ !app.instances.contains(where: { $0.origin == server.url }) else { continue }
+ app.add(SavedInstance(
+ origin: server.url,
+ deviceToken: creds.token,
+ screenName: creds.name ?? server.name ?? "",
+ userPub: creds.userPub,
+ userPriv: creds.userPriv,
+ groupName: server.name ?? "",
+ starred: server.isStarred ? true : nil
+ ))
+ added += 1
+ }
+ // Vault wins for servers on BOTH sides: its stars/names/order apply to
+ // the device before anything flows back.
+ app.applyVault(opened.contents.servers.map {
+ (url: $0.url, starred: $0.isStarred, name: $0.name)
+ })
+ // Device-only servers (joined before signing in) flow back up, so home
+ // matches the device from the very first sign-in.
+ HomeSync.pushSoon(instances: app.instances)
+ return added > 0 ? .restored(added) : .emptyBackup
+ }
+
+ /// The device's servers as vault entries (used for a first backup).
+ static func vaultServers(from instances: [SavedInstance]) -> [HomeVault.Server] {
+ instances.map { inst in
+ HomeVault.Server(
+ url: inst.origin,
+ name: inst.groupName.isEmpty ? nil : inst.groupName,
+ starred: inst.isStarred ? true : nil,
+ credentials: .init(token: inst.deviceToken, userPub: inst.userPub,
+ userPriv: inst.userPriv, name: inst.screenName))
+ }
+ }
+
+ private static func message(_ e: HomeClientError) -> String {
+ switch e {
+ case .network: return "Couldn't reach Eleven. Check your connection and try again."
+ case .http(let code, _): return "Eleven turned that down (\(code)). Try again in a moment."
+ }
+ }
+
+ // The key window backs the ASAuthorizationController's presentation anchor.
+ private static func keyWindow() -> UIWindow? {
+ UIApplication.shared.connectedScenes
+ .compactMap { $0 as? UIWindowScene }
+ .flatMap(\.windows)
+ .first { $0.isKeyWindow }
+ }
+}
+
+// The first-run screen: "Sign in with Eleven" is the hero; joining a specific
+// chat by invite link (paste / QR) is demoted to a quiet secondary action.
+struct SignInFirstRun: View {
+ @EnvironmentObject var app: AppState
+ @ObservedObject var model: HomeSignInModel
+ /// Opens the existing invite-link / QR add sheet.
+ var onAddLink: () -> Void
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Spacer()
+ VStack(spacing: 16) {
+ Image("ElevenMark") // the real Eleven brand mark (purple "11" tile)
+ .resizable()
+ .frame(width: 76, height: 76)
+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+ .accessibilityHidden(true)
+ Text("Welcome to Eleven")
+ .font(.title2.weight(.semibold))
+ Text("Group chat made by dreams")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 8)
+ }
+
+ // Status / result folds in under the pitch, occupying layout space
+ // (no floating toast — the project's inline-feedback rule).
+ statusLine
+ .frame(minHeight: 22)
+ .padding(.top, 14)
+
+ Spacer()
+
+ if model.phase == .syncing {
+ // Authenticated; the backup is restoring. RootView carries the
+ // user into their first space the moment instances land, so
+ // this shows only for the fetch's beat.
+ VStack(spacing: 12) {
+ ProgressView()
+ Text("Syncing your spaces…")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.bottom, 48)
+ } else {
+ VStack(spacing: 12) {
+ Button(action: { Task { await model.signIn(into: app) } }) {
+ HStack(spacing: 8) {
+ if model.phase == .working { ProgressView().controlSize(.small) }
+ Text(model.phase == .working ? "Signing in…" : "Sign in")
+ .fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .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)
+
+ Button("Add with an invite link", action: onAddLink)
+ .font(.callout)
+ .padding(.top, 2)
+ .disabled(model.phase.busy)
+ }
+ .padding(.horizontal, 32)
+ .padding(.bottom, 8)
+ }
+ }
+ .padding()
+ }
+
+ @ViewBuilder private var statusLine: some View {
+ switch model.phase {
+ case .restored(let n):
+ 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.")
+ .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
+ case .backedUp(let n):
+ Label("Backed up \(n) space\(n == 1 ? "" : "s") to Eleven.", systemImage: "checkmark.circle.fill")
+ .font(.callout).foregroundStyle(.green)
+ case .failed(let msg):
+ Label(msg, systemImage: "exclamationmark.triangle")
+ .font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center)
+ case .idle, .working, .syncing:
+ EmptyView()
+ }
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/DevReset.swift b/ios/LChatCore/Sources/LChatCore/DevReset.swift
new file mode 100644
index 0000000..aa3ca42
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/DevReset.swift
@@ -0,0 +1,44 @@
+import Foundation
+import Security
+
+// 🤖 Dev-only full reset: wipes everything the app persists — Keychain
+// identities, thread keys, the home session, UserDefaults, and the on-disk
+// stores — so the next launch is a true first run. This exists because
+// Keychain items SURVIVE an app uninstall on iOS: deleting the app and
+// reinstalling brings every identity straight back, which makes first-run
+// flows untestable. Only Debug UI ever calls this.
+public enum DevReset {
+ public static func wipeAll() {
+ // Whole-service deletes (no account = every item), with and without
+ // the app-family access group — matching how the stores write.
+ for service in ["com.lchat.client", "com.lchat.client.threadkeys"] {
+ for withGroup in [true, false] {
+ var query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecUseDataProtectionKeychain as String: true,
+ ]
+ if withGroup {
+ // Same group constant as Keychain/SharedThreadKeys.
+ query[kSecAttrAccessGroup as String] =
+ "J5NDF5PCD8.com.elevenmessenger.messenger.shared"
+ }
+ SecItemDelete(query as CFDictionary)
+ }
+ }
+ if let bundleID = Bundle.main.bundleIdentifier {
+ UserDefaults.standard.removePersistentDomain(forName: bundleID)
+ }
+ let fm = FileManager.default
+ for dir: FileManager.SearchPathDirectory in [.applicationSupportDirectory,
+ .cachesDirectory, .documentDirectory] {
+ for url in fm.urls(for: dir, in: .userDomainMask) {
+ for item in (try? fm.contentsOfDirectory(at: url,
+ includingPropertiesForKeys: nil)) ?? [] {
+ try? fm.removeItem(at: item)
+ }
+ }
+ }
+ try? fm.removeItem(at: fm.temporaryDirectory)
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/HomeAuth.swift b/ios/LChatCore/Sources/LChatCore/HomeAuth.swift
new file mode 100644
index 0000000..2e9d44f
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/HomeAuth.swift
@@ -0,0 +1,177 @@
+import AuthenticationServices
+import Foundation
+
+#if canImport(UIKit)
+import UIKit
+public typealias HomeAuthAnchor = UIWindow
+#elseif canImport(AppKit)
+import AppKit
+public typealias HomeAuthAnchor = NSWindow
+#endif
+
+// 🤖 The native "Sign in with Eleven" passkey ceremony (AuthenticationServices,
+// iOS 18 / macOS 15 for the PRF extension). It runs a discoverable login (or a
+// registration for a first backup), evaluates the SAME PRF input the web uses,
+// reshapes Apple's raw ceremony fields into the WebAuthn JSON the go-webauthn
+// server verifies, and returns the link token + credential id + PRF output.
+// It never sends the PRF output anywhere — that stays here for HomeCrypto.
+@available(iOS 18.0, macOS 15.0, *)
+public final class HomeAuth: NSObject {
+ public struct Result {
+ public let token: String
+ public let proof: String?
+ public let credentialID: String // base64url
+ public let prf: Data
+ }
+
+ private let client: HomeClient
+ private let rpID: String
+ private let anchor: HomeAuthAnchor
+ private var continuation: CheckedContinuation<ASAuthorization, Error>?
+
+ public init(client: HomeClient = HomeClient(),
+ rpID: String = "home.elevenmessenger.com",
+ anchor: HomeAuthAnchor) {
+ self.client = client
+ self.rpID = rpID
+ self.anchor = anchor
+ }
+
+ // ---- sign in (discoverable assertion) -----------------------------------
+
+ public func signIn() async throws -> Result {
+ let begin = try await client.loginBegin()
+ guard let challenge = Data(base64urlEncoded: begin.options.publicKey.challenge) else {
+ throw HomeAuthError.badOptions
+ }
+ let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpID)
+ let request = provider.createCredentialAssertionRequest(challenge: challenge)
+ request.prf = ASAuthorizationPublicKeyCredentialPRFAssertionInput.inputValues(.init(saltInput1: HomeCrypto.prfEvalInput))
+
+ let auth = try await run(request)
+ guard let cred = auth.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion else {
+ throw HomeAuthError.wrongCredential
+ }
+ // Apple's Swift overlay hands the PRF output back as a SymmetricKey; its
+ // raw bytes are exactly what the web feeds HKDF, so byte-compat holds.
+ guard let prfKey = cred.prf?.first else { throw HomeAuthError.noPRF }
+ let prf = prfKey.withUnsafeBytes { Data($0) }
+
+ let json = try Self.assertionJSON(cred)
+ let fin = try await client.finish(register: false, session: begin.session, credentialJSON: json)
+ return Result(token: fin.token, proof: fin.proof,
+ credentialID: fin.credential_id, prf: prf)
+ }
+
+ // ---- register a first backup (attestation) ------------------------------
+
+ public func register() async throws -> Result {
+ let begin = try await client.registerBegin()
+ guard let challenge = Data(base64urlEncoded: begin.options.publicKey.challenge),
+ let userIDB64 = begin.options.publicKey.user?.id,
+ let userID = Data(base64urlEncoded: userIDB64) else {
+ throw HomeAuthError.badOptions
+ }
+ let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpID)
+ let request = provider.createCredentialRegistrationRequest(
+ challenge: challenge, name: "Eleven", userID: userID)
+ request.prf = ASAuthorizationPublicKeyCredentialPRFRegistrationInput.inputValues(
+ .init(saltInput1: HomeCrypto.prfEvalInput))
+
+ let auth = try await run(request)
+ guard let cred = auth.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration else {
+ throw HomeAuthError.wrongCredential
+ }
+ let json = try Self.registrationJSON(cred)
+ let fin = try await client.finish(register: true, session: begin.session, credentialJSON: json)
+
+ // Most authenticators evaluate PRF at create time; if not, one assertion
+ // with the same input recovers it (mirrors backup-core.js runRegister).
+ var prf = cred.prf?.first.map { $0.withUnsafeBytes { Data($0) } }
+ if prf == nil {
+ prf = try await prfViaAssertion(credentialID: cred.credentialID)
+ }
+ guard let prf else { throw HomeAuthError.noPRF }
+ return Result(token: fin.token, proof: fin.proof,
+ credentialID: fin.credential_id, prf: prf)
+ }
+
+ private func prfViaAssertion(credentialID: Data) async throws -> Data? {
+ let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpID)
+ var localChallenge = Data(count: 32)
+ localChallenge.withUnsafeMutableBytes { _ = SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!) }
+ let request = provider.createCredentialAssertionRequest(challenge: localChallenge)
+ request.allowedCredentials = [
+ ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: credentialID)]
+ request.prf = ASAuthorizationPublicKeyCredentialPRFAssertionInput.inputValues(.init(saltInput1: HomeCrypto.prfEvalInput))
+ let auth = try await run(request)
+ guard let key = (auth.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion)?
+ .prf?.first else { return nil }
+ return key.withUnsafeBytes { Data($0) }
+ }
+
+ // ---- WebAuthn JSON assembly (go-webauthn wire) --------------------------
+
+ private static func registrationJSON(_ c: ASAuthorizationPlatformPublicKeyCredentialRegistration) throws -> Data {
+ let id = c.credentialID.base64urlEncodedString()
+ var response: [String: Any] = [
+ "clientDataJSON": c.rawClientDataJSON.base64urlEncodedString(),
+ ]
+ if let att = c.rawAttestationObject {
+ response["attestationObject"] = att.base64urlEncodedString()
+ }
+ return try JSONSerialization.data(withJSONObject: [
+ "id": id, "rawId": id, "type": "public-key",
+ "clientExtensionResults": [String: Any](),
+ "response": response,
+ ])
+ }
+
+ private static func assertionJSON(_ c: ASAuthorizationPlatformPublicKeyCredentialAssertion) throws -> Data {
+ let id = c.credentialID.base64urlEncodedString()
+ var response: [String: Any] = [
+ "clientDataJSON": c.rawClientDataJSON.base64urlEncodedString(),
+ "authenticatorData": c.rawAuthenticatorData.base64urlEncodedString(),
+ "signature": c.signature.base64urlEncodedString(),
+ ]
+ if !c.userID.isEmpty { response["userHandle"] = c.userID.base64urlEncodedString() }
+ return try JSONSerialization.data(withJSONObject: [
+ "id": id, "rawId": id, "type": "public-key",
+ "clientExtensionResults": [String: Any](),
+ "response": response,
+ ])
+ }
+
+ // ---- controller driving -------------------------------------------------
+
+ private func run(_ request: ASAuthorizationRequest) async throws -> ASAuthorization {
+ try await withCheckedThrowingContinuation { cont in
+ self.continuation = cont
+ let controller = ASAuthorizationController(authorizationRequests: [request])
+ controller.delegate = self
+ controller.presentationContextProvider = self
+ controller.performRequests()
+ }
+ }
+}
+
+@available(iOS 18.0, macOS 15.0, *)
+extension HomeAuth: ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
+ public func authorizationController(controller: ASAuthorizationController,
+ didCompleteWithAuthorization authorization: ASAuthorization) {
+ continuation?.resume(returning: authorization)
+ continuation = nil
+ }
+
+ public func authorizationController(controller: ASAuthorizationController,
+ didCompleteWithError error: Error) {
+ continuation?.resume(throwing: error)
+ continuation = nil
+ }
+
+ public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
+ anchor
+ }
+}
+
+public enum HomeAuthError: Error { case badOptions, wrongCredential, noPRF }
diff --git a/ios/LChatCore/Sources/LChatCore/HomeClient.swift b/ios/LChatCore/Sources/LChatCore/HomeClient.swift
new file mode 100644
index 0000000..bed79d7
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/HomeClient.swift
@@ -0,0 +1,134 @@
+import Foundation
+
+// 🤖 The home-server API client: WebAuthn ceremony begin/finish and the
+// encrypted blob's GET/PUT. It speaks the same JSON wire the web pages do
+// (base64url everywhere), so a native passkey ceremony's results — reshaped
+// into WebAuthn JSON by HomeAuth — verify against the same go-webauthn server.
+// This layer holds NO key material; the ceremony's PRF output and the derived
+// DEK live in HomeCrypto / the caller.
+public struct HomeClient {
+ public let base: URL
+
+ public init(base: URL = URL(string: "https://home.elevenmessenger.com")!) {
+ self.base = base
+ }
+
+ // ---- ceremony options (only the fields the native ceremony needs) -------
+
+ public struct BeginResponse: Decodable {
+ public let session: String
+ public let options: Options
+ public struct Options: Decodable { public let publicKey: PublicKey }
+ public struct PublicKey: Decodable {
+ public let challenge: String // base64url
+ public let user: User? // register only
+ public let rp: RP?
+ public struct User: Decodable { public let id: String; public let name: String? }
+ public struct RP: Decodable { public let id: String? }
+ }
+ }
+
+ // ---- ceremony result ----------------------------------------------------
+
+ public struct FinishResponse: Decodable {
+ public let token: String
+ public let proof: String?
+ public let credential_id: String
+ }
+
+ public func registerBegin() async throws -> BeginResponse {
+ try await getJSON("POST", "/api/register/begin")
+ }
+
+ public func loginBegin() async throws -> BeginResponse {
+ try await getJSON("POST", "/api/login/begin")
+ }
+
+ /// Post an assembled WebAuthn credential JSON to finish a ceremony.
+ public func finish(register: Bool, session: String, credentialJSON: Data) async throws -> FinishResponse {
+ var comps = URLComponents(url: base.appending(path: register ? "/api/register/finish"
+ : "/api/login/finish"),
+ resolvingAgainstBaseURL: false)!
+ comps.queryItems = [URLQueryItem(name: "session", value: session)]
+ var req = URLRequest(url: comps.url!)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = credentialJSON
+ let (data, resp) = try await URLSession.shared.data(for: req)
+ try Self.ensureOK(resp, data)
+ return try JSONDecoder().decode(FinishResponse.self, from: data)
+ }
+
+ // ---- the blob -----------------------------------------------------------
+
+ public struct BlobFetch { public let bytes: Data; public let version: String }
+
+ /// Returns the stored envelope + its version, or nil on 404 (no backup).
+ public func fetchBlob(token: String) async throws -> BlobFetch? {
+ var req = URLRequest(url: base.appending(path: "/api/blob"))
+ req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
+ let (data, resp) = try await URLSession.shared.data(for: req)
+ guard let http = resp as? HTTPURLResponse else { throw HomeClientError.network }
+ if http.statusCode == 404 { return nil }
+ try Self.ensureOK(resp, data)
+ let version = (http.value(forHTTPHeaderField: "ETag") ?? "\"0\"")
+ .replacingOccurrences(of: "\"", with: "")
+ return BlobFetch(bytes: data, version: version)
+ }
+
+ /// Stores an envelope with optimistic concurrency; nil = 409 version race
+ /// (caller re-fetches + retries), else the new version.
+ public func putBlob(token: String, bytes: Data, ifMatch version: String) async throws -> String? {
+ var req = URLRequest(url: base.appending(path: "/api/blob"))
+ req.httpMethod = "PUT"
+ req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
+ req.setValue("\"\(version)\"", forHTTPHeaderField: "If-Match")
+ req.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
+ req.httpBody = bytes
+ let (data, resp) = try await URLSession.shared.data(for: req)
+ guard let http = resp as? HTTPURLResponse else { throw HomeClientError.network }
+ if http.statusCode == 409 { return nil }
+ try Self.ensureOK(resp, data)
+ struct R: Decodable { let version: JSONValueString }
+ return try JSONDecoder().decode(R.self, from: data).version.value
+ }
+
+ // ---- plumbing -----------------------------------------------------------
+
+ private func getJSON<T: Decodable>(_ method: String, _ path: String) async throws -> T {
+ var req = URLRequest(url: base.appending(path: path))
+ req.httpMethod = method
+ let (data, resp) = try await URLSession.shared.data(for: req)
+ try Self.ensureOK(resp, data)
+ return try JSONDecoder().decode(T.self, from: data)
+ }
+
+ static func ensureOK(_ resp: URLResponse, _ body: Data) throws {
+ guard let http = resp as? HTTPURLResponse else { throw HomeClientError.network }
+ guard (200..<300).contains(http.statusCode) else {
+ throw HomeClientError.http(http.statusCode,
+ String(data: body, encoding: .utf8) ?? "")
+ }
+ }
+}
+
+public enum HomeClientError: Error { case network, http(Int, String) }
+
+// The server returns version as a JSON string; tolerate a number too.
+struct JSONValueString: Decodable {
+ let value: String
+ init(from decoder: Decoder) throws {
+ let c = try decoder.singleValueContainer()
+ if let s = try? c.decode(String.self) { value = s }
+ else if let n = try? c.decode(Int.self) { value = String(n) }
+ else { value = "0" }
+ }
+}
+
+extension CharacterSet {
+ static let urlQueryValueAllowed: CharacterSet = {
+ var cs = CharacterSet.urlQueryAllowed
+ cs.remove(charactersIn: "&=?/")
+ return cs
+ }()
+}
diff --git a/ios/LChatCore/Sources/LChatCore/HomeCrypto.swift b/ios/LChatCore/Sources/LChatCore/HomeCrypto.swift
new file mode 100644
index 0000000..2c7e23e
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/HomeCrypto.swift
@@ -0,0 +1,122 @@
+import CryptoKit
+import Foundation
+
+// 🤖 The home-server backup crypto, byte-compatible with home/web/static/crypto.js
+// so a blob written by the web (or another device) opens here and vice-versa.
+// The passkey's PRF output derives a per-credential KEK (HKDF); a random DEK
+// encrypts the padded blob (AES-GCM); the DEK is wrapped to each enrolled
+// passkey's KEK. The server only ever stores the opaque envelope.
+//
+// Wire formats mirrored EXACTLY (see crypto.js): encryptBytes = iv(12)||ct||tag,
+// KEK = HKDF-SHA256(prf, salt:∅, info:"eleven-home-kek"), DEK wrap =
+// b64url(encryptBytes(kek, rawDEK)), plaintext padded to 16 KiB with a 4-byte
+// big-endian length prefix, envelope = "EHB1"||u32 headerLen||headerJSON||body.
+public enum HomeCrypto {
+ public static let blobPad = 16384
+
+ // The fixed, public PRF eval input — secrecy lives in the passkey, not here.
+ public static var prfEvalInput: Data { Data("eleven-home-kek-v1".utf8) }
+
+ // ---- primitives (iv(12)||ct||tag, matching WebCrypto AES-GCM) -----------
+
+ static func encryptBytes(key: SymmetricKey, _ plain: Data) throws -> Data {
+ let iv = AES.GCM.Nonce() // 12 random bytes
+ let sealed = try AES.GCM.seal(plain, using: key, nonce: iv)
+ // WebCrypto appends the 16-byte tag to the ciphertext; CryptoKit keeps
+ // them apart. Concatenate to match: iv || ciphertext || tag.
+ return Data(iv) + sealed.ciphertext + sealed.tag
+ }
+
+ static func decryptBytes(key: SymmetricKey, _ combined: Data) throws -> Data {
+ let iv = combined.prefix(12)
+ let rest = combined.dropFirst(12)
+ let ct = rest.dropLast(16)
+ let tag = rest.suffix(16)
+ let box = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: iv),
+ ciphertext: ct, tag: tag)
+ return try AES.GCM.open(box, using: key)
+ }
+
+ // ---- PRF → KEK ----------------------------------------------------------
+
+ public static func kekFromPRF(_ prfOutput: Data) -> SymmetricKey {
+ HKDF<SHA256>.deriveKey(
+ inputKeyMaterial: SymmetricKey(data: prfOutput),
+ salt: Data(),
+ info: Data("eleven-home-kek".utf8),
+ outputByteCount: 32)
+ }
+
+ // ---- DEK wrap / unwrap --------------------------------------------------
+
+ public static func wrapDEK(_ dek: SymmetricKey, kek: SymmetricKey) throws -> String {
+ let raw = dek.withUnsafeBytes { Data($0) }
+ return try encryptBytes(key: kek, raw).base64urlEncodedString()
+ }
+
+ public static func unwrapDEK(_ wrappedB64: String, kek: SymmetricKey) throws -> SymmetricKey {
+ guard let wrapped = Data(base64urlEncoded: wrappedB64) else { throw HomeCryptoError.badBlob }
+ return SymmetricKey(data: try decryptBytes(key: kek, wrapped))
+ }
+
+ // ---- fixed-size padding (length leaks nothing) --------------------------
+
+ static func pad(_ bytes: Data) throws -> Data {
+ guard bytes.count <= blobPad - 4 else { throw HomeCryptoError.tooLarge }
+ var out = Data(count: blobPad)
+ let len = UInt32(bytes.count).bigEndian
+ withUnsafeBytes(of: len) { out.replaceSubrange(0..<4, with: $0) }
+ out.replaceSubrange(4..<(4 + bytes.count), with: bytes)
+ return out
+ }
+
+ static func unpad(_ bytes: Data) throws -> Data {
+ guard bytes.count >= 4 else { throw HomeCryptoError.badBlob }
+ let len = bytes.prefix(4).reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
+ let start = 4, end = 4 + Int(len)
+ guard end <= bytes.count else { throw HomeCryptoError.badBlob }
+ return bytes.subdata(in: start..<end)
+ }
+
+ // ---- the envelope: "EHB1" || u32 headerLen || header || iv||ct||tag ------
+
+ private static let magic = Data("EHB1".utf8)
+
+ /// Split an envelope into (wrapped-key header, encrypted body) without
+ /// decrypting — to find our wrapped DEK and to re-seal preserving others'.
+ public static func unpack(_ bytes: Data) throws -> (keys: [String: String], body: Data) {
+ guard bytes.count >= 8, bytes.prefix(4) == magic else { throw HomeCryptoError.badBlob }
+ let headerLen = bytes.subdata(in: 4..<8).reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
+ let headerEnd = 8 + Int(headerLen)
+ guard headerEnd <= bytes.count else { throw HomeCryptoError.badBlob }
+ let headerData = bytes.subdata(in: 8..<headerEnd)
+ struct Header: Decodable { let keys: [String: String]? }
+ let header = try JSONDecoder().decode(Header.self, from: headerData)
+ return (header.keys ?? [:], bytes.subdata(in: headerEnd..<bytes.count))
+ }
+
+ /// Decrypt an envelope's body with an already-unwrapped DEK → the JSON obj.
+ public static func openBlob(_ bytes: Data, dek: SymmetricKey) throws -> (obj: Data, keys: [String: String]) {
+ let (keys, body) = try unpack(bytes)
+ let obj = try unpad(decryptBytes(key: dek, body))
+ return (obj, keys)
+ }
+
+ /// Seal a JSON obj (raw bytes) under the DEK, packed with the wrapped-key
+ /// header. `keys` is [credentialId: wrappedDEK].
+ public static func sealBlob(objJSON: Data, dek: SymmetricKey, keys: [String: String]) throws -> Data {
+ let body = try encryptBytes(key: dek, pad(objJSON))
+ let header = try JSONSerialization.data(withJSONObject: ["keys": keys],
+ options: [.sortedKeys])
+ var out = magic
+ var headerLen = UInt32(header.count).bigEndian
+ out.append(Data(bytes: &headerLen, count: 4))
+ out.append(header)
+ out.append(body)
+ return out
+ }
+
+ public static func generateDEK() -> SymmetricKey { SymmetricKey(size: .bits256) }
+}
+
+public enum HomeCryptoError: Error { case badBlob, tooLarge }
diff --git a/ios/LChatCore/Sources/LChatCore/HomeSync.swift b/ios/LChatCore/Sources/LChatCore/HomeSync.swift
new file mode 100644
index 0000000..dabd4e5
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/HomeSync.swift
@@ -0,0 +1,131 @@
+import CryptoKit
+import Foundation
+
+// 🤖 Keeping the device's server list and the home backup in lockstep.
+//
+// After a sign-in the app holds a HomeSession — the home LINK TOKEN (bearer;
+// auth only) plus the blob DEK — persisted in the Keychain so routine changes
+// (star, reorder, add) sync without re-running Face ID each time. Storing the
+// DEK adds no new exposure class: it decrypts the backup blob, whose contents
+// (each server's credentials) already live in this same Keychain via
+// InstancesStore. Neither the DEK nor anything it opens ever leaves the device;
+// the server still only ever sees the sealed envelope.
+//
+// Sync model (v1, deliberately simple):
+// - PULL (sign-in): the vault wins — its membership, order and stars are applied
+// to the device; device-only servers are kept, tailed, and pushed back up.
+// - PUSH (any local change): the device's list wins for the servers it knows —
+// order, stars, names, credentials are upserted; vault-only entries (spaces
+// saved on another device or by URL alone) are preserved, tailed in their
+// relative order. Optimistic concurrency: one refetch+reapply on a 409.
+// - Nothing is ever deleted from the vault by sync. Removing a space from the
+// backup is an explicit act on the home page, not a side effect of removing
+// it from one device.
+public struct HomeSession: Codable {
+ public var token: String // home link token — transport auth, NOT a key
+ public var credentialID: String // which enrolled passkey wraps our DEK
+ public var dekB64: String // the backup blob's DEK (base64url raw bytes)
+
+ public var dek: SymmetricKey? {
+ Data(base64urlEncoded: dekB64).map(SymmetricKey.init(data:))
+ }
+
+ public init(token: String, credentialID: String, dek: SymmetricKey) {
+ self.token = token
+ self.credentialID = credentialID
+ self.dekB64 = dek.withUnsafeBytes { Data($0) }.base64urlEncodedString()
+ }
+}
+
+public enum HomeSessionStore {
+ static let account = "lchat.home.session"
+
+ public static func load() -> HomeSession? {
+ Keychain.load(HomeSession.self, account: account)
+ }
+
+ @discardableResult
+ public static func save(_ session: HomeSession) -> Bool {
+ Keychain.save(session, account: account)
+ }
+
+ public static func clear() {
+ Keychain.clear(account: account)
+ }
+}
+
+public enum HomeSync {
+ /// Fire-and-forget push for UI mutation paths: no-op unless signed in.
+ /// Serialised on a single task chain so two quick star-taps can't interleave
+ /// their read-modify-write cycles and drop one.
+ public static func pushSoon(instances: [SavedInstance]) {
+ guard let session = HomeSessionStore.load() else { return }
+ queue = (queue ?? Task {}).then {
+ await push(instances: instances, session: session)
+ }
+ }
+
+ private static var queue: Task<Void, Never>?
+
+ /// Upsert the device's list (order + stars + credentials) into the vault.
+ /// Works on the RAW JSON object and patches only the fields this build
+ /// owns, so anything a newer web/app build stored — unknown top-level
+ /// keys, per-server or per-credentials fields — rides through untouched
+ /// (the web spread-merges the same way; a strict-Codable re-encode here
+ /// would strip them vault-wide on the first star-tap).
+ /// Returns true when the write landed (retries once on a version race).
+ @discardableResult
+ public static func push(instances: [SavedInstance], session: HomeSession,
+ client: HomeClient = HomeClient()) async -> Bool {
+ guard let dek = session.dek else { return false }
+ for _ in 0..<2 {
+ guard let fetch = try? await client.fetchBlob(token: session.token),
+ var obj = try? HomeVault.openRawObject(fetch.bytes, dek: dek) else { return false }
+
+ let existing = (obj["servers"] as? [[String: Any]]) ?? []
+ var byUrl: [String: [String: Any]] = [:]
+ for e in existing { if let u = e["url"] as? String { byUrl[u] = e } }
+
+ // Device servers first, in device order — each PATCHED onto its
+ // existing vault entry so unknown fields survive.
+ var servers: [[String: Any]] = []
+ for inst in instances {
+ var e = byUrl.removeValue(forKey: inst.origin) ?? [:]
+ e["url"] = inst.origin
+ // Only write a name we actually know — the device's ignorance
+ // must not delete one another client stored.
+ if !inst.groupName.isEmpty { e["name"] = inst.groupName }
+ if inst.isStarred { e["starred"] = true } else { e.removeValue(forKey: "starred") }
+ var creds = (e["credentials"] as? [String: Any]) ?? [:]
+ creds["token"] = inst.deviceToken
+ creds["userPub"] = inst.userPub
+ creds["userPriv"] = inst.userPriv
+ creds["name"] = inst.screenName
+ e["credentials"] = creds
+ servers.append(e)
+ }
+ // Vault-only entries survive verbatim, tailed in their relative order.
+ for e in existing {
+ if let u = e["url"] as? String, byUrl[u] != nil { servers.append(e) }
+ }
+ obj["servers"] = servers
+
+ guard let sealed = try? HomeVault.sealRawObject(obj, existingEnvelope: fetch.bytes,
+ dek: dek)
+ else { return false }
+ if let _ = try? await client.putBlob(token: session.token, bytes: sealed,
+ ifMatch: fetch.version) {
+ return true
+ }
+ // nil = 409 (someone else wrote meanwhile) → loop refetches + reapplies.
+ }
+ return false
+ }
+}
+
+private extension Task where Success == Void, Failure == Never {
+ /// Chain a follow-on operation after this task, preserving order.
+ func then(_ op: @escaping () async -> Void) -> Task<Void, Never> {
+ Task { await self.value; await op() }
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/HomeVault.swift b/ios/LChatCore/Sources/LChatCore/HomeVault.swift
new file mode 100644
index 0000000..f47dbfa
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/HomeVault.swift
@@ -0,0 +1,180 @@
+import CryptoKit
+import Foundation
+
+// 🤖 The decrypted backup's contents + the read/modify/write cycle against the
+// home server. The blob JSON matches the web's exactly:
+// { v:1, servers:[ { url, name?, starred?, credentials:{token,userPub,userPriv,name?} } ] }
+// The ARRAY ORDER is the master order; `starred` is a per-server boolean. The
+// list renders as a **Starred** group and the rest, each keeping its own relative
+// array order. home/web/static/serverlist.js is the shared contract — these pure
+// helpers mirror it exactly (HomeVaultTests ports its test), so the native rail
+// and the home page sort a member's spaces identically.
+// Writes are last-writer-wins per url with optimistic-concurrency retry.
+public struct HomeVault {
+ public struct Server: Codable, Equatable {
+ public var url: String
+ public var name: String?
+ public var starred: Bool?
+ // Optional: home can hold a credential-less entry (a space saved by URL
+ // alone — home.js pushes bare {url, name}). Restore skips those; a native
+ // push must preserve them untouched.
+ public var credentials: Credentials?
+
+ public struct Credentials: Codable, Equatable {
+ public var token: String
+ public var userPub: String
+ public var userPriv: String
+ public var name: String?
+
+ public init(token: String, userPub: String, userPriv: String, name: String? = nil) {
+ self.token = token
+ self.userPub = userPub
+ self.userPriv = userPriv
+ self.name = name
+ }
+ }
+
+ public var isStarred: Bool { starred ?? false }
+
+ public init(url: String, name: String? = nil, starred: Bool? = nil,
+ credentials: Credentials? = nil) {
+ self.url = url
+ self.name = name
+ self.starred = starred
+ self.credentials = credentials
+ }
+ }
+
+ public struct Contents: Codable {
+ public var v: Int
+ public var servers: [Server]
+
+ public init(v: Int = 1, servers: [Server]) {
+ self.v = v
+ self.servers = servers
+ }
+ }
+
+ // ---- pure list helpers (mirror serverlist.js — keep in lockstep) --------
+
+ /// Split into (starred, others), each preserving its own array order.
+ public static func partition(_ servers: [Server]) -> (starred: [Server], others: [Server]) {
+ var starred: [Server] = [], others: [Server] = []
+ for s in servers { if s.isStarred { starred.append(s) } else { others.append(s) } }
+ return (starred, others)
+ }
+
+ /// Flip a server's starred flag AND reposition it: starring sends it to the
+ /// END of the Starred group; unstarring sends it to the TOP of the rest —
+ /// both the boundary between the two groups. Returns a NEW array (never
+ /// mutates the input); an unknown url passes through unchanged.
+ public static func toggleStar(_ servers: [Server], url: String) -> [Server] {
+ guard let target = servers.first(where: { $0.url == url }) else { return servers }
+ var updated = target
+ updated.starred = target.isStarred ? nil : true // nil ⇒ omitted, like the web's drop
+ let starred = servers.filter { $0.url != url && $0.isStarred }
+ let others = servers.filter { $0.url != url && !$0.isStarred }
+ return starred + [updated] + others
+ }
+
+ /// Swap the entry at `url` with its nearest SAME-GROUP neighbour in `dir`
+ /// (-1 = up, +1 = down), skipping the other group; unchanged if it's already
+ /// at an end of its list. The keyboard-reorder fallback.
+ public static func moveInGroup(_ servers: [Server], url: String, dir: Int) -> [Server] {
+ var arr = servers
+ guard let i = arr.firstIndex(where: { $0.url == url }) else { return arr }
+ let group = arr[i].isStarred
+ var j = i + dir
+ while j >= 0, j < arr.count, arr[j].isStarred != group { j += dir }
+ guard j >= 0, j < arr.count else { return arr }
+ arr.swapAt(i, j)
+ return arr
+ }
+
+ /// Reorder to match `urlsInOrder` (the full top-to-bottom display order);
+ /// entries whose url isn't listed keep their relative order at the end.
+ /// Each entry (with its starred flag + credentials) is preserved.
+ public static func order(_ servers: [Server], urlsInOrder: [String]) -> [Server] {
+ var byUrl = Dictionary(servers.map { ($0.url, $0) }, uniquingKeysWith: { a, _ in a })
+ var out: [Server] = []
+ for url in urlsInOrder {
+ if let s = byUrl.removeValue(forKey: url) { out.append(s) }
+ }
+ for s in servers where byUrl[s.url] != nil { out.append(s) }
+ return out
+ }
+
+ // ---- open a fetched blob with a PRF output ------------------------------
+
+ /// Decrypt a fetched envelope: find our credential's wrapped DEK, unwrap it
+ /// with the PRF-derived KEK, open the blob. Returns nil if this passkey
+ /// isn't one of the backup's keys.
+ public static func open(_ bytes: Data, credentialID: String, prf: Data)
+ throws -> (contents: Contents, dek: SymmetricKey)? {
+ let (keys, _) = try HomeCrypto.unpack(bytes)
+ guard let wrapped = keys[credentialID] else { return nil }
+ let kek = HomeCrypto.kekFromPRF(prf)
+ let dek = try HomeCrypto.unwrapDEK(wrapped, kek: kek)
+ let (objJSON, _) = try HomeCrypto.openBlob(bytes, dek: dek)
+ let contents = try JSONDecoder().decode(Contents.self, from: objJSON)
+ return (contents, dek)
+ }
+
+ /// Open a fetched envelope with an already-unwrapped DEK (a persisted home
+ /// session) — no passkey ceremony needed for routine sync reads.
+ public static func open(_ bytes: Data, dek: SymmetricKey) throws -> Contents {
+ let (objJSON, _) = try HomeCrypto.openBlob(bytes, dek: dek)
+ return try JSONDecoder().decode(Contents.self, from: objJSON)
+ }
+
+ /// The decrypted blob as a RAW JSON object — for read-modify-write cycles
+ /// (HomeSync.push), which must round-trip fields this build doesn't know:
+ /// the web spread-merges unknown keys, and a native writer that re-encoded
+ /// through strict Codable would silently strip them vault-wide.
+ public static func openRawObject(_ bytes: Data, dek: SymmetricKey) throws -> [String: Any] {
+ let (objJSON, _) = try HomeCrypto.openBlob(bytes, dek: dek)
+ return (try JSONSerialization.jsonObject(with: objJSON) as? [String: Any]) ?? [:]
+ }
+
+ /// Re-seal a raw object under the same DEK, keeping the existing wrapped-key
+ /// header (openRawObject's write-back partner).
+ public static func sealRawObject(_ obj: [String: Any], existingEnvelope: Data,
+ dek: SymmetricKey) throws -> Data {
+ let (keys, _) = try HomeCrypto.unpack(existingEnvelope)
+ let objJSON = try JSONSerialization.data(withJSONObject: obj)
+ return try HomeCrypto.sealBlob(objJSON: objJSON, dek: dek, keys: keys)
+ }
+
+ /// A brand-new envelope for a first backup: fresh random DEK, wrapped only
+ /// to this passkey (via its PRF-derived KEK) — the web's createBlobFor.
+ public static func sealFresh(_ contents: Contents, credentialID: String, prf: Data)
+ throws -> (bytes: Data, dek: SymmetricKey) {
+ let dek = SymmetricKey(size: .bits256)
+ let wrapped = try HomeCrypto.wrapDEK(dek, kek: HomeCrypto.kekFromPRF(prf))
+ let objJSON = try JSONEncoder().encode(contents)
+ let bytes = try HomeCrypto.sealBlob(objJSON: objJSON, dek: dek,
+ keys: [credentialID: wrapped])
+ return (bytes, dek)
+ }
+
+ // ---- write back, preserving every other passkey's wrapped DEK -----------
+
+ /// Re-seal `contents` under the same DEK, keeping the existing wrapped-key
+ /// header so other enrolled passkeys still open the blob.
+ public static func seal(_ contents: Contents, existingEnvelope: Data, dek: SymmetricKey)
+ throws -> Data {
+ let (keys, _) = try HomeCrypto.unpack(existingEnvelope)
+ let objJSON = try JSONEncoder().encode(contents)
+ return try HomeCrypto.sealBlob(objJSON: objJSON, dek: dek, keys: keys)
+ }
+
+ /// Upsert a server by url (last-writer-wins), preserving its position or
+ /// appending at the end.
+ public static func upsert(_ contents: inout Contents, _ server: Server) {
+ if let i = contents.servers.firstIndex(where: { $0.url == server.url }) {
+ contents.servers[i] = server
+ } else {
+ contents.servers.append(server)
+ }
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift
index 6597150..22c30d8 100644
--- a/ios/LChatCore/Sources/LChatCore/Models.swift
+++ b/ios/LChatCore/Sources/LChatCore/Models.swift
@@ -14,10 +14,16 @@ public struct SavedInstance: Codable, Identifiable, Equatable, Hashable {
public var userPriv: String // base64url PKCS#8 private key — Keychain only
public var myUserId: String = "" // our roster user_id, resolved on first roster load
public var groupName: String = ""// cached instance name, for the switcher row
+ // Whether this space is starred — it populates the rail; the rest collapse
+ // under "More". Optional so old saved lists (no key) still decode. Synced to
+ // the home vault's `starred` flag when signed in.
+ public var starred: Bool?
+
+ public var isStarred: Bool { starred ?? false }
public init(origin: String, deviceToken: String, screenName: String,
userPub: String, userPriv: String,
- myUserId: String = "", groupName: String = "") {
+ myUserId: String = "", groupName: String = "", starred: Bool? = nil) {
self.origin = origin
self.deviceToken = deviceToken
self.screenName = screenName
@@ -25,6 +31,7 @@ public struct SavedInstance: Codable, Identifiable, Equatable, Hashable {
self.userPriv = userPriv
self.myUserId = myUserId
self.groupName = groupName
+ self.starred = starred
}
// One identity per instance per device (v1). The origin is the stable handle.
diff --git a/ios/LChatCore/Tests/LChatCoreTests/HomeCryptoTests.swift b/ios/LChatCore/Tests/LChatCoreTests/HomeCryptoTests.swift
new file mode 100644
index 0000000..380a805
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/HomeCryptoTests.swift
@@ -0,0 +1,68 @@
+import CryptoKit
+import XCTest
+@testable import LChatCore
+
+// 🤖 Cross-implementation vector: the envelope below was sealed by the home
+// server's JS crypto (home/web/static/crypto.js) with a fixed PRF output and
+// DEK. If Swift opens it to the expected server list, the KEK derivation,
+// AES-GCM primitive layout, DEK unwrap, padding and envelope framing all match
+// the web byte-for-byte — so a backup written on the web restores on the phone.
+// Regenerate with /tmp/genhomevec.mjs against the JS crypto.
+final class HomeCryptoTests: XCTestCase {
+ // Base64url inputs from the JS generator.
+ let prf = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
+ let dekRaw = "AAcOFRwjKjE4P0ZNVFtiaXB3foWMk5qhqK-2vcTL0tk"
+ let credId = "cred-abc"
+ let wrapped = "LfDy9OjW1vWNWfVcpShcxANG7mcWIpsrDtS2yrlhic7KwdN1abiQIF8zOg7EdHF5p9oaI0f09LGg_Wvl"
+
+ private func data(_ b64url: String) -> Data { Data(base64urlEncoded: b64url)! }
+
+ func testKEKUnwrapsTheJSWrappedDEK() throws {
+ let kek = HomeCrypto.kekFromPRF(data(prf))
+ let dek = try HomeCrypto.unwrapDEK(wrapped, kek: kek)
+ XCTAssertEqual(dek.withUnsafeBytes { Data($0) }, data(dekRaw),
+ "Swift KEK + unwrap must recover the JS-wrapped DEK")
+ }
+
+ func testOpensJSSealedEnvelope() throws {
+ // The full JS-sealed envelope (truncated here is not allowed — it's the
+ // exact bytes the generator printed).
+ let envelope = data(HomeCryptoVectors.envelope)
+ let dek = SymmetricKey(data: data(dekRaw))
+ let (objJSON, keys) = try HomeCrypto.openBlob(envelope, dek: dek)
+ XCTAssertEqual(keys[credId], wrapped, "header preserves the wrapped-DEK map")
+
+ struct Blob: Decodable {
+ struct Server: Decodable { let url: String; let name: String }
+ let v: Int
+ let servers: [Server]
+ }
+ let blob = try JSONDecoder().decode(Blob.self, from: objJSON)
+ XCTAssertEqual(blob.v, 1)
+ XCTAssertEqual(blob.servers.first?.url, "https://test.luan.chat")
+ XCTAssertEqual(blob.servers.first?.name, "Test")
+ }
+
+ func testSwiftRoundTrip() throws {
+ let dek = HomeCrypto.generateDEK()
+ let kek = HomeCrypto.kekFromPRF(Data("some-prf-output-32-bytes--------!".utf8))
+ let wrappedDEK = try HomeCrypto.wrapDEK(dek, kek: kek)
+ let obj = Data(#"{"v":1,"servers":[{"url":"https://a.example","name":"A"}]}"#.utf8)
+ let sealed = try HomeCrypto.sealBlob(objJSON: obj, dek: dek, keys: ["c1": wrappedDEK])
+
+ // Unwrap the DEK the way a real open would, then read it back.
+ let reDEK = try HomeCrypto.unwrapDEK(wrappedDEK, kek: kek)
+ let (out, keys) = try HomeCrypto.openBlob(sealed, dek: reDEK)
+ XCTAssertEqual(out, obj)
+ XCTAssertEqual(keys["c1"], wrappedDEK)
+ }
+
+ func testPaddingHidesLength() throws {
+ let small = try HomeCrypto.pad(Data([1, 2, 3]))
+ let bigger = try HomeCrypto.pad(Data(repeating: 9, count: 5000))
+ XCTAssertEqual(small.count, HomeCrypto.blobPad)
+ XCTAssertEqual(bigger.count, HomeCrypto.blobPad)
+ XCTAssertEqual(try HomeCrypto.unpad(small), Data([1, 2, 3]))
+ XCTAssertEqual(try HomeCrypto.unpad(bigger), Data(repeating: 9, count: 5000))
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/HomeCryptoVectors.swift b/ios/LChatCore/Tests/LChatCoreTests/HomeCryptoVectors.swift
new file mode 100644
index 0000000..fa6d08f
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/HomeCryptoVectors.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+// 🤖 GENERATED by /tmp/genhomevec.mjs — the JS-sealed home blob envelope, the
+// cross-impl vector HomeCryptoTests opens. Do not hand-edit; regenerate.
+enum HomeCryptoVectors {
+ static let envelope = "RUhCMQAAAGh7ImtleXMiOnsiY3JlZC1hYmMiOiJMZkR5OU9qVzF2V05XZlZjcFNoY3hBTkc3bWNXSXBzckR0UzJ5cmxoaWM3S3dkTjFhYmlRSUY4ek9nN0VkSEY1cDlvYUkwZjA5TEdnX1d2bCJ9feX2_FHgHiOgyPqUTQAO09tkCyLCXaRzhbvfCJLVSZP4J7jostzcTdylEB7yKaLbJb2yS6GsgM3HVGkiUMfIH580UEjTIqni2QfLybkQbu3OeToE9axHhBjDlt4qPd5Su-ld4yEWY7-5iCuXlziWMBt0UHkv6v6hwdi6jrQfUv3qNv-ZZI8Ybdl0CGJmpDXIrsuuFs17aOx83IKYo-hWAJqToYprcDpMPFiJXsJXLIMUK9yJVAj8J6pIab9jeIHztUBLNliHy4XWpQfzYAxrfImOc2z4D6GG3E-JCb8okgr8lDshK3cTj1GfSIIxVsXEfgabpaRJJ9Lutme07YEMFp5XP78g15vyjt_DjmAuk2NqIctGUb-uE2npEMkCVQ0LRmksS0GYgBOUH1sfHYPBTahSOrZcRmPXMr4XCgfFvoepVNep-GGhOfyuBlOdEmRO6-p9_MxZBkXgnwrCi3iCj3mBRWlSNTBtNJQVaBmkm2YTnVxxEkOCILPOxTtm5TQ509EoCdVekZPnHSDIK2V9kVNiVXRNknH44anT6vkFcyTUAHqaW2wZZvjzc3W368P604yXnkXyFPXUuzO1dVDZB-Lv02fp7fZ6iDX-Z0j-cf8-IitnNHU0XaTjmJKxvgVaLjR88shGxnQOPalxrk8SipMUrJK02-nMIgsgEaxpJBYrbswykUxPS_xMYnoH_m-4RiwqbHB_BkGgRP_mnZehrT9AyA5SJib46nTHSGTCaZo393aRxwjbsmrHSBYJX8KatKLsMPQt4TpxpzK4Lp7aFOhrTzR0jA_4m7iNWMyuiQVwvhtJtaYu-oWJSXeotIHNeAD60TzSeIuIN8iRtmIkHQyYABQrA8TNxYe02DH07Jn6KWhx7Jw6GTBaRr1jsyf3_XbfOBFWqIN8GJAt2Y4dVY-TFAGNeok8S9CSEZ1hp4jnfxtbwDb7h8RfPP0G7fzw7KznVZq6dJvZEUAx9bvxVx-WSo4_gj00WM3yXefKYQtfwwcqjEeH3oq9u-93TOUVEG7w1nKmTf2O2JYcOqE8-VCeghfjpJgvb5n8Omjc8XwrVD1yjFvB-Dyy8VG858_i-MBDkdeCzS5HV9E-Gaqlz-6Z71VBBDB9HZ6rQxx0Tb6WDfwICLYbZPrxNY_D9otozy4-sRwBBXMzvcoTOpoDuOFPSBpGvt8ZK5YzeUVu20oPVteWtDvIfOPdYI2Tspt-ZVK4KjFkq688wKSyV6m_MoJdEa1Uc-ATjaZRMebDy0wKNCEM827DStRKLJnXqEinkEC1jS5eae7wOGWHirVcAGElYK6SgmDYfujdVb1NvGz17cuNThUc88uQ9IAff8GnmCSSZdSug8GrXlVjSGNvqt49jhcVRjF_buCAwQeHUbRX04EujXM2IFMmMY9kvoyc4wq8GprFn2RApdt7mdQpKuARGspQ1FYlve2XGgzJXLahdo-Vi4XtDuDDHKtzJ7XfWEz00KO-yD5wu6fZlqwqbSLDUXjBh8AAeJdg_dEBEea6ykOVaTVrrv9SZ8RUcQlD2sWUGOa95f4tCmAHL4r58kOX7XphZf9-ytL0SSiQ119agA024dqjfzGaTYCskSeezIsdKVGWayWbQjI2c_9MUTvgBcu_iUEW9CmZ5jdmpNmDeUYvdWCbxyCgXC2QZ_1_m5MR-7Zzgh24lbTKbAsVWuj5tTHR2ADxbNjmf_s5s6bBwi2Vb-Lqqf99zF_idh6UdGyOl9QTR4nhIZccNC_FQVaNIHDx9tyVmksbikXpJ4nTI0M1cTaOYFrBeM2E6f8x-pLNr5Xfd4NAS4IzDtPwmfu7vkL6t_AvZmndKUJBr5pGMw8Cv5Fju5qk6-7kqopthUKcYKroH7Tvf0W9ISfliVNf34QG3mKYzDbAMVjxItuAymBDxa9Ch3eGpe-SA-gsvkSqODA17BEiSSF7uLkiLGulAZtqiV93UHS92lL2md4JEByVh4V4xcLYL_4Brpp0t2AKqlYrVIMg12XQinZAC32eQtHaf8uJqb_enGmY4GOge9ENH0GKixMciPjxWDcCbJMuJLFr50RjpV5KE-N4ddUN0B6A5wgFpFGWowDDdh_D33RYWZ8ydSHbGytQFWdbAPfAKVAY1646i2JjDtnoLJeSIhd7jZv71uIkkmlXZY2YixSc9YbRKyXEGpn0eM4m0rkpts6oBhN8RrlQQim3Tt14JKX0fk45CAerPMG2tYVclqtoy2ex35OHu1HihYlnzyDmagdlIhnf_wr6ypGMQ3_8aj6IohxTfj21Wb0G9dKu7uB5LtLX0gm_Xc1pU2TdCViaNzxYFmZ-87miPz_-zENCIT52_m04GXpLCUTs9GtyP5VsJC2Hw1iOjzbHd_W9fbnnCzHwwH6Fr5Gsc4xbWA8ZhgqMaLkbg-ba6jF1UpgghuKvhYMtdgD-msmWarHb11Ewo1d-D33oFjdO1XyC5QdWrF0cVubufp92Xo_MQ_r-W5C5kh0xwKFCY7CmkbvWMRAwdBGPMNA3HRTh50ITXxL-mI9f_bGbdA1OuUmZJkpDnYBChT1_78K3S0lSZVX46VSizh80m_6Usc9s-fdvT6ztgnTTylAcwEZv-clG_uZIZgMVEgHwDfC9ol_CZa-cSbAtiI4_I0_EsSujg9a4PJ6uCemXL9k1o9UUlqQ2KqKR1ANiugX0cWl2ftJL-CxAj3Z6HD2msA2mmBVMGzPQHW8EMivVm6plWJVKDvSx_2WRZ-Nw1YchzziwhGPxlbxZPmfGHvJ7gQDDe6rR_tSpyhSGp2QAoOZFXNaR2TEA1N0zbtQTOyuTOHfGcsnQsNQu7-OVTiufRkatgkwhPAEKWwjAAbKkAU55JjLBP0VBoAgtdS2sACNetvSGp40QxoVx7dmlj-x3CIbltHpiNGag7bnHlen7qkDn4xWCsm_V2db3JgeZbIHz3W6RcinNsMaNuL1UMkyWEFRtKGWfFbZdOBl17b4d0LtSA26BqfTfa1P-Nl2ITzJSLZJ9-iw8AK7lTe-tbdbuR18f7bubwyWO8bveBY1i7CAtas3i8wKZRRjXPa-MbLh9ylTH5QjhXi47_hlDCSzSTtWUppe6sS9FHpvCH-A22yIzC-7ipKOLHeK6u-ichR6-4su61mFQC59ar3dwFw6PLqwRmvV-aSdzrVkJifmMboL8vbyyHbMHE_GI_SOOiLiQ3CtSQMRiPAdk7sr80jLkR50XEVjfDfJv2xDXBo-N-9V4joRmBiv8txaXauwR3mUHbbFzsOg6dZir3uD2RUXtjQEpdedMx0S6kxDI5YsKdj7tjf84A0idqPBq2ExUlkpJK-Ww8NFblmBxSWyj5_KbnLW8C8uB3H8z1vIEqvI11h3OotxKhjSghqRkAIvIYNml1rqonXM2xe4syDRV1-feBJBzkInGxXrujjo4wGRsAyD9kpRsFx3pfBAeMEvE2pOfp5wchDEyAhv-m4h04xEUwUB-ns7TsnIIbXOPQu5cM6YYci90y5enE8jYPEAq-ZDLeKMHC7nBNBe0wQcHOik7QmI_5bn8CRrmginaSTJgGvgaseegkheTULmXgDeLc6XE27uXlreAJaKMUzRQH4NLDchgizHTs_YLSjxYZSUP1dzvmrAysrkGf7Y_i4LY1LPAf8tpx5Ci_Mj6WeuS84ZkIwSfSx9GH-Ea2hSsOHNzzliDoYUNesXTwDIOYVoKxU06v9Q_cyk25K2YjrFPLn2MQCq11QrnoMGR3lqN7oJgsn2qXQ_7XtNKZAND-UQBikczrXB5BFA_kiMUqv260juAhyhlMXUCZlAvQ3bRkW9pd4CVz3UQGchkLln9vJ5CILtSbWtE5yf8iAh7YNpHXIQIW7fFbJ_-I2qSVdW6Ajn6D9ml0kWkfRAW2lXDzAu1Ck3bhaQLFM-CLh7t_J8jOxh5Z2-haUGVuJj7NDgB5k0QIOi-T7o3OboX4EVqvt1DWsAMs69hDUssYbFVsNTHGw94wZuAoHop-ezX1X0td2K7pUqXF8kf-CRr25z2U8YozaRMOg2v6zCn1Q0MWaAJzduIAcSRuB4TE4IOi7CCADFCMNl1VftzS3o1TVin6W9NA2mRhweMzaxFwuiLu_FNEBkQ1PFi16UoKLLKfG-xJwcz4W7SaU4Tv8-5s-HjpJm4Tx8QtvFSu0uZL9Rq-67kyKeKgA-Ejf8SWEd4L8KLw_K26W1RC5kFqo5AXEQ_26PVJrf0XoFre5nqnsvXwJStkBFkcwmY3w5kqHE4EemzuNhFnmq_r4bgd8y-NlGjURegy1xKtDU4F5G78wwoAn0YLpMhc7rNVWPN6ckSuo0X4ZYgtiWc8LPcrqB2bHzf11G-TxyNo-GldgvsFzDcJDKuuSUN1uX8FpvkdvPy2gANvozvMj903SqJ9WHpOxMd4DaTzHuaLHhwrsAzKt7RrcWSVGxkxU07-Bg-Qh7VaVd-ZsIRz6m2jrv_CWVx5N-RiRasFzZQ-C42qvkOEulg5zLHjSxoXyUV9e8DqavTdtoDy-fZBIVYRMS0pjmbuDXzGRuMUlTdJMq8fi1yhMdKR4U6O9g8gY1NLaPYt4id7i7dMuNntrD1vXzdz34PXI3IrtfEYPKG3jcwv1nWNgLwfjakfSCxuNrv00Ki-4aIGRpCpupQkruxDAc2Puxz7JuZ4oQOCsjIM0THl6pkIgvZPZCgvgrmOVnt6LnqCd3xJ9on3uO7BW1iaYmUzAZWcQ5dKDULoMeB1S9DY6aAmXqYA2utAB_0yGNW26Ek6U-XWO2gJFFQwlXfvRFldcROsWnTI06x7UL5AbkgEx5k_BzArQXOTELFY2LMq7Df5shai2t1kPSAcUWXxrCj2jKCROsK76ZzV7vD0Jthj1JUygUTetvQ8IaBcdaaMmsBjMdaTVC7_c06lhezkOD2a8kaEi3xaqSlgpFix5_HFGYKSkKH8vlr0et3Lv4h_g5pHqs0j4pTU-hwQOtf0erkXWPkRlc7vpdTTmI1wzEowRIZsirHs8vJTetFrjKG0KIv8S3knfziCVeWIzUKntA8xwhzgHHmhKoBT1yTmF-zEHl4uWoMV47dKkggAaxPYUOXBwhz91gyN5JZuNmWqtxKtTIVg3aKHUr1rqebc8LEHcI2DNx2xAISDBrros8G_K_fJu96RXY5uDzMwSp8jG_GN8ctZOe0po9TOw4sLgLAT67M7gV2VqKqzks4LP-mJ0SHeVEZmfv5vM9cMarBfQkk4lxsGYFFxbENrh5p0s1eCvjktIKxW1a69mvuJilCqsSEv1AA7K1ntjVcEA1UkJFKavG6JshRvbw8Ven-l4xTK6Zwrq7ivtOm3k8cALj1Ok3JCuNhGMBbwqGxbdmeaQpDVQTtCqGSf8hJsbrXg1BXvaliybDEYkLUzVU2kJ5qC2b3Y3zxnK4p4-VdY8Oe_t53ecmv-wky6Ot9NCvG5Rk_dzL0xE1Xo2JCDgwLTlpQ8gShYQKHkDsl_-urWRNZ84PMa4_ijxjhMF1zRzYlNaIS54_LxhSehLr2LQQQwFFfXe8nUaut_YpB-2uv8rv4JAP0iZz3FUm-D-X1p9wct64goaiY1LzbmOd7UB2PTIwvopS12593BQQJY8eimnarhX3TmfTkWG5aN7R9Ad4jBr945hj9JbmMsj9tQ6B4Bmz9lh-CaGqU-nc0b7Yi9NiGVegx9SAm1zB9ATh_UPLHo--pVAWqqfed6nKpUE-oIV2bE0OYuCmnXAC6gw_U9rx7ZnpRTgGTouRXg4mh3iFJrQcmZcXaJpeINQsJ5g06FZBes3pNenPvpiHqt5neOxt1BbVEmRTR_hbJeZF_ZO3CilSjQMpMR3oSmSIIRP6640wwp5ncUo1ymfkOkHQy9PCXH1GgrrT5g9PS0oNTh8za-nTw_NR45NB704a2m2vfuMI_4Cy6FBIT1LNQr3xhlb5B6v5Vj8Cbf7C9E5XpxgKDP_Fq9IWHlsJnVWTAb1udVJShj10_rPQMs2xe610-azOsxftCVI3fenF3c48psTEleJAzhCQlf1fo__fngKrfgGTh3ho1kQrzgLV_gaDHnFKjPJo52bqSNzARtEq_QcliQRcsx58pe2SQY9a7Y-ygo5hVtwS5z0bigAksw9iuOb9kVIGfeiF_RAmkEwqyzmB59GBJiMwjWGH6oqif8lEXTLvcJv0rvvG9Ijbn_Vm02ojfU_ynXv0axTfko3ZS_UtPC3_LIZqxtNwklkTp87bAtXCwDcTBru8KjXIhvL2KeUHsBcIGahEI7o8anZHsWcyYSrT4nn5DZvAHpKA90w6D3TUs3OQF1JbNcMM51JBjFWYmJCLHqMJsqUe5N5n_qmUId8_qRZuptZMOSq5QWvE9Ebsk6XF1kIby8dTu5oXs3gkG55dVpKZJKDuTUhUB-LXa5NFZ2DnKUZrHaGVyFcHY77272GA1IVyDFs-st9ZDwqrCNn1Mt1t-obc2p69jUGLfcR4VIaVZgxthySHKDNimigx5ICjnUio7pyKN3XdBeK3cAjI8zbrSvIKImBkxP48CzkOmuFRrrGYlb8R4C4GLDxtzzkgVqVLAPwpsFJTfjYTtNokIG1Azigh7hCRKa84fPXIQ1DgWHFffIyU8MYsgap3iYCKgLMbBkl55680t068kcSpnZOzBAbMJ9o-xG5UsOOjYbkISN66YWcvdHU16GDt9UyFh4k4AOgg8AjyMcacqrWNlRCPUiGkdStwfYI-iWnLl05bMeCJHRGY_QQjo3WNlODZKiNaUQkoVIocqptt8SGzlnYr-n1jX8kWlLtGlJZMMbgyoHAKl_JfBJJ2ZmR0p2unDMxmQRp5R7F9dP9O1qPcgd7NsW6c31g5_jx36I4zZ_IEL4IEK_K9zgT5JCfRge1dUNjCRI9khuoVTi8if1QuhbdwYAis4Ta2SmppxP_ro8XEemtSUvofAVNoAJUvUlphspavnuJKyBQCV_cfhwsKOfr0_2spReSMYckNh6i3wwpRQvkUsbqf7CKLgnEdNPdATK_dZJvhwEuaZuhUsz1MagxTLk8wjhINZGvgS4ux-OKZ_8T-8SCmOfYEqa3oqGr-3LUi2Kt1vHHEcVgmk2UVr2G8hyM8A34SgQquvqlOmc2WkFIc60dkXImc6Yx568U9FN7tMOWDTM7PCEXq-iYSqvdCfqxFqx0L8Dl66f3YA3mXO_O-J6ku0XfshzB86DVJ95_bEE42uxB2-rpnprFZnNPm2neCLdYRdAVA4CFwHk3xL3_i7uv-G1y5Kf7ESDwB2vmBTBfrJ0w8h6h4eYamAFfR63UOXh0pJIKLfGRWQgBLhFUBDrWmxcPV7rL94OCvZ3_sFfxTt-Xm4RHMPWEEHZoFfjmYe2G2Zn_uikOON3WC0paiFOI1YEIvfvAekXwTOwi7dHt3sNKjUWmJLbhxp1yYNk5Lfx6tW7ZWHxS7zi-VP1XN9ET2Xj5minq1p-KgHeXmctDlRI8OvtF3Yf2v1HnKSMvMkw5i5Fk_27QVrbIWcYe4mIT4WsLYXOn5V2b2jzAcgjfhZKYZSok680aTNpt3Qxlfl8TyjlOH0X4xmowaVYDxAMugCYzKeG69V2kXeT2enoKIM9sqYhcV-c3w3j3T0g4-bH7TrxPNVR-gFIdmZTPquloHOb-qi9xQ_HDygZpTdJ1fOvcWrtWFmOaoxH9jFUvHG6cAqtHZF04afoCMQpTwvhTdsqlVg7UsxX48pXqRGSCVHa5gGm_ictjQwYOhhRxJXqbdh9tCHhvPGs7MxMA5JtP344dAxHOXcScAsluQoSPJI0d6Cxh-MBrkBiPmJOSXBdqL51xRFQwI7Q6Jv8l9tcpE0WGR-_EeuabSGgYHvsVFj8znTf_0_ch4iAGeEl5utVN7Jm4LFYcN5ZNb-DLTk2HTmhSlVyrqBwScz6S1LnwcgVv4qy_GX5qTXzDHDmQBpGq9Wzl7nxCSElxEGMm9CgolVZL0naAD9Jmd6ZsvPDLTcUYaQSJrEgoo6iSf6U5MWXJ4m8J3noJkMU8G0qHGK_jwlKvtoPXm1D1cNzpZY0XrkOHgyMS1qKoWPX2_28iWIvk6mgGFzIX2jY5Q6JvdMq1lWb9nBl2EoKv5Es3c1KEXuiC1Z7I1jPIV3mbVRfkYEARQY6HahBI1NHgki1imEQemFnUd1M1M6lAxu1W7JPgOn_OT6mWI7NHAaUDjFMrmO769fRwYcUHURk9Vg-v6svjBmY8Wndwu9JNFXqXtEBpQfeGIk0KDBe6WsgIkaSESDiRu7ZlW0HYHix1GEXTOwrozs6XU2LawtUyIYxBjy534bBCaAW8GsK6dI_xefU4QlPGJv1i69R3_fFpueL4hFL2TtmxnWkxlbgyf2K5QETP3W_-u6t67gJ41uKcd6JUGJEXIXWsoleu8_BeB6wweMdWxTLdiw6aE5hCKe9mNNp3Z-sk3fCkpLIcVL-5YZYLrVg4AHUDBsHSwIzxZMJT7zDfH-qmrI4zDqhY6OcOVpU7Tpx303yVJLs6VhrhpBbWNOKmkmdiWzVuNJAfXlbjGukF2ruQoRsGxm2ihvWiqgJ5pQRMWsBjwynh8vm0CQ084NiNn7LhVuJADgmnvgJS4SPDwjyUpZOHAOpHAKwDHW1Dr8dR0aIGoJLSBgeTa53zGUT8vlb4cIhqyjcypK4gjKeO0fXd73lFLsyjjvFsnFXsFX1YUMJ25s-KhqnTUU0l8esDwxUSaxbb71-ATJZ72mUcRIXXlOL62AVClM0h0W4WPmAH35AyUw5lgV7kL5bdtgIhvZWWKEOHGDpm6XhEYAexM8w3sMYt_oFqM7L7C2DoP32kzk1RfKRcsy4fEaThKWWWDKI-gykoKJieHdVg8pnmw7H4bskJqrqb60TxybMJN1YnoSoc0tF8v9p0LJmDHKPtH-_nygzRqhVZuAqwcBNzOs7OJUWddjQWM8MMJH_g0kCF6cejv5kJHbT1JkaZXGxtBVtztJlJZiYRBVKVD21lEfCAFaxeYvNPd2aHcTeVz_Z2o1o5nBWvw4JnAfwv2Jw9P04IESDH3N0-DQORWT11oqrMu3DUjakaug56OKQpw2aQJQj1G6MegTBoKE25TH9nD5r2pvWO2jJowGZ_needcogd3gPJh-YY2SY9jPKqmNX2rWdPY19UW0kZ8MjsE9O7p743LmQ2NRll2BPntZ9F9aQqx3MG60NgSPAIdwfXrvhiWiQ4zGWcC5bFdweKYIdQtBJi42fuXCzvLJhIqdtSI-fy0Ah9iTZIl5LQXZe5gukWnrZsFluSlkLKGCFsTEF-VeMXT7H2P3hypfPrTX0TB-UBafXyVfr3Ifrkde03oarnxF5zO6SWI_SqB1qF-CEnV5AcCjsbAj7uYfve6fSzhsi1ayaqt0Atx_BZby_AdcI9aa1u_G8CYdnwyfhsCAIdRaelmm5JN5ihQYLOWaYyeGntcpUCpLHrKbNQI_1CreYQVwGhpksS3yy0eSGdNg7Fx0uFXCCIE-lkR9vUixOqLVQmXuUf4dc-fhTwUIoQPazbQEdrvuIWSOWkyJz5f8QrTNbb2wcrPbuHIL5kv1ofi30-iu-ISDmhJOCZ2IT_eDX4zq4yVClEXZAHryQYCAy1bmUCcMQN6Agpk6fYzZNOxbnJKiWA036ftJFXSCCoAahIg5itLgbrrLK3zO8d7Mz8u0lQkJKO9wBsuDGG8dpttFMr7i7FqCiMlngOoE4c9gv_FYVGAmBozlcBlVs3rKmBsGX3VKaZbczzwHT06Vk2MSbJPR1OLUu6xP1tjJOwGv0ikOf8cr1-NmJX1xnbTkaOOmJhyfFv_UhoosFDNs8X9J2UUz7OyR9CZI-tzISm3biIpXO4ugo6PxJ6rT8-7mK-hgWNQnhE4paz_MWk8mJr7FNsCYTYSvgYii8PohC5hjvNXnFTsmw3WILsN7zpdph7qPGU1SmMWPt1XNrmvRdS7nvYwOxHmuUInHdjzv6D9vGQcQp4Wx3L-6wrVHvAkiRAsiwJi3q6ufdOAFloq84OZg46KDWYhqvbo1AvehvX16Bm9Eqxunn3jgSY3rgX6CYBm4lMUZyPrtu2WgckgJSaHeEvTohNEo3aDo0M0JQO5UXZmuEI1LVGUhcVrlufDWZaRozoV02l-7c-YaAvRTCq7UkiuwnymZNZEM1LChpvcIH5EHUXJ0uR3D-1IIMakIYPqa4-mFryx2liDFWxWLIblDBZR2PDgyJf0t1Eab76GlVONCOdBw4e2mKo3Hgub1sFBRCIAHaDxJX8y_ujYMgqh4lAuV3oNu-ePOoJvMa5tMDjScZv6ZX_bTWIsfIAgmxRhA7vlIoFRFAVNbPbWBxsZNAD_VCX5OLA4udmyEfKkHBI62i3cuQKkHp4KZ8rtKwZpIIRNEIZuYxR4LHKy5_litXq4V3ODGvYbpMJvnjjkJYouBOIw5yWeXnkQ-qmKhUDpeNnACit56zFBwVfdqbm9bjGvt2-fP73Rm_UXHoUFpDT2V2bBwcMNq9cGrW-bvGdJgwcgxDOCA-tTKRa73t8VGjU5QiZri-ILD_ADG98IHcgtdQCWVEHerX85-A7sAHHY29min2eAe5cFBSxnjYdmM94T0dZHY3raKk_PW5Tk1EfPt4G5EPeBb0bWyjkndUWm6Gc87P1aGmssszveg4fPaAkRs9KZWIbEmwMOemLXrjkMUB5vmQzQzhB7Xo5ZdlXmiP1qJKgbVxTlfw1DCVd8PNEiQfq2NZXa7xfCfB46D5oUY2ATCGBwoBovGaT51ksh4yzqkc3GQy5jOYeC0NRS5ldkNpVg_yTeClfCc7WllRpvwCsDKWYjZTH5aHlYyaMe4ucpARij-BLevI98TC68TWmxeV9ivK_RWIdLmVojQsvwXBvErhjn9RPuSl_jJOdbzapr2mGiA-xeLaD6eVzdR0hE8HYGjaFigKhIB432uWQr26jNQlVgWm1bVTMWhVEUPYU4OdZzQJBNSerVvC-qtOM0PTEETmmqYtGowXNnl3xltzdR1QG-bNBF3t35tQUx0_XWIfsykdkrxOhU6vKDXxSY3S2sMLKZFElAb7Jc8rtfM886DFbt2QC4HS1fu4cvZihgMwqm0MygbhD1-_1I-5LrinBzBcd6y9k_h9vhJrPDvyskVXLf_sfY2ALFkQ0UcM4pwU01VR268zIqFHCOhDJ7dmARCiRhJCUk9egs7C4TTyxf4GAYLLh_JnTa9HZXacL2ltLOmaWVnSyJe-C0jgE6r19nbbRJokcbKQIwM5sx2SS2EasTc3kVKLa6IFO6Y1jziRBEuR_2dXtIhsFVnYZpPL5f5oYppg1yi7VPNBPMK95NbgjUzNAN6kPti5BOq4hyKwLmkAgQrzHUEaQbTIJk_B2B7yi7AzxCzACOg7NrtC4S01LXsNzTITbmr4jYRohj4DllIu50tSVnmlVNNQCzqla7qf4ADpHyaEjcdS4c72S3xWqQVu7LDJKiofx3_O5TtRszw-5VDbfPdk83SClvVsN9BVJ9Q74Z9DJ0Sj8Hqv_6uA4TvpEM6tRKsFdWIppPUe2ZBUZECVQzmqRx1k36VUsWzf6hLhXq8zzkFM7Ll8vIomzfLwTV502AVjEsOqnxjdzNNVLvqd24nxqUh34_t_ak3pGphZyujfkDsG-1-syCMGhee4G8P6XZFdU5rVuQxq856XFVGK9RO90SvUC7WMxLsQih70Tifn-NSdCaBD53pWn8cFSBq9Jd-A9PeurzxIZUwGK4rT5BD6PbU1Y75VQuX6dwqaXwk9LwqlEp7U1gmdxOiDUtPS4iu8WJexXmHiwQIWlaNxYFL5W3emQZ5W0PyGWS4eX_L370Ov1OZhbSeNi2_Mxg9hsR85TN-dOSy1mHR6Pcbqb61yQuOxBbjGGKpxQqdMOz47x-m8h9sHpKHLZ8YczQimoDJE0_4VYuNKw6Wk-DhV1u-DjVuAm9rWijO9YYNORLX0gEu5QYGNxX0X5FSD74S1j1WzsVwMtqxo9f0p4symLkLBWwdHNi7Nv3vjlvH9eNfxQDIZHAU4Q86wX_kPKxRaXs_wUE44OL49TyaBpu5MOfYHWwybkEKlZR2PHRuZI3-BsOoqnOyPolincM64Y0loPY75qysXOvuJWS8sKXbS5I-WdRwaDzNGnzei-6zBL9isBkdKho2AqsnVNB9yuXnqR9fobq11I1cR_HNqHsNiaYg8hfDcJ-PM26x34KhLeAWTnRj6XY7XFOQecW0pkC7PxHX6CHKGI4iTKyF2PER3Hm4N-t_s0xnvJ-_R2ma8wFTiSeFVwmygWlT_iBrabjj5gW82Y2lxUVxarTYykm-BRjwS6J15gwK67pro4gl4lAi5VNzpdwYPwAuuP8EB07JBiHcPmTlx6-KtLxhV6jMLzk009cHZV-Bk1UmrBUJ3n1VObRmHfab-ZkkoUJ16MkLBQpIlnwbNVLLR4AjVm9VxpcPe7HD710drZyajfBavOwAiAimYHZqkDiAB2rKswgRCgbbDRmXBcPmZxdTSK-f47rEKtdKLjk00XCS5fjRe63f2vVW2FKbmqIAMgcTyCyV-zdiuOzpj35wkiv59boY5krDZ98lQBnEpINaLuvw_Pbf502l21XdccUSk7mGAZAURl8RC3lWjZt1muX8uJQVFqooCojv7KE7FYg4De-cvkclkOUPcKFx9En2L_F6EExdf1QebGRCv3Oiop62-WdNBYsgfvZs2Eruma8m0oddzbBw9lonvPl8RN4hZ23h91jef7tJO7e3h5tN9knhn_WKXHh9WcNkPoad32ojwWEGo-vSGZslbCMAOuoH4iDwz7JRKyU_qZf-wVcVf0sQjh9By3W0YZwJK_fBWZ34I3ARZYxmwPR6jLU4ew111qo9pwNPJ843BsHgDHQtM2J7TmvAjc4iiKUALfOiEBZ439yQTIyEi__lzl9b1GHI8_8hmvgzcTUC3Kp8vzFMyNmJk1tOZAgyORTJ2oRAgV1r_wfUb-72baXB2RNOvvOnLtyMB8W2p9gtZx3xo3dtURWQ7w8_mKkkbV8PeaB0sH4uUtfpQgpDPTfNhRzS_L3djJKeb19FnOqZl7_G_WHzky2Y_72ZcFJRULeGAX2vWvdL3gZoOt5vK3akCXFY_n_ATrjtxJoa-kgMJhsZz2CnFW0GFQeYeYW2RYWeF6cfmm8pLO_P9zlja6P7vOZxHik3gtj5cjNYBd3Iv9IEx3X1k0SWeKJF0PaMKgiSQvCRxU2QPY8fwtzYFuhwymt6LBzPjkNYQivznhPrSyxqecMroSeUMCBsyfkKSpeYanOeXFaBzKVDeEbl9E2kncBH5wMTHUdjF5CJ8XMBxZl-MjB-7ZAqlUmYDi2f1r4GbotRiF6NqZI9rNjzBzDgMM_9sCiIAHLWESK1gwyL6uujOlYq1yrLEd4N7T3HQZi8FfZXfr9livEbrv_wDfDMaBY5ibRp_y3PNaLV7YZ5LYtwucH0E8LbhLB7ESovDf6sQyLewymjPmCX8gKTgFqiGT5O4zInaI5t8eVwVUDuRxOQC_UUGjye2GDa5hH89oqv7ehdfKCDjoUE0tAfRwt0fxV2EKItiYU5W32KQIoVXuE6usGMMTRtwJ2TNGnonOOsGqMFyRMUEsrTnrHDj2g16zx4BUhIjTFldSX-idIb5xXIY2NCkVgv-23xQVzUDTw_7yUuqMMZRK4oVjgpg8PKRHaf3_sLI-yd-8ST03nZjH2ZDboJkCs2zQG-kyfX35hu-FlfJvnX90miwgk3lkayKzMvVTY_xd8-a9ccOwlwWhfyDkyyEU3YpoJLQ8DIKbDjoIlGItyqFpha4m25jiiKL8YxeEOeM4zYBODy6CR-B0anzzmkjvGR81vo2SOTHdBHHXmkjPSC1EJp5grrFXMzeIj3240zri-2ygZ3bjFgZpb6WJu4HwTurbHd2T6smtnGLDKP2FTmF5aLGi82CAELZMLCPXSBkSMy76gO49yWAISpl331GuEkKAmjqgPQKtSkcR7V8eizCBeMzg2ePIMR1tpDgrjg33vX4cup6B_GOfMkWEsaBpcpxxd-Ei1lrjufnSW0NB4MDg60FhNVfE3fkM4p0Bp4Y6sDjsj4W_OCAxkt2jgaExg2XZdG4Lrqz75tV0CL7pFNVhBvT3uG5v-LoCHgnsJwdXxPlxA0exdNNdb2R2sa089svAUhbQJiH1-3PLA4P5NyJdGQr5DmQeFxMkPwaRwNRxfHcscQ3Dx6YGzSewmf2gQO7WZxG-Qr4DbZ8Ql4OdESFB7vHEjMbr21fsTaexSmLk6vPVqvd_89EY5AHzMOjzfmTrfA-VUh8lVI_DLRInhvJ5KU4qOi7BruGLaBVB41z9wyhn7bFxEw53j_MRg8QtsAR5KZf75Q5XK0EKdEij4k6DSEQEuTNpJlADxc0ZC-gbnhjWzkCpzF1Y9R9Pto_Z4OZkSo97u1cu_HSNumH2rStynbhCJgz-8tTow4t-9i6r9JVaWRVwKuWQWbiBr7aP25sHxGbOaC2YiRHWnmFPjKJ1TTmq-oM12fAJybUxtzo-Z0kvBsnD8Z1mkV9X_HcqK-WVAzfqH6KjS0aaMDwEn0yq38KH31-UvbUxKfgJKlfCcuVsv-UwAxYLJQ8qN0_gac58a7xpCNZuHpwBjYqBn52jCVvLF3ROIpKlFwtlERhMqKz_81NzYQ9lZb_8qjKRlKo2fx3Ch0U_Ud58y9YGtJeJs-UHYwCNOsY4krkFvBCt8HA7sFkj8Hfvi8GLMojlBzw7G09OxUs7G7MBygHtLqQwtvB2N8_CfZk7mX9rFI79ho5Usug-368EmipPPb3au9ugjVSMz5ZwEcVwTukI5ymmThk139sBpilNgXGn6U22vKjFIs5A0wAlggJfKXeu20vnVoZ5fCLyVeRDvzudqzuOZLfReCH2KZpE5eDOuZfqevQhlJxllkkyiR0K13yZ-5G1IZgGrcJYjv3tf3ADNepvTZg9PtyRWrnIJDjbqjaIQpv7purEIvBe-5WJPkJ4Ncq8FMKuvaHr2qMw9-nYuxHCMH14i6nMoehs06nWJqNrJe3oARtOOjvJgY7JXH9yhIXbxLUCA3i_myzea0l-1U1TQDBsDoYJjiW_lbILIlY5tON4XWXcYxr-KB-EhX8fYVO6JubUBSPu-05Yw1mllSF9C41xpZRSPWnMduRgS-niG9JW3r_tx60hkygdKq3fc0bGkApEyzmGIO3h1lpac5GzAp8ErsL6UWgoGBtWxn5DgvJUTOlLWfE0CcQoDdzcaYLUIWuySl_arGFS6_48FwAB1n0U7PxHLTtYsjfM_XyMcOrhmDv3VjqlNeFis_XiDsMne90q4auXE6oKea8KgI6ArU30p8ja_WaYZrCDw48ke7fYAnTwajvnqfn7eb3G0TW-UMdSNLz_KcVI89WvwYUXqk_fEQbrQInj8Y2FZ5FIjY2TCY-5X0lx13euo60azRwduaIuP3wGo0Z-i2FLvBbJFXwbDv8gaaxzBub5CpvK_y2ic3ClCikR6ZYGLicrsBHKALvDbo55fjzHNEJ72SJ_AHVcX757Qal51DZ_tvvio8bGwOMxbSig5hKOfTx18jQ44JG5UQIXl4zzYkp9zpuNicZ5SmL-qkQfCFTeqjoTR20uZhDzWoEi65LjVK-d4vEew2J4d1N1Dyhv0F1JUhrN1o7vfwc01iWvaaBrarX3c5I_RNP2RybhUu4Kdgy6auZmFjr_EKzTIIqRbk0ewtuAImACh6Jb6vY8VomF5T47GAOleKcLsf0KfIHPIzDoMJ0dkJAHeRu9623HRJyAnJW2T3qnbFnsd2kA2PEzqVfo0QkBufpPrYzsShV7FLpmHyeARLZeYKNNgI7oo45UCAeeXxU7y9u3zOAl4WFK2zofs3ci11Ay5_lppB_rbqtkdwMgaasqkJA2Dl34pMJX1HvEv2ZDmyjlNbBEW2nTd-PIyfSbMXsyMH-4iznGufshztvjenmumhaiHEgiLfceV4udX-HzQM3fqC6LLBAom9tWdl1tBDQ5To1m2Z90DC2V8Jm4O3lcFwZsamGMbmBAwyfFPqLtcdZRYxroNkjGoH8yJ2-dMvkCmMB2K6p0JzfYy0nD5D6p_W5lgZ3tLo0iqhoNW8GoMveJqUaAF0eV6Dh9slSrhRbpF3Q16xSJn8DwqGpGyu797FBnGe3AljY9NNb_APtJGWKC6XOrstSjsh0fhuAckB4BGaASnOHfZ_iBhJ7CFxzf0mFp56FNTNL1LDYTCE2tzk3VKqjqW5BeIbhf0jUzaSMBpVKA3YNkAbKe11NXp1HpiEI8HoSxhkTyjkUwO9_F6G1SNAuxz8kN7xc8u4Xcohx55GxqjKmwanV1dyorpWxIDMyMorudxFz-bIC8xEoGOUjjTMxOUbhwqBIJ4lWCigGow6j8DSzQYyLANgBzSGuDHF0nyoMGXDu-O1EYzizx8lISxAQZcNaXYAJGdWW8PuB6Nr7ugGl7P7RplyTLxJ75jYVJojwsz0VljuPBZBI1FcvfyDwm42vQceQOpO8YWJUJ8txtXKN5E07Kb4JF1gNFp9OSLMlhFrCQPzbTBTQslv0NRqEj6-CBeM6ZlS8ED7j9g4KF9NTJ6G4kzPvc7XUq-oIIphdTsMIzzajTuWPfZaGlgP0bZPZkyJnSnD60BbwcmFfqQ-Ef_-3rQCT5a_Eskr3F3s1DLPTAVwDiqFEzYNowcyCZkJ7f-8i7_Yqh9QSpQxMniVFyuMU-M0qgzYAFZqXp0EAcilBB3fh28lWXyx9wiGIKA98HAZBWJKMKdNHiqseSecOn8B0nTS0c-p22TDFIj0cLAOu8WYKVCc-U_zsbofxgbM22RCAp5HIW1j9kpH82CSY5gysusa4F5wNPJu6eAg0q-VTugWGu5zMOjkt38l5tmHXRbBoA9JiAtS-BFy4OpE_fAvm4pAr66x6c3d_tb2-_CCMDwPd_Dhbvd85xqLVJOAPxWt-hEilCZki-PnxYIWszua8bqQ4h4nGmOQ7HowIBiBswtN_Vu9cMwKLblIo-cyUC2hcySVjZ6Udg-Pq0StPCliqg1ypY3JDbdMGiLDgunSsmeC_jjMS_XNWHF41bBI5AeRjSUeNnuSZk3NUjQ2cymzeaeeIEShwHDq9XkZVr0FwtkcL7fmPoI7eBCMyh01SObb3f5Pja9glVcj9NdZpSWmqUXg2iSRBJP43R5cmhOnOn3b4U2hP0mJr34ivdn7kIZAw8oBtcs3tBIngIhNouWTC0Adtp9I9l8ynWIq4J5zijHyufpLnGe3vNH3rpVJvF9rkksnAWAEJ1ohiDN7f17oZvq9v7V3CaXXZcl6HN0ivxmmjTwJqyJUkV4-4axWnei6M5rpkQhGLEJOKLxdVJAW-wxHN_VRJcj4o4mXy3P6Scjl2iPZ4ncZerBK1xRX8VOBKu188V7tK3GDiMRfowQ0DNRONWtw7KgG5nYXi8mNXZBKrQ5s7Hf1Mqgaz81VrBRpIGOTMviLzRwD5R7RIXh_m6cm-mRIFeMHRgHJPxQI-UtxnMAesR6EbypI-0BWTinSLzeozOKENxvWyUVlZnguJMtSr3Uo3fCHw72CboWT02eWlIXWcnZXTu8iUn8bdWt6-ithJVqTTkzgbzdAwtuo2AL1i8h6j4PRjEhDJAm5gnGuUSt3UQ2r2ptqjfa6DR5qWhGo8ylpYFcL0YfzobEa3_eVZT7FPDqUjj8JbrIh56Rz0WV0G4wuMDbH14POeg_3N-qSNP0SXbHQ-i3QpRv-sTozLdokLHRrgacQCbYb0gQAlfrAo6i8bll-If5L6v9-pJ3eIdPMq8RElfuUpCxE9Garnw8ttBnXpPwZ_sjJLo6W7VZem57fswenf-OhYkaCrv5iQ3wuoQeSfk7Ktca2ogMIbZCk0gwxx-0as3bE5D-MnLjG6b3VdXngdqA98oqSE6Crl8eS0YNMniH3VoTt5-a4AILKfGxqsxKf75JHv81yXnYE_PFFeSfVYrJPEB7kHBbeXm44hQCij8So3oQ2w3cZC3zx9Hal7aBuQmKpKVSzQJvbOyIa9RD0aalAiMgIZhQt-jLEi4KGI1ctk1nEuA-hQ3SgcW2-56172wfCHvTxowN-khiD0qu-eyt3LQkpAI5nkTpyecPBkNTHFOJYF7J18jq8o-8MIp46_pRIhoYoW9l4xiG5HLe8QlwaaXmq1A0Afm-GwjAdzRhsw1f0e-6mditGvyWrv3uKYXcRz6n0APXTRH75ebBNQZdrDKcQOcV5nifp1ndAEU4uafPjZCpUKtPyT-bpJhYKD5Goi3MykXpMs_wdeHg6ctQlPnFHnGdqeqgzYHTkTW_KgjssFT-HPTRwDaXeXgPOzyyKmt61syntPMapgx4V2kvPCZcPcLFeQES15zYxM9scdvHhnbkzU-S8gVYmd15zAarpGC8mvF7hLIxXHVdjEwA1zwwgeOYSCxSK7eVlfyqPqpJ8QDYUJJJzkT02h2Vh_bAhxYYVGjfEujZJQXytES78Xqr_jiaiOrQr7NdNN16Dnh6UKn_yN_yqa9WPrOWSB5xfM3jWENMc381e-r8N2q6OdV0zhqeUp0FkiClei9q8F2ytMhUaxxCJsCy2Fk_VlNnniU5PWED-hPwiNiPyPyu_ArGfFgaQnSng0n1bhHk9IvrXG0psNjEx-UmQbeSRAxi0GcJtt9CN1XfCIWS2IhJ12j5fJrHWE61KP5h1rQAogA87_i75A964RIPebWIjSkOXkzMj533Cte6E9IfpGJg2J6jYsaF6U6P4-jTl2ZB2pJoQZAPQr6KTxqMQJrDHNi_fHDnJ1YwVJvdwusZFz5s1F8SQywm0pmVyNGkINM-oGKy63nNQijGGkCfDcX8a-2giQU229L0AYP4c92fn5AP2545gbKCVi_xylGV7k_hnyYmnCnGMomwgFvNAMssCxQUE0ZJvVG6zXQBQuDiiCyzwt5ScCozvSk3-lHlDyQTqzPVcLwCyghlCS27VCPZSV0vQ8nrk-E7TuJy6sQ7mThruQzJKlrub3S5LB-xNlngq7ZYF-neNfo0oTJbnv_2Xr6nONU-0YbnTM6wx1uyJijNiQP36oRXc-reu2oGmjFVKdCaVU34jF-uoHolQH8x-BPJQoIlwpCLoN5omU0p8XJhV9QUWrg2d1fedHSCtvfZKyoKwdte3t0mJsLD3dCsHIBIMXke1a6Y5m_9AqJt2vm3JC5R5DrnH0Y4T0gfESIxjg6mULNhuQPWgrrN1uB6CcRbZWFE4EhhQi0T3FpzMLvW0VrqFeOxyhd25B_7upnq3mH1condFrkdn-54e_XU12jodhJowjpzbSN_PoAOMLZWLW9fgw-JuSdpu1SowCFG3barXSrZNNXhoxe0SpKzhGKMPT9zl_TB3_KjoPrn1_MvLtGInzkt3xkGj-Hh75Vb3fok9BZ7zjccKz0dUIqrHcaRY7qtxz_po1ImMw-UgZHdqh2ZljnJizZn2x1YxpiViKIOpzkBZX7ZmumoH-pQ6diINMOxAxfIUqaHUYoQvP-fqrJld41AvE5mrH8jKJdEphGdfGxAWVpBhOcag0kNp7OO93WLmCvhj3tYXlYrUK6pV74_VdjDyzr0YaJGVh6G3CLdgCbxUez60HvkdX3KLF2fzAWwmfHP-ZLxjbxAQ8PwclinC2ao0AlL-lsoe_0ch1Q2Q356MRXtNgRtYs8JLHy-meFjQxUyZH8jIDZzagiSTfJmbk-7Mj1w6XLBUHI2Z1yd44HJHPzyluyBSPdvlIkxjvk7dongR1rDSHFR_JA4ER6fubJ2jHzBcyBw3m47dXoF0eEK8yZMSEVSdbdRJEVW0bQRhGpuZENfQ7JyKTJk8-DAHZX8w74a8G5575SB7_Pcbh1Aq8cZQ6375FugK-KUzNQXCBYAZYFNJsYu8cwijccWQDh7IbHmfmgv-5phfGdwaow0Q_jVxI_9K2gq1GqdpHSmYwhWoH66IR1H59rPUwKTZ_CNfLtGbJZtvwPi7ovChSM9kKWZD9kMLoEmEsjosBbCJN4U7vADDyHXNsJJLXQkr408aOqDSTbMFbiE8aEl4QXnvU6SvQIjOTtvfe7krK4Z6qNXRMI2dntglP5gmjnFRk22g2rAKTVcmEHy6CIFA0bFsNjtr07HpIJ8dvt8OvW5665YbBPyddzkXiZu66gVsqz6XlhWdGN21g6iDAB7Ge0s6eGb_ja8CyK32sXWj-RhEYzVWV_QiQpT6nJobYXLX260IPDihx6rkRkwR6GnM-doG0ZpX711ISylDPlC0bWpTJ0JBxCRubGnvo4SSmPOIZmWk4mYwa1YjVRbkuN-BAWzxGHUvNuPEu4zMZLUpdJsj3cNiCtLvBcr69spuLnM5RLsP4NFJZHuESKzAnSpCwZV88XPf1U4LHPivrdDtgQo2u4tUUMHjr2-gx9RZEQjJ7bzCtXUB2lt7WOsrTv-kzwd0OZInUDEwS7otTwNvqQ4h9VE7COyHXAh-4QukmHQeviR9duyabXuCQBgiOskmOoCP29-3VWA7G7pK_c2H0uxJQSdQGz-eIc3NjRm1fNeQyrQhjFlEj1fjKc4UWsfk4VDAVtarUZuAbNujCLLJZItJOms4tvmjETJFFglT_vlBipdAEQUsvVuyIUBNdI7zeJZ33GbT2PMdnZ-DY2QVoYqw3L7gFkj2z9xRUyyV8lRlp1nP-vUTyntx6DpzEwbBzuMfTINJ0YCeh-vObbfggeASy5qdrPylQn5kkB4X0tqpLs5PgDkllZrFQ4Lsvgh9RjTQ7k4zeQp7oD_-8gFZQgXWfjjpR3ap2xftGBIjIQU5YXXVs_pu_6O7cY3v_RSaEsabu8xLwjog9J3y0MRRd3y6DuoqrpespcpGCSBUk6Dgfx8l3kALdlGsW1NuVvQ99FUpYlEml7bzE7pQw9B0tnR_p5PX82dpDaIExYTExyPKJqOzyZ_O2rxO1YX9L99KAypco4kzzKCWp2v_lVKTMaJxDBCbtnNRi3knE0kiE1ZQjFMtux_inv0BuN4XYgq7FSkk7tkmt5gyjbkm10pOD9_KFV3rhHD8id1-FuPGCjq1uAA8SA3oEWmJ_sFHrxMSA-ikuHvM7gx_TVapKnt1CUpR8rWYXOHHNry0HI5T4AujfjcM9jQqXf11uUwmzUftPxepbwt-_iYefmeIT1AjT-86OP2cXbs-JKQnEEE1IPPk5FCUUWD_fLbSEOaQkqHFH36KEMOSZrZnFMDW9poaHT0sV6t8wCVDdOCCmw2GH2tdBdLEC1NxxG7yPM8q5RZMNqcMdFHylrpxl4JCmkj_VUe073MU29KFTW_Rbwc5aU7zuyCMtlzcXEY2849m_EWvjERp4GQ9iwk1BXuWgfdqcYH1cd2OEu-NKUPFrUc9DoV4kQ8CUuR_63lCGRkKnlJ1L-aIg8aUbVM0q6pajmrSfeBuw9MGwy2q6Rz1vPrR-VHWLTudk80oRh-jGNrJtOVQLzYTBaSsypFm2DbcZokawVAZVttkylkvaDajQ2-fqrDA5_psj5CO3wBLxVtNWXsKDeEx0ZWv3MJZNbeboqdk68Kr8GS0ZP4gIKtwGuXJxVsCHSoXaSzEYTevEqVhAbXpXhBcFWAy0n_wLUbBs5P3tv3_d40dkVLlL-GBy7IePuOCyu7xpSiQFYgInUOwR8UX-NtEto87sOFJUkaOnx_QhgFYn-eHdTdFQId9Hkr9aVe-aRx2py-D300PaeABmH-OrZMYBs1MAtfJH2avlbhSrXY4BtVWr2606Drrfg40rQ1VZ0nIghJIHpI2pJXC17a-YYLZOlohy1VdcQFi4Dv4FhhpkIcATiWOcJFdcOtjc7cyVBa3M-AaBWaLwD1jM56Ze0p9BXgMmOXB70Nw5oSf18G76I0Hc50sHYfOIV_BJ-GOaXWZPSuqUWpjoMDmHQE_IonP4C38"
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift b/ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift
new file mode 100644
index 0000000..f2ffb24
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift
@@ -0,0 +1,125 @@
+import CryptoKit
+import XCTest
+@testable import LChatCore
+
+// 🤖 The saved-spaces list helpers, ported from home/test/serverlist.test.mjs so
+// the native rail sorts a member's spaces exactly like the home page (same star
+// + independent-sort contract).
+final class HomeVaultTests: XCTestCase {
+ private func srv(_ url: String, starred: Bool? = nil, name: String? = nil) -> HomeVault.Server {
+ HomeVault.Server(url: url, name: name, starred: starred,
+ credentials: .init(token: "t", userPub: "p", userPriv: "k", name: nil))
+ }
+ private func urls(_ list: [HomeVault.Server]) -> [String] { list.map(\.url) }
+
+ func testPartitionKeepsArrayOrder() {
+ let servers = [srv("a", starred: true), srv("b"), srv("c", starred: true), srv("d")]
+ let (starred, others) = HomeVault.partition(servers)
+ XCTAssertEqual(urls(starred), ["a", "c"])
+ XCTAssertEqual(urls(others), ["b", "d"])
+ }
+
+ func testPartitionToleratesEmpty() {
+ let (s, o) = HomeVault.partition([])
+ XCTAssertTrue(s.isEmpty && o.isEmpty)
+ }
+
+ func testToggleStarAddsThenRemovesWithoutMutating() {
+ let servers = [srv("a"), srv("b")]
+ let on = HomeVault.toggleStar(servers, url: "a")
+ XCTAssertEqual(on.first(where: { $0.url == "a" })?.starred, true)
+ XCTAssertNil(servers[0].starred, "input must be untouched")
+ let off = HomeVault.toggleStar(on, url: "a")
+ XCTAssertNil(off.first(where: { $0.url == "a" })?.starred, "unstarred entry carries no flag")
+ }
+
+ func testStarringSendsToEndOfStarred() {
+ let servers = [srv("x", starred: true), srv("y", starred: true), srv("a"), srv("b"), srv("c")]
+ let (starred, others) = HomeVault.partition(HomeVault.toggleStar(servers, url: "b"))
+ XCTAssertEqual(urls(starred), ["x", "y", "b"])
+ XCTAssertEqual(urls(others), ["a", "c"])
+ }
+
+ func testUnstarringSendsToTopOfRest() {
+ let servers = [srv("x", starred: true), srv("y", starred: true), srv("a"), srv("b")]
+ let (starred, others) = HomeVault.partition(HomeVault.toggleStar(servers, url: "x"))
+ XCTAssertEqual(urls(starred), ["y"])
+ XCTAssertEqual(urls(others), ["x", "a", "b"])
+ }
+
+ func testToggleStarUnknownUrlUnchanged() {
+ let servers = [srv("a", name: "A")]
+ XCTAssertEqual(HomeVault.toggleStar(servers, url: "zzz"), servers)
+ }
+
+ func testMoveInGroupStaysWithinGroup() {
+ // starred: [a, c] others: [b, d] — move 'a' down swaps with 'c', not 'b'.
+ let servers = [srv("a", starred: true), srv("b"), srv("c", starred: true), srv("d")]
+ let moved = HomeVault.moveInGroup(servers, url: "a", dir: 1)
+ let (starred, others) = HomeVault.partition(moved)
+ XCTAssertEqual(urls(starred), ["c", "a"])
+ XCTAssertEqual(urls(others), ["b", "d"])
+ }
+
+ func testMoveInGroupAtEndIsNoOp() {
+ let servers = [srv("a", starred: true), srv("b")]
+ XCTAssertEqual(HomeVault.moveInGroup(servers, url: "a", dir: -1), servers)
+ }
+
+ func testOrderByUrlPreservesEntriesAndTailsUnknowns() {
+ let servers = [srv("a", starred: true), srv("b"), srv("c")]
+ let out = HomeVault.order(servers, urlsInOrder: ["c", "a"]) // b not listed → tails
+ XCTAssertEqual(urls(out), ["c", "a", "b"])
+ XCTAssertEqual(out.first(where: { $0.url == "a" })?.starred, true, "flag preserved")
+ }
+
+ // `starred: nil` must serialise as an ABSENT key (like the web's drop), not
+ // `"starred": null` — otherwise the two impls' blobs would diverge.
+ func testNilStarredOmittedFromJSON() throws {
+ let json = try JSONEncoder().encode(srv("a"))
+ let s = String(data: json, encoding: .utf8)!
+ XCTAssertFalse(s.contains("starred"), "nil starred must not appear in JSON")
+ let jsonOn = try JSONEncoder().encode(srv("a", starred: true))
+ XCTAssertTrue(String(data: jsonOn, encoding: .utf8)!.contains("\"starred\":true"))
+ }
+}
+
+// 🤖 The raw read-modify-write cycle HomeSync.push uses must round-trip fields
+// this build doesn't know — the web spread-merges unknown keys, so a native
+// writer that stripped them would corrupt the vault for every other client.
+extension HomeVaultTests {
+ func testRawRoundTripPreservesUnknownFields() throws {
+ // A vault a NEWER client wrote: unknown top-level key, unknown
+ // per-server + per-credentials fields, and a credential-less entry.
+ let obj: [String: Any] = [
+ "v": 1,
+ "futureTopLevel": ["keep": true],
+ "servers": [
+ ["url": "https://a.example", "starred": true, "futureField": "keep-me",
+ "credentials": ["token": "t", "userPub": "p", "userPriv": "k",
+ "futureCred": 7]],
+ ["url": "https://bookmark.example", "name": "Bookmark only"],
+ ],
+ ]
+ let dek = SymmetricKey(size: .bits256)
+ let objJSON = try JSONSerialization.data(withJSONObject: obj)
+ let wrapped = try HomeCrypto.wrapDEK(dek, kek: SymmetricKey(size: .bits256))
+ let envelope = try HomeCrypto.sealBlob(objJSON: objJSON, dek: dek,
+ keys: ["cred": wrapped])
+
+ var round = try HomeVault.openRawObject(envelope, dek: dek)
+ // Patch the way push does: update one known field on server A.
+ var servers = round["servers"] as! [[String: Any]]
+ servers[0]["starred"] = true
+ round["servers"] = servers
+ let resealed = try HomeVault.sealRawObject(round, existingEnvelope: envelope, dek: dek)
+ let reopened = try HomeVault.openRawObject(resealed, dek: dek)
+
+ XCTAssertNotNil(reopened["futureTopLevel"], "unknown top-level key must survive")
+ let outServers = reopened["servers"] as! [[String: Any]]
+ XCTAssertEqual(outServers[0]["futureField"] as? String, "keep-me")
+ XCTAssertEqual((outServers[0]["credentials"] as? [String: Any])?["futureCred"] as? Int, 7)
+ XCTAssertEqual(outServers[1]["name"] as? String, "Bookmark only",
+ "credential-less entry must ride through untouched")
+ }
+}
diff --git a/ios/LChatMac/LChatMac-Dev.entitlements b/ios/LChatMac/LChatMac-Dev.entitlements
new file mode 100644
index 0000000..fff4028
--- /dev/null
+++ b/ios/LChatMac/LChatMac-Dev.entitlements
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!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. -->
+ <key>com.apple.developer.associated-domains</key>
+ <array>
+ <string>webcredentials:home.elevenmessenger.com?mode=developer</string>
+ </array>
+</dict>
+</plist>
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index 8bb1328..904eaf0 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -218,6 +218,10 @@ final class MacAppState: ObservableObject {
if let i = instances.firstIndex(where: { $0.origin == origin }) {
inst.myUserId = instances[i].myUserId
inst.groupName = instances[i].groupName
+ // Every locally-resolved field must survive the refresh — dropping
+ // one silently reverts it on each page load (starred was cleared
+ // this way, un-starring the space everywhere via the vault sync).
+ inst.starred = instances[i].starred
guard instances[i] != inst else { return }
instances[i] = inst
} else {
@@ -295,9 +299,115 @@ final class MacAppState: ObservableObject {
instances.removeAll { $0.origin == origin }
InstancesStore.save(instances)
Logger.identity.info("removed native identity for \(origin, privacy: .public)")
+ syncHome()
}
}
+ // ---- home-vault sync ----------------------------------------------------
+
+ /// Instances in RAIL order (`chats` drives the rail; identities without a
+ /// rail entry tail in their stored order) — the order the vault should hold.
+ func instancesInRailOrder() -> [SavedInstance] {
+ var rest = instances
+ var out: [SavedInstance] = []
+ for chat in chats {
+ guard let origin = chat.origin?.absoluteString,
+ let i = rest.firstIndex(where: { $0.origin == origin }) else { continue }
+ out.append(rest.remove(at: i))
+ }
+ return out + rest
+ }
+
+ /// Push the rail's order + stars to the home vault. DORMANT until Mac
+ /// sign-in ships: the Keychain session is per-app, and the Mac has no
+ /// sign-in entry point yet (needs the webcredentials entitlement — a real
+ /// provisioning profile + this Mac registered — and the macOS 15 PRF API).
+ /// Wired now so starring works locally and syncs the moment sign-in lands;
+ /// until then Mac stars live on this device only (iOS-first, deliberately).
+ func syncHome() { HomeSync.pushSoon(instances: instancesInRailOrder()) }
+
+ /// Sign-in merge — the vault wins: upsert each vault server's identity +
+ /// star, ensure a rail entry (SavedChat) exists, and apply the vault's
+ /// order to the rail (Mac-only chats tail). Returns how many servers were
+ /// NEW to this Mac. The caller pushes after, so the write-back reflects
+ /// the merged state (the iOS data-loss lesson).
+ func applyVault(_ servers: [HomeVault.Server]) -> Int {
+ var added = 0
+ for s in servers {
+ guard let creds = s.credentials else { continue } // bookmark entry
+ var inst = SavedInstance(origin: s.url, deviceToken: creds.token,
+ screenName: creds.name ?? s.name ?? "",
+ userPub: creds.userPub, userPriv: creds.userPriv,
+ groupName: s.name ?? "",
+ starred: s.isStarred ? true : nil)
+ if let i = instances.firstIndex(where: { $0.origin == s.url }) {
+ inst.myUserId = instances[i].myUserId
+ if inst.groupName.isEmpty { inst.groupName = instances[i].groupName }
+ instances[i] = inst
+ } else {
+ instances.append(inst)
+ }
+ if !chats.contains(where: { $0.origin?.absoluteString == s.url }) {
+ chats.append(SavedChat(url: s.url, name: creds.name ?? ""))
+ added += 1
+ }
+ }
+ // Vault order drives the rail; Mac-only chats keep their relative order
+ // at the end.
+ var rest = chats
+ var ordered: [SavedChat] = []
+ for s in servers {
+ if let i = rest.firstIndex(where: { $0.origin?.absoluteString == s.url }) {
+ ordered.append(rest.remove(at: i))
+ }
+ }
+ chats = ordered + rest
+ InstancesStore.save(instances)
+ MacChatsStore.save(chats)
+ if selection == nil { selection = chats.first?.id }
+ return added
+ }
+
+ // ---- starring (rail = stars only; the rest fold under "More") ----------
+
+ /// Star state lives on the shared SavedInstance (the same record iOS uses and
+ /// the home vault syncs) — a chat is starred iff its native identity is.
+ func isStarred(_ chat: SavedChat) -> Bool {
+ guard let origin = chat.origin?.absoluteString else { return false }
+ return instances.first { $0.origin == origin }?.isStarred ?? false
+ }
+
+ /// A chat can be starred once its native identity exists (extracted on first
+ /// open). Before that there's nothing durable to pin the flag to.
+ func canStar(_ chat: SavedChat) -> Bool {
+ guard let origin = chat.origin?.absoluteString else { return false }
+ return instances.contains { $0.origin == origin }
+ }
+
+ /// Star or unstar a chat: flip the flag on its SavedInstance and reposition
+ /// BOTH lists with the vault's toggleStar semantics (star → end of the starred
+ /// group, unstar → top of the rest), so the rail, iOS, and home stay in step.
+ func toggleStar(_ chat: SavedChat) {
+ guard let origin = chat.origin?.absoluteString,
+ let i = instances.firstIndex(where: { $0.origin == origin }) else { return }
+ var flipped = instances[i]
+ flipped.starred = flipped.isStarred ? nil : true
+ let starredInst = instances.filter { $0.origin != origin && $0.isStarred }
+ let otherInst = instances.filter { $0.origin != origin && !$0.isStarred }
+ instances = starredInst + [flipped] + otherInst
+ InstancesStore.save(instances)
+ // The rail renders from `chats` — mirror the same boundary move there.
+ // (isStarred reads the just-updated instances, so partitioning is fresh.)
+ if let j = chats.firstIndex(where: { $0.id == chat.id }) {
+ let moved = chats.remove(at: j)
+ let starredChats = chats.filter { isStarred($0) }
+ let otherChats = chats.filter { !isStarred($0) }
+ chats = starredChats + [moved] + otherChats
+ MacChatsStore.save(chats)
+ }
+ syncHome()
+ }
+
// Live-reorder the rail during a drag: pull the dragged chat out and reinsert it
// at the hovered chat's slot. Call persistChats() when the drop lands.
func reorder(_ dragging: SavedChat, over target: SavedChat) {
@@ -308,7 +418,10 @@ final class MacAppState: ObservableObject {
chats.insert(item, at: to)
}
- func persistChats() { MacChatsStore.save(chats) }
+ func persistChats() {
+ MacChatsStore.save(chats)
+ syncHome()
+ }
// Native appearance follows the web feed's light/dark: each webview reports its
// effective mode (setAppearance), and the *selected* instance's mode drives the
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift
new file mode 100644
index 0000000..27e598a
--- /dev/null
+++ b/ios/LChatMac/MacHomeSignIn.swift
@@ -0,0 +1,136 @@
+import AppKit
+import AuthenticationServices
+import LChatCore
+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).
+//
+// ACTIVATION: the ceremony needs (1) macOS 15 (PRF — runtime-gated below, the
+// target stays 14) and (2) the webcredentials associated domain, which needs a
+// real Development profile — flip CODE_SIGN_ENTITLEMENTS to
+// LChatMac-Dev.entitlements in project.yml once this Mac is registered in the
+// team. Until then the ceremony fails with "not associated with domain".
+//
+// The PRF output never leaves this process; only the link token + blob DEK
+// persist (Keychain, HomeSessionStore) — same posture as iOS.
+@MainActor
+final class MacHomeSignIn: ObservableObject {
+ enum Phase: Equatable {
+ case idle, working
+ case restored(Int) // servers added to this Mac
+ case emptyBackup // signed in; account holds no servers yet
+ case failed(String)
+ }
+ @Published private(set) var phase: Phase = .idle
+ @Published private(set) var sessionExists = HomeSessionStore.load() != nil
+
+ func signIn(into app: MacAppState) async {
+ guard #available(macOS 15.0, *) else {
+ phase = .failed("Sign in with Eleven needs macOS 15.")
+ return
+ }
+ guard let anchor = NSApp.keyWindow ?? NSApp.windows.first else {
+ phase = .failed("Couldn't find a window to present the passkey sheet.")
+ return
+ }
+ phase = .working
+ do {
+ let result = try await HomeAuth(anchor: anchor).signIn()
+ 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 {
+ phase = .failed(error.localizedDescription)
+ }
+ }
+
+ func signOut() {
+ HomeSessionStore.clear()
+ sessionExists = false
+ phase = .idle
+ }
+
+ /// 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).
+ @available(macOS 15.0, *)
+ private func restore(_ result: HomeAuth.Result, into app: MacAppState) async throws -> Phase {
+ guard let fetch = try await HomeClient().fetchBlob(token: result.token) else {
+ // Blob-less account: recoverable right now with the PRF in hand
+ // (mirrors the iOS path — bailing was a permanent dead-end). Seal
+ // the Mac's servers straight into the first envelope — an empty
+ // seal + async push raced and misreported the result.
+ let servers = app.instancesInRailOrder().map { inst in
+ HomeVault.Server(
+ url: inst.origin,
+ name: inst.groupName.isEmpty ? nil : inst.groupName,
+ starred: inst.isStarred ? true : nil,
+ credentials: .init(token: inst.deviceToken, userPub: inst.userPub,
+ userPriv: inst.userPriv, name: inst.screenName))
+ }
+ let sealed = try HomeVault.sealFresh(HomeVault.Contents(v: 1, servers: servers),
+ credentialID: result.credentialID,
+ 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.")
+ }
+ HomeSessionStore.save(HomeSession(token: result.token,
+ credentialID: result.credentialID,
+ dek: sealed.dek))
+ return servers.isEmpty ? .emptyBackup : .restored(0)
+ }
+ 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.")
+ }
+ HomeSessionStore.save(HomeSession(token: result.token,
+ credentialID: result.credentialID,
+ dek: opened.dek))
+ let added = app.applyVault(opened.contents.servers)
+ app.syncHome()
+ return added > 0 ? .restored(added) : .emptyBackup
+ }
+}
+
+// The Settings section (MacSettingsView mounts it above Theme): one line of
+// state and one action, matching the pane's quiet register.
+struct HomeSignInSection: View {
+ @EnvironmentObject var app: MacAppState
+ @ObservedObject var model: MacHomeSignIn
+
+ var body: some View {
+ Section("Eleven") {
+ if model.sessionExists {
+ LabeledContent("Signed in") {
+ Button("Sign Out") { model.signOut() }
+ }
+ if case .restored(let n) = model.phase, n > 0 {
+ Text("\(n) space\(n == 1 ? "" : "s") restored.")
+ .font(.footnote).foregroundStyle(.secondary)
+ }
+ Text("Your server list syncs with Eleven — stars and order follow you between devices.")
+ .font(.footnote).foregroundStyle(.secondary)
+ } else {
+ LabeledContent("Not signed in") {
+ if model.phase == .working {
+ ProgressView().controlSize(.small)
+ } else {
+ Button("Sign In with Eleven…") {
+ Task { await model.signIn(into: app) }
+ }
+ }
+ }
+ if case .failed(let msg) = model.phase {
+ Text(msg).font(.footnote).foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+}
diff --git a/ios/LChatMac/MacSettingsView.swift b/ios/LChatMac/MacSettingsView.swift
index d2efb14..31f86a7 100644
--- a/ios/LChatMac/MacSettingsView.swift
+++ b/ios/LChatMac/MacSettingsView.swift
@@ -18,8 +18,11 @@ struct MacSettingsView: View {
app.instances.first { $0.id == selectedInstanceID } ?? app.instances.first
}
+ @StateObject private var homeSignIn = MacHomeSignIn()
+
var body: some View {
Form {
+ HomeSignInSection(model: homeSignIn)
Section("Theme") {
Picker("Chat theme", selection: $themeName) {
ForEach(ChatTheme.presets, id: \.id) { preset in
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 98a1ab6..c30732e 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -270,9 +270,23 @@ struct NativeFeedView: View {
.onChange(of: model.messages.last?.id) { old, id in
guard id != nil else { return }
let mine = model.messages.last?.mine == true
- let nearBottom = old.flatMap { frames.rows[$0]?.minY }
- .map { $0 < frames.viewport.height + 150 } ?? true
- if mine || nearBottom { proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) }
+ // Near-bottom only when the previous last row is MATERIALISED
+ // near the viewport. Defaulting the unknown case to true
+ // treated a reader deep in history (old row destroyed, no
+ // frame) as at-the-bottom and yanked them down on arrivals.
+ let nearBottom = old == nil || old.flatMap { frames.rows[$0]?.minY }
+ .map { $0 < frames.viewport.height + 150 } ?? false
+ // No materialised rows at all while messages exist = the feed
+ // is showing blank space (the offset outlived a content
+ // shrink) — always re-pin our way out of that.
+ let wedged = frames.rows.isEmpty && !model.messages.isEmpty
+ // A VERIFIED pin, not a one-shot scrollTo: the send shrinks
+ // the composer inset (draft clearing) mid-jump and rows
+ // materialise with real heights, so the content can resize
+ // under a one-shot target and leave the viewport clamped past
+ // the real end — a fully blank feed. pinUntilSettled re-pins
+ // against measured geometry until the last row is truly flush.
+ if mine || nearBottom || wedged { pinUntilSettled(proxy) }
}
// The typing bubble appearing at the bottom should scroll into
// view when the reader is already there.
diff --git a/ios/LChatMac/Sidebar.swift b/ios/LChatMac/Sidebar.swift
index c2cd53d..40f59ae 100644
--- a/ios/LChatMac/Sidebar.swift
+++ b/ios/LChatMac/Sidebar.swift
@@ -13,28 +13,30 @@ import UniformTypeIdentifiers
struct Sidebar: View {
@EnvironmentObject var app: MacAppState
@State private var dragging: SavedChat?
+ // The folded (unstarred) icons, revealed by the "More" toggle. Auto-opens
+ // when the selected chat lives in the fold, so you never lose your place.
+ @State private var showMore = false
+
+ private var starredChats: [SavedChat] { app.chats.filter { app.isStarred($0) } }
+ private var otherChats: [SavedChat] { app.chats.filter { !app.isStarred($0) } }
var body: some View {
- let inits = app.initials()
+ // Once anything is starred, the rail shows only the stars; the rest fold
+ // behind "More" (matching the home page + iOS drawer). Nothing starred →
+ // the flat rail, exactly as before.
+ let folded = !starredChats.isEmpty
return ScrollView {
VStack(spacing: 10) {
- ForEach(app.chats) { chat in
- ServerIcon(
- initial: inits[chat.id] ?? "?",
- image: app.avatars[chat.id],
- hue: app.hue(for: chat.id),
- selected: app.selection == chat.id,
- tooltip: app.serverName(chat)
- ) { app.selection = chat.id }
- .opacity(dragging?.id == chat.id ? 0.5 : 1)
- .contextMenu {
- Button("Remove", role: .destructive) { app.remove(chat) }
- }
- .onDrag {
- dragging = chat
- return NSItemProvider(object: "\(chat.id)" as NSString)
+ ForEach(folded ? starredChats : app.chats) { chat in
+ icon(chat)
+ }
+ if folded, !otherChats.isEmpty {
+ moreButton
+ if showMore {
+ ForEach(otherChats) { chat in
+ icon(chat)
+ }
}
- .onDrop(of: [.text], delegate: ServerDropDelegate(item: chat, dragging: $dragging, app: app))
}
addButton
}
@@ -52,6 +54,51 @@ struct Sidebar: View {
// Keyed by the SET of chat ids (not the array) so reordering doesn't re-run
// the name fetch — only adding/removing an instance does.
.task(id: Set(app.chats.map(\.id))) { await loadNames() }
+ // Selecting something folded pops the fold open (and keeps it open).
+ .onChange(of: app.selection) { _, sel in
+ if otherChats.contains(where: { $0.id == sel }) { showMore = true }
+ }
+ .onAppear { showMore = otherChats.contains { $0.id == app.selection } }
+ }
+
+ private func icon(_ chat: SavedChat) -> some View {
+ ServerIcon(
+ initial: app.initials()[chat.id] ?? "?",
+ image: app.avatars[chat.id],
+ hue: app.hue(for: chat.id),
+ selected: app.selection == chat.id,
+ tooltip: app.serverName(chat)
+ ) { app.selection = chat.id }
+ .opacity(dragging?.id == chat.id ? 0.5 : 1)
+ .contextMenu {
+ // Star/Unstar rides the native identity; a never-opened chat has
+ // none yet, so the item only appears once there's something to pin.
+ if app.canStar(chat) {
+ Button(app.isStarred(chat) ? "Unstar" : "Star") { app.toggleStar(chat) }
+ }
+ Button("Remove", role: .destructive) { app.remove(chat) }
+ }
+ .onDrag {
+ dragging = chat
+ return NSItemProvider(object: "\(chat.id)" as NSString)
+ }
+ .onDrop(of: [.text], delegate: ServerDropDelegate(item: chat, dragging: $dragging, app: app))
+ }
+
+ // The fold toggle between the starred rail and everything else — a quiet
+ // ellipsis circle, Discord's "more servers" idiom.
+ private var moreButton: some View {
+ Button { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { showMore.toggle() } } label: {
+ Image(systemName: showMore ? "chevron.up" : "ellipsis")
+ .font(.callout.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .frame(width: 34, height: 34)
+ .background(Circle().fill(Color.secondary.opacity(0.12)))
+ .frame(maxWidth: .infinity)
+ .contentShape(Circle())
+ }
+ .buttonStyle(.plain)
+ .railTooltip(showMore ? "Less" : "More")
}
private var addButton: some View {
diff --git a/ios/project.yml b/ios/project.yml
index 561eb82..2e88c9a 100644
--- a/ios/project.yml
+++ b/ios/project.yml
@@ -48,6 +48,13 @@ targets:
CODE_SIGN_ENTITLEMENTS: LChat/LChat.entitlements
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
+ configs:
+ # Debug builds use ?mode=developer on the webcredentials association so
+ # dev installs bind to home immediately (no Apple-CDN wait; needs the
+ # device's Developer Mode, which dev installs require anyway). Release
+ # keeps the clean association — the Debug file must never ship.
+ Debug:
+ CODE_SIGN_ENTITLEMENTS: LChat/LChat-Debug.entitlements
info:
path: LChat/Info.plist
properties:
@@ -104,6 +111,13 @@ targets:
# ios/release-mac.sh). Sparkle's bundled XPC helpers need no entitlement
# exceptions on top of this for a non-sandboxed app.
ENABLE_HARDENED_RUNTIME: YES
+ configs:
+ # Debug carries the webcredentials association for Mac "Sign in with
+ # Eleven" (?mode=developer — never ships; Release keeps the plain
+ # entitlements). Needs a real Development profile: this Mac is
+ # registered in the team (device 8VWU966HQ3, 2026-07-08).
+ Debug:
+ CODE_SIGN_ENTITLEMENTS: LChatMac/LChatMac-Dev.entitlements
info:
path: LChatMac/Info.plist
properties: