eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3// πŸ€– Background message sync: pull what's new for every joined instance and
4// file it β€” decrypted β€” into the local store (MessageStore), entirely off the
5// main thread. Driven by iOS Background App Refresh (BGAppRefreshTask in the
6// app target); the Mac doesn't need it (its sockets are live while it runs).
7//
8// This is the same ingest path the feed and the NSE use: fetch ciphertext,
9// unwrap the thread key natively, decrypt once, upsert by (instance, thread,
10// id). Idempotent by construction, so overlapping with a live app session or
11// an NSE ingest is harmless.
12public enum BackgroundSync {
13 /// Threads synced per instance per pass, newest activity first β€” bounds
14 /// the pass to the BG-task budget (~30s); anything missed catches up on
15 /// the next pass or the next app open.
16 public static let maxThreadsPerInstance = 20
17
18 /// Sync every instance. Returns the number of new messages stored.
19 /// Cooperatively cancellable (the BG task's expiration handler cancels).
20 @discardableResult
21 public static func run(instances: [SavedInstance]) async -> Int {
22 var stored = 0
23 for inst in instances {
24 if Task.isCancelled { break }
25 stored += await sync(inst)
26 }
27 return stored
28 }
29
30 private static func sync(_ inst: SavedInstance) async -> Int {
31 guard let base = inst.baseURL else { return 0 }
32 let api = LChatAPI(baseURL: base, token: inst.deviceToken)
33 let threads = await api.threads()
34 .sorted { ($0.last_at ?? 0) > ($1.last_at ?? 0) }
35 .prefix(maxThreadsPerInstance)
36 var stored = 0
37 for t in threads {
38 if Task.isCancelled { break }
39 guard let wrapped = t.wrapped, !wrapped.isEmpty,
40 let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: inst.userPriv),
41 let key = try? GroupKey(rawKey: raw) else { continue }
42 let local = await MessageStore.shared.latest(instance: inst.id, thread: t.id)
43 // Nothing newer than what we hold β€” skip the messages round-trip.
44 if local.at > 0, (t.last_at ?? 0) <= local.at { continue }
45 let rows = await api.messages(thread: t.id, after: local.id)
46 guard !rows.isEmpty else { continue }
47 // Deletes that happened while the app was suspended arrive as
48 // tombstone rows (#582) β€” drop the targets from the store (and
49 // its FTS index) before filing the page's real messages.
50 for id in ChatFeedModel.tombstoneEvictions(rows) {
51 await MessageStore.shared.delete(instance: inst.id, thread: t.id, id: id)
52 }
53 let batch: [MessageStore.StoredMessage] = rows.compactMap { row in
54 guard (row.tombstone_of ?? 0) <= 0 else { return nil }
55 guard let p = ChatFeedModel.renderable(row.blob, key: key,
56 group: t.kind == "group") else { return nil }
57 // Reactions are encrypted events now β€” rows carry no tally;
58 // the feed derives it by folding the events on load.
59 return .init(id: row.id, at: row.created_at,
60 edited: (row.edited_at ?? 0) > 0,
61 reactions: [], payload: p)
62 }
63 guard !batch.isEmpty else { continue }
64 await MessageStore.shared.upsert(instance: inst.id, thread: t.id, batch)
65 stored += batch.count
66 }
67 return stored
68 }
69}
70