Commit 4f441dbc04

4f441dbc04105a797619d63c5cb50e1166137fe9

parent: dad5507958

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 22:50 UTC

push: buffer a tap that arrives before the wiring

Tapping a notification while the app is not running is the ordinary
case, and iOS can deliver the tap before the root task has set the
delegate's session and router. The delegate parks the raw userInfo and
the root task drains it through the same resolve path.

Ref krz/gitbay#89

Layout: unified · split

gitbay/Push/AppDelegate.swift +36 −2
@@ -14,6 +14,19 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
14 weak var router: PushRouter? 14 weak var router: PushRouter?
15 weak var session: SessionStore? 15 weak var session: SessionStore?
16 16
17 /// A tap that arrived before `gitbayApp` wired this delegate up.
18 ///
19 /// This is the common case, not an edge one: tapping a notification
20 /// while the app is not running launches it, and iOS can deliver
21 /// the tap before SwiftUI's root `.task` has run. Held as the raw
22 /// `userInfo` rather than a resolved `PushTarget`, because resolving
23 /// one needs `session.accounts` — precisely what is missing at that
24 /// moment.
25 ///
26 /// One slot, newest wins. A queue would open several screens in
27 /// sequence, which is not what a tap asks for.
28 private var pendingUserInfo: [AnyHashable: Any]?
29
17 func application( 30 func application(
18 _ application: UIApplication, 31 _ application: UIApplication,
19 didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil 32 didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil
@@ -50,8 +63,29 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
50 _ center: UNUserNotificationCenter, 63 _ center: UNUserNotificationCenter,
51 didReceive response: UNNotificationResponse 64 didReceive response: UNNotificationResponse
52 ) async { 65 ) async {
53 guard let session, let router, 66 deliver(response.notification.request.content.userInfo)
54 let payload = PushPayload(userInfo: response.notification.request.content.userInfo), 67 }
68
69 /// Drains a tap that arrived before the wiring did. Called from
70 /// `gitbayApp`'s root task, once `session` and `router` are set.
71 func deliverPendingNotification() {
72 guard let userInfo = pendingUserInfo else { return }
73 // Cleared whatever the resolve decides: a payload for an
74 // account the user has since signed out of resolves to no
75 // target, and must not sit here waiting to fire later.
76 pendingUserInfo = nil
77 deliver(userInfo)
78 }
79
80 /// The one resolve-and-deliver path, so a buffered tap and a live
81 /// one cannot diverge. Parks the payload if the wiring is not up
82 /// yet; `deliverPendingNotification` comes back for it.
83 private func deliver(_ userInfo: [AnyHashable: Any]) {
84 guard let session, let router else {
85 pendingUserInfo = userInfo
86 return
87 }
88 guard let payload = PushPayload(userInfo: userInfo),
55 let target = PushTarget(payload: payload, accounts: session.accounts) 89 let target = PushTarget(payload: payload, accounts: session.accounts)
56 else { return } 90 else { return }
57 router.pending = target 91 router.pending = target
gitbay/gitbayApp.swift +3
@@ -40,6 +40,9 @@ struct gitbayApp: App {
40 session.willRemoveAccount = { [weak registrar] account in 40 session.willRemoveAccount = { [weak registrar] account in
41 await registrar?.deregister(account) 41 await registrar?.deregister(account)
42 } 42 }
43 // A tap that launched the app reaches the delegate
44 // before this runs; it parked the payload.
45 appDelegate.deliverPendingNotification()
43 await registrar.registerIfAlreadyAuthorized() 46 await registrar.registerIfAlreadyAuthorized()
44 } 47 }
45 } 48 }