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>
25 files changed,
+760
−309
apns.go+9 −6apns_relay_test.go+4 −4docs/push-relay.md+6 −2hub.go+1 −1internal/apns/apns.go+17 −5internal/apns/apns_test.go+13 −6ios/LChat/BackgroundRefresh.swift+63 −0ios/LChat/Info.plist+8 −0ios/LChat/LChatApp.swift+6 −1ios/LChat/PushManager.swift+2 −0ios/LChat/Views/NativeChatScreen.swift+7 −0ios/LChatCore/Sources/LChatCore/BackgroundSync.swift+59 −0ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+175 −88ios/LChatCore/Sources/LChatCore/ChatSocket.swift+7 −1ios/LChatCore/Sources/LChatCore/MessageStore.swift+57 −4ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift+5 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+237 −169ios/LChatMac/NativeChatView.swift+19 −11ios/LChatNSE/LChatNSE.entitlements+7 −0ios/LChatNSE/NotificationService.swift+38 −3ios/project.yml+6 −0main.go+1 −1miniapps.go+3 −3push.go+3 −3push/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: |