ios: notification taps carry their space — NSE origin stamp + last-active fallback (#1447)
The tap route's remaining strand: multi-space, no parked key, thread in no snapshot (an undecryptable push for a brand-new thread) deliberately dropped the tap on the chat list. Two new rungs close it: the NSE stamps the owning origin onto the banner at delivery time (userInfo, on-device only) and the route trusts it first, so routing survives park eviction; and when no evidence exists at all, the tap falls back to the space the device was last in — a right guess opens the chat, a wrong one costs a 'Chat not found' with a working back. .unresolved now only means 'nothing to even guess with'. Mac unscoped: its banners are local notifications that already carry the instance in their payload. Claude-Session: https://claude.ai/code/session_014mt8aQ3bwLs1kcqeP598xr
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+108
−19
ios/LChat/LChatApp.swift+17 −10ios/LChat/PushManager.swift+6 −0ios/LChatCore/Sources/LChatCore/NotificationRoute.swift+23 −1ios/LChatCore/Tests/LChatCoreTests/NotificationRouteTests.swift+49 −6ios/LChatNSE/NotificationService.swift+13 −2
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index 2ad08a9..7a6dab6 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -590,20 +590,27 @@ struct RootView: View { |
| // is a no-op. |
| private func routeNotificationTap() { |
| guard let threadID = push.pendingThreadID else { return } |
| + let stampedOrigin = push.pendingThreadOrigin |
| push.pendingThreadID = nil |
| - // The parked SharedThreadKeys entry is the fast path; a stale or |
| - // missing park falls back to the spaces' thread snapshots, then to |
| - // the only signed-in space (NotificationRoute) — a tap must never |
| - // silently strand the member on the chat list. |
| + push.pendingThreadOrigin = nil |
| + // The NSE's delivery-time origin stamp is the fast path; an unstamped |
| + // banner falls down the ladder — parked SharedThreadKeys entry, the |
| + // spaces' thread snapshots, the only signed-in space, and finally the |
| + // space this device was last in (NotificationRoute) — a tap must |
| + // never silently strand the member on the chat list. |
| let inst: SavedInstance |
| - switch NotificationRoute.resolve(threadID: threadID, instances: app.instances) { |
| - case .parkedKey(let i), .snapshot(let i), .onlySpace(let i): |
| + switch NotificationRoute.resolve(threadID: threadID, |
| + origin: stampedOrigin, |
| + lastActiveID: UserDefaults.standard.string(forKey: "lastInstanceID"), |
| + instances: app.instances) { |
| + case .stamped(let i), .parkedKey(let i), .snapshot(let i), |
| + .onlySpace(let i), .lastActive(let i): |
| inst = i |
| case .unresolved: |
| - // Multi-space and the thread is in none of their records — no |
| - // safe guess exists. Leave the member where the launch landed |
| - // them, and say why in the log (the one place field reports of |
| - // "the tap did nothing" can be diagnosed from). |
| + // Nothing to even guess with (no spaces, or multi-space with no |
| + // record of ever opening one). Leave the member where the launch |
| + // landed them, and say why in the log (the one place field reports |
| + // of "the tap did nothing" can be diagnosed from). |
| NSLog("notification tap: thread not resolvable to a space, dropped") |
| return |
| } |
diff --git a/ios/LChat/PushManager.swift b/ios/LChat/PushManager.swift| index 7e7f6a8..3bd9a8b 100644 |
| --- a/ios/LChat/PushManager.swift |
| +++ b/ios/LChat/PushManager.swift |
| @@ -22,6 +22,11 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs |
| // appear, so the tap routes whether it arrives before or after the view is up. |
| @Published var pendingThreadID: String? |
| + // The NSE-stamped owning origin riding beside pendingThreadID (nil when the |
| + // extension couldn't decrypt). Set BEFORE the published id so the route |
| + // always reads a matched pair; plain var — the id's change is the signal. |
| + var pendingThreadOrigin: String? |
| + |
| // Bumped whenever a push lands while the app is open, for a thread other than |
| // the one on-screen. RootView watches it and re-fetches the active thread list, |
| // so a chat someone *just* started with us (or a message in another chat) shows |
| @@ -200,6 +205,7 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs |
| // route to the right instance + chat. (UN delegate callbacks are on the |
| // main thread, so touching @Published / this static here is safe.) |
| Self.forceScrollThreadID = thread |
| + pendingThreadOrigin = response.notification.request.content.userInfo["origin"] as? String |
| pendingThreadID = thread |
| } else if response.notification.request.content.userInfo["changelog"] != nil { |
| // "What's new" (the release-notes banner): route to the Admin app. |
diff --git a/ios/LChatCore/Sources/LChatCore/NotificationRoute.swift b/ios/LChatCore/Sources/LChatCore/NotificationRoute.swift| index 09b0af1..16de667 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/NotificationRoute.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/NotificationRoute.swift |
| @@ -12,16 +12,23 @@ public enum NotificationRoute { |
| /// Where a resolution came from — the caller logs it so a stranded tap in |
| /// the field names its failing rung instead of vanishing. |
| public enum Resolution { |
| + case stamped(SavedInstance) // the NSE wrote the owning origin onto the banner |
| case parkedKey(SavedInstance) // SharedThreadKeys entry matched |
| case snapshot(SavedInstance) // found in a space's thread snapshot |
| case onlySpace(SavedInstance) // one signed-in space — must be it |
| + case lastActive(SavedInstance) // best guess: the space this device was last in |
| case unresolved |
| } |
| - /// The production ladder. `entryOrigin` and `snapshotHasThread` are seams |
| + /// The production ladder. `origin` is the NSE's delivery-time stamp (the |
| + /// banner remembers which space it came from even if the park is gone by |
| + /// tap time); `lastActiveID` is the device's last-opened space, the |
| + /// no-evidence fallback. `entryOrigin` and `snapshotHasThread` are seams |
| /// so the ladder's order and edge cases stay unit-testable without a |
| /// keychain (ThreadStoreTests pattern). |
| public static func resolve(threadID: String, |
| + origin: String? = nil, |
| + lastActiveID: String? = nil, |
| instances: [SavedInstance], |
| entryOrigin: (String) -> String? = { |
| SharedThreadKeys.lookup(threadID: $0)?.origin |
| @@ -30,6 +37,12 @@ public enum NotificationRoute { |
| ThreadListSnapshot.load(origin: origin)? |
| .rows.contains { $0.id == thread } ?? false |
| }) -> Resolution { |
| + // The stamp is delivery-time truth: the NSE resolved this thread's |
| + // space when it decrypted the banner, so trust it above the (evictable) |
| + // park it was read from. |
| + if let origin, let inst = instances.first(where: { $0.origin == origin }) { |
| + return .stamped(inst) |
| + } |
| if let origin = entryOrigin(threadID), |
| let inst = instances.first(where: { $0.origin == origin }) { |
| return .parkedKey(inst) |
| @@ -45,6 +58,15 @@ public enum NotificationRoute { |
| let active = instances.filter { !$0.isArchived } |
| if active.count == 1 { return .onlySpace(active[0]) } |
| if instances.count == 1 { return .onlySpace(instances[0]) } |
| + // Multi-space with no record anywhere (an undecryptable push for a |
| + // brand-new thread): the space this device was last in is the best |
| + // prior — and it's where the launch lands anyway, so a right guess |
| + // (the common case) opens the chat in place, and a wrong one costs a |
| + // "Chat not found" with a working back. Either beats dropping the |
| + // tap on the chat list. |
| + if let lastActiveID, let inst = instances.first(where: { $0.id == lastActiveID }) { |
| + return .lastActive(inst) |
| + } |
| return .unresolved |
| } |
| } |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/NotificationRouteTests.swift b/ios/LChatCore/Tests/LChatCoreTests/NotificationRouteTests.swift| index 5639ae5..425635d 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/NotificationRouteTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/NotificationRouteTests.swift |
| @@ -1,9 +1,10 @@ |
| import XCTest |
| @testable import LChatCore |
| -// 🤖 The notification-tap resolution ladder (NotificationRoute): parked key → |
| -// snapshot scan → only-space. The silent-drop bug this pins: a stale or |
| -// missing SharedThreadKeys park used to strand the tap on the chat list. |
| +// 🤖 The notification-tap resolution ladder (NotificationRoute): NSE origin |
| +// stamp → parked key → snapshot scan → only-space → last-active space. The |
| +// silent-drop bug this pins: a stale or missing SharedThreadKeys park used to |
| +// strand the tap on the chat list. |
| final class NotificationRouteTests: XCTestCase { |
| private func inst(_ origin: String, archived: Bool = false) -> SavedInstance { |
| @@ -13,6 +14,31 @@ final class NotificationRouteTests: XCTestCase { |
| return i |
| } |
| + func testStampedOriginWinsOverEverything() { |
| + // The NSE stamped the banner at delivery time — the route trusts it |
| + // before consulting any on-device state (which can churn between |
| + // delivery and tap). |
| + let a = inst("https://a.example"), b = inst("https://b.example") |
| + let r = NotificationRoute.resolve( |
| + threadID: "th1", origin: "https://b.example", instances: [a, b], |
| + entryOrigin: { _ in XCTFail("park consulted despite stamp"); return "https://a.example" }, |
| + snapshotHasThread: { _, _ in XCTFail("snapshot consulted despite stamp"); return false }) |
| + guard case .stamped(let hit) = r else { return XCTFail("\(r)") } |
| + XCTAssertEqual(hit.origin, b.origin) |
| + } |
| + |
| + func testStaleStampFallsDownTheLadder() { |
| + // A stamp for a space that's since been signed out must not dead-end |
| + // the tap — the ladder still runs beneath it. |
| + let a = inst("https://a.example"), b = inst("https://b.example") |
| + let r = NotificationRoute.resolve( |
| + threadID: "th1", origin: "https://gone.example", instances: [a, b], |
| + entryOrigin: { _ in "https://a.example" }, |
| + snapshotHasThread: { _, _ in false }) |
| + guard case .parkedKey(let hit) = r else { return XCTFail("\(r)") } |
| + XCTAssertEqual(hit.origin, a.origin) |
| + } |
| + |
| func testParkedKeyWins() { |
| let a = inst("https://a.example"), b = inst("https://b.example") |
| let r = NotificationRoute.resolve( |
| @@ -68,12 +94,29 @@ final class NotificationRouteTests: XCTestCase { |
| XCTAssertEqual(hit.origin, live.origin) |
| } |
| - func testMultiSpaceUnknownThreadStaysUnresolved() { |
| + func testMultiSpaceNoEvidenceFallsBackToLastActive() { |
| + // Undecryptable push for a brand-new thread, several spaces: the |
| + // space this device was last in is the optimistic guess — the tap |
| + // opens a chat screen there instead of dropping on the list. |
| let a = inst("https://a.example"), b = inst("https://b.example") |
| let r = NotificationRoute.resolve( |
| - threadID: "th1", instances: [a, b], |
| + threadID: "th1", lastActiveID: "https://b.example", instances: [a, b], |
| entryOrigin: { _ in nil }, |
| snapshotHasThread: { _, _ in false }) |
| - guard case .unresolved = r else { return XCTFail("\(r)") } |
| + guard case .lastActive(let hit) = r else { return XCTFail("\(r)") } |
| + XCTAssertEqual(hit.origin, b.origin) |
| + } |
| + |
| + func testMultiSpaceUnknownThreadStaysUnresolved() { |
| + // No last-active record either (or it names a space that's gone) — |
| + // only then does the tap drop. |
| + let a = inst("https://a.example"), b = inst("https://b.example") |
| + for last in [nil, "https://gone.example"] { |
| + let r = NotificationRoute.resolve( |
| + threadID: "th1", lastActiveID: last, instances: [a, b], |
| + entryOrigin: { _ in nil }, |
| + snapshotHasThread: { _, _ in false }) |
| + guard case .unresolved = r else { return XCTFail("\(r)") } |
| + } |
| } |
| } |
diff --git a/ios/LChatNSE/NotificationService.swift b/ios/LChatNSE/NotificationService.swift| index 83da5b8..65ca178 100644 |
| --- a/ios/LChatNSE/NotificationService.swift |
| +++ b/ios/LChatNSE/NotificationService.swift |
| @@ -39,8 +39,19 @@ final class NotificationService: UNNotificationServiceExtension { |
| let info = request.content.userInfo |
| guard let thread = info["thread"] as? String, |
| let blob = info["blob"] as? String, |
| - let entry = SharedThreadKeys.lookup(threadID: thread), |
| - let key = try? GroupKey(rawKey: entry.key), |
| + let entry = SharedThreadKeys.lookup(threadID: thread) |
| + else { |
| + ship(content) |
| + return |
| + } |
| + |
| + // Stamp the owning space onto the delivered banner: the tap route |
| + // reads it before anything else (NotificationRoute .stamped), so |
| + // routing survives the park being evicted between delivery and tap. |
| + // On-device only — userInfo never travels anywhere. |
| + content.userInfo["origin"] = entry.origin |
| + |
| + guard let key = try? GroupKey(rawKey: entry.key), |
| let payload = try? key.decryptJSON(MessagePayload.self, blob: blob) |
| else { |
| ship(content) |