Remove the need to register with the push proxy

This commit is contained in:
Shadowfacts 2024-04-12 16:15:52 -04:00
parent 2e31d34e9d
commit 19ca930ee8
7 changed files with 40 additions and 338 deletions

View File

@ -32,7 +32,7 @@ class NotificationService: UNNotificationServiceExtension {
} }
guard request.content.userInfo["v"] as? Int == 1, guard request.content.userInfo["v"] as? Int == 1,
let accountID = request.content.userInfo["ctx"] as? String, let accountID = (request.content.userInfo["ctx"] as? String).flatMap(\.removingPercentEncoding),
let account = UserAccountsManager.shared.getAccount(id: accountID), let account = UserAccountsManager.shared.getAccount(id: accountID),
let subscription = getSubscription(account: account), let subscription = getSubscription(account: account),
let encryptedBody = (request.content.userInfo["data"] as? String).flatMap({ Data(base64Encoded: $0) }), let encryptedBody = (request.content.userInfo["data"] as? String).flatMap({ Data(base64Encoded: $0) }),

View File

@ -13,15 +13,11 @@ class DisabledPushManager: _PushManager {
false false
} }
var proxyRegistration: PushProxyRegistration? {
nil
}
var subscriptions: [PushSubscription] { var subscriptions: [PushSubscription] {
[] []
} }
func createSubscription(account: UserAccountInfo) throws -> PushSubscription { func createSubscription(account: UserAccountInfo) async throws -> PushSubscription {
throw Disabled() throw Disabled()
} }
@ -35,14 +31,6 @@ class DisabledPushManager: _PushManager {
nil nil
} }
func register() async throws -> PushProxyRegistration {
throw Disabled()
}
func unregister() async throws {
throw Disabled()
}
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async { func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async {
} }

View File

