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