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 @@ |
| 1 | import UIKit |
| 2 | import 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. |
| 11 | final 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 @@ |
| 1 | 1 | import Foundation |
| 2 | 2 | import Observation |
| 3 | 3 | import UIKit |
| 4 | import UserNotifications |
| 4 | 5 | |
| 5 | 6 | /// Registers this install's APNs token against every signed-in account, |
| 6 | 7 | /// and deregisters one when its account goes away. |
| @@ -73,6 +74,30 @@ final class PushRegistrar { |
| 73 | 74 | defaults.removeObject(forKey: key) |
| 74 | 75 | } |
| 75 | 76 | |
| 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 | |
| 76 | 101 | private static func idKey(_ account: Account) -> String { |
| 77 | 102 | "pushDeviceID#\(account.id)" |
| 78 | 103 | } |
gitbay/Push/PushRouter.swift
added
+13
| @@ -0,0 +1,13 @@ |
| 1 | import 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 |
| 11 | final class PushRouter { |
| 12 | var pending: PushTarget? |
| 13 | } |
gitbay/gitbayApp.swift
+25 −1
| @@ -3,21 +3,45 @@ import SwiftUI |
| 3 | 3 | @main |
| 4 | 4 | struct gitbayApp: App { |
| 5 | 5 | |
| 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 |
| 7 | 10 | |
| 8 | 11 | init() { |
| 9 | 12 | 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)) |
| 10 | 20 | } |
| 11 | 21 | |
| 12 | 22 | var body: some Scene { |
| 13 | 23 | WindowGroup { |
| 14 | 24 | ContentView() |
| 15 | 25 | .environment(session) |
| 26 | .environment(router) |
| 27 | .environment(registrar) |
| 16 | 28 | .font(.gbSans(.body)) |
| 17 | 29 | .tint(.gbAccent) |
| 18 | 30 | // The screenshot checkpoint forces appearance per run; |
| 19 | 31 | // simulator clones ignore the base device's setting. |
| 20 | 32 | .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 | } |
| 21 | 45 | } |
| 22 | 46 | } |
| 23 | 47 | |