eleven / ocho Public

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>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev d39441edf2a717cb4e74c47b0826497ad491abdf parent 69352ab
5 files changed, +108 −19
  • ios/LChat/LChatApp.swift +17 −10
  • ios/LChat/PushManager.swift +6 −0
  • ios/LChatCore/Sources/LChatCore/NotificationRoute.swift +23 −1
  • ios/LChatCore/Tests/LChatCoreTests/NotificationRouteTests.swift +49 −6
  • ios/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)