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,
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 subscription = getSubscription(account: account),
let encryptedBody = (request.content.userInfo["data"] as? String).flatMap({ Data(base64Encoded: $0) }),

View File

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

View File

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

View File

@ -20,26 +20,13 @@ class PushManagerImpl: _PushManager {
#if DEBUG
"development"
#else
"release"
"production"
#endif
}
private var remoteNotificationsRegistrationContinuation: CheckedContinuation<Data, any Error>?
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] {
get {
if let array = defaults.array(forKey: "PushSubscriptions") as? [[String: Any]] {
@ -57,10 +44,7 @@ class PushManagerImpl: _PushManager {
self.endpoint = endpoint
}
func createSubscription(account: UserAccountInfo) throws -> PushSubscription {
guard let proxyRegistration else {
throw CreateSubscriptionError.notRegisteredWithProxy
}
func createSubscription(account: UserAccountInfo) async throws -> PushSubscription {
if let existing = pushSubscription(account: account) {
return existing
}
@ -72,9 +56,10 @@ class PushManagerImpl: _PushManager {
guard res == errSecSuccess else {
throw CreateSubscriptionError.generatingAuthSecret(res)
}
let token = try await getDeviceToken()
let subscription = PushSubscription(
accountID: account.id,
endpoint: endpointURL(registration: proxyRegistration, accountID: account.id),
endpoint: endpointURL(deviceToken: token, accountID: account.id),
secretKey: key,
authSecret: authSecret,
alerts: [],
@ -84,10 +69,10 @@ class PushManagerImpl: _PushManager {
return subscription
}
private func endpointURL(registration: PushProxyRegistration, accountID: String) -> URL {
var endpoint = URLComponents(url: registration.endpoint, resolvingAgainstBaseURL: false)!
endpoint.queryItems = endpoint.queryItems ?? []
endpoint.queryItems!.append(URLQueryItem(name: "ctx", value: accountID))
private func endpointURL(deviceToken: Data, accountID: String) -> URL {
var endpoint = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
let accountID = accountID.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!
endpoint.path = "/push/v1/\(apnsEnvironment)/\(deviceToken.hexEncodedString())/\(accountID)"
return endpoint.url!
}
@ -109,70 +94,28 @@ class PushManagerImpl: _PushManager {
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 {
guard let proxyRegistration else {
let subscriptions = self.subscriptions
guard !subscriptions.isEmpty else {
return
}
PushManager.logger.debug("Push proxy registration: \(proxyRegistration.id, privacy: .public)")
do {
let token = try await getDeviceToken().hexEncodedString()
guard token != proxyRegistration.deviceToken else {
// already up-to-date, nothing to do
return
}
let newRegistration = try await update(registration: proxyRegistration, deviceToken: token)
self.proxyRegistration = newRegistration
if proxyRegistration.endpoint != newRegistration.endpoint {
self.subscriptions = await AsyncSequenceAdaptor(wrapping: self.subscriptions).map {
var copy = $0
copy.endpoint = await self.endpointURL(registration: newRegistration, accountID: $0.accountID)
if await updateSubscription(copy) {
return copy
} else {
return $0
}
}.reduce(into: [], { partialResult, el in
partialResult.append(el)
})
}
let token = try await getDeviceToken()
self.subscriptions = await AsyncSequenceAdaptor(wrapping: subscriptions).map {
let newEndpoint = await self.endpointURL(deviceToken: token, accountID: $0.accountID)
guard newEndpoint != $0.endpoint else {
return $0
}
var copy = $0
copy.endpoint = newEndpoint
if await updateSubscription(copy) {
return copy
} else {
return $0
}
}.reduce(into: [], { partialResult, el in
partialResult.append(el)
})
} catch {
PushManager.logger.error("Failed to update push registration: \(String(describing: error), privacy: .public)")
PushManager.captureError?(error)
@ -195,47 +138,11 @@ class PushManagerImpl: _PushManager {
remoteNotificationsRegistrationContinuation?.resume(throwing: PushRegistrationError.registeringForRemoteNotifications(error))
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 {
case alreadyRegistering
case registeringForRemoteNotifications(any Error)
case registeringWithProxy(any Error)
case unregistering(any Error)
var errorDescription: String? {
switch self {
@ -243,71 +150,21 @@ enum PushRegistrationError: LocalizedError {
"Already registering"
case .registeringForRemoteNotifications(let error):
"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 {
case notRegisteredWithProxy
case generatingAuthSecret(OSStatus)
var errorDescription: String? {
switch self {
case .notRegisteredWithProxy:
"Not registered with proxy"
case .generatingAuthSecret(let 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 {
func hexEncodedString() -> String {
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 {
@State private var error: NotificationsSetupError?
@State private var isSetup = AsyncToggle.Mode.off
@State private var pushProxyRegistration: PushProxyRegistration?
@ObservedObject private var userAccounts = UserAccountsManager.shared
var body: some View {
List {
enableSection
if isSetup == .on,
let pushProxyRegistration {
accountsSection(pushProxyRegistration: pushProxyRegistration)
Section {
ForEach(userAccounts.accounts) { account in
PushInstanceSettingsView(account: account)
}
}
.appGroupedListRowBackground()
}
.listStyle(.insetGrouped)
.appGroupedListBackground(container: PreferencesNavigationController.self)
.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 {
case requestingAuthorization(any Error)
case registering(any Error)
case unregistering(any Error)
var errorDescription: String? {
switch self {
case .requestingAuthorization(let error):
"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 {
let account: UserAccountInfo
let pushProxyRegistration: PushProxyRegistration
@State private var mode: AsyncToggle.Mode
@State private var error: Error?
@State private var subscription: PushNotifications.PushSubscription?
@State private var showReLoginRequiredAlert = false
@MainActor
init(account: UserAccountInfo, pushProxyRegistration: PushProxyRegistration) {
init(account: UserAccountInfo) {
self.account = account
self.pushProxyRegistration = pushProxyRegistration
let subscription = PushManager.shared.pushSubscription(account: account)
self.subscription = subscription
self.mode = subscription == nil ? .off : .on
@ -52,13 +50,6 @@ struct PushInstanceSettingsView: View {
} message: {
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 {
@ -88,6 +79,11 @@ struct PushInstanceSettingsView: View {
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 mastodonController = await MastodonController.getForAccount(account)
do {