forked from shadowfacts/Tusker
Remove the need to register with the push proxy
This commit is contained in:
parent
2e31d34e9d
commit
19ca930ee8
|
@ -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) }),
|
||||
|
|
|
@ -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 {
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue