Commit dad5507958

dad5507958b38470caf6be7bf68e3c36696f19e5

parent: 4debe09221

Verified · cmc

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

push: receive the device token and notification taps

PushRouter is owned above ContentView because ContentView is keyed on
the account id, so a cross-account tap destroys anything held inside
it.

Ref krz/gitbay#89

Layout: unified · split

gitbay/Push/AppDelegate.swift added +59
@@ -0,0 +1,59 @@
1import UIKit
2import UserNotifications
3
4/// The only way to receive an APNs device token and a notification tap.
5/// SwiftUI has no equivalent, so the app keeps one delegate whose whole
6/// job is handing both to the push types.
7///
8/// The references are weak: `gitbayApp` holds all three in `@State` for
9/// the life of the app, so this delegate borrows them rather than
10/// keeping them alive.
11final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
12
13 weak var registrar: PushRegistrar?
14 weak var router: PushRouter?
15 weak var session: SessionStore?
16
17 func application(
18 _ application: UIApplication,
19 didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil
20 ) -> Bool {
21 UNUserNotificationCenter.current().delegate = self
22 return true
23 }
24
25 func application(
26 _ application: UIApplication,
27 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
28 ) {
29 Task { await registrar?.deviceTokenArrived(deviceToken) }
30 }
31
32 func application(
33 _ application: UIApplication,
34 didFailToRegisterForRemoteNotificationsWithError error: any Error
35 ) {
36 // Nothing to do and nothing to say: push is a side channel, and
37 // the next launch tries again.
38 }
39
40 /// A notification arriving while the app is open still shows — you
41 /// may be reading a different repository when one lands.
42 func userNotificationCenter(
43 _ center: UNUserNotificationCenter,
44 willPresent notification: UNNotification
45 ) async -> UNNotificationPresentationOptions {
46 [.banner, .sound, .badge]
47 }
48
49 func userNotificationCenter(
50 _ center: UNUserNotificationCenter,
51 didReceive response: UNNotificationResponse
52 ) async {
53 guard let session, let router,
54 let payload = PushPayload(userInfo: response.notification.request.content.userInfo),
55 let target = PushTarget(payload: payload, accounts: session.accounts)
56 else { return }
57 router.pending = target
58 }
59}
gitbay/Push/PushRegistrar.swift +25
@@ -1,6 +1,7 @@
11import Foundation
22import Observation
33import UIKit
4import UserNotifications
45
56/// Registers this install's APNs token against every signed-in account,
67/// and deregisters one when its account goes away.
@@ -73,6 +74,30 @@ final class PushRegistrar {
7374 defaults.removeObject(forKey: key)
7475 }
7576
77 /// Asks iOS for permission and, on approval, registers for remote
78 /// notifications. Called from the notifications screen's toggle, so
79 /// the system prompt only appears because the user asked for it.
80 func requestPushPermission() async -> Bool {
81 let center = UNUserNotificationCenter.current()
82 let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
83 if granted {
84 UIApplication.shared.registerForRemoteNotifications()
85 }
86 return granted
87 }
88
89 /// Whether iOS has been asked, and what it said.
90 func authorizationStatus() async -> UNAuthorizationStatus {
91 await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
92 }
93
94 /// Re-registers on launch when permission is already granted.
95 func registerIfAlreadyAuthorized() async {
96 if await authorizationStatus() == .authorized {
97 UIApplication.shared.registerForRemoteNotifications()
98 }
99 }
100
76101 private static func idKey(_ account: Account) -> String {
77102 "pushDeviceID#\(account.id)"
78103 }
gitbay/Push/PushRouter.swift added +13
@@ -0,0 +1,13 @@
1import Observation
2
3/// Carries a tapped notification's destination into the view tree.
4///
5/// Owned by `gitbayApp`, above `ContentView` — which is `.id(account.id)`
6/// and is therefore destroyed and rebuilt by the account switch a
7/// cross-account tap performs. A target held inside that subtree would
8/// go with it; held here, the rebuilt tree drains it on appear.
9@Observable
10@MainActor
11final class PushRouter {
12 var pending: PushTarget?
13}
gitbay/gitbayApp.swift +25 −1
@@ -3,21 +3,45 @@ import SwiftUI
33@main
44struct gitbayApp: App {
55
6 @State private var session = SessionStore()
6 @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
7 @State private var session: SessionStore
8 @State private var router = PushRouter()
9 @State private var registrar: PushRegistrar
710
811 init() {
912 GitbayFonts.register()
13 // The registrar takes the session at construction, so both are
14 // built here rather than as property initialisers — one
15 // registrar for the life of the app, which the delegate and
16 // SessionStore's removal hook then both point at.
17 let session = SessionStore()
18 _session = State(wrappedValue: session)
19 _registrar = State(wrappedValue: PushRegistrar(session: session))
1020 }
1121
1222 var body: some Scene {
1323 WindowGroup {
1424 ContentView()
1525 .environment(session)
26 .environment(router)
27 .environment(registrar)
1628 .font(.gbSans(.body))
1729 .tint(.gbAccent)
1830 // The screenshot checkpoint forces appearance per run;
1931 // simulator clones ignore the base device's setting.
2032 .preferredColorScheme(Self.forcedColorScheme)
33 .task {
34 let registrar = self.registrar
35 appDelegate.registrar = registrar
36 appDelegate.router = router
37 appDelegate.session = session
38 // Weak: the registrar holds the session, and this
39 // closure is stored on the session.
40 session.willRemoveAccount = { [weak registrar] account in
41 await registrar?.deregister(account)
42 }
43 await registrar.registerIfAlreadyAuthorized()
44 }
2145 }
2246 }
2347