@ -41,16 +41,13 @@ public struct PushManager {
@MainActor @MainActor
public protocol _PushManager { public protocol _PushManager {
var enabled: Bool { get } var enabled: Bool { get }
var proxyRegistration: PushProxyRegistration? { get }
var subscriptions: [PushSubscription] { get } var subscriptions: [PushSubscription] { get }
func createSubscription(account: UserAccountInfo) throws -> PushSubscription func createSubscription(account: UserAccountInfo) async throws -> PushSubscription
func removeSubscription(account: UserAccountInfo) func removeSubscription(account: UserAccountInfo)
func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy) func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy)
func pushSubscription(account: UserAccountInfo) -> PushSubscription? func pushSubscription(account: UserAccountInfo) -> PushSubscription?
func register() async throws -> PushProxyRegistration
func unregister() async throws
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async
func didRegisterForRemoteNotifications(deviceToken: Data) func didRegisterForRemoteNotifications(deviceToken: Data)

View File

@ -20,26 +20,13 @@ class PushManagerImpl: _PushManager {
#if DEBUG #if DEBUG
"development" "development"
#else #else
"release" "production"
#endif #endif
} }
private var remoteNotificationsRegistrationContinuation: CheckedContinuation<Data, any Error>? private var remoteNotificationsRegistrationContinuation: CheckedContinuation<Data, any Error>?
private let defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")! private let defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
private(set) var proxyRegistration: PushProxyRegistration? {
get {
if let dict = defaults.dictionary(forKey: "PushProxyRegistration") as? [String: String],
let registration = PushProxyRegistration(defaultsDict: dict) {
return registration
} else {
return nil
}
}
set {
defaults.setValue(newValue?.defaultsDict, forKey: "PushProxyRegistration")
}
}
public private(set) var subscriptions: [PushSubscription] { public private(set) var subscriptions: [PushSubscription] {
get { get {
if let array = defaults.array(forKey: "PushSubscriptions") as? [[String: Any]] { if let array = defaults.array(forKey: "PushSubscriptions") as? [[String: Any]] {
@ -57,10 +44,7 @@ class PushManagerImpl: _PushManager {
self.endpoint = endpoint self.endpoint = endpoint
} }
func createSubscription(account: UserAccountInfo) throws -> PushSubscription { func createSubscription(account: UserAccountInfo) async throws -> PushSubscription {
guard let proxyRegistration else {
throw CreateSubscriptionError.notRegisteredWithProxy
}
if let existing = pushSubscription(account: account) { if let existing = pushSubscription(account: account) {
return existing return existing
} }
@ -72,9 +56,10 @@ class PushManagerImpl: _PushManager {
guard res == errSecSuccess else { guard res == errSecSuccess else {
throw CreateSubscriptionError.generatingAuthSecret(res) throw CreateSubscriptionError.generatingAuthSecret(res)
} }
let token = try await getDeviceToken()
let subscription = PushSubscription( let subscription = PushSubscription(
accountID: account.id, accountID: account.id,
endpoint: endpointURL(registration: proxyRegistration, accountID: account.id), endpoint: endpointURL(deviceToken: token, accountID: account.id),
secretKey: key, secretKey: key,
authSecret: authSecret, authSecret: authSecret,
alerts: [], alerts: [],
@ -84,10 +69,10 @@ class PushManagerImpl: _PushManager {
return subscription return subscription
} }
private func endpointURL(registration: PushProxyRegistration, accountID: String) -> URL { private func endpointURL(deviceToken: Data, accountID: String) -> URL {
var endpoint = URLComponents(url: registration.endpoint, resolvingAgainstBaseURL: false)! var endpoint = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
endpoint.queryItems = endpoint.queryItems ?? [] let accountID = accountID.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!
endpoint.queryItems!.append(URLQueryItem(name: "ctx", value: accountID)) endpoint.path = "/push/v1/\(apnsEnvironment)/\(deviceToken.hexEncodedString())/\(accountID)"
return endpoint.url! return endpoint.url!
} }
@ -109,70 +94,28 @@ class PushManagerImpl: _PushManager {
subscriptions.first { $0.accountID == account.id } subscriptions.first { $0.accountID == account.id }
} }
func register() async throws -> PushProxyRegistration {
guard remoteNotificationsRegistrationContinuation == nil else {
throw PushRegistrationError.alreadyRegistering
}
let deviceToken = try await getDeviceToken().hexEncodedString()
PushManager.logger.debug("Got device token: \(deviceToken)")
let registration: PushProxyRegistration
do {
registration = try await register(deviceToken: deviceToken)
PushManager.logger.debug("Got endpoint: \(registration.endpoint)")
} catch {
PushManager.logger.error("Proxy registration failed: \(String(describing: error))")
throw PushRegistrationError.registeringWithProxy(error)
}
proxyRegistration = registration
return registration
}
func unregister() async throws {
guard let proxyRegistration else {
return
}
var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
url.path = "/app/v1/registrations/\(proxyRegistration.id)"
var request = URLRequest(url: url.url!)
request.httpMethod = "DELETE"
let (data, resp) = try await URLSession.shared.data(for: request)
let status = (resp as! HTTPURLResponse).statusCode
if (200...299).contains(status) {
self.proxyRegistration = nil
PushManager.logger.debug("Unregistered from proxy")
} else {
PushManager.logger.error("Unregistering: unexpected status \(status)")
let error = (try? JSONDecoder().decode(ProxyRegistrationError.self, from: data)) ?? ProxyRegistrationError(error: "Unknown error", fields: nil)
throw PushRegistrationError.unregistering(error)
}
}
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async { func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async {
guard let proxyRegistration else { let subscriptions = self.subscriptions
guard !subscriptions.isEmpty else {
return return
} }
PushManager.logger.debug("Push proxy registration: \(proxyRegistration.id, privacy: .public)")
do { do {
let token = try await getDeviceToken().hexEncodedString() let token = try await getDeviceToken()
guard token != proxyRegistration.deviceToken else { self.subscriptions = await AsyncSequenceAdaptor(wrapping: subscriptions).map {
// already up-to-date, nothing to do let newEndpoint = await self.endpointURL(deviceToken: token, accountID: $0.accountID)
return guard newEndpoint != $0.endpoint else {
} return $0
let newRegistration = try await update(registration: proxyRegistration, deviceToken: token) }
self.proxyRegistration = newRegistration var copy = $0
if proxyRegistration.endpoint != newRegistration.endpoint { copy.endpoint = newEndpoint
self.subscriptions = await AsyncSequenceAdaptor(wrapping: self.subscriptions).map { if await updateSubscription(copy) {
var copy = $0 return copy
copy.endpoint = await self.endpointURL(registration: newRegistration, accountID: $0.accountID) } else {
if await updateSubscription(copy) { return $0
return copy }
} else { }.reduce(into: [], { partialResult, el in
return $0 partialResult.append(el)
} })
}.reduce(into: [], { partialResult, el in
partialResult.append(el)
})
}
} catch { } catch {
PushManager.logger.error("Failed to update push registration: \(String(describing: error), privacy: .public)") PushManager.logger.error("Failed to update push registration: \(String(describing: error), privacy: .public)")
PushManager.captureError?(error) PushManager.captureError?(error)
@ -195,47 +138,11 @@ class PushManagerImpl: _PushManager {
remoteNotificationsRegistrationContinuation?.resume(throwing: PushRegistrationError.registeringForRemoteNotifications(error)) remoteNotificationsRegistrationContinuation?.resume(throwing: PushRegistrationError.registeringForRemoteNotifications(error))
remoteNotificationsRegistrationContinuation = nil remoteNotificationsRegistrationContinuation = nil
} }
private func register(deviceToken: String) async throws -> PushProxyRegistration {
var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
url.path = "/app/v1/registrations"
var request = URLRequest(url: url.url!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "content-type")
request.httpBody = try! JSONEncoder().encode(PushRegistrationParams(environment: apnsEnvironment, deviceToken: deviceToken, pushVersion: 1))
let (data, resp) = try await URLSession.shared.data(for: request)
let status = (resp as! HTTPURLResponse).statusCode
guard (200...299).contains(status) else {
PushManager.logger.error("Registering: unexpected status \(status)")
let error = (try? JSONDecoder().decode(ProxyRegistrationError.self, from: data)) ?? ProxyRegistrationError(error: "Unknown error", fields: [])
throw error
}
return try JSONDecoder().decode(PushProxyRegistration.self, from: data)
}
private func update(registration: PushProxyRegistration, deviceToken: String) async throws -> PushProxyRegistration {
var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
url.path = "/app/v1/registrations/\(registration.id)"
var request = URLRequest(url: url.url!)
request.httpMethod = "PUT"
request.setValue("application/json", forHTTPHeaderField: "content-type")
request.httpBody = try! JSONEncoder().encode(PushUpdateParams(environment: apnsEnvironment, deviceToken: deviceToken, pushVersion: 1))
let (data, resp) = try await URLSession.shared.data(for: request)
let status = (resp as! HTTPURLResponse).statusCode
guard (200...299).contains(status) else {
PushManager.logger.error("Updating: unexpected status \(status)")
let error = (try? JSONDecoder().decode(ProxyRegistrationError.self, from: data)) ?? ProxyRegistrationError(error: "Unknown error", fields: [])
throw error
}
return try JSONDecoder().decode(PushProxyRegistration.self, from: data)
}
} }
enum PushRegistrationError: LocalizedError { enum PushRegistrationError: LocalizedError {
case alreadyRegistering case alreadyRegistering
case registeringForRemoteNotifications(any Error) case registeringForRemoteNotifications(any Error)
case registeringWithProxy(any Error)
case unregistering(any Error)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
@ -243,71 +150,21 @@ enum PushRegistrationError: LocalizedError {
"Already registering" "Already registering"
case .registeringForRemoteNotifications(let error): case .registeringForRemoteNotifications(let error):
"Remote notifications: \(error.localizedDescription)" "Remote notifications: \(error.localizedDescription)"
case .registeringWithProxy(let error):
"Proxy: \(error.localizedDescription)"
case .unregistering(let error):
"Unregistering: \(error.localizedDescription)"
} }
} }
} }
struct ProxyRegistrationError: LocalizedError, Decodable {
let error: String
let fields: [Field]?
var errorDescription: String? {
if let fields,
!fields.isEmpty {
error + ": " + fields.map { "\($0.key): \($0.reason)" }.joined(separator: ", ")
} else {
error
}
}
struct Field: Decodable {
let key: String
let reason: String
}
}
enum CreateSubscriptionError: LocalizedError { enum CreateSubscriptionError: LocalizedError {
case notRegisteredWithProxy
case generatingAuthSecret(OSStatus) case generatingAuthSecret(OSStatus)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .notRegisteredWithProxy:
"Not registered with proxy"
case .generatingAuthSecret(let code): case .generatingAuthSecret(let code):
"Generating auth secret: \(code)" "Generating auth secret: \(code)"
} }
} }
} }
private struct PushRegistrationParams: Encodable {
let environment: String
let deviceToken: String
let pushVersion: Int
enum CodingKeys: String, CodingKey {
case environment
case deviceToken = "device_token"
case pushVersion = "push_version"
}
}
private struct PushUpdateParams: Encodable {
let environment: String
let deviceToken: String
let pushVersion: Int
enum CodingKeys: String, CodingKey {
case environment
case deviceToken = "device_token"
case pushVersion = "push_version"
}
}
private extension Data { private extension Data {
func hexEncodedString() -> String { func hexEncodedString() -> String {
String(unsafeUninitializedCapacity: count * 2) { buffer in String(unsafeUninitializedCapacity: count * 2) { buffer in

View File

@ -1,39 +0,0 @@
//
// PushProxyRegistration.swift
// PushNotifications
//
// Created by Shadowfacts on 4/7/24.
//
import Foundation
public struct PushProxyRegistration: Decodable {
let id: String
public let endpoint: URL
let deviceToken: String
var defaultsDict: [String: String] {
[
"id": id,
"endpoint": endpoint.absoluteString,
"deviceToken": deviceToken,
]
}
init?(defaultsDict: [String: String]) {
guard let id = defaultsDict["id"],
let endpoint = defaultsDict["endpoint"].flatMap(URL.init(string:)),
let deviceToken = defaultsDict["deviceToken"] else {
return nil
}
self.id = id
self.endpoint = endpoint
self.deviceToken = deviceToken
}
private enum CodingKeys: String, CodingKey {
case id
case endpoint
case deviceToken = "device_token"
}
}

View File

@ -14,127 +14,30 @@ import TuskerComponents
struct NotificationsPrefsView: View { struct NotificationsPrefsView: View {
@State private var error: NotificationsSetupError? @State private var error: NotificationsSetupError?
@State private var isSetup = AsyncToggle.Mode.off
@State private var pushProxyRegistration: PushProxyRegistration?
@ObservedObject private var userAccounts = UserAccountsManager.shared @ObservedObject private var userAccounts = UserAccountsManager.shared
var body: some View { var body: some View {
List { List {
enableSection Section {
if isSetup == .on, ForEach(userAccounts.accounts) { account in
let pushProxyRegistration { PushInstanceSettingsView(account: account)
accountsSection(pushProxyRegistration: pushProxyRegistration) }
} }
.appGroupedListRowBackground()
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
.appGroupedListBackground(container: PreferencesNavigationController.self) .appGroupedListBackground(container: PreferencesNavigationController.self)
.navigationTitle("Notifications") .navigationTitle("Notifications")
} }
private var enableSection: some View {
Section {
AsyncToggle("Push Notifications", mode: $isSetup, onChange: isSetupChanged(newValue:))
}
.appGroupedListRowBackground()
.alertWithData("An Error Occurred", data: $error) { error in
Button("OK") {}
} message: { error in
Text(error.localizedDescription)
}
.task { @MainActor in
pushProxyRegistration = PushManager.shared.proxyRegistration
isSetup = pushProxyRegistration != nil ? .on : .off
if !UIApplication.shared.isRegisteredForRemoteNotifications {
_ = await registerForRemoteNotifications()
}
}
}
private func accountsSection(pushProxyRegistration: PushProxyRegistration) -> some View {
Section {
ForEach(userAccounts.accounts) { account in
PushInstanceSettingsView(account: account, pushProxyRegistration: pushProxyRegistration)
}
}
.appGroupedListRowBackground()
}
private func isSetupChanged(newValue: Bool) async -> Bool {
if newValue {
return await startRegistration()
} else {
return await unregister()
}
}
private func startRegistration() async -> Bool {
let authorized: Bool
do {
authorized = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .providesAppNotificationSettings])
} catch {
self.error = .requestingAuthorization(error)
return false
}
guard authorized else {
return false
}
return await registerForRemoteNotifications()
}
private func registerForRemoteNotifications() async -> Bool {
do {
pushProxyRegistration = try await PushManager.shared.register()
return true
} catch {
self.error = .registering(error)
return false
}
}
private func unregister() async -> Bool {
do {
try await PushManager.shared.unregister()
pushProxyRegistration = nil
for subscription in PushManager.shared.subscriptions {
if let account = UserAccountsManager.shared.getAccount(id: subscription.accountID) {
let mastodonController = MastodonController.getForAccount(account)
do {
try await mastodonController.deletePushSubscription()
PushManager.shared.removeSubscription(account: account)
PushManager.logger.debug("Push subscription removed on \(account.instanceURL)")
// this is a bit of a hack. the PushInstanceSettingsViews need to know to update
// their @State variables after we remove the subscription
NotificationCenter.default.post(name: .pushSubscriptionRemoved, object: account.id)
} catch {
PushManager.logger.error("Erroring removing push subscription: \(String(describing: error))")
}
}
}
return true
} catch {
self.error = .unregistering(error)
return false
}
}
} }
private enum NotificationsSetupError: LocalizedError { private enum NotificationsSetupError: LocalizedError {
case requestingAuthorization(any Error) case requestingAuthorization(any Error)
case registering(any Error)
case unregistering(any Error)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .requestingAuthorization(let error): case .requestingAuthorization(let error):
"Notifications authorization request failed: \(error.localizedDescription)" "Notifications authorization request failed: \(error.localizedDescription)"
case .registering(let error):
"Registration failed: \(error.localizedDescription)"
case .unregistering(let error):
"Deactivation failed: \(error.localizedDescription)"
} }
} }
} }
extension Notification.Name {
static let pushSubscriptionRemoved = Notification.Name("Tusker.pushSubscriptionRemoved")
}

View File

@ -14,16 +14,14 @@ import TuskerComponents
struct PushInstanceSettingsView: View { struct PushInstanceSettingsView: View {
let account: UserAccountInfo let account: UserAccountInfo
let pushProxyRegistration: PushProxyRegistration
@State private var mode: AsyncToggle.Mode @State private var mode: AsyncToggle.Mode
@State private var error: Error? @State private var error: Error?
@State private var subscription: PushNotifications.PushSubscription? @State private var subscription: PushNotifications.PushSubscription?
@State private var showReLoginRequiredAlert = false @State private var showReLoginRequiredAlert = false
@MainActor @MainActor
init(account: UserAccountInfo, pushProxyRegistration: PushProxyRegistration) { init(account: UserAccountInfo) {
self.account = account self.account = account
self.pushProxyRegistration = pushProxyRegistration
let subscription = PushManager.shared.pushSubscription(account: account) let subscription = PushManager.shared.pushSubscription(account: account)
self.subscription = subscription self.subscription = subscription
self.mode = subscription == nil ? .off : .on self.mode = subscription == nil ? .off : .on
@ -52,13 +50,6 @@ struct PushInstanceSettingsView: View {
} message: { } message: {
Text("You must grant permission on \(account.instanceURL.host!) to turn on push notifications.") Text("You must grant permission on \(account.instanceURL.host!) to turn on push notifications.")
} }
.onReceive(NotificationCenter.default
.publisher(for: .pushSubscriptionRemoved)
.filter { ($0.object as? String) == account.id }
) { _ in
mode = .off
subscription = nil
}
} }
private func updateNotificationsEnabled(enabled: Bool) async -> Bool { private func updateNotificationsEnabled(enabled: Bool) async -> Bool {
@ -88,6 +79,11 @@ struct PushInstanceSettingsView: View {
return false return false
} }
let authorized = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .providesAppNotificationSettings])
guard authorized else {
return false
}
let subscription = try await PushManager.shared.createSubscription(account: account) let subscription = try await PushManager.shared.createSubscription(account: account)
let mastodonController = await MastodonController.getForAccount(account) let mastodonController = await MastodonController.getForAccount(account)
do { do {