Update Mastodon push subscriptions when endpoint changes

This commit is contained in:
Shadowfacts 2024-04-09 12:35:00 -04:00
parent 840b83012a
commit 68dad77f81
8 changed files with 146 additions and 54 deletions

View File

@ -36,7 +36,7 @@ class DisabledPushManager: _PushManager {
throw Disabled() throw Disabled()
} }
func updateIfNecessary() async { func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async {
} }
func didRegisterForRemoteNotifications(deviceToken: Data) { func didRegisterForRemoteNotifications(deviceToken: Data) {

View File

@ -51,7 +51,7 @@ public protocol _PushManager {
func register(transactionID: UInt64) async throws -> PushProxyRegistration func register(transactionID: UInt64) async throws -> PushProxyRegistration
func unregister() async throws func unregister() async throws
func updateIfNecessary() async func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async
func didRegisterForRemoteNotifications(deviceToken: Data) func didRegisterForRemoteNotifications(deviceToken: Data)
func didFailToRegisterForRemoteNotifications(error: any Error) func didFailToRegisterForRemoteNotifications(error: any Error)

View File

@ -74,7 +74,7 @@ class PushManagerImpl: _PushManager {
} }
let subscription = PushSubscription( let subscription = PushSubscription(
accountID: account.id, accountID: account.id,
endpoint: pushProxyRegistration.endpoint, endpoint: endpointURL(registration: pushProxyRegistration, accountID: account.id),
secretKey: key, secretKey: key,
authSecret: authSecret, authSecret: authSecret,
alerts: [], alerts: [],
@ -84,6 +84,13 @@ class PushManagerImpl: _PushManager {
return subscription 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) { func removeSubscription(account: UserAccountInfo) {
pushSubscriptions.removeAll { $0.accountID == account.id } 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 { guard let pushProxyRegistration else {
return return
} }
@ -142,8 +149,19 @@ class PushManagerImpl: _PushManager {
return return
} }
let newRegistration = try await update(registration: pushProxyRegistration, deviceToken: token) let newRegistration = try await update(registration: pushProxyRegistration, deviceToken: token)
self.pushProxyRegistration = newRegistration
if pushProxyRegistration.endpoint != newRegistration.endpoint { 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 { } 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)")
@ -296,3 +314,25 @@ private extension Data {
} }
} }
} }
private struct AsyncSequenceAdaptor<S: Sequence>: 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()
}
}
}

View File

@ -9,8 +9,8 @@ import Foundation
import CryptoKit import CryptoKit
public struct PushSubscription { public struct PushSubscription {
let accountID: String public let accountID: String
let endpoint: URL public internal(set) var endpoint: URL
public let secretKey: P256.KeyAgreement.PrivateKey public let secretKey: P256.KeyAgreement.PrivateKey
public let authSecret: Data public let authSecret: Data
public var alerts: Alerts public var alerts: Alerts

View File

@ -93,6 +93,7 @@
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */; }; D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */; };
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */; }; D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */; };
D630C3C82BC43AFD00208903 /* PushNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3C72BC43AFD00208903 /* PushNotifications */; }; 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 */; }; D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; };
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.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 */; }; 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 = "<group>"; }; D62E9984279CA23900C26176 /* URLSession+Development.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+Development.swift"; sourceTree = "<group>"; };
D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMetaIndicatorsView.swift; sourceTree = "<group>"; }; D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMetaIndicatorsView.swift; sourceTree = "<group>"; };
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringHelperTests.swift; sourceTree = "<group>"; }; D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringHelperTests.swift; sourceTree = "<group>"; };
D630C3C92BC59FF500208903 /* MastodonController+Push.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonController+Push.swift"; sourceTree = "<group>"; };
D6311C4F25B3765B00B27539 /* ImageDataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDataCache.swift; sourceTree = "<group>"; }; D6311C4F25B3765B00B27539 /* ImageDataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDataCache.swift; sourceTree = "<group>"; };
D6333B362137838300CE884A /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = "<group>"; }; D6333B362137838300CE884A /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = "<group>"; };
D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; }; D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; };
@ -1651,6 +1653,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6F953EF21251A2900CF0F2B /* MastodonController.swift */, D6F953EF21251A2900CF0F2B /* MastodonController.swift */,
D630C3C92BC59FF500208903 /* MastodonController+Push.swift */,
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */, D61ABEFD28F1C92600B29151 /* FavoriteService.swift */,
D621733228F1D5ED004C7DB1 /* ReblogService.swift */, D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
D6F6A54F291F058600F496A8 /* CreateListService.swift */, D6F6A54F291F058600F496A8 /* CreateListService.swift */,
@ -2228,6 +2231,7 @@
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */, D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */,
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */, D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */, D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */,
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */, D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */,
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */, D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */, D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,

View File

@ -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
}
}
}

View File

@ -84,10 +84,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
BackgroundManager.shared.registerHandlers() BackgroundManager.shared.registerHandlers()
Task { initializePushManager()
PushManager.captureError = { SentrySDK.capture(error: $0) }
await PushManager.shared.updateIfNecessary()
}
return true return true
} }
@ -183,6 +180,26 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
PushManager.shared.didFailToRegisterForRemoteNotifications(error: error) 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) #if !os(visionOS)
private func swizzleStatusBar() { private func swizzleStatusBar() {
let selector = Selector(("handleTapAction:")) let selector = Selector(("handleTapAction:"))

View File

@ -68,19 +68,9 @@ struct PushInstanceSettingsView: View {
private func enableNotifications() async throws { private func enableNotifications() async throws {
let subscription = try await PushManager.shared.createSubscription(account: account) 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) let mastodonController = await MastodonController.getForAccount(account)
do { 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)") PushManager.logger.debug("Push subscription \(result.id) created on \(account.instanceURL)")
self.subscription = subscription self.subscription = subscription
} catch { } catch {
@ -91,19 +81,17 @@ struct PushInstanceSettingsView: View {
} }
private func disableNotifications() async throws { private func disableNotifications() async throws {
let req = Pachyderm.PushSubscription.delete()
let mastodonController = await MastodonController.getForAccount(account) let mastodonController = await MastodonController.getForAccount(account)
_ = try await mastodonController.run(req) try await mastodonController.deletePushSubscription()
await PushManager.shared.removeSubscription(account: account) await PushManager.shared.removeSubscription(account: account)
subscription = nil subscription = nil
PushManager.logger.debug("Push subscription removed on \(account.instanceURL)") PushManager.logger.debug("Push subscription removed on \(account.instanceURL)")
} }
private func updateSubscription(alerts: PushNotifications.PushSubscription.Alerts, policy: PushNotifications.PushSubscription.Policy) async -> Bool { 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) let mastodonController = await MastodonController.getForAccount(account)
do { 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)") PushManager.logger.debug("Push subscription \(result.id) updated on \(account.instanceURL)")
subscription?.alerts = alerts subscription?.alerts = alerts
subscription?.policy = policy 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 { //#Preview {
// PushInstanceSettingsView() // PushInstanceSettingsView()
//} //}