1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import Foundation
import os
import UIKit
import UserNotifications
private let logger = Logger(subsystem: "social.grain.grain", category: "Push")
/// Manages push notification registration and token delivery to grain-serve.
@Observable
@MainActor
final class PushManager: NSObject {
private weak var authManager: AuthManager?
func configure(authManager: AuthManager) {
self.authManager = authManager
}
/// Request notification permission and register for remote notifications.
func registerIfNeeded() {
Task {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
switch settings.authorizationStatus {
case .notDetermined:
do {
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
if granted {
logger.info("Push permission granted")
UIApplication.shared.registerForRemoteNotifications()
}
} catch {
logger.error("Push permission request failed: \(error)")
}
case .authorized, .provisional:
UIApplication.shared.registerForRemoteNotifications()
default:
logger.info("Push notifications not authorized (status: \(String(describing: settings.authorizationStatus)))")
}
}
}
/// Called when APNs returns a device token.
func didRegisterForRemoteNotifications(deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
logger.info("APNs token: \(token.prefix(16))...")
Task {
await sendTokenToServer(token: token)
}
}
/// Called when APNs registration fails.
func didFailToRegisterForRemoteNotifications(error: Error) {
logger.error("APNs registration failed: \(error)")
}
/// Unregister the current token when an account stops being active — on
/// sign-out, or on a switch to another account.
///
/// Takes the outgoing account's auth context explicitly and uses a client
/// with no refresh hook: by the time this resolves, the app may already be
/// acting as somebody else, and unregistering *their* device token is
/// exactly the bug this avoids.
func unregisterToken(auth: AuthContext?) async {
guard let token = currentToken, let auth else { return }
currentToken = nil
let client = XRPCClient(baseURL: AuthManager.serverURL)
do {
try await client.procedure(
"social.grain.notification.unregisterPush",
input: PushDevice(token: token),
auth: auth
)
logger.info("Push token unregistered from server")
} catch {
logger.error("Failed to unregister push token: \(error)")
}
}
// MARK: - Private
private var currentToken: String? {
get { StorageEnvironment.defaults.string(forKey: "apns_device_token") }
set { StorageEnvironment.defaults.set(newValue, forKey: "apns_device_token") }
}
private func sendTokenToServer(token: String) async {
currentToken = token
guard let authManager, let auth = await authManager.authContext() else {
logger.warning("No auth context, skipping token registration")
return
}
let client = authManager.makeClient()
do {
try await client.procedure(
"social.grain.notification.registerPush",
input: PushDevice(token: token),
auth: auth
)
logger.info("Push token registered with server")
} catch {
logger.error("Failed to register push token: \(error)")
}
}
}
/// A device, as `social.grain.notification.registerPush` and
/// `unregisterPush` name it: the service that pushes to it (grain-serve),
/// its APNs token, and the app it runs.
struct PushDevice: Encodable {
let serviceDid: String
let token: String
let platform: String
let appId: String
init(token: String) {
serviceDid = AuthManager.appviewDID
self.token = token
platform = "ios"
appId = Bundle.main.bundleIdentifier ?? "social.grain.grain"
}
}