Create/remove instance push subscriptions

This commit is contained in:
Shadowfacts 2024-04-08 12:25:39 -04:00
parent b03991ae1d
commit 94c1eb2c81
9 changed files with 196 additions and 35 deletions

View File

@ -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<PushSubscription> {
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

View File

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

View File

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

View File

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

View File

@ -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] {

View File

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

View File

@ -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()
//}

View File

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

View File

@ -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<Mode>, onChange: @escaping (Bool) async -> Void) {
self.titleKey = titleKey
self._mode = mode
self.onChange = onChange
self.isOn = mode.wrappedValue == .on
}
var body: some View {
toggleOrSpinner