From 68dad77f81bb2ed98b833dae4279dbacfcd797ef Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 9 Apr 2024 12:35:00 -0400 Subject: [PATCH] Update Mastodon push subscriptions when endpoint changes --- .../DisabledPushManager.swift | 2 +- .../PushNotifications/PushManager.swift | 2 +- .../PushNotifications/PushManagerImpl.swift | 46 +++++++++++- .../PushNotifications/PushSubscription.swift | 4 +- Tusker.xcodeproj/project.pbxproj | 4 ++ Tusker/API/MastodonController+Push.swift | 71 +++++++++++++++++++ Tusker/AppDelegate.swift | 25 +++++-- .../PushInstanceSettingsView.swift | 46 +----------- 8 files changed, 146 insertions(+), 54 deletions(-) create mode 100644 Tusker/API/MastodonController+Push.swift diff --git a/Packages/PushNotifications/Sources/PushNotifications/DisabledPushManager.swift b/Packages/PushNotifications/Sources/PushNotifications/DisabledPushManager.swift index 2829f1a0..db73ed1d 100644 --- a/Packages/PushNotifications/Sources/PushNotifications/DisabledPushManager.swift +++ b/Packages/PushNotifications/Sources/PushNotifications/DisabledPushManager.swift @@ -36,7 +36,7 @@ class DisabledPushManager: _PushManager { throw Disabled() } - func updateIfNecessary() async { + func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async { } func didRegisterForRemoteNotifications(deviceToken: Data) { diff --git a/Packages/PushNotifications/Sources/PushNotifications/PushManager.swift b/Packages/PushNotifications/Sources/PushNotifications/PushManager.swift index 437c836d..c355d10a 100644 --- a/Packages/PushNotifications/Sources/PushNotifications/PushManager.swift +++ b/Packages/PushNotifications/Sources/PushNotifications/PushManager.swift @@ -51,7 +51,7 @@ public protocol _PushManager { func register(transactionID: UInt64) async throws -> PushProxyRegistration func unregister() async throws - func updateIfNecessary() async + func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async func didRegisterForRemoteNotifications(deviceToken: Data) func didFailToRegisterForRemoteNotifications(error: any Error) diff --git a/Packages/PushNotifications/Sources/PushNotifications/PushManagerImpl.swift b/Packages/PushNotifications/Sources/PushNotifications/PushManagerImpl.swift index 20b5f74b..5d42d8be 100644 --- a/Packages/PushNotifications/Sources/PushNotifications/PushManagerImpl.swift +++ b/Packages/PushNotifications/Sources/PushNotifications/PushManagerImpl.swift @@ -74,7 +74,7 @@ class PushManagerImpl: _PushManager { } let subscription = PushSubscription( accountID: account.id, - endpoint: pushProxyRegistration.endpoint, + endpoint: endpointURL(registration: pushProxyRegistration, accountID: account.id), secretKey: key, authSecret: authSecret, alerts: [], @@ -84,6 +84,13 @@ 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)) + return endpoint.url! + } + func removeSubscription(account: UserAccountInfo) { pushSubscriptions.removeAll { $0.accountID == account.id } } @@ -130,7 +137,7 @@ class PushManagerImpl: _PushManager { } } - func updateIfNecessary() async { + func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async { guard let pushProxyRegistration else { return } @@ -142,8 +149,19 @@ class PushManagerImpl: _PushManager { return } let newRegistration = try await update(registration: pushProxyRegistration, deviceToken: token) + self.pushProxyRegistration = newRegistration if pushProxyRegistration.endpoint != newRegistration.endpoint { - // TODO: update subscriptions if the endpoint's changed + self.pushSubscriptions = await AsyncSequenceAdaptor(wrapping: self.pushSubscriptions).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) + }) } } catch { PushManager.logger.error("Failed to update push registration: \(String(describing: error), privacy: .public)") @@ -296,3 +314,25 @@ private extension Data { } } } + +private struct AsyncSequenceAdaptor: AsyncSequence { + typealias Element = S.Element + + let base: S + + init(wrapping base: S) { + self.base = base + } + + func makeAsyncIterator() -> AsyncIterator { + AsyncIterator(base: base.makeIterator()) + } + + struct AsyncIterator: AsyncIteratorProtocol { + var base: S.Iterator + + mutating func next() async -> Element? { + base.next() + } + } +} diff --git a/Packages/PushNotifications/Sources/PushNotifications/PushSubscription.swift b/Packages/PushNotifications/Sources/PushNotifications/PushSubscription.swift index 716f5156..bafabcd0 100644 --- a/Packages/PushNotifications/Sources/PushNotifications/PushSubscription.swift +++ b/Packages/PushNotifications/Sources/PushNotifications/PushSubscription.swift @@ -9,8 +9,8 @@ import Foundation import CryptoKit public struct PushSubscription { - let accountID: String - let endpoint: URL + public let accountID: String + public internal(set) var endpoint: URL public let secretKey: P256.KeyAgreement.PrivateKey public let authSecret: Data public var alerts: Alerts diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 73b63548..36a4e5be 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -93,6 +93,7 @@ D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */; }; D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */; }; D630C3C82BC43AFD00208903 /* PushNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3C72BC43AFD00208903 /* PushNotifications */; }; + D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3C92BC59FF500208903 /* MastodonController+Push.swift */; }; D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; }; D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; }; D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; }; @@ -497,6 +498,7 @@ D62E9984279CA23900C26176 /* URLSession+Development.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+Development.swift"; sourceTree = ""; }; D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMetaIndicatorsView.swift; sourceTree = ""; }; D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringHelperTests.swift; sourceTree = ""; }; + D630C3C92BC59FF500208903 /* MastodonController+Push.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonController+Push.swift"; sourceTree = ""; }; D6311C4F25B3765B00B27539 /* ImageDataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDataCache.swift; sourceTree = ""; }; D6333B362137838300CE884A /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = ""; }; D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = ""; }; @@ -1651,6 +1653,7 @@ isa = PBXGroup; children = ( D6F953EF21251A2900CF0F2B /* MastodonController.swift */, + D630C3C92BC59FF500208903 /* MastodonController+Push.swift */, D61ABEFD28F1C92600B29151 /* FavoriteService.swift */, D621733228F1D5ED004C7DB1 /* ReblogService.swift */, D6F6A54F291F058600F496A8 /* CreateListService.swift */, @@ -2228,6 +2231,7 @@ D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */, D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */, D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */, + D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */, D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */, D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */, D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */, diff --git a/Tusker/API/MastodonController+Push.swift b/Tusker/API/MastodonController+Push.swift new file mode 100644 index 00000000..99569855 --- /dev/null +++ b/Tusker/API/MastodonController+Push.swift @@ -0,0 +1,71 @@ +// +// MastodonController+Push.swift +// Tusker +// +// Created by Shadowfacts on 4/9/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import Foundation +import Pachyderm +import PushNotifications +import CryptoKit + +extension MastodonController { + func createPushSubscription(subscription: PushNotifications.PushSubscription) async throws -> Pachyderm.PushSubscription { + let req = Pachyderm.PushSubscription.create( + endpoint: subscription.endpoint, + // mastodon docs just say "Base64 encoded string of a public key from a ECDH keypair using the prime256v1 curve." + // other apps use SecKeyCopyExternalRepresentation which is documented to use X9.63 for elliptic curve keys + // and that seems to work + publicKey: subscription.secretKey.publicKey.x963Representation, + authSecret: subscription.authSecret, + alerts: .init(subscription.alerts), + policy: .init(subscription.policy) + ) + return try await run(req).0 + } + + func updatePushSubscription(subscription: PushNotifications.PushSubscription) async throws -> Pachyderm.PushSubscription { + // when updating anything other than the alerts/policy, we need to go through the create route + return try await createPushSubscription(subscription: subscription) + } + + func updatePushSubscription(alerts: PushNotifications.PushSubscription.Alerts, policy: PushNotifications.PushSubscription.Policy) async throws -> Pachyderm.PushSubscription { + let req = Pachyderm.PushSubscription.update(alerts: .init(alerts), policy: .init(policy)) + return try await run(req).0 + } + + func deletePushSubscription() async throws { + let req = Pachyderm.PushSubscription.delete() + _ = try await run(req) + } +} + +private extension Pachyderm.PushSubscription.Alerts { + init(_ alerts: PushNotifications.PushSubscription.Alerts) { + self.init( + mention: alerts.contains(.mention), + status: alerts.contains(.status), + reblog: alerts.contains(.reblog), + follow: alerts.contains(.follow), + followRequest: alerts.contains(.followRequest), + favourite: alerts.contains(.favorite), + poll: alerts.contains(.poll), + update: alerts.contains(.update) + ) + } +} + +private extension Pachyderm.PushSubscription.Policy { + init(_ policy: PushNotifications.PushSubscription.Policy) { + switch policy { + case .all: + self = .all + case .followers: + self = .followers + case .followed: + self = .followed + } + } +} diff --git a/Tusker/AppDelegate.swift b/Tusker/AppDelegate.swift index bd2f3e95..a5bb3583 100644 --- a/Tusker/AppDelegate.swift +++ b/Tusker/AppDelegate.swift @@ -84,10 +84,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { BackgroundManager.shared.registerHandlers() - Task { - PushManager.captureError = { SentrySDK.capture(error: $0) } - await PushManager.shared.updateIfNecessary() - } + initializePushManager() return true } @@ -183,6 +180,26 @@ class AppDelegate: UIResponder, UIApplicationDelegate { PushManager.shared.didFailToRegisterForRemoteNotifications(error: error) } + private func initializePushManager() { + Task { + PushManager.captureError = { SentrySDK.capture(error: $0) } + await PushManager.shared.updateIfNecessary(updateSubscription: { + guard let account = UserAccountsManager.shared.getAccount(id: $0.accountID) else { + return false + } + let mastodonController = MastodonController.getForAccount(account) + do { + let result = try await mastodonController.updatePushSubscription(subscription: $0) + PushManager.logger.debug("Updated push subscription \(result.id) on \(mastodonController.instanceURL)") + return true + } catch { + PushManager.logger.error("Error updating push subscription: \(String(describing: error))") + return false + } + }) + } + } + #if !os(visionOS) private func swizzleStatusBar() { let selector = Selector(("handleTapAction:")) diff --git a/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift b/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift index 43bff3e2..b48126b8 100644 --- a/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift +++ b/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift @@ -68,19 +68,9 @@ struct PushInstanceSettingsView: View { private func enableNotifications() async throws { let subscription = try await PushManager.shared.createSubscription(account: account) - let req = Pachyderm.PushSubscription.create( - endpoint: pushProxyRegistration.endpoint, - // mastodon docs just say "Base64 encoded string of a public key from a ECDH keypair using the prime256v1 curve." - // other apps use SecKeyCopyExternalRepresentation which is documented to use X9.63 for elliptic curve keys - // and that seems to work - publicKey: subscription.secretKey.publicKey.x963Representation, - authSecret: subscription.authSecret, - alerts: .init(subscription.alerts), - policy: .init(subscription.policy) - ) let mastodonController = await MastodonController.getForAccount(account) do { - let (result, _) = try await mastodonController.run(req) + let result = try await mastodonController.createPushSubscription(subscription: subscription) PushManager.logger.debug("Push subscription \(result.id) created on \(account.instanceURL)") self.subscription = subscription } catch { @@ -91,19 +81,17 @@ struct PushInstanceSettingsView: View { } private func disableNotifications() async throws { - let req = Pachyderm.PushSubscription.delete() let mastodonController = await MastodonController.getForAccount(account) - _ = try await mastodonController.run(req) + try await mastodonController.deletePushSubscription() await PushManager.shared.removeSubscription(account: account) subscription = nil PushManager.logger.debug("Push subscription removed on \(account.instanceURL)") } private func updateSubscription(alerts: PushNotifications.PushSubscription.Alerts, policy: PushNotifications.PushSubscription.Policy) async -> Bool { - let req = Pachyderm.PushSubscription.update(alerts: .init(alerts), policy: .init(policy)) let mastodonController = await MastodonController.getForAccount(account) do { - let (result, _) = try await mastodonController.run(req) + let result = try await mastodonController.updatePushSubscription(alerts: alerts, policy: policy) PushManager.logger.debug("Push subscription \(result.id) updated on \(account.instanceURL)") subscription?.alerts = alerts subscription?.policy = policy @@ -133,34 +121,6 @@ private enum Error: LocalizedError { } } -private extension Pachyderm.PushSubscription.Alerts { - init(_ alerts: PushNotifications.PushSubscription.Alerts) { - self.init( - mention: alerts.contains(.mention), - status: alerts.contains(.status), - reblog: alerts.contains(.reblog), - follow: alerts.contains(.follow), - followRequest: alerts.contains(.followRequest), - favourite: alerts.contains(.favorite), - poll: alerts.contains(.poll), - update: alerts.contains(.update) - ) - } -} - -private extension Pachyderm.PushSubscription.Policy { - init(_ policy: PushNotifications.PushSubscription.Policy) { - switch policy { - case .all: - self = .all - case .followers: - self = .followers - case .followed: - self = .followed - } - } -} - //#Preview { // PushInstanceSettingsView() //}