forked from shadowfacts/Tusker
Create/remove instance push subscriptions
This commit is contained in:
parent
b03991ae1d
commit
94c1eb2c81
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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] {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
//}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue