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 alerts: Alerts
|
||||||
public let policy: Policy
|
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> {
|
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([
|
return Request(method: .post, path: "/api/v1/push/subscription", body: ParametersBody([
|
||||||
"subscription[endpoint]" => endpoint.absoluteString,
|
"subscription[endpoint]" => endpoint.absoluteString,
|
||||||
|
@ -70,6 +85,17 @@ extension PushSubscription {
|
||||||
public let poll: Bool
|
public let poll: Bool
|
||||||
public let update: 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 {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case mention
|
case mention
|
||||||
case status
|
case status
|
||||||
|
|
|
@ -17,6 +17,13 @@ class DisabledPushManager: _PushManager {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createSubscription(account: UserAccountInfo) throws -> PushSubscription {
|
||||||
|
throw Disabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSubscription(account: UserAccountInfo) {
|
||||||
|
}
|
||||||
|
|
||||||
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
|
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ public struct PushManager {
|
||||||
@MainActor
|
@MainActor
|
||||||
public static let shared = createPushManager()
|
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() {}
|
private init() {}
|
||||||
|
|
||||||
|
@ -45,6 +45,8 @@ public protocol _PushManager {
|
||||||
var enabled: Bool { get }
|
var enabled: Bool { get }
|
||||||
var pushProxyRegistration: PushProxyRegistration? { get }
|
var pushProxyRegistration: PushProxyRegistration? { get }
|
||||||
|
|
||||||
|
func createSubscription(account: UserAccountInfo) throws -> PushSubscription
|
||||||
|
func removeSubscription(account: UserAccountInfo)
|
||||||
func pushSubscription(account: UserAccountInfo) -> PushSubscription?
|
func pushSubscription(account: UserAccountInfo) -> PushSubscription?
|
||||||
|
|
||||||
func register(transactionID: UInt64) async throws -> PushProxyRegistration
|
func register(transactionID: UInt64) async throws -> PushProxyRegistration
|
||||||
|
|
|
@ -10,6 +10,7 @@ import UserAccounts
|
||||||
#if canImport(Sentry)
|
#if canImport(Sentry)
|
||||||
import Sentry
|
import Sentry
|
||||||
#endif
|
#endif
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
class PushManagerImpl: _PushManager {
|
class PushManagerImpl: _PushManager {
|
||||||
private let endpoint: URL
|
private let endpoint: URL
|
||||||
|
@ -59,6 +60,37 @@ class PushManagerImpl: _PushManager {
|
||||||
self.endpoint = endpoint
|
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? {
|
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
|
||||||
pushSubscriptions.first { $0.accountID == account.id }
|
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 {
|
private struct PushRegistrationParams: Encodable {
|
||||||
let transactionID: String
|
let transactionID: String
|
||||||
let environment: String
|
let environment: String
|
||||||
|
|
|
@ -9,7 +9,7 @@ import Foundation
|
||||||
|
|
||||||
public struct PushProxyRegistration: Decodable {
|
public struct PushProxyRegistration: Decodable {
|
||||||
let id: String
|
let id: String
|
||||||
let endpoint: URL
|
public let endpoint: URL
|
||||||
let deviceToken: String
|
let deviceToken: String
|
||||||
|
|
||||||
var defaultsDict: [String: String] {
|
var defaultsDict: [String: String] {
|
||||||
|
|
|
@ -6,17 +6,22 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
public struct PushSubscription {
|
public struct PushSubscription {
|
||||||
let accountID: String
|
let accountID: String
|
||||||
let endpoint: URL
|
let endpoint: URL
|
||||||
let alerts: Alerts
|
public let secretKey: P256.KeyAgreement.PrivateKey
|
||||||
let policy: Policy
|
public let authSecret: Data
|
||||||
|
public var alerts: Alerts
|
||||||
|
public var policy: Policy
|
||||||
|
|
||||||
var defaultsDict: [String: Any] {
|
var defaultsDict: [String: Any] {
|
||||||
[
|
[
|
||||||
"accountID": accountID,
|
"accountID": accountID,
|
||||||
"endpoint": endpoint.absoluteString,
|
"endpoint": endpoint.absoluteString,
|
||||||
|
"secretKey": secretKey.rawRepresentation,
|
||||||
|
"authSecret": authSecret,
|
||||||
"alerts": alerts.rawValue,
|
"alerts": alerts.rawValue,
|
||||||
"policy": policy.rawValue
|
"policy": policy.rawValue
|
||||||
]
|
]
|
||||||
|
@ -25,30 +30,47 @@ public struct PushSubscription {
|
||||||
init?(defaultsDict: [String: Any]) {
|
init?(defaultsDict: [String: Any]) {
|
||||||
guard let accountID = defaultsDict["accountID"] as? String,
|
guard let accountID = defaultsDict["accountID"] as? String,
|
||||||
let endpoint = (defaultsDict["endpoint"] as? String).flatMap(URL.init(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 alerts = defaultsDict["alerts"] as? Int,
|
||||||
let policy = (defaultsDict["policy"] as? String).flatMap(Policy.init(rawValue:)) else {
|
let policy = (defaultsDict["policy"] as? String).flatMap(Policy.init(rawValue:)) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
self.accountID = accountID
|
self.accountID = accountID
|
||||||
self.endpoint = endpoint
|
self.endpoint = endpoint
|
||||||
|
self.secretKey = secretKey
|
||||||
|
self.authSecret = authSecret
|
||||||
self.alerts = Alerts(rawValue: alerts)
|
self.alerts = Alerts(rawValue: alerts)
|
||||||
self.policy = policy
|
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
|
case all, followed, followers
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Alerts: OptionSet {
|
public struct Alerts: OptionSet {
|
||||||
static let mention = Alerts(rawValue: 1 << 0)
|
public static let mention = Alerts(rawValue: 1 << 0)
|
||||||
static let status = Alerts(rawValue: 1 << 1)
|
public static let status = Alerts(rawValue: 1 << 1)
|
||||||
static let reblog = Alerts(rawValue: 1 << 2)
|
public static let reblog = Alerts(rawValue: 1 << 2)
|
||||||
static let follow = Alerts(rawValue: 1 << 3)
|
public static let follow = Alerts(rawValue: 1 << 3)
|
||||||
static let followRequest = Alerts(rawValue: 1 << 4)
|
public static let followRequest = Alerts(rawValue: 1 << 4)
|
||||||
static let favorite = Alerts(rawValue: 1 << 5)
|
public static let favorite = Alerts(rawValue: 1 << 5)
|
||||||
static let poll = Alerts(rawValue: 1 << 6)
|
public static let poll = Alerts(rawValue: 1 << 6)
|
||||||
static let update = Alerts(rawValue: 1 << 7)
|
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 {
|
struct PushInstanceSettingsView: View {
|
||||||
let account: UserAccountInfo
|
let account: UserAccountInfo
|
||||||
let pushProxyRegistration: PushProxyRegistration
|
let pushProxyRegistration: PushProxyRegistration
|
||||||
@State private var mode: TriStateToggle.Mode = .off
|
@State private var mode: TriStateToggle.Mode
|
||||||
@State private var error: Error?
|
@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 {
|
var body: some View {
|
||||||
VStack(alignment: .prefsAvatar) {
|
VStack(alignment: .prefsAvatar) {
|
||||||
HStack {
|
HStack {
|
||||||
PrefsAccountView(account: account)
|
PrefsAccountView(account: account)
|
||||||
|
Spacer()
|
||||||
TriStateToggle(titleKey: "\(account.instanceURL.host!) notifications enabled", mode: $mode, onChange: updateNotificationsEnabled(enabled:))
|
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
|
.alertWithData("An Error Occurred", data: $error) { data in
|
||||||
Button("OK") {}
|
Button("OK") {}
|
||||||
} message: { data in
|
} message: { data in
|
||||||
Text(data.localizedDescription)
|
Text(data.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateNotificationsEnabled(enabled: Bool) async {
|
private func updateNotificationsEnabled(enabled: Bool) async {
|
||||||
|
@ -38,29 +48,47 @@ struct PushInstanceSettingsView: View {
|
||||||
do {
|
do {
|
||||||
try await enableNotifications()
|
try await enableNotifications()
|
||||||
} catch {
|
} catch {
|
||||||
|
PushManager.logger.error("Error creating instance subscription: \(String(describing: error))")
|
||||||
self.error = .enabling(error)
|
self.error = .enabling(error)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
do {
|
do {
|
||||||
try await disableNotifications()
|
try await disableNotifications()
|
||||||
} catch {
|
} catch {
|
||||||
|
PushManager.logger.error("Error removing instance subscription: \(String(describing: error))")
|
||||||
self.error = .disabling(error)
|
self.error = .disabling(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enableNotifications() async throws {
|
private func enableNotifications() async throws {
|
||||||
// let req = Pachyderm.PushSubscription.create(
|
let subscription = try await PushManager.shared.createSubscription(account: account)
|
||||||
// endpoint: pushProxyRegistration.endpoint,
|
let req = Pachyderm.PushSubscription.create(
|
||||||
// publicKey: <#T##Data#>,
|
endpoint: pushProxyRegistration.endpoint,
|
||||||
// authSecret: <#T##Data#>,
|
publicKey: subscription.secretKey.publicKey.rawRepresentation,
|
||||||
// alerts: <#T##PushSubscription.Alerts#>,
|
authSecret: subscription.authSecret,
|
||||||
// policy: <#T##PushSubscription.Policy#>
|
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 {
|
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 {
|
//#Preview {
|
||||||
// PushInstanceSettingsView()
|
// PushInstanceSettingsView()
|
||||||
//}
|
//}
|
||||||
|
|
|
@ -12,19 +12,14 @@ import PushNotifications
|
||||||
|
|
||||||
struct PushSubscriptionView: View {
|
struct PushSubscriptionView: View {
|
||||||
let account: UserAccountInfo
|
let account: UserAccountInfo
|
||||||
@State private var subscription: PushSubscription?
|
let subscription: PushSubscription?
|
||||||
|
|
||||||
@MainActor
|
|
||||||
init(account: UserAccountInfo) {
|
|
||||||
self.account = account
|
|
||||||
self.subscription = PushManager.shared.pushSubscription(account: account)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let subscription {
|
if let subscription {
|
||||||
|
Text("wee")
|
||||||
} else {
|
} else {
|
||||||
Text("No notifications")
|
Text("No notifications")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,14 @@ struct TriStateToggle: View {
|
||||||
let titleKey: LocalizedStringKey
|
let titleKey: LocalizedStringKey
|
||||||
@Binding var mode: Mode
|
@Binding var mode: Mode
|
||||||
let onChange: (Bool) async -> Void
|
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 {
|
var body: some View {
|
||||||
toggleOrSpinner
|
toggleOrSpinner
|
||||||
|
|
Loading…
Reference in New Issue