From 94c1eb2c81dd9cdd89015f335462030e5a851483 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 8 Apr 2024 12:25:39 -0400 Subject: [PATCH] Create/remove instance push subscriptions --- .../Pachyderm/Model/PushSubscription.swift | 26 +++++++ .../DisabledPushManager.swift | 7 ++ .../PushNotifications/PushManager.swift | 4 +- .../PushNotifications/PushManagerImpl.swift | 46 +++++++++++ .../PushProxyRegistration.swift | 2 +- .../PushNotifications/PushSubscription.swift | 48 ++++++++---- .../PushInstanceSettingsView.swift | 78 ++++++++++++++++--- .../Notifications/PushSubscriptionView.swift | 11 +-- .../Notifications/TriStateToggle.swift | 9 ++- 9 files changed, 196 insertions(+), 35 deletions(-) diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/PushSubscription.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/PushSubscription.swift index e97a1349..b91d252d 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/PushSubscription.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/PushSubscription.swift @@ -15,6 +15,21 @@ public struct PushSubscription: Decodable, Sendable { public let alerts: Alerts public let policy: Policy + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // id is documented as being a string, but mastodon returns a json number + if let s = try? container.decode(String.self, forKey: .id) { + self.id = s + } else { + let i = try container.decode(Int.self, forKey: .id) + self.id = i.description + } + self.endpoint = try container.decode(URL.self, forKey: .endpoint) + self.serverKey = try container.decode(String.self, forKey: .serverKey) + self.alerts = try container.decode(PushSubscription.Alerts.self, forKey: .alerts) + self.policy = try container.decode(PushSubscription.Policy.self, forKey: .policy) + } + public static func create(endpoint: URL, publicKey: Data, authSecret: Data, alerts: Alerts, policy: Policy) -> Request { return Request(method: .post, path: "/api/v1/push/subscription", body: ParametersBody([ "subscription[endpoint]" => endpoint.absoluteString, @@ -70,6 +85,17 @@ extension PushSubscription { public let poll: Bool public let update: Bool + public init(mention: Bool, status: Bool, reblog: Bool, follow: Bool, followRequest: Bool, favourite: Bool, poll: Bool, update: Bool) { + self.mention = mention + self.status = status + self.reblog = reblog + self.follow = follow + self.followRequest = followRequest + self.favourite = favourite + self.poll = poll + self.update = update + } + private enum CodingKeys: String, CodingKey { case mention case status diff --git a/Packages/PushNotifications/Sources/PushNotifications/DisabledPushManager.swift b/Packages/PushNotifications/Sources/PushNotifications/DisabledPushManager.swift index 6a715a43..2829f1a0 100644 --- a/Packages/PushNotifications/Sources/PushNotifications/DisabledPushManager.swift +++ b/Packages/PushNotifications/Sources/PushNotifications/DisabledPushManager.swift @@ -17,6 +17,13 @@ class DisabledPushManager: _PushManager { nil } + func createSubscription(account: UserAccountInfo) throws -> PushSubscription { + throw Disabled() + } + + func removeSubscription(account: UserAccountInfo) { + } + func pushSubscription(account: UserAccountInfo) -> PushSubscription? { nil } diff --git a/Packages/PushNotifications/Sources/PushNotifications/PushManager.swift b/Packages/PushNotifications/Sources/PushNotifications/PushManager.swift index cd449e6d..58e61bff 100644 --- a/Packages/PushNotifications/Sources/PushNotifications/PushManager.swift +++ b/Packages/PushNotifications/Sources/PushNotifications/PushManager.swift @@ -17,7 +17,7 @@ public struct PushManager { @MainActor public static let shared = createPushManager() - static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PushManager") + public static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PushManager") private init() {} @@ -45,6 +45,8 @@ public protocol _PushManager { var enabled: Bool { get } var pushProxyRegistration: PushProxyRegistration? { get } + func createSubscription(account: UserAccountInfo) throws -> PushSubscription + func removeSubscription(account: UserAccountInfo) func pushSubscription(account: UserAccountInfo) -> PushSubscription? func register(transactionID: UInt64) async throws -> PushProxyRegistration diff --git a/Packages/PushNotifications/Sources/PushNotifications/PushManagerImpl.swift b/Packages/PushNotifications/Sources/PushNotifications/PushManagerImpl.swift index ed7bb81f..bdc138f3 100644 --- a/Packages/PushNotifications/Sources/PushNotifications/PushManagerImpl.swift +++ b/Packages/PushNotifications/Sources/PushNotifications/PushManagerImpl.swift @@ -10,6 +10,7 @@ import UserAccounts #if canImport(Sentry) import Sentry #endif +import CryptoKit class PushManagerImpl: _PushManager { private let endpoint: URL @@ -59,6 +60,37 @@ class PushManagerImpl: _PushManager { self.endpoint = endpoint } + func createSubscription(account: UserAccountInfo) throws -> PushSubscription { + guard let pushProxyRegistration else { + throw CreateSubscriptionError.notRegisteredWithProxy + } + if let existing = pushSubscription(account: account) { + return existing + } + let key = P256.KeyAgreement.PrivateKey() + var authSecret = Data(count: 16) + let res = authSecret.withUnsafeMutableBytes { ptr in + SecRandomCopyBytes(kSecRandomDefault, 16, ptr.baseAddress!) + } + guard res == errSecSuccess else { + throw CreateSubscriptionError.generatingAuthSecret(res) + } + let subscription = PushSubscription( + accountID: account.id, + endpoint: pushProxyRegistration.endpoint, + secretKey: key, + authSecret: authSecret, + alerts: [], + policy: .all + ) + pushSubscriptions.append(subscription) + return subscription + } + + func removeSubscription(account: UserAccountInfo) { + pushSubscriptions.removeAll { $0.accountID == account.id } + } + func pushSubscription(account: UserAccountInfo) -> PushSubscription? { pushSubscriptions.first { $0.accountID == account.id } } @@ -216,6 +248,20 @@ struct ProxyRegistrationError: LocalizedError, Decodable { } } +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 transactionID: String let environment: String diff --git a/Packages/PushNotifications/Sources/PushNotifications/PushProxyRegistration.swift b/Packages/PushNotifications/Sources/PushNotifications/PushProxyRegistration.swift index 395cda90..0e5c46f2 100644 --- a/Packages/PushNotifications/Sources/PushNotifications/PushProxyRegistration.swift +++ b/Packages/PushNotifications/Sources/PushNotifications/PushProxyRegistration.swift @@ -9,7 +9,7 @@ import Foundation public struct PushProxyRegistration: Decodable { let id: String - let endpoint: URL + public let endpoint: URL let deviceToken: String var defaultsDict: [String: String] { diff --git a/Packages/PushNotifications/Sources/PushNotifications/PushSubscription.swift b/Packages/PushNotifications/Sources/PushNotifications/PushSubscription.swift index 4003a4eb..07e24757 100644 --- a/Packages/PushNotifications/Sources/PushNotifications/PushSubscription.swift +++ b/Packages/PushNotifications/Sources/PushNotifications/PushSubscription.swift @@ -6,17 +6,22 @@ // import Foundation +import CryptoKit public struct PushSubscription { let accountID: String let endpoint: URL - let alerts: Alerts - let policy: Policy + public let secretKey: P256.KeyAgreement.PrivateKey + public let authSecret: Data + public var alerts: Alerts + public var policy: Policy var defaultsDict: [String: Any] { [ "accountID": accountID, "endpoint": endpoint.absoluteString, + "secretKey": secretKey.rawRepresentation, + "authSecret": authSecret, "alerts": alerts.rawValue, "policy": policy.rawValue ] @@ -25,30 +30,47 @@ public struct PushSubscription { init?(defaultsDict: [String: Any]) { guard let accountID = defaultsDict["accountID"] as? String, let endpoint = (defaultsDict["endpoint"] as? String).flatMap(URL.init(string:)), + let secretKey = (defaultsDict["secretKey"] as? Data).flatMap({ try? P256.KeyAgreement.PrivateKey(rawRepresentation: $0) }), + let authSecret = defaultsDict["authSecret"] as? Data, let alerts = defaultsDict["alerts"] as? Int, let policy = (defaultsDict["policy"] as? String).flatMap(Policy.init(rawValue:)) else { return nil } self.accountID = accountID self.endpoint = endpoint + self.secretKey = secretKey + self.authSecret = authSecret self.alerts = Alerts(rawValue: alerts) self.policy = policy } - enum Policy: String { + init(accountID: String, endpoint: URL, secretKey: P256.KeyAgreement.PrivateKey, authSecret: Data, alerts: Alerts, policy: Policy) { + self.accountID = accountID + self.endpoint = endpoint + self.secretKey = secretKey + self.authSecret = authSecret + self.alerts = alerts + self.policy = policy + } + + public enum Policy: String { case all, followed, followers } - struct Alerts: OptionSet { - static let mention = Alerts(rawValue: 1 << 0) - static let status = Alerts(rawValue: 1 << 1) - static let reblog = Alerts(rawValue: 1 << 2) - static let follow = Alerts(rawValue: 1 << 3) - static let followRequest = Alerts(rawValue: 1 << 4) - static let favorite = Alerts(rawValue: 1 << 5) - static let poll = Alerts(rawValue: 1 << 6) - static let update = Alerts(rawValue: 1 << 7) + public struct Alerts: OptionSet { + public static let mention = Alerts(rawValue: 1 << 0) + public static let status = Alerts(rawValue: 1 << 1) + public static let reblog = Alerts(rawValue: 1 << 2) + public static let follow = Alerts(rawValue: 1 << 3) + public static let followRequest = Alerts(rawValue: 1 << 4) + public static let favorite = Alerts(rawValue: 1 << 5) + public static let poll = Alerts(rawValue: 1 << 6) + public static let update = Alerts(rawValue: 1 << 7) - let rawValue: Int + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } } } diff --git a/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift b/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift index fac58441..655da076 100644 --- a/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift +++ b/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift @@ -14,23 +14,33 @@ import PushNotifications struct PushInstanceSettingsView: View { let account: UserAccountInfo let pushProxyRegistration: PushProxyRegistration - @State private var mode: TriStateToggle.Mode = .off + @State private var mode: TriStateToggle.Mode @State private var error: Error? + @State private var subscription: PushNotifications.PushSubscription? + + @MainActor + init(account: UserAccountInfo, pushProxyRegistration: PushProxyRegistration) { + self.account = account + self.pushProxyRegistration = pushProxyRegistration + let subscription = PushManager.shared.pushSubscription(account: account) + self.subscription = subscription + self.mode = subscription == nil ? .off : .on + } var body: some View { VStack(alignment: .prefsAvatar) { HStack { PrefsAccountView(account: account) + Spacer() TriStateToggle(titleKey: "\(account.instanceURL.host!) notifications enabled", mode: $mode, onChange: updateNotificationsEnabled(enabled:)) } - PushSubscriptionView(account: account) + PushSubscriptionView(account: account, subscription: subscription) } .alertWithData("An Error Occurred", data: $error) { data in Button("OK") {} } message: { data in Text(data.localizedDescription) } - } private func updateNotificationsEnabled(enabled: Bool) async { @@ -38,29 +48,47 @@ struct PushInstanceSettingsView: View { do { try await enableNotifications() } catch { + PushManager.logger.error("Error creating instance subscription: \(String(describing: error))") self.error = .enabling(error) } } else { do { try await disableNotifications() } catch { + PushManager.logger.error("Error removing instance subscription: \(String(describing: error))") self.error = .disabling(error) } } } private func enableNotifications() async throws { -// let req = Pachyderm.PushSubscription.create( -// endpoint: pushProxyRegistration.endpoint, -// publicKey: <#T##Data#>, -// authSecret: <#T##Data#>, -// alerts: <#T##PushSubscription.Alerts#>, -// policy: <#T##PushSubscription.Policy#> -// ) + let subscription = try await PushManager.shared.createSubscription(account: account) + let req = Pachyderm.PushSubscription.create( + endpoint: pushProxyRegistration.endpoint, + publicKey: subscription.secretKey.publicKey.rawRepresentation, + 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) + PushManager.logger.debug("Push subscription \(result.id) created on \(account.instanceURL)") + self.subscription = subscription + } catch { + // if creation failed, remove the subscription locally as well + await PushManager.shared.removeSubscription(account: account) + throw error + } } private func disableNotifications() async throws { - + let req = Pachyderm.PushSubscription.delete() + let mastodonController = await MastodonController.getForAccount(account) + _ = try await mastodonController.run(req) + await PushManager.shared.removeSubscription(account: account) + subscription = nil + PushManager.logger.debug("Push subscription removed on \(account.instanceURL)") } } @@ -78,6 +106,34 @@ 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() //} diff --git a/Tusker/Screens/Preferences/Notifications/PushSubscriptionView.swift b/Tusker/Screens/Preferences/Notifications/PushSubscriptionView.swift index 02c6604b..0cb39098 100644 --- a/Tusker/Screens/Preferences/Notifications/PushSubscriptionView.swift +++ b/Tusker/Screens/Preferences/Notifications/PushSubscriptionView.swift @@ -12,19 +12,14 @@ import PushNotifications struct PushSubscriptionView: View { let account: UserAccountInfo - @State private var subscription: PushSubscription? - - @MainActor - init(account: UserAccountInfo) { - self.account = account - self.subscription = PushManager.shared.pushSubscription(account: account) - } + let subscription: PushSubscription? var body: some View { if let subscription { - + Text("wee") } else { Text("No notifications") + .foregroundStyle(.secondary) } } } diff --git a/Tusker/Screens/Preferences/Notifications/TriStateToggle.swift b/Tusker/Screens/Preferences/Notifications/TriStateToggle.swift index 70d57a17..32b48b9a 100644 --- a/Tusker/Screens/Preferences/Notifications/TriStateToggle.swift +++ b/Tusker/Screens/Preferences/Notifications/TriStateToggle.swift @@ -12,7 +12,14 @@ struct TriStateToggle: View { let titleKey: LocalizedStringKey @Binding var mode: Mode let onChange: (Bool) async -> Void - @State private var isOn: Bool = false + @State private var isOn: Bool + + init(titleKey: LocalizedStringKey, mode: Binding, onChange: @escaping (Bool) async -> Void) { + self.titleKey = titleKey + self._mode = mode + self.onChange = onChange + self.isOn = mode.wrappedValue == .on + } var body: some View { toggleOrSpinner