eleven / ocho Public

iOS: push-time message ingest, background refresh, and strictly off-main crypto/SQLite (#423)

* server: APNs payload carries message id + created_at

The NSE will use these to ingest the decrypted message into the device's
local store at push time. Routing metadata only (integers the server
already stores beside the ciphertext); omitted when zero (test pushes),
optional at the relay so old/new instances and relays mix freely.

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

* ios: NSE ingests pushed messages into the shared local store

MessageStore moves to the App Group container (one-time migration from
Application Support; busy_timeout now two processes share the file). The
notification extension already decrypts every push to rewrite the banner
— now it also files the message into the store, so the chat is caught up
before the banner is even tapped. Payloads without id/at (older server or
relay) keep today's behaviour.

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

* ios: parked feed models catch up from the local store on open

A parked model's socket is down while the app is suspended; the NSE (and
background refresh) file new messages into the shared store meanwhile.
Re-attaching from the cache — and foregrounding with a chat on screen —
now merges those rows from disk, so a notification tap renders the pushed
message instantly; the socket's history re-pull converges the rest.

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

* ios: Background App Refresh syncs messages into the local store

BackgroundSync (LChatCore) pulls each instance's threads, unwraps keys,
fetches messages after the local watermark, decrypts and upserts — all off
the main thread; a BGAppRefreshTask drives it on the system's cadence
(registered at launch, re-armed on every background + from the handler).
Capped at the 20 most recently active threads per instance per pass.

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

* ios+mac: crypto and SQLite strictly off the main thread

ChatFeedModel gains a serial off-main lane: live msg/edit/typing frame
decrypts and every outgoing encrypt (send, action, app event, edit,
typing beat) run detached, in order; attachment encrypt/decrypt and image
re-encoding move off main too. ThreadStore.refresh now does ALL its
crypto in one detached fold — instance-key unwrap, roster labels,
per-thread unwraps, previews, titles (the name_blob used to be opened up
to four times per thread, on main, on every refresh) — the main actor
only publishes rows. Chat-create/add-member/invite/rename wraps are
detached; the Mac's backgrounded-banner decrypt likewise. Dead foldPage
removed.

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

* ios: public surface for the NSE ingest (renderable, StoredMessage init)

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

* ios: reminder schedule encrypt off main (rebase follow-up)

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

* ios: read-sync watermarks ride Folded back to main (rebase fix)

The detached refresh fold can't touch serverReadMarks (main-actor state);
the raw last_read_at values return in Folded.readMarks and merge on main.
ReadState (UserDefaults) stays fold-side — it's thread-safe.

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d1748158b8ed2d1e79c6270197be24c65a804096 parent 4ab23a4
25 files changed, +760 −309
  • apns.go +9 −6
  • apns_relay_test.go +4 −4
  • docs/push-relay.md +6 −2
  • hub.go +1 −1
  • internal/apns/apns.go +17 −5
  • internal/apns/apns_test.go +13 −6
  • ios/LChat/BackgroundRefresh.swift +63 −0
  • ios/LChat/Info.plist +8 −0
  • ios/LChat/LChatApp.swift +6 −1
  • ios/LChat/PushManager.swift +2 −0
  • ios/LChat/Views/NativeChatScreen.swift +7 −0
  • ios/LChatCore/Sources/LChatCore/BackgroundSync.swift +59 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +175 −88
  • ios/LChatCore/Sources/LChatCore/ChatSocket.swift +7 −1
  • ios/LChatCore/Sources/LChatCore/MessageStore.swift +57 −4
  • ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift +5 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +237 −169
  • ios/LChatMac/NativeChatView.swift +19 −11
  • ios/LChatNSE/LChatNSE.entitlements +7 −0
  • ios/LChatNSE/NotificationService.swift +38 −3
  • ios/project.yml +6 −0
  • main.go +1 −1
  • miniapps.go +3 −3
  • push.go +3 −3
  • push/main.go +7 −1
diff --git a/apns.go b/apns.go
index af2b9ea..5f6200a 100644
--- a/apns.go
+++ b/apns.go
@@ -100,15 +100,15 @@ func (db *DB) deleteAPNsTokensForToken(token string) error {
// local .p8 and calls Apple; relaySender holds no key and POSTs to the central
// push relay. nil means APNs is disabled (Web Push only), exactly as before.
type apnsSender interface {
- send(deviceToken string, sandbox bool, threadID, blob string) error
+ send(deviceToken string, sandbox bool, threadID, blob string, id, at int64) error
}
// directSender is today's transport: the .p8 lives in this instance's env and
// the shared client (internal/apns) signs + calls Apple directly.
type directSender struct{ c *apns.Client }
-func (d directSender) send(deviceToken string, sandbox bool, threadID, blob string) error {
- return d.c.Send(deviceToken, sandbox, threadID, blob)
+func (d directSender) send(deviceToken string, sandbox bool, threadID, blob string, id, at int64) error {
+ return d.c.Send(deviceToken, sandbox, threadID, blob, id, at)
}
// relaySender forwards to push.elevenmessenger.com. It posts the same
@@ -124,9 +124,12 @@ type relaySender struct {
http *http.Client
}
-func (r relaySender) send(deviceToken string, sandbox bool, threadID, blob string) error {
+func (r relaySender) send(deviceToken string, sandbox bool, threadID, blob string, id, at int64) error {
+ // id/at ride along for the NSE's local-store ingest (see apns.BuildPayload);
+ // an older relay just ignores the extra fields and the feature stays off.
body, _ := json.Marshal(map[string]any{
"device_token": deviceToken, "sandbox": sandbox, "thread": threadID, "blob": blob,
+ "id": id, "at": at,
})
req, err := http.NewRequest("POST", r.url, bytes.NewReader(body))
if err != nil {
@@ -192,7 +195,7 @@ func newAPNsClient() apnsSender {
// apnsFanOut applies the selectRecipients rules to APNs tokens (keep the filter
// in lockstep with push.go's selectRecipients) and sends concurrently. No-op
// when APNs isn't configured.
-func (s *Server) apnsFanOut(threadID, blob, exceptToken string, skipActive bool, recipients map[string]bool) int {
+func (s *Server) apnsFanOut(threadID, blob string, id, at int64, exceptToken string, skipActive bool, recipients map[string]bool) int {
if s.apns == nil {
return 0
}
@@ -232,7 +235,7 @@ func (s *Server) apnsFanOut(threadID, blob, exceptToken string, skipActive bool,
go func(t APNsToken) {
defer wg.Done()
defer func() { <-sem }()
- err := s.apns.send(t.APNsToken, t.Sandbox, threadID, blob)
+ err := s.apns.send(t.APNsToken, t.Sandbox, threadID, blob, id, at)
switch {
case err == nil:
mu.Lock()
diff --git a/apns_relay_test.go b/apns_relay_test.go
index a37b8b7..a8ca434 100644
--- a/apns_relay_test.go
+++ b/apns_relay_test.go
@@ -24,19 +24,19 @@ func TestRelaySenderClassifies(t *testing.T) {
rs := relaySender{url: stub.URL + "/api/push", token: "tok", http: http.DefaultClient}
status = http.StatusOK
- if err := rs.send("aabbccdd", false, "th", "blob"); err != nil {
+ if err := rs.send("aabbccdd", false, "th", "blob", 1, 1); err != nil {
t.Errorf("200: want nil, got %v", err)
}
status = http.StatusGone
- if err := rs.send("aabbccdd", false, "th", "blob"); !errors.Is(err, apns.ErrGone) {
+ if err := rs.send("aabbccdd", false, "th", "blob", 1, 1); !errors.Is(err, apns.ErrGone) {
t.Errorf("410: want ErrGone, got %v", err)
}
status = http.StatusTooManyRequests
- if err := rs.send("aabbccdd", false, "th", "blob"); err == nil || errors.Is(err, apns.ErrGone) {
+ if err := rs.send("aabbccdd", false, "th", "blob", 1, 1); err == nil || errors.Is(err, apns.ErrGone) {
t.Errorf("429: want a non-gone error, got %v", err)
}
status = http.StatusBadGateway
- if err := rs.send("aabbccdd", false, "th", "blob"); err == nil || errors.Is(err, apns.ErrGone) {
+ if err := rs.send("aabbccdd", false, "th", "blob", 1, 1); err == nil || errors.Is(err, apns.ErrGone) {
t.Errorf("502: want a non-gone error, got %v", err)
}
}
diff --git a/docs/push-relay.md b/docs/push-relay.md
index 65ea6d9..6b8a27b 100644
--- a/docs/push-relay.md
+++ b/docs/push-relay.md
@@ -83,8 +83,12 @@ can't drift (a `BuildPayload` byte-stability test pins the NSE contract).
`device_token` matches `^[0-9a-fA-F]{1,200}$` (400 — it's interpolated into the
Apple URL path, so non-hex could inject).
- Body: `{"device_token": "<hex>", "sandbox": bool, "thread": "<id>", "blob":
- "<base64url ciphertext>"}`. The relay builds the aps envelope and sets
- `apns-topic` from its **own** env.
+ "<base64url ciphertext>", "id": <message id>, "at": <created_at seconds>}`.
+ The relay builds the aps envelope and sets `apns-topic` from its **own** env.
+ `id`/`at` are optional routing metadata (older instances omit them), forwarded
+ verbatim so the device's NSE can file the decrypted message into its local
+ store at push time — still no content, and nothing Apple couldn't already
+ infer from delivery order and timing.
- Response is a **gone-normalised passthrough**: Apple 410 / `BadDeviceToken` →
HTTP **410** (the instance prunes the token); Apple 2xx → 200; anything else →
502. Relay-internal errors (401/429/400/413/502) are **never** 410 and never
diff --git a/hub.go b/hub.go
index ee2b41a..4d846e8 100644
--- a/hub.go
+++ b/hub.go
@@ -312,7 +312,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
out, _ := json.Marshal(wsOut{Type: "msg", ThreadID: in.ThreadID, ID: m.ID, CreatedAt: m.CreatedAt, Blob: m.Blob})
s.broadcastToThread(in.ThreadID, out)
// Notify the thread's other members (not the sender), best-effort, async.
- go s.pushNewMessage(m.Blob, in.ThreadID, token)
+ go s.pushNewMessage(m.Blob, in.ThreadID, token, m.ID, m.CreatedAt)
case "edit":
if in.MessageID <= 0 || in.Blob == "" || len(in.Blob) > maxBlobBytes ||
diff --git a/internal/apns/apns.go b/internal/apns/apns.go
index eab833b..86c6686 100644
--- a/internal/apns/apns.go
+++ b/internal/apns/apns.go
@@ -147,8 +147,15 @@ func (c *Client) bearer() (string, error) {
// BuildPayload builds the aps envelope. Exported so it can be pinned by a test
// (the NSE mirrors these exact keys — mutable-content lets the app decrypt and
// rewrite the placeholder title; thread/blob mirror the Web Push payload).
-func BuildPayload(threadID, blob string) []byte {
- payload, _ := json.Marshal(map[string]any{
+//
+// id/at are the message's server id + created_at (seconds) — routing metadata
+// the server already stores beside the ciphertext, added so the NSE can ingest
+// the decrypted message into the device's local store at push time (the chat is
+// already caught up when the notification is tapped). Still no content: Apple
+// sees an integer sequence number and a timestamp it could infer from delivery
+// anyway. Zero values are omitted (the test push has no message).
+func BuildPayload(threadID, blob string, id, at int64) []byte {
+ m := map[string]any{
"aps": map[string]any{
"alert": map[string]string{"title": "New message"},
"mutable-content": 1,
@@ -157,13 +164,18 @@ func BuildPayload(threadID, blob string) []byte {
},
"thread": threadID,
"blob": blob,
- })
+ }
+ if id > 0 {
+ m["id"] = id
+ m["at"] = at
+ }
+ payload, _ := json.Marshal(m)
return payload
}
// Send posts one alert to one device, returning ErrGone when Apple says the
// token is dead so the caller can prune it.
-func (c *Client) Send(deviceToken string, sandbox bool, threadID, blob string) error {
+func (c *Client) Send(deviceToken string, sandbox bool, threadID, blob string, id, at int64) error {
if !ValidDeviceToken(deviceToken) {
return errors.New("apns: invalid device token") // never ErrGone — not a prune signal
}
@@ -171,7 +183,7 @@ func (c *Client) Send(deviceToken string, sandbox bool, threadID, blob string) e
if sandbox {
host = c.sandboxHost
}
- req, err := http.NewRequest("POST", host+"/3/device/"+deviceToken, bytes.NewReader(BuildPayload(threadID, blob)))
+ req, err := http.NewRequest("POST", host+"/3/device/"+deviceToken, bytes.NewReader(BuildPayload(threadID, blob, id, at)))
if err != nil {
return err
}
diff --git a/internal/apns/apns_test.go b/internal/apns/apns_test.go
index 9c53fd2..ed9e730 100644
--- a/internal/apns/apns_test.go
+++ b/internal/apns/apns_test.go
@@ -57,11 +57,18 @@ func TestValidDeviceToken(t *testing.T) {
// Notification Service Extension mirrors these exact keys; if this changes,
// on-device decryption breaks — so a drift must be a deliberate, reviewed edit.
func TestBuildPayloadStable(t *testing.T) {
- got := string(BuildPayload("t1", "ciphertext-blob"))
+ got := string(BuildPayload("t1", "ciphertext-blob", 0, 0))
want := `{"aps":{"alert":{"title":"New message"},"mutable-content":1,"sound":"default","thread-id":"t1"},"blob":"ciphertext-blob","thread":"t1"}`
if got != want {
t.Errorf("envelope drift:\n got: %s\nwant: %s", got, want)
}
+ // With a message id/created_at the payload carries them (the NSE's local
+ // store ingest); id 0 (test pushes, old relays) must omit both.
+ got = string(BuildPayload("t1", "ciphertext-blob", 42, 1700000000))
+ want = `{"aps":{"alert":{"title":"New message"},"mutable-content":1,"sound":"default","thread-id":"t1"},"at":1700000000,"blob":"ciphertext-blob","id":42,"thread":"t1"}`
+ if got != want {
+ t.Errorf("envelope drift (id/at):\n got: %s\nwant: %s", got, want)
+ }
}
func TestSendClassifiesResponses(t *testing.T) {
@@ -88,16 +95,16 @@ func TestSendClassifiesResponses(t *testing.T) {
defer stub.Close()
c := newTestClient(t, stub.URL)
- if err := c.Send(live, false, "th", "blob"); err != nil {
+ if err := c.Send(live, false, "th", "blob", 1, 1); err != nil {
t.Errorf("live token: want nil, got %v", err)
}
- if err := c.Send(dead, false, "th", "blob"); !errors.Is(err, ErrGone) {
+ if err := c.Send(dead, false, "th", "blob", 1, 1); !errors.Is(err, ErrGone) {
t.Errorf("410: want ErrGone, got %v", err)
}
- if err := c.Send(bad, false, "th", "blob"); !errors.Is(err, ErrGone) {
+ if err := c.Send(bad, false, "th", "blob", 1, 1); !errors.Is(err, ErrGone) {
t.Errorf("BadDeviceToken: want ErrGone, got %v", err)
}
- if err := c.Send(err5, false, "th", "blob"); err == nil || errors.Is(err, ErrGone) {
+ if err := c.Send(err5, false, "th", "blob", 1, 1); err == nil || errors.Is(err, ErrGone) {
t.Errorf("500: want a non-gone error, got %v", err)
}
}
@@ -109,7 +116,7 @@ func TestSendRejectsBadToken(t *testing.T) {
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true }))
defer stub.Close()
c := newTestClient(t, stub.URL)
- err := c.Send("aa/../bb", false, "th", "blob")
+ err := c.Send("aa/../bb", false, "th", "blob", 1, 1)
if err == nil || errors.Is(err, ErrGone) {
t.Errorf("bad token: want a non-gone error, got %v", err)
}
diff --git a/ios/LChat/BackgroundRefresh.swift b/ios/LChat/BackgroundRefresh.swift
new file mode 100644
index 0000000..121c0ac
--- /dev/null
+++ b/ios/LChat/BackgroundRefresh.swift
@@ -0,0 +1,63 @@
+import BackgroundTasks
+import Foundation
+import LChatCore
+
+// 🤖 Background App Refresh: iOS periodically wakes the app (cadence is the
+// system's call — it learns from usage) and BackgroundSync pulls + decrypts +
+// files new messages into the local store off the main thread. Together with
+// the NSE's per-push ingest this keeps chats caught up before they're opened.
+//
+// The identifier must be listed in Info.plist's
+// BGTaskSchedulerPermittedIdentifiers (project.yml), and registration must
+// happen before didFinishLaunching returns (PushManager wires it).
+enum BackgroundRefresh {
+ static var taskID: String {
+ (Bundle.main.bundleIdentifier ?? "com.elevenmessenger.messenger") + ".refresh"
+ }
+
+ static func register() {
+ BGTaskScheduler.shared.register(forTaskWithIdentifier: taskID, using: nil) { task in
+ guard let refresh = task as? BGAppRefreshTask else {
+ task.setTaskCompleted(success: false)
+ return
+ }
+ handle(refresh)
+ }
+ }
+
+ /// (Re)request the next wake. Cheap and idempotent — a duplicate submit
+ /// for a pending request just replaces it. Called when the app backgrounds
+ /// and from the handler itself, so the chain never breaks.
+ static func schedule() {
+ let req = BGAppRefreshTaskRequest(identifier: taskID)
+ req.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
+ try? BGTaskScheduler.shared.submit(req)
+ }
+
+ private static func handle(_ task: BGAppRefreshTask) {
+ schedule()
+ // completed-once guard: the sync finishing and the expiration handler
+ // can race; BGTaskScheduler wants exactly one completion call.
+ let done = OneShot()
+ let work = Task {
+ await BackgroundSync.run(instances: InstancesStore.load())
+ if done.claim() { task.setTaskCompleted(success: true) }
+ }
+ task.expirationHandler = {
+ work.cancel()
+ if done.claim() { task.setTaskCompleted(success: false) }
+ }
+ }
+
+ private final class OneShot: @unchecked Sendable {
+ private let lock = NSLock()
+ private var used = false
+ func claim() -> Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ if used { return false }
+ used = true
+ return true
+ }
+ }
+}
diff --git a/ios/LChat/Info.plist b/ios/LChat/Info.plist
index 66c9150..a5e74a0 100644
--- a/ios/LChat/Info.plist
+++ b/ios/LChat/Info.plist
@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
+ <key>BGTaskSchedulerPermittedIdentifiers</key>
+ <array>
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER).refresh</string>
+ </array>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@@ -40,6 +44,10 @@
</dict>
<key>NSCameraUsageDescription</key>
<string>Camera access is needed to scan QR codes for joining chats</string>
+ <key>UIBackgroundModes</key>
+ <array>
+ <string>fetch</string>
+ </array>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index cdd2858..de65544 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -291,7 +291,12 @@ struct RootView: View {
// started with us while we were away (thread creation sends no push) is
// there when we look. The `.task(id:)` in ThreadListView only fires on first
// appear / server switch, so foregrounding otherwise never refreshed.
- .onChange(of: scenePhase) { _, phase in refreshInbox(when: phase == .active) }
+ .onChange(of: scenePhase) { _, phase in
+ refreshInbox(when: phase == .active)
+ // Leaving for the background arms the next Background App Refresh
+ // wake, so chats keep syncing into the local store while we sleep.
+ if phase == .background, !app.instances.isEmpty { BackgroundRefresh.schedule() }
+ }
// A push landing while we're open (see PushManager.inboxPing) refreshes live.
.onChange(of: push.inboxPing) { _, _ in refreshInbox(when: true) }
}
diff --git a/ios/LChat/PushManager.swift b/ios/LChat/PushManager.swift
index 78f1e15..0104a12 100644
--- a/ios/LChat/PushManager.swift
+++ b/ios/LChat/PushManager.swift
@@ -36,6 +36,8 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
UNUserNotificationCenter.current().delegate = self
+ // BGTaskScheduler requires registration before launch finishes.
+ BackgroundRefresh.register()
return true
}
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 97b6402..2f6d892 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -457,6 +457,13 @@ struct NativeChatScreen: View {
// settled (parked models are ready immediately).
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { landed = true }
}
+ // Foregrounding with this chat on screen: merge whatever the NSE
+ // filed while we were suspended straight from disk — the pushed
+ // message shows before the socket reconnects and re-pulls.
+ .onChange(of: scenePhase) { _, phase in
+ guard phase == .active else { return }
+ Task { await model.catchUpFromStore() }
+ }
// The feed reveals only once history has folded AND the pins have
// run — the reader finds the chat already at the bottom, never
// watching it snap there. Cold loads show the loader meanwhile.
diff --git a/ios/LChatCore/Sources/LChatCore/BackgroundSync.swift b/ios/LChatCore/Sources/LChatCore/BackgroundSync.swift
new file mode 100644
index 0000000..ba4ca2a
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/BackgroundSync.swift
@@ -0,0 +1,59 @@
+import Foundation
+
+// 🤖 Background message sync: pull what's new for every joined instance and
+// file it — decrypted — into the local store (MessageStore), entirely off the
+// main thread. Driven by iOS Background App Refresh (BGAppRefreshTask in the
+// app target); the Mac doesn't need it (its sockets are live while it runs).
+//
+// This is the same ingest path the feed and the NSE use: fetch ciphertext,
+// unwrap the thread key natively, decrypt once, upsert by (instance, thread,
+// id). Idempotent by construction, so overlapping with a live app session or
+// an NSE ingest is harmless.
+public enum BackgroundSync {
+ /// Threads synced per instance per pass, newest activity first — bounds
+ /// the pass to the BG-task budget (~30s); anything missed catches up on
+ /// the next pass or the next app open.
+ public static let maxThreadsPerInstance = 20
+
+ /// Sync every instance. Returns the number of new messages stored.
+ /// Cooperatively cancellable (the BG task's expiration handler cancels).
+ @discardableResult
+ public static func run(instances: [SavedInstance]) async -> Int {
+ var stored = 0
+ for inst in instances {
+ if Task.isCancelled { break }
+ stored += await sync(inst)
+ }
+ return stored
+ }
+
+ private static func sync(_ inst: SavedInstance) async -> Int {
+ guard let base = inst.baseURL else { return 0 }
+ let api = LChatAPI(baseURL: base, token: inst.deviceToken)
+ let threads = await api.threads()
+ .sorted { ($0.last_at ?? 0) > ($1.last_at ?? 0) }
+ .prefix(maxThreadsPerInstance)
+ var stored = 0
+ for t in threads {
+ if Task.isCancelled { break }
+ guard let wrapped = t.wrapped, !wrapped.isEmpty,
+ let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: inst.userPriv),
+ let key = try? GroupKey(rawKey: raw) else { continue }
+ let local = await MessageStore.shared.latest(instance: inst.id, thread: t.id)
+ // Nothing newer than what we hold — skip the messages round-trip.
+ if local.at > 0, (t.last_at ?? 0) <= local.at { continue }
+ let rows = await api.messages(thread: t.id, after: local.id)
+ guard !rows.isEmpty else { continue }
+ let batch: [MessageStore.StoredMessage] = rows.compactMap { row in
+ guard let p = ChatFeedModel.renderable(row.blob, key: key) else { return nil }
+ return .init(id: row.id, at: row.created_at,
+ edited: (row.edited_at ?? 0) > 0,
+ reactions: row.reactions ?? [], payload: p)
+ }
+ guard !batch.isEmpty else { continue }
+ await MessageStore.shared.upsert(instance: inst.id, thread: t.id, batch)
+ stored += batch.count
+ }
+ return stored
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index 06eb81a..5c986f5 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -73,6 +73,30 @@ public final class ChatFeedModel: ObservableObject {
private let key: GroupKey?
private var socket: ChatSocket?
+ // The off-main crypto lane: every per-frame decrypt and every outgoing
+ // encrypt runs detached through this serial chain — the main actor never
+ // touches AES — while arrival/send ORDER is preserved (a message and its
+ // edit must fold in sequence; two sends must reach the socket in the order
+ // they were typed). One frame's work is tiny; the chain exists for order,
+ // not throughput.
+ private var offMainChain: Task<Void, Never>?
+
+ private func enqueueOffMain(_ job: @escaping @Sendable () async -> Void) {
+ offMainChain = Task.detached(priority: .userInitiated) { [prev = offMainChain] in
+ await prev?.value
+ await job()
+ }
+ }
+
+ // decrypt (detached) → fold (main): the shape every inbound frame uses.
+ private func decryptThenFold<T: Sendable>(_ decrypt: @escaping @Sendable () -> T?,
+ fold: @escaping @MainActor (ChatFeedModel, T) -> Void) {
+ enqueueOffMain { [weak self] in
+ guard let value = decrypt(), let self else { return }
+ await MainActor.run { fold(self, value) }
+ }
+ }
+
public init(instance: SavedInstance, thread: ChatThread, myUserId: String) {
self.instance = instance
self.threadID = thread.id
@@ -111,7 +135,11 @@ public final class ChatFeedModel: ObservableObject {
} else {
data = await plainFetch(url: url)
}
- guard let plain = data.flatMap({ try? key.decryptBytes($0) }) else { return nil }
+ // A video/file blob is tens of MB — its AES-GCM open must never run on
+ // the main actor (this class's home).
+ guard let plain = await Task.detached(priority: .userInitiated, operation: {
+ data.flatMap { try? key.decryptBytes($0) }
+ }).value else { return nil }
BlobDataCache.shared.put(att.id, plain)
return plain
}
@@ -145,7 +173,11 @@ public final class ChatFeedModel: ObservableObject {
/// — the server only ever sees ciphertext tagged with the thread for GC.
public func uploadImage(_ data: Data, name: String,
onProgress: (@MainActor (Double) -> Void)? = nil) async -> Attachment? {
- guard let processed = processImage(data), processed.bytes.count <= maxUploadBytes,
+ // Decode + downscale + re-encode is the heaviest client-side work a
+ // send can do — off the main actor with the crypto.
+ guard let processed = await Task.detached(priority: .userInitiated,
+ operation: { processImage(data) }).value,
+ processed.bytes.count <= maxUploadBytes,
let id = await upload(processed.bytes, onProgress: onProgress) else { return nil }
let base = name.replacingOccurrences(of: "\\.[^.]+$", with: "", options: .regularExpression)
return Attachment(id: id, name: base + ".jpg", type: "image/jpeg",
@@ -208,7 +240,11 @@ public final class ChatFeedModel: ObservableObject {
URLQueryItem(name: "token", value: instance.deviceToken),
URLQueryItem(name: "thread", value: threadID),
]
- guard let url = comps.url, let enc = try? key.encryptBytes(plain) else { return nil }
+ // Encrypting a full-size attachment (up to 50 MB video) off main.
+ guard let url = comps.url,
+ let enc = await Task.detached(priority: .userInitiated,
+ operation: { try? key.encryptBytes(plain) }).value
+ else { return nil }
var req = URLRequest(url: url)
req.httpMethod = "POST"
struct Resp: Decodable { let id: String? }
@@ -348,6 +384,22 @@ public final class ChatFeedModel: ObservableObject {
var sid: String? // remind.add: server schedule id ("" = offline fallback)
}
+ // Encrypt an outgoing payload and send it, on the off-main lane (the lane
+ // also guarantees two sends leave in the order they were typed). Callers
+ // check socket.isLive FIRST so "keep the typed text on a down socket"
+ // still gets its synchronous answer; a socket that dies in the gap just
+ // drops the frame, exactly as today's fire-and-forget send did.
+ private func encryptAndSend<T: Encodable & Sendable>(_ payload: T, via socket: ChatSocket) {
+ guard let key else { return }
+ let tid = threadID
+ enqueueOffMain {
+ guard let blob = try? key.encryptJSON(payload),
+ let frame = try? JSONEncoder().encode(OutFrame(thread_id: tid, blob: blob)),
+ let json = String(data: frame, encoding: .utf8) else { return }
+ socket.send(json)
+ }
+ }
+
/// Emit a mini-app event into this thread (the default-command path: plain
/// text typed into To-do becomes todo.add). Encrypted + sent like any
/// message; the echo folds back through the app projection, so the action
@@ -356,19 +408,16 @@ public final class ChatFeedModel: ObservableObject {
@discardableResult
public func sendAppEvent(type: String, text: String, id: String = UUID().uuidString,
when: Double? = nil, sid: String? = nil) -> Bool {
- guard !text.isEmpty, let key, let socket else { return false }
+ guard !text.isEmpty, key != nil, let socket, socket.isLive else { return false }
var payload = AppEventOut(type: type, id: id, text: text,
name: instance.screenName,
ts: Int64(Date().timeIntervalSince1970 * 1000))
if !myUserId.isEmpty { payload.uid = myUserId }
payload.when = when
payload.sid = sid
- guard let blob = try? key.encryptJSON(payload),
- let frame = try? JSONEncoder().encode(OutFrame(thread_id: threadID, blob: blob)),
- let json = String(data: frame, encoding: .utf8) else { return false }
- let sent = socket.send(json)
- if sent { typingBeat(active: false) }
- return sent
+ encryptAndSend(payload, via: socket)
+ typingBeat(active: false)
+ return true
}
// The scheduled remind.fire payload — encrypted NOW and held by the server
@@ -410,9 +459,13 @@ public final class ChatFeedModel: ObservableObject {
// falls back to sid "". So the guard here is ONLY trap-safety; the
// server stays the one range authority for both platforms.
let sec = when / 1000
- if let api, sec.isFinite, sec > 0, sec < 9e18, // Int64-conversion safety, not policy
- let blob = try? key.encryptJSON(fire) {
- sid = await api.schedule(thread: threadID, deliverAt: Int64(sec), blob: blob)
+ if let api, sec.isFinite, sec > 0, sec < 9e18 { // Int64-conversion safety, not policy
+ let sendable = fire
+ let blob = await Task.detached(priority: .userInitiated,
+ operation: { try? key.encryptJSON(sendable) }).value
+ if let blob {
+ sid = await api.schedule(thread: threadID, deliverAt: Int64(sec), blob: blob)
+ }
}
let sent = sendAppEvent(type: "remind.add", text: text, id: id, when: when, sid: sid ?? "")
if !sent, let sid, let api {
@@ -425,17 +478,14 @@ public final class ChatFeedModel: ObservableObject {
/// the full text; here it rides with action:true, like the web's ctx.emit.
@discardableResult
public func sendAction(_ text: String) -> Bool {
- guard !text.isEmpty, let key, let socket else { return false }
+ guard !text.isEmpty, key != nil, let socket, socket.isLive else { return false }
var payload = Outgoing(text: text, name: instance.screenName,
ts: Int64(Date().timeIntervalSince1970 * 1000))
if !myUserId.isEmpty { payload.uid = myUserId }
payload.action = true
- guard let blob = try? key.encryptJSON(payload),
- let frame = try? JSONEncoder().encode(OutFrame(thread_id: threadID, blob: blob)),
- let json = String(data: frame, encoding: .utf8) else { return false }
- let sent = socket.send(json)
- if sent { typingBeat(active: false) }
- return sent
+ encryptAndSend(payload, via: socket)
+ typingBeat(active: false)
+ return true
}
// One hub action frame (react/unreact/delete/edit) — nil fields are
@@ -490,13 +540,21 @@ public final class ChatFeedModel: ObservableObject {
/// Re-encrypts {…raw, text} like the web, so attachments and the reply
/// reference survive the rewrite. Returns false on a down socket.
public func edit(_ id: Int64, text: String) -> Bool {
- guard let key, let m = messages.first(where: { $0.id == id }) else { return false }
+ guard let key, let socket, socket.isLive,
+ let m = messages.first(where: { $0.id == id }) else { return false }
var raw = m.raw
raw.text = emojify(text.trimmingCharacters(in: .whitespacesAndNewlines))
- guard !(raw.text ?? "").isEmpty,
- let blob = try? key.encryptJSON(raw) else { return false }
- return sendFrame(ActionFrame(type: "edit", thread_id: threadID,
- message_id: id, blob: blob))
+ guard !(raw.text ?? "").isEmpty else { return false }
+ let tid = threadID
+ let edited = raw
+ enqueueOffMain {
+ guard let blob = try? key.encryptJSON(edited),
+ let data = try? JSONEncoder().encode(ActionFrame(
+ type: "edit", thread_id: tid, message_id: id, blob: blob)),
+ let json = String(data: data, encoding: .utf8) else { return }
+ socket.send(json)
+ }
+ return true
}
private struct OutFrame: Encodable {
@@ -513,20 +571,18 @@ public final class ChatFeedModel: ObservableObject {
// Shortcodes interpolate at send time (emoji.js emojify), so the
// ciphertext stores the real glyph and renders everywhere.
let trimmed = emojify(text.trimmingCharacters(in: .whitespacesAndNewlines))
- guard !trimmed.isEmpty || !attachments.isEmpty, let key, let socket else { return false }
+ guard !trimmed.isEmpty || !attachments.isEmpty, key != nil,
+ let socket, socket.isLive else { return false }
var payload = Outgoing(text: trimmed, name: instance.screenName,
ts: Int64(Date().timeIntervalSince1970 * 1000))
if !myUserId.isEmpty { payload.uid = myUserId }
if !attachments.isEmpty { payload.attachments = attachments }
payload.replyTo = replyTo
- guard let blob = try? key.encryptJSON(payload),
- let frame = try? JSONEncoder().encode(OutFrame(thread_id: threadID, blob: blob)),
- let json = String(data: frame, encoding: .utf8) else { return false }
// The hub stores + broadcasts to everyone including us; the echo frame
// folds the message into the list (same as the web — no optimistic row).
- let sent = socket.send(json)
- if sent { typingBeat(active: false) } // a send clears the indicator now
- return sent
+ encryptAndSend(payload, via: socket)
+ typingBeat(active: false) // a send clears the indicator now
+ return true
}
/// Send over REST — the share extension has no WebSocket. Same encrypted
@@ -538,7 +594,9 @@ public final class ChatFeedModel: ObservableObject {
ts: Int64(Date().timeIntervalSince1970 * 1000))
if !myUserId.isEmpty { payload.uid = myUserId }
if !attachments.isEmpty { payload.attachments = attachments }
- guard let blob = try? key.encryptJSON(payload),
+ let sendable = payload
+ guard let blob = await Task.detached(priority: .userInitiated,
+ operation: { try? key.encryptJSON(sendable) }).value,
var comps = URLComponents(url: base.appending(path: "api/messages"),
resolvingAgainstBaseURL: false) else { return false }
comps.queryItems = [URLQueryItem(name: "token", value: instance.deviceToken)]
@@ -605,23 +663,7 @@ public final class ChatFeedModel: ObservableObject {
status = "\(messages.count) messages"
}
- // Fold a whole page with ONE published update: per-message publishing made
- // SwiftUI re-diff the entire list for every folded row (O(n²) as history
- // grew — the freeze that looked like a crash).
- private func foldPage(_ rows: [ServerMessage]) {
- guard let key else { return }
- var list = messages
- for row in rows {
- fold(id: row.id, at: row.created_at,
- payload: try? key.decryptJSON(MessagePayload.self, blob: row.blob),
- edited: (row.edited_at ?? 0) > 0, reactions: row.reactions ?? [],
- into: &list)
- }
- refreshReplyCounts(&list)
- messages = list
- }
-
- // The off-main variant: a 200-message page is 200 AES-GCM opens + JSON
+ // A 200-message page is 200 AES-GCM opens + JSON
// decodes + Message builds — done on the main actor (this class's home)
// it froze the UI on open, worst on long chats and the prewarm burst.
// The whole page builds detached; main only merges (dictionary upsert,
@@ -629,16 +671,43 @@ public final class ChatFeedModel: ObservableObject {
// the length-scaled stall).
// Map a stored (already-decrypted) page into view messages and publish.
private func publishStored(_ stored: [MessageStore.StoredMessage]) {
- var list: [Message] = stored.compactMap { s in
- guard s.payload.isRenderableChat else { return nil }
- let p = s.payload
- let mine = p.uid.map { !myUserId.isEmpty && $0 == myUserId }
- ?? (p.name == instance.screenName)
- return Message(id: s.id, name: p.name ?? "", text: p.text ?? "",
- mine: mine, at: s.at, action: p.action ?? false,
- edited: s.edited, attachments: p.attachments ?? [],
- reply: p.replyTo, reactions: s.reactions, raw: p)
+ var list: [Message] = stored.compactMap(viewMessage)
+ refreshReplyCounts(&list)
+ messages = list
+ }
+
+ // One stored row → one view message (nil for non-chat payloads).
+ private func viewMessage(_ s: MessageStore.StoredMessage) -> Message? {
+ guard s.payload.isRenderableChat else { return nil }
+ let p = s.payload
+ let mine = p.uid.map { !myUserId.isEmpty && $0 == myUserId }
+ ?? (p.name == instance.screenName)
+ return Message(id: s.id, name: p.name ?? "", text: p.text ?? "",
+ mine: mine, at: s.at, action: p.action ?? false,
+ edited: s.edited, attachments: p.attachments ?? [],
+ reply: p.replyTo, reactions: s.reactions, raw: p)
+ }
+
+ /// Merge store rows this model never folded live. The NSE ingests pushed
+ /// messages (and background refresh syncs) while the app is suspended —
+ /// its sockets down, parked models frozen — so re-attaching a parked model
+ /// (NativeFeedModelCache) or foregrounding pulls those rows from disk and
+ /// the tapped chat is caught up instantly; the socket's history re-pull
+ /// then converges reactions/edits. Rows already in memory are left alone
+ /// (the live fold's state is richer than an NSE row).
+ public func catchUpFromStore() async {
+ guard ready else { return } // the initial load reads the store itself
+ let stored = await MessageStore.shared.page(instance: instance.id, thread: threadID)
+ var list = messages
+ let known = Set(list.map(\.id))
+ var changed = false
+ for s in stored where !known.contains(s.id) {
+ guard let msg = viewMessage(s) else { continue }
+ list.append(msg)
+ changed = true
}
+ guard changed else { return }
+ list.sort { $0.id < $1.id }
refreshReplyCounts(&list)
messages = list
}
@@ -697,14 +766,18 @@ public final class ChatFeedModel: ObservableObject {
// Ephemeral frames are relay-only presence — fold typing beats,
// render nothing.
if frame.ephemeral == true {
- if let blob = frame.blob { foldTyping(blob) }
+ if let blob = frame.blob { foldTypingBlob(blob) }
return
}
- guard let id = frame.id, let blob = frame.blob else { return }
- fold(id: id, at: frame.created_at ?? 0, blob: blob)
+ guard let id = frame.id, let blob = frame.blob, let key else { return }
+ let at = frame.created_at ?? 0
+ decryptThenFold({ Self.renderable(blob, key: key) }) { model, payload in
+ model.foldDecrypted(id: id, at: at, payload: payload,
+ edited: false, reactions: [])
+ }
case "typing":
// The dedicated relay frame (bots use this path) — same payload.
- if let blob = frame.blob { foldTyping(blob) }
+ if let blob = frame.blob { foldTypingBlob(blob) }
case "reaction":
// The hub rebroadcasts the FULL per-emoji tally after every toggle.
// The broadcast can't carry `mine` (one frame fans out to the whole
@@ -727,17 +800,18 @@ public final class ChatFeedModel: ObservableObject {
id: id, tally)
}
case "edit":
- guard let id = frame.id, let blob = frame.blob, let key,
- let p = try? key.decryptJSON(MessagePayload.self, blob: blob),
- let i = messages.firstIndex(where: { $0.id == id }) else { return }
- messages[i].text = p.text ?? ""
- messages[i].edited = true
- // Keep the source payload current too — edit() re-encrypts from it
- // and reply snippets quote it; a stale raw silently reverted a
- // second edit back to the pre-first-edit text.
- messages[i].raw = p
- storeMirror(.init(id: id, at: messages[i].at, edited: true,
- reactions: messages[i].reactions, payload: p))
+ guard let id = frame.id, let blob = frame.blob, let key else { return }
+ decryptThenFold({ try? key.decryptJSON(MessagePayload.self, blob: blob) }) { model, p in
+ guard let i = model.messages.firstIndex(where: { $0.id == id }) else { return }
+ model.messages[i].text = p.text ?? ""
+ model.messages[i].edited = true
+ // Keep the source payload current too — edit() re-encrypts from
+ // it and reply snippets quote it; a stale raw silently reverted
+ // a second edit back to the pre-first-edit text.
+ model.messages[i].raw = p
+ model.storeMirror(.init(id: id, at: model.messages[i].at, edited: true,
+ reactions: model.messages[i].reactions, payload: p))
+ }
case "delete":
guard let id = frame.id else { return }
var list = messages
@@ -759,18 +833,26 @@ public final class ChatFeedModel: ObservableObject {
// ---- typing (the web's chat.typing beats) ------------------------------
- private struct TypingPayload: Decodable {
+ private struct TypingPayload: Decodable, Sendable {
let type: String?
let name: String?
let uid: String?
let expiry: Double?
}
+ // A beat arrives for every keystroke burst of every other member — decrypt
+ // it off main like any other frame, then fold the plaintext here.
+ private func foldTypingBlob(_ blob: String) {
+ guard let key else { return }
+ decryptThenFold({ try? key.decryptJSON(TypingPayload.self, blob: blob) }) { model, p in
+ model.foldTyping(p)
+ }
+ }
+
// Fold one beat, mirroring chat.js: skip our own (uid, else name), clamp
// expiry to 0…60s, 0 removes; a sweep timer retires stale names.
- private func foldTyping(_ blob: String) {
- guard let key, let p = try? key.decryptJSON(TypingPayload.self, blob: blob),
- p.type == "chat.typing", let name = p.name, !name.isEmpty else { return }
+ private func foldTyping(_ p: TypingPayload) {
+ guard p.type == "chat.typing", let name = p.name, !name.isEmpty else { return }
if let uid = p.uid, !myUserId.isEmpty {
if uid == myUserId { return }
} else if name == instance.screenName {
@@ -832,13 +914,19 @@ public final class ChatFeedModel: ObservableObject {
}
private func sendTypingBeat(expiry: Int) {
- guard let key else { return }
+ guard let key, let socket else { return }
var payload = TypingOut(name: instance.screenName,
ts: Int64(Date().timeIntervalSince1970 * 1000), expiry: expiry)
if !myUserId.isEmpty { payload.uid = myUserId }
- guard let blob = try? key.encryptJSON(payload) else { return }
lastBeatSent = Date()
- sendFrame(EphemeralFrame(thread_id: threadID, blob: blob))
+ let tid = threadID
+ let sendable = payload
+ enqueueOffMain {
+ guard let blob = try? key.encryptJSON(sendable),
+ let data = try? JSONEncoder().encode(EphemeralFrame(thread_id: tid, blob: blob)),
+ let json = String(data: data, encoding: .utf8) else { return }
+ socket.send(json)
+ }
}
private struct EphemeralFrame: Encodable {
@@ -848,12 +936,11 @@ public final class ChatFeedModel: ObservableObject {
let ephemeral = true
}
- // A live frame folds one message straight into the published list (one
- // decrypt — fine on main), mirrored into the local store.
- private func fold(id: Int64, at: Int64, blob: String,
- edited: Bool = false, reactions: [Reaction] = []) {
- guard let key else { return }
- let payload = Self.renderable(blob, key: key)
+ // A live frame's already-decrypted payload folds into the published list
+ // (the decrypt happened on the off-main lane), mirrored into the local
+ // store.
+ private func foldDecrypted(id: Int64, at: Int64, payload: MessagePayload?,
+ edited: Bool, reactions: [Reaction]) {
var list = messages
fold(id: id, at: at, payload: payload,
edited: edited, reactions: reactions, into: &list)
@@ -883,7 +970,7 @@ public final class ChatFeedModel: ObservableObject {
// companion apps' Chat view projects them into chat lines on the web, and
// this is that projection natively: decode the richer event and synthesize
// a renderable payload (bot bubble or action line).
- nonisolated static func renderable(_ blob: String, key: GroupKey) -> MessagePayload? {
+ nonisolated public static func renderable(_ blob: String, key: GroupKey) -> MessagePayload? {
guard let p = try? key.decryptJSON(MessagePayload.self, blob: blob) else { return nil }
if p.isRenderableChat { return p }
guard let ev = try? key.decryptJSON(AppChatProjection.AppEvent.self, blob: blob),
diff --git a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
index 8726425..74962c1 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
@@ -72,10 +72,16 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
session = nil
}
+ /// Whether a live task is up — the send paths check this synchronously
+ /// (keep-the-typed-text-on-a-down-socket), then encrypt + send off main.
+ public var isLive: Bool { task?.state == .running }
+
/// Send one already-encoded JSON frame. Returns false when the socket has
/// no live task — the caller keeps what it was sending (the web's emit
/// resolves false on a down socket for the same reason: never silently
- /// drop what was typed).
+ /// drop what was typed). Thread-safe: the underlying WebSocket task
+ /// accepts sends from any thread, so the off-main crypto lane calls this
+ /// directly.
@discardableResult
public func send(_ jsonText: String) -> Bool {
guard let task, task.state == .running else { return false }
diff --git a/ios/LChatCore/Sources/LChatCore/MessageStore.swift b/ios/LChatCore/Sources/LChatCore/MessageStore.swift
index 3e17f8d..c6c15e5 100644
--- a/ios/LChatCore/Sources/LChatCore/MessageStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/MessageStore.swift
@@ -25,16 +25,50 @@ public actor MessageStore {
public let edited: Bool
public let reactions: [Reaction]
public let payload: MessagePayload
+
+ public init(id: Int64, at: Int64, edited: Bool,
+ reactions: [Reaction], payload: MessagePayload) {
+ self.id = id
+ self.at = at
+ self.edited = edited
+ self.reactions = reactions
+ self.payload = payload
+ }
}
private var db: OpaquePointer?
- private func open() -> OpaquePointer? {
- if let db { return db }
- let dir = (FileManager.default.urls(for: .applicationSupportDirectory,
- in: .userDomainMask).first
+ // The App Group container, so the notification extension can ingest a
+ // pushed message into the SAME store the app reads (still on-device,
+ // OS-protected, never backed up). Falls back to Application Support where
+ // no group container exists (ad-hoc dev signing without the entitlement).
+ private static let appGroupID = "group.com.elevenmessenger.messenger"
+
+ private func storeDir() -> URL {
+ let fm = FileManager.default
+ let fallback = (fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSTemporaryDirectory()))
.appendingPathComponent("lchat", isDirectory: true)
+ guard let group = fm.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupID)
+ else { return fallback }
+ let dir = group.appendingPathComponent("lchat", isDirectory: true)
+ try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
+ // One-time migration from the pre-group location: move the db (and its
+ // WAL sidecars) so existing history survives the path change.
+ let old = fallback.appendingPathComponent("messages.db")
+ let new = dir.appendingPathComponent("messages.db")
+ if fm.fileExists(atPath: old.path), !fm.fileExists(atPath: new.path) {
+ for suffix in ["", "-wal", "-shm"] {
+ try? fm.moveItem(at: URL(fileURLWithPath: old.path + suffix),
+ to: URL(fileURLWithPath: new.path + suffix))
+ }
+ }
+ return dir
+ }
+
+ private func open() -> OpaquePointer? {
+ if let db { return db }
+ let dir = storeDir()
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
var url = dir.appendingPathComponent("messages.db")
var handle: OpaquePointer?
@@ -43,6 +77,9 @@ public actor MessageStore {
nil) == SQLITE_OK, let handle else { return nil }
exec(handle, "PRAGMA journal_mode=WAL")
exec(handle, "PRAGMA synchronous=NORMAL")
+ // Two processes share this file now (app + NSE); wait out the other's
+ // write instead of failing with SQLITE_BUSY.
+ exec(handle, "PRAGMA busy_timeout=3000")
exec(handle, """
CREATE TABLE IF NOT EXISTS messages(
instance TEXT NOT NULL,
@@ -165,6 +202,22 @@ public actor MessageStore {
return out.reversed()
}
+ /// The newest stored message's (id, at) for a thread — the incremental-sync
+ /// watermark background refresh and the NSE-ingest catch-up key off. (0, 0)
+ /// when nothing is stored yet.
+ public func latest(instance: String, thread: String) -> (id: Int64, at: Int64) {
+ guard let handle = open() else { return (0, 0) }
+ var stmt: OpaquePointer?
+ guard sqlite3_prepare_v2(handle,
+ "SELECT id, at FROM messages WHERE instance=? AND thread=? ORDER BY id DESC LIMIT 1",
+ -1, &stmt, nil) == SQLITE_OK else { return (0, 0) }
+ defer { sqlite3_finalize(stmt) }
+ bind(stmt, 1, instance)
+ bind(stmt, 2, thread)
+ guard sqlite3_step(stmt) == SQLITE_ROW else { return (0, 0) }
+ return (sqlite3_column_int64(stmt, 0), sqlite3_column_int64(stmt, 1))
+ }
+
/// Full-text search across an instance (newest first).
public func search(instance: String, query: String, limit: Int = 50)
-> [(thread: String, message: StoredMessage)] {
diff --git a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift
index b64dab0..e031ab0 100644
--- a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift
+++ b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift
@@ -28,6 +28,11 @@ public final class NativeFeedModelCache {
if var entry = entries[key], entry.seedKey == thread.threadKeyB64 {
entry.lastUsed = Date()
entries[key] = entry
+ // A parked model missed whatever the NSE / background refresh
+ // filed while the app was suspended — merge it from disk now, so
+ // a notification tap lands on an already-caught-up chat.
+ let model = entry.model
+ Task { await model.catchUpFromStore() }
return entry.model
}
entries.removeValue(forKey: key)?.model.stop()
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index 39cb350..c023998 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -49,24 +49,6 @@ public final class ThreadStore: ObservableObject {
return AvatarSource(blobID: blobID, keyB64: key, baseURL: base, token: instance.deviceToken)
}
- // A thread row's avatar: a group's own photo (thread key) or, for a DM/self
- // chat, the person's profile photo (instance key) — the web list's
- // group→threadAvatarFor, dm/self→avatarFor split (web/static/chat.js).
- private func avatarSource(kind: String, threadAvatar: String?, threadKey: Data,
- members: [String], rosterAvatars: [String: String]) -> AvatarSource? {
- switch kind {
- case "group":
- guard let blob = threadAvatar, !blob.isEmpty, let base = instance.baseURL else { return nil }
- return AvatarSource(blobID: blob, keyB64: threadKey.base64urlEncodedString(),
- baseURL: base, token: instance.deviceToken)
- case "self":
- return memberAvatar(rosterAvatars[myUserId])
- default: // dm
- let other = members.first { $0 != myUserId } ?? ""
- return memberAvatar(rosterAvatars[other])
- }
- }
-
public func refresh() async {
guard let api else { loadError = "This instance's address is invalid."; return }
loading = true
@@ -74,13 +56,53 @@ public final class ThreadStore: ObservableObject {
await api.ensurePubkey(instance.userPub)
- var people = await api.roster()
+ let people = await api.roster()
if myUserId.isEmpty, let me = people.first(where: { $0.public_key == instance.userPub }) {
myUserId = me.user_id
}
+ let wrappedInstanceKey = await api.instanceKeyWrapped()
+ instanceName = await api.groupName()
+ let serverThreads = await api.threads()
+
+ // ALL crypto in one detached pass — the instance-key unwrap, roster
+ // labels, per-thread ECDH unwraps, previews, titles, app ids. refresh()
+ // fires on any instance activity via the live sockets, so the main
+ // actor must only publish the finished rows (each unwrap alone is a
+ // milliseconds-scale P-256 agreement; each preview/title an AES open).
+ let inst = instance
+ let uid = myUserId
+ let folded = await Task.detached(priority: .userInitiated) {
+ Self.fold(instance: inst, myUserId: uid, peopleIn: people,
+ wrappedInstanceKey: wrappedInstanceKey, serverThreads: serverThreads)
+ }.value
+ instanceKeyB64 = folded.instanceKeyB64
+ roster = folded.people
+ rosterLabels = folded.rosterLabels
+ for (tid, mark) in folded.readMarks {
+ serverReadMarks[tid] = max(serverReadMarks[tid] ?? 0, mark)
+ }
+ threads = folded.rows
+ }
+
+ // Everything refresh() publishes, computed off the main actor.
+ private struct Folded {
+ var instanceKeyB64: String?
+ var people: [RosterEntry]
+ var rosterLabels: [String: String]
+ var rows: [ChatThread]
+ // Raw server read watermarks by thread — merged into serverReadMarks
+ // on the main actor (the fold runs detached and can't touch it).
+ var readMarks: [String: Int64]
+ }
+ nonisolated private static func fold(instance: SavedInstance, myUserId: String,
+ peopleIn: [RosterEntry],
+ wrappedInstanceKey: String?,
+ serverThreads: [ServerThread]) -> Folded {
+ var people = peopleIn
+ var instanceKeyB64: String?
var instanceGroupKey: GroupKey?
- if let wrapped = await api.instanceKeyWrapped(),
+ if let wrapped = wrappedInstanceKey,
let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: instance.userPriv) {
instanceKeyB64 = raw.base64urlEncodedString()
instanceGroupKey = try? GroupKey(rawKey: raw)
@@ -98,38 +120,72 @@ public final class ThreadStore: ObservableObject {
people[i].label = name
}
}
- roster = people
- rosterLabels = Dictionary(people.map { ($0.user_id, $0.label) }, uniquingKeysWith: { a, _ in a })
+ let rosterLabels = Dictionary(people.map { ($0.user_id, $0.label) },
+ uniquingKeysWith: { a, _ in a })
// Member profile-photo blob ids, for DM/self avatars (decrypted under the
// instance key, like the labels above). Groups use their own thread key.
let rosterAvatars = Dictionary(
people.compactMap { e in e.avatar.map { (e.user_id, $0) } },
uniquingKeysWith: { a, _ in a })
- instanceName = await api.groupName()
-
- let serverThreads = await api.threads()
- // The per-thread key unwrap is a P-256 ECDH agreement — milliseconds
- // EACH, and refresh() fires on any instance activity via the live
- // sockets. On the main actor that was the remaining intermittent
- // scroll stall; unwrap the whole batch detached, then fold cheaply.
- let priv = instance.userPriv
- let unwrappedKeys: [String: Data] = await Task.detached(priority: .userInitiated) {
- var out: [String: Data] = [:]
- for t in serverThreads {
- guard let wrapped = t.wrapped, !wrapped.isEmpty,
- let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: priv)
- else { continue }
- out[t.id] = raw
- }
- return out
- }.value
+ // A member's profile photo (instance key) — DM/self rows and the picker.
+ func memberAvatar(_ blobID: String?) -> AvatarSource? {
+ guard let blobID, !blobID.isEmpty, let key = instanceKeyB64,
+ let base = instance.baseURL else { return nil }
+ return AvatarSource(blobID: blobID, keyB64: key, baseURL: base,
+ token: instance.deviceToken)
+ }
var rows: [ChatThread] = []
+ var readMarks: [String: Int64] = [:]
for t in serverThreads {
// No usable key → can't decrypt anything about this thread; skip it.
- guard let raw = unwrappedKeys[t.id] else { continue }
+ guard let wrapped = t.wrapped, !wrapped.isEmpty,
+ let raw = try? ECIES.unwrap(wrappedB64: wrapped,
+ privPkcs8B64: instance.userPriv)
+ else { continue }
let key = try? GroupKey(rawKey: raw)
+ let members = t.members ?? []
+ let other = members.first { $0 != myUserId } ?? ""
+
+ // The encrypted name_blob opens ONCE per thread (it used to be
+ // decrypted up to four times — appId, isApp, glyph, then title).
+ // A self-thread's carries {app, title}; a group's carries {name}.
+ var appPayload: AppThreadPayload?
+ var groupName: String?
+ if let nb = t.name_blob, !nb.isEmpty, let key {
+ if t.kind == "self" {
+ appPayload = try? key.decryptJSON(AppThreadPayload.self, blob: nb)
+ } else if t.kind != "dm" {
+ groupName = (try? key.decryptJSON(GroupNamePayload.self, blob: nb))?.name
+ }
+ }
+ let app: String? = (appPayload?.app?.isEmpty == false) ? appPayload?.app : nil
+
+ let title: String
+ switch t.kind {
+ case "self":
+ // Built-in apps take their name from the catalog, so renaming an
+ // app (e.g. Connected services → Integrations) reaches existing
+ // threads too — matching the web, where displayName() also reads
+ // the catalog title. Unknown/custom apps keep the stored title;
+ // a bare self-thread is "Notes".
+ if let app { title = appTitles[app] ?? appPayload?.title ?? app.capitalized }
+ else { title = "Notes" }
+ case "dm":
+ title = rosterLabels[other] ?? "Someone"
+ default:
+ if let n = groupName, !n.isEmpty {
+ title = n
+ } else {
+ // Unnamed group: title from the other members' names — mirrors
+ // autoGroupTitle in web/static/format.js exactly.
+ let names = members.filter { $0 != myUserId }
+ .compactMap { rosterLabels[$0] }.filter { !$0.isEmpty }
+ title = autoGroupTitle(names)
+ }
+ }
+
var preview = ""
var lastMine = false
if let lb = t.last_blob, !lb.isEmpty, let key,
@@ -147,28 +203,62 @@ public final class ThreadStore: ObservableObject {
// An app thread with no activity yet shows its blurb instead of a
// blank, vertically-centred title (the row layout top-aligns the
// moment there's a subtitle) — same intent as the web sidebar.
- if preview.isEmpty, let app = appId(t, key: key), let blurb = Self.appDescriptions[app] {
+ if preview.isEmpty, let app, let blurb = appDescriptions[app] {
preview = blurb
}
+
+ // The list chip, mirroring the web's badges: APP for a mini-app
+ // thread, AI/BOT for a DM whose other member is a helper
+ // (shell.js agentTag: kind "ai" = AI, otherwise BOT).
+ var badge: String?
+ if app != nil {
+ badge = "APP"
+ } else if t.kind == "dm",
+ let entry = people.first(where: { $0.user_id == other }),
+ entry.isAgent {
+ badge = entry.isAI ? "AI" : "BOT"
+ }
+
+ // A thread row's avatar: a group's own photo (thread key) or, for a
+ // DM/self chat, the person's profile photo (instance key) — the web
+ // list's group→threadAvatarFor, dm/self→avatarFor split. App threads
+ // show the app's emoji face instead — a self-thread's member photo
+ // (your own) would be wrong there.
+ var avatar: AvatarSource?
+ if app == nil {
+ switch t.kind {
+ case "group":
+ if let blob = t.avatar, !blob.isEmpty, let base = instance.baseURL {
+ avatar = AvatarSource(blobID: blob, keyB64: raw.base64urlEncodedString(),
+ baseURL: base, token: instance.deviceToken)
+ }
+ case "self":
+ avatar = memberAvatar(rosterAvatars[myUserId])
+ default: // dm
+ avatar = memberAvatar(rosterAvatars[other])
+ }
+ }
+
let lastAt = t.last_at ?? 0
// Fold the server's read watermark (advanced by ANY of this user's
// devices) into the local one — reading a chat on the phone clears
// the dot here too. Forward-only, like the server: a stale local
// value advances, a newer one (this device read something the
- // server hasn't confirmed yet) stands.
- serverReadMarks[t.id] = max(serverReadMarks[t.id] ?? 0, t.last_read_at ?? 0)
+ // server hasn't confirmed yet) stands. (ReadState is UserDefaults —
+ // thread-safe here; serverReadMarks is main-actor state, so the raw
+ // value rides back in Folded.readMarks instead.)
+ readMarks[t.id] = t.last_read_at ?? 0
if let sm = t.last_read_at, sm > ReadState.lastSeen(instance: instance.id, thread: t.id) {
ReadState.setLastSeen(instance: instance.id, thread: t.id, at: sm)
}
// Park the key + decrypted title for the notification extension: a
// push's ciphertext is decrypted on-device against this store.
SharedThreadKeys.save(threadID: t.id, entry: .init(
- key: raw, title: threadTitle(t, key: key),
- origin: instance.origin, group: t.kind == "group"))
+ key: raw, title: title, origin: instance.origin, group: t.kind == "group"))
rows.append(ChatThread(
id: t.id,
kind: t.kind,
- title: threadTitle(t, key: key),
+ title: title,
preview: preview,
lastAt: lastAt,
unread: Self.isUnread(lastAt: lastAt, lastMine: lastMine,
@@ -176,28 +266,25 @@ public final class ThreadStore: ObservableObject {
manualUnreadAt: ReadState.manualUnreadAt(instance: instance.id, thread: t.id)),
pinned: t.pinned ?? false,
hidden: t.hidden ?? false,
- members: t.members ?? [],
+ members: members,
threadKeyB64: raw.base64urlEncodedString(),
- // App threads show the app's emoji face — a self-thread's
- // member photo (your own) would be wrong there.
- avatar: isAppThread(t, key: key) ? nil
- : avatarSource(kind: t.kind, threadAvatar: t.avatar, threadKey: raw,
- members: t.members ?? [], rosterAvatars: rosterAvatars),
- isApp: isAppThread(t, key: key),
- appId: appId(t, key: key),
- glyph: appId(t, key: key).map { Self.appGlyphs[$0] ?? "▦" },
- badge: threadBadge(t, key: key)
+ avatar: avatar,
+ isApp: app != nil,
+ appId: app,
+ glyph: app.map { appGlyphs[$0] ?? "▦" },
+ badge: badge
))
}
// Pinned chats first, then most-recent activity — mirrors the web list.
// Closed (hidden) chats stay in the array — ThreadListView drops them from
// the default view but reveals them in search, so they can be reopened.
- rows.sort(by: Self.order)
- threads = rows
+ rows.sort(by: order)
+ return Folded(instanceKeyB64: instanceKeyB64, people: people,
+ rosterLabels: rosterLabels, rows: rows, readMarks: readMarks)
}
// Pinned chats first, then most-recent activity — mirrors the web list.
- public static func order(_ a: ChatThread, _ b: ChatThread) -> Bool {
+ nonisolated public static func order(_ a: ChatThread, _ b: ChatThread) -> Bool {
(a.pinned != b.pinned) ? a.pinned : a.lastAt > b.lastAt
}
@@ -246,24 +333,36 @@ public final class ThreadStore: ObservableObject {
return nil
}
- let raw = ECIES.randomAESKey()
let kind = others.isEmpty ? "self" : (others.count == 1 ? "dm" : "group")
- var nameBlob = ""
- if kind == "group", let n = groupName?.trimmingCharacters(in: .whitespaces), !n.isEmpty,
- let key = try? GroupKey(rawKey: raw) {
- nameBlob = (try? key.encryptJSON(["name": n])) ?? ""
- }
-
- var members: [[String: String]] = []
- for id in ids {
- guard let pub = pubs[id], let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: pub) else {
- loadError = "Couldn't wrap the chat key."
- return nil
- }
- members.append(["user_id": id, "wrapped": wrapped])
+ let name = groupName?.trimmingCharacters(in: .whitespaces)
+ let memberIDs = ids
+ let memberPubs = pubs
+ // Key generation, the name encrypt and one ECDH wrap PER MEMBER — a
+ // big group is many milliseconds; all of it happens off main.
+ let built: (nameBlob: String, members: [[String: String]])? =
+ await Task.detached(priority: .userInitiated) {
+ let raw = ECIES.randomAESKey()
+ var nameBlob = ""
+ if kind == "group", let n = name, !n.isEmpty,
+ let key = try? GroupKey(rawKey: raw) {
+ nameBlob = (try? key.encryptJSON(["name": n])) ?? ""
+ }
+ var members: [[String: String]] = []
+ for id in memberIDs {
+ guard let pub = memberPubs[id],
+ let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: pub)
+ else { return nil }
+ members.append(["user_id": id, "wrapped": wrapped])
+ }
+ return (nameBlob, members)
+ }.value
+ guard let built else {
+ loadError = "Couldn't wrap the chat key."
+ return nil
}
- guard let tid = await api.createThread(kind: kind, nameBlob: nameBlob, members: members) else {
+ guard let tid = await api.createThread(kind: kind, nameBlob: built.nameBlob,
+ members: built.members) else {
loadError = "Couldn't start the chat. Try again."
return nil
}
@@ -331,7 +430,7 @@ public final class ThreadStore: ObservableObject {
// Built-in mini-app display titles, for app-threads created before the title was
// stored in name_blob (newer ones carry their own). Keep in sync with each app's
// `title` in web/static/apps/*.js.
- private static let appTitles: [String: String] = [
+ nonisolated private static let appTitles: [String: String] = [
"todo": "To-do", "remind": "Reminders", "poll": "Poll",
"bookmarks": "Bookmarks", "board": "Whiteboard", "chat": "Chat",
"connect": "Integrations", "dnsimple": "DNSimple", "admin": "Admin",
@@ -340,7 +439,7 @@ public final class ThreadStore: ObservableObject {
// Each app's emoji face — the sidebar/list avatar, mirroring the `glyph`
// in web/static/apps/*.js (keep in sync like appTitles). The web and native
// faces must match, so a service looks the same on every surface.
- private static let appGlyphs: [String: String] = [
+ nonisolated private static let appGlyphs: [String: String] = [
"todo": "✓", "remind": "⏰", "poll": "📊",
"bookmarks": "🔖", "board": "🎨", "chat": "💬", "admin": "🛠️",
"connect": "🔌", "dnsimple": "🌐",
@@ -349,7 +448,7 @@ public final class ThreadStore: ObservableObject {
// A one-line "what you'd do here" blurb, shown as the sidebar subtitle for an
// app thread with no activity yet — instead of a lonely, vertically-centred
// title. Mirrors each app's `blurb` in web/static/apps/*.js (keep in sync).
- private static let appDescriptions: [String: String] = [
+ nonisolated private static let appDescriptions: [String: String] = [
"todo": "Create a to-do list", "remind": "Set a reminder",
"poll": "Start a poll", "bookmarks": "Save a bookmark",
"board": "Sketch on a whiteboard", "connect": "Connect third-party services",
@@ -367,13 +466,24 @@ public final class ThreadStore: ObservableObject {
/// the ids actually added.
public func addMembers(_ thread: ChatThread, userIDs: [String]) async -> [String] {
guard let api, let raw = Data(base64urlEncoded: thread.threadKeyB64) else { return [] }
+ let pubs = Dictionary(roster.map { ($0.user_id, $0.public_key) },
+ uniquingKeysWith: { a, _ in a })
+ // All the ECDH wraps in one detached hop; only the registration calls
+ // run up here.
+ let wraps: [(id: String, wrapped: String)] =
+ await Task.detached(priority: .userInitiated) {
+ userIDs.compactMap { id in
+ guard let pub = pubs[id],
+ let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: pub)
+ else { return nil }
+ return (id, wrapped)
+ }
+ }.value
var added: [String] = []
- for id in userIDs {
- guard let pub = roster.first(where: { $0.user_id == id })?.public_key,
- let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: pub),
- await api.addMember(thread: thread.id, userID: id, wrapped: wrapped)
+ for w in wraps {
+ guard await api.addMember(thread: thread.id, userID: w.id, wrapped: w.wrapped)
else { continue }
- added.append(id)
+ added.append(w.id)
}
if !added.isEmpty { await refresh() }
return added
@@ -389,17 +499,23 @@ public final class ThreadStore: ObservableObject {
return (nil, "still setting up — try again in a moment")
}
let t = InviteCrypto.randomSecret()
- let raw = ECIES.randomAESKey()
- let b64 = raw.base64urlEncodedString()
- guard let derived = try? InviteCrypto.derive(t),
- let selfWrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: instance.userPub),
- let wrapped = try? derived.wrapKey.encryptJSON(["threadKey": b64]),
- let labelBlob = try? ik.encryptJSON(name) else {
- return (nil, "couldn't build the invite")
- }
- let r = await api.createUserInvite(labelBlob: labelBlob, id: derived.id,
- wrapped: wrapped, selfWrapped: selfWrapped,
- claimHash: derived.claimHash)
+ let userPub = instance.userPub
+ // Key derivation + two wraps + two encrypts, off main.
+ let built: (id: String, wrapped: String, selfWrapped: String,
+ labelBlob: String, claimHash: String)? =
+ await Task.detached(priority: .userInitiated) {
+ let raw = ECIES.randomAESKey()
+ let b64 = raw.base64urlEncodedString()
+ guard let derived = try? InviteCrypto.derive(t),
+ let selfWrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: userPub),
+ let wrapped = try? derived.wrapKey.encryptJSON(["threadKey": b64]),
+ let labelBlob = try? ik.encryptJSON(name) else { return nil }
+ return (derived.id, wrapped, selfWrapped, labelBlob, derived.claimHash)
+ }.value
+ guard let built else { return (nil, "couldn't build the invite") }
+ let r = await api.createUserInvite(labelBlob: built.labelBlob, id: built.id,
+ wrapped: built.wrapped, selfWrapped: built.selfWrapped,
+ claimHash: built.claimHash)
guard r.threadID != nil else { return (nil, r.error) }
await refresh()
let link = instance.origin + "/join/join#" + InviteCrypto.fragment(t, name: name)
@@ -411,9 +527,12 @@ public final class ThreadStore: ObservableObject {
/// the new name into the SavedInstance so outgoing payloads carry it.
@discardableResult
public func setScreenName(_ name: String) async -> Bool {
- guard let api, let ikB64 = instanceKeyB64,
- let ik = try? GroupKey(base64url: ikB64),
- let blob = try? ik.encryptJSON(name) else { return false }
+ guard let api, let ikB64 = instanceKeyB64 else { return false }
+ let blob = await Task.detached(priority: .userInitiated) { () -> String? in
+ guard let ik = try? GroupKey(base64url: ikB64) else { return nil }
+ return try? ik.encryptJSON(name)
+ }.value
+ guard let blob else { return false }
let ok = await api.postLabel(labelBlob: blob)
if ok { await refresh() }
return ok
@@ -422,9 +541,12 @@ public final class ThreadStore: ObservableObject {
/// Rename a group (encrypted under its own thread key, like the web).
@discardableResult
public func renameGroup(_ thread: ChatThread, to name: String) async -> Bool {
- guard let api, let raw = Data(base64urlEncoded: thread.threadKeyB64),
- let key = try? GroupKey(rawKey: raw),
- let blob = try? key.encryptJSON(["name": name]) else { return false }
+ guard let api, let raw = Data(base64urlEncoded: thread.threadKeyB64) else { return false }
+ let blob = await Task.detached(priority: .userInitiated) { () -> String? in
+ guard let key = try? GroupKey(rawKey: raw) else { return nil }
+ return try? key.encryptJSON(["name": name])
+ }.value
+ guard let blob else { return false }
let ok = await api.setThreadName(thread: thread.id, nameBlob: blob)
if ok { await refresh() }
return ok
@@ -436,83 +558,29 @@ public final class ThreadStore: ObservableObject {
public func openOrCreateAppThread(app: String, title: String) async -> String? {
if let existing = threads.first(where: { $0.appId == app }) { return existing.id }
guard let api, !myUserId.isEmpty else { return nil }
- let raw = ECIES.randomAESKey()
- guard let key = try? GroupKey(rawKey: raw),
- let nameBlob = try? key.encryptJSON(["app": app, "title": title]),
- let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: instance.userPub) else {
- return nil
- }
- guard let tid = await api.createThread(kind: "self", nameBlob: nameBlob,
- members: [["user_id": myUserId, "wrapped": wrapped]]) else {
+ let userPub = instance.userPub
+ let built: (nameBlob: String, wrapped: String)? =
+ await Task.detached(priority: .userInitiated) {
+ let raw = ECIES.randomAESKey()
+ guard let key = try? GroupKey(rawKey: raw),
+ let nameBlob = try? key.encryptJSON(["app": app, "title": title]),
+ let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: userPub)
+ else { return nil }
+ return (nameBlob, wrapped)
+ }.value
+ guard let built,
+ let tid = await api.createThread(kind: "self", nameBlob: built.nameBlob,
+ members: [["user_id": myUserId,
+ "wrapped": built.wrapped]]) else {
return nil
}
await refresh()
return tid
}
- // The list chip beside a thread's title, mirroring the web's badges: APP
- // for a mini-app thread, AI/BOT for a DM whose other member is a helper
- // (shell.js agentTag: kind "ai" = AI, otherwise BOT).
- private func threadBadge(_ t: ServerThread, key: GroupKey?) -> String? {
- if isAppThread(t, key: key) { return "APP" }
- guard t.kind == "dm" else { return nil }
- let other = (t.members ?? []).first { $0 != myUserId } ?? ""
- guard let entry = roster.first(where: { $0.user_id == other }), entry.isAgent else { return nil }
- return entry.isAI ? "AI" : "BOT"
- }
-
- // A mini-app self-thread: kind "self" whose encrypted name_blob carries an
- // app id. These render as the app's own webview interface ("Apps stay
- // WKWebView"), never as a raw native event-log feed.
- private func appId(_ t: ServerThread, key: GroupKey?) -> String? {
- guard t.kind == "self", let nb = t.name_blob, !nb.isEmpty, let key,
- let p = try? key.decryptJSON(AppThreadPayload.self, blob: nb),
- let app = p.app, !app.isEmpty else { return nil }
- return app
- }
-
- private func isAppThread(_ t: ServerThread, key: GroupKey?) -> Bool {
- appId(t, key: key) != nil
- }
-
- private func threadTitle(_ t: ServerThread, key: GroupKey?) -> String {
- switch t.kind {
- case "self":
- // A self-thread is either the user's own "Notes" or a mini-app thread.
- // The app id (and, from newer clients, its title) rides encrypted in
- // name_blob — the server can't tell them apart — so decrypt to find out.
- // Prefer the blob's title (future apps need no native change), then the
- // known-app map, then a prettified id; a bare self-thread is "Notes".
- if let nb = t.name_blob, !nb.isEmpty, let key,
- let p = try? key.decryptJSON(AppThreadPayload.self, blob: nb),
- let app = p.app, !app.isEmpty {
- // Built-in apps take their name from the catalog, so renaming an
- // app (e.g. Connected services → Integrations) reaches existing
- // threads too — matching the web, where displayName() also reads
- // the catalog title. Unknown/custom apps keep the stored title.
- return Self.appTitles[app] ?? p.title ?? app.capitalized
- }
- return "Notes"
- case "dm":
- let other = (t.members ?? []).first { $0 != myUserId } ?? ""
- return rosterLabels[other] ?? "Someone"
- default:
- if let nb = t.name_blob, !nb.isEmpty, let key,
- let p = try? key.decryptJSON(GroupNamePayload.self, blob: nb),
- let n = p.name, !n.isEmpty {
- return n
- }
- // Unnamed group: title from the other members' names — mirrors
- // autoGroupTitle in web/static/format.js exactly.
- let names = (t.members ?? []).filter { $0 != myUserId }
- .compactMap { rosterLabels[$0] }.filter { !$0.isEmpty }
- return Self.autoGroupTitle(names)
- }
- }
-
// "A" / "A & B" / "A, B & C" / "A, B & N more" — keep in lockstep with
// format.js's autoGroupTitle so a chat is called the same thing everywhere.
- public static func autoGroupTitle(_ names: [String]) -> String {
+ nonisolated public static func autoGroupTitle(_ names: [String]) -> String {
switch names.count {
case 0: return "Group"
case 1: return names[0]
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index fbc7a94..0c89f7c 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -912,18 +912,26 @@ struct NativeChatView: View {
private func notifyIfBackgrounded(_ frame: ChatSocket.Frame) {
guard !NSApp.isActive,
let tid = frame.thread_id, let blob = frame.blob,
- let thread = store.threads.first(where: { $0.id == tid }),
- let key = try? GroupKey(base64url: thread.threadKeyB64),
- let p = try? key.decryptJSON(MessagePayload.self, blob: blob),
- p.uid != store.myUserId
+ let thread = store.threads.first(where: { $0.id == tid })
else { return }
- let content = UNMutableNotificationContent()
- content.title = thread.title
- content.body = previewText(kind: thread.kind, payload: p)
- content.sound = .default
- content.userInfo = ["instance": instance.id, "thread": tid]
- UNUserNotificationCenter.current().add(
- UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
+ let myUserId = store.myUserId
+ let instanceID = instance.id
+ // The AES open runs detached — this fires for every message on the
+ // instance socket while backgrounded; posting the banner is
+ // thread-safe.
+ Task.detached(priority: .utility) {
+ guard let key = try? GroupKey(base64url: thread.threadKeyB64),
+ let p = try? key.decryptJSON(MessagePayload.self, blob: blob),
+ p.uid != myUserId
+ else { return }
+ let content = UNMutableNotificationContent()
+ content.title = thread.title
+ content.body = previewText(kind: thread.kind, payload: p)
+ content.sound = .default
+ content.userInfo = ["instance": instanceID, "thread": tid]
+ UNUserNotificationCenter.current().add(
+ UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
+ }
}
private func scheduleRefresh() {
diff --git a/ios/LChatNSE/LChatNSE.entitlements b/ios/LChatNSE/LChatNSE.entitlements
index 8893321..5820db5 100644
--- a/ios/LChatNSE/LChatNSE.entitlements
+++ b/ios/LChatNSE/LChatNSE.entitlements
@@ -8,5 +8,12 @@
<array>
<string>$(AppIdentifierPrefix)com.elevenmessenger.messenger.shared</string>
</array>
+ <!-- Same app group as the app: the local message store (MessageStore) lives
+ in the group container, so a pushed message is decrypted + filed HERE at
+ delivery time — the chat is already caught up when the banner is tapped. -->
+ <key>com.apple.security.application-groups</key>
+ <array>
+ <string>group.com.elevenmessenger.messenger</string>
+ </array>
</dict>
</plist>
diff --git a/ios/LChatNSE/NotificationService.swift b/ios/LChatNSE/NotificationService.swift
index e90a096..6c678e2 100644
--- a/ios/LChatNSE/NotificationService.swift
+++ b/ios/LChatNSE/NotificationService.swift
@@ -8,10 +8,28 @@ import UserNotifications
// missing (no key yet, undecryptable, dev build without the shared keychain
// entitlement) the placeholder ships as-is — never an error, never plaintext
// leaving the device.
+//
+// Since the decrypt happens here anyway, the message is also FILED into the
+// shared local store (MessageStore, App Group container) at delivery time —
+// so when the banner is tapped, the chat renders the message from disk
+// instantly instead of waiting on a network round-trip. Best-effort like the
+// banner itself: a store failure never blocks the notification.
final class NotificationService: UNNotificationServiceExtension {
private var handler: ((UNNotificationContent) -> Void)?
private var bestEffort: UNMutableNotificationContent?
+ // One-shot: the store-ingest Task and the expiry callback can race; the
+ // banner must ship exactly once.
+ private let shipLock = NSLock()
+ private var shipped = false
+ private func ship(_ content: UNNotificationContent) {
+ shipLock.lock()
+ let already = shipped
+ shipped = true
+ shipLock.unlock()
+ if !already { handler?(content) }
+ }
+
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
handler = contentHandler
@@ -25,7 +43,7 @@ final class NotificationService: UNNotificationServiceExtension {
let key = try? GroupKey(rawKey: entry.key),
let payload = try? key.decryptJSON(MessagePayload.self, blob: blob)
else {
- contentHandler(content)
+ ship(content)
return
}
@@ -39,10 +57,27 @@ final class NotificationService: UNNotificationServiceExtension {
// In a DM the title already names the sender; groups prefix them.
content.body = entry.group ? "\(sender): \(text)" : text
content.threadIdentifier = thread
- contentHandler(content)
+
+ // Ingest into the shared store when the payload carries the message's
+ // server id (older servers/relays don't — the banner still works, the
+ // app just fetches on open as before). Stored via the same projection
+ // the feed folds (renderable), so app events land as their chat lines.
+ guard let id = (info["id"] as? NSNumber)?.int64Value, id > 0,
+ let at = (info["at"] as? NSNumber)?.int64Value,
+ let stored = ChatFeedModel.renderable(blob, key: key)
+ else {
+ ship(content)
+ return
+ }
+ Task {
+ await MessageStore.shared.upsert(
+ instance: entry.origin, thread: thread,
+ [.init(id: id, at: at, edited: false, reactions: [], payload: stored)])
+ ship(content)
+ }
}
override func serviceExtensionTimeWillExpire() {
- if let bestEffort { handler?(bestEffort) }
+ if let bestEffort { ship(bestEffort) }
}
}
diff --git a/ios/project.yml b/ios/project.yml
index 2e88c9a..1fbd114 100644
--- a/ios/project.yml
+++ b/ios/project.yml
@@ -87,6 +87,12 @@ targets:
# test against a `go run .` instance during development.
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
+ # Background App Refresh: periodic wakes sync + decrypt new messages
+ # into the local store (BackgroundRefresh.swift / BackgroundSync).
+ UIBackgroundModes:
+ - fetch
+ BGTaskSchedulerPermittedIdentifiers:
+ - $(PRODUCT_BUNDLE_IDENTIFIER).refresh
# A genuinely native macOS app (NOT Catalyst). Shares the platform-neutral core
# (LChatCore) with the iOS target; its own identity model + all crypto live
diff --git a/main.go b/main.go
index 85339bc..d7a7af3 100644
--- a/main.go
+++ b/main.go
@@ -788,7 +788,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
out, _ := json.Marshal(wsOut{Type: "msg", ThreadID: body.ThreadID, ID: m.ID, CreatedAt: m.CreatedAt, Blob: m.Blob})
s.broadcastToThread(body.ThreadID, out)
// Notify the thread's other members (not the poster), best-effort, async.
- go s.pushNewMessage(m.Blob, body.ThreadID, token)
+ go s.pushNewMessage(m.Blob, body.ThreadID, token, m.ID, m.CreatedAt)
writeJSON(w, http.StatusOK, m)
}
diff --git a/miniapps.go b/miniapps.go
index c8a7ea2..139ac25 100644
--- a/miniapps.go
+++ b/miniapps.go
@@ -233,7 +233,7 @@ func (s *Server) sweepDueScheduled() int {
n++
out, _ := json.Marshal(wsOut{Type: "msg", ThreadID: sm.ThreadID, ID: m.ID, CreatedAt: m.CreatedAt, Blob: m.Blob})
s.broadcastToThread(sm.ThreadID, out)
- go s.pushScheduled(m.Blob, sm.ThreadID)
+ go s.pushScheduled(m.Blob, sm.ThreadID, m.ID, m.CreatedAt)
}
return n
}
@@ -242,7 +242,7 @@ func (s *Server) sweepDueScheduled() int {
// difference: NO sender exclusion. The creator is exactly who wants this push (a
// reminder that skipped its own author would be useless). skipActive stays on —
// an open client receives the WS frame and the app's notify hook handles it.
-func (s *Server) pushScheduled(blob, threadID string) {
+func (s *Server) pushScheduled(blob, threadID string, id, at int64) {
members, err := s.db.memberUserIDs(threadID)
if err != nil {
log.Println("schedule: push members:", err)
@@ -254,5 +254,5 @@ func (s *Server) pushScheduled(blob, threadID string) {
}
payload, _ := json.Marshal(map[string]string{"blob": blob, "thread": threadID})
s.sendToRecipients(payload, "sched", "", true, set)
- s.apnsFanOut(threadID, blob, "", true, set)
+ s.apnsFanOut(threadID, blob, id, at, "", true, set)
}
diff --git a/push.go b/push.go
index 5effa73..10bbb0d 100644
--- a/push.go
+++ b/push.go
@@ -159,7 +159,7 @@ func (db *DB) ensureVAPID() (pub, priv string, err error) {
// any of your own devices for a message you just sent. The sender's device token
// (from their WebSocket) identifies both that device and, via its owning user,
// all their other devices, which selectRecipients excludes.
-func (s *Server) pushNewMessage(blob, threadID, senderToken string) {
+func (s *Server) pushNewMessage(blob, threadID, senderToken string, id, at int64) {
members, err := s.db.memberUserIDs(threadID)
if err != nil {
log.Println("push: thread members:", err)
@@ -175,7 +175,7 @@ func (s *Server) pushNewMessage(blob, threadID, senderToken string) {
s.sendToRecipients(payload, "msg", senderToken, true, set)
// Same message, same rules, over APNs to the native iOS apps (no-op unless
// LCHAT_APNS_* is configured).
- s.apnsFanOut(threadID, blob, senderToken, true, set)
+ s.apnsFanOut(threadID, blob, id, at, senderToken, true, set)
}
// sendTestPush delivers a generic "it works" notification to every subscriber,
@@ -189,7 +189,7 @@ func (s *Server) pushNewMessage(blob, threadID, senderToken string) {
func (s *Server) sendTestPush() int {
payload, _ := json.Marshal(map[string]bool{"test": true})
sent := s.sendToRecipients(payload, "test", "", false, nil)
- sent += s.apnsFanOut("test", "test", "", false, nil) // no-op unless APNs/relay configured
+ sent += s.apnsFanOut("test", "test", 0, 0, "", false, nil) // no-op unless APNs/relay configured
return sent
}
diff --git a/push/main.go b/push/main.go
index 07c7377..cc7a79d 100644
--- a/push/main.go
+++ b/push/main.go
@@ -164,6 +164,12 @@ func (s *Server) pushHandler(w http.ResponseWriter, r *http.Request) {
Sandbox bool `json:"sandbox"`
Thread string `json:"thread"`
Blob string `json:"blob"`
+ // Message id + created_at, forwarded verbatim into the aps envelope so
+ // the device's NSE can ingest the message into its local store. Still
+ // content-blind: integers the instance already exposes to Apple's
+ // delivery timing. Optional — older instances omit them.
+ ID int64 `json:"id"`
+ At int64 `json:"at"`
}
r.Body = http.MaxBytesReader(w, r.Body, maxPushBytes)
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@@ -181,7 +187,7 @@ func (s *Server) pushHandler(w http.ResponseWriter, r *http.Request) {
}
start := time.Now()
- err := s.apns.Send(body.DeviceToken, body.Sandbox, body.Thread, body.Blob)
+ err := s.apns.Send(body.DeviceToken, body.Sandbox, body.Thread, body.Blob, body.ID, body.At)
latency := time.Since(start).Milliseconds()
switch {
case err == nil: