415 lines
14 KiB
Swift
415 lines
14 KiB
Swift
//
|
|
// PushManager.swift
|
|
// Tusker
|
|
//
|
|
// Created by Shadowfacts on 4/6/24.
|
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import OSLog
|
|
#if canImport(Sentry)
|
|
import Sentry
|
|
#endif
|
|
import Pachyderm
|
|
import UserAccounts
|
|
|
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PushManager")
|
|
|
|
@MainActor
|
|
struct PushManager {
|
|
static let shared = createPushManager()
|
|
|
|
private init() {}
|
|
|
|
private static func createPushManager() -> any _PushManager {
|
|
guard let info = Bundle.main.object(forInfoDictionaryKey: "TuskerInfo") as? [String: Any],
|
|
let scheme = info["PushProxyScheme"] as? String,
|
|
let host = info["PushProxyHost"] as? String,
|
|
!scheme.isEmpty,
|
|
!host.isEmpty else {
|
|
logger.debug("Missing proxy info, push disabled")
|
|
return DisabledPushManager()
|
|
}
|
|
var endpoint = URLComponents()
|
|
endpoint.scheme = scheme
|
|
endpoint.host = host
|
|
let url = endpoint.url!
|
|
logger.debug("Push notifications enabled with proxy \(url.absoluteString, privacy: .public)")
|
|
return PushManagerImpl(endpoint: url)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
protocol _PushManager {
|
|
var enabled: Bool { get }
|
|
var pushProxyRegistration: PushProxyRegistration? { get }
|
|
|
|
func pushSubscription(account: UserAccountInfo) -> PushSubscription?
|
|
|
|
func register(transactionID: UInt64) async throws -> PushProxyRegistration
|
|
func unregister() async throws
|
|
func updateIfNecessary() async
|
|
|
|
func didRegisterForRemoteNotifications(deviceToken: Data)
|
|
func didFailToRegisterForRemoteNotifications(error: any Error)
|
|
}
|
|
|
|
private class DisabledPushManager: _PushManager {
|
|
var enabled: Bool {
|
|
false
|
|
}
|
|
|
|
var pushProxyRegistration: PushProxyRegistration? {
|
|
nil
|
|
}
|
|
|
|
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
|
|
nil
|
|
}
|
|
|
|
func register(transactionID: UInt64) async throws -> PushProxyRegistration {
|
|
throw Disabled()
|
|
}
|
|
|
|
func unregister() async throws {
|
|
throw Disabled()
|
|
}
|
|
|
|
func updateIfNecessary() async {
|
|
}
|
|
|
|
func didRegisterForRemoteNotifications(deviceToken: Data) {
|
|
}
|
|
func didFailToRegisterForRemoteNotifications(error: any Error) {
|
|
}
|
|
|
|
struct Disabled: LocalizedError {
|
|
var errorDescription: String? {
|
|
"Push notifications disabled"
|
|
}
|
|
}
|
|
}
|
|
|
|
private class PushManagerImpl: _PushManager {
|
|
private let endpoint: URL
|
|
|
|
var enabled: Bool {
|
|
true
|
|
}
|
|
|
|
private var apnsEnvironment: String {
|
|
#if DEBUG
|
|
"development"
|
|
#else
|
|
"release"
|
|
#endif
|
|
}
|
|
|
|
private var remoteNotificationsRegistrationContinuation: CheckedContinuation<Data, any Error>?
|
|
|
|
private let defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
|
|
private(set) var pushProxyRegistration: PushProxyRegistration? {
|
|
get {
|
|
if let dict = defaults.dictionary(forKey: "PushProxyRegistration") as? [String: String],
|
|
let registration = PushProxyRegistration(defaultsDict: dict) {
|
|
return registration
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
set {
|
|
defaults.setValue(newValue?.defaultsDict, forKey: "PushProxyRegistration")
|
|
}
|
|
}
|
|
private(set) var pushSubscriptions: [PushSubscription] {
|
|
get {
|
|
if let array = defaults.array(forKey: "PushSubscriptions") as? [[String: Any]] {
|
|
return array.compactMap(PushSubscription.init(defaultsDict:))
|
|
} else {
|
|
return []
|
|
}
|
|
}
|
|
set {
|
|
defaults.setValue(newValue.map(\.defaultsDict), forKey: "PushSubscriptions")
|
|
}
|
|
}
|
|
|
|
init(endpoint: URL) {
|
|
self.endpoint = endpoint
|
|
}
|
|
|
|
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
|
|
pushSubscriptions.first { $0.accountID == account.id }
|
|
}
|
|
|
|
func register(transactionID: UInt64) async throws -> PushProxyRegistration {
|
|
guard remoteNotificationsRegistrationContinuation == nil else {
|
|
throw PushRegistrationError.alreadyRegistering
|
|
}
|
|
let deviceToken = try await getDeviceToken().hexEncodedString()
|
|
logger.debug("Got device token: \(deviceToken)")
|
|
let registration: PushProxyRegistration
|
|
do {
|
|
registration = try await register(deviceToken: deviceToken)
|
|
logger.debug("Got endpoint: \(registration.endpoint)")
|
|
} catch {
|
|
logger.error("Proxy registration failed: \(String(describing: error))")
|
|
throw PushRegistrationError.registeringWithProxy(error)
|
|
}
|
|
pushProxyRegistration = registration
|
|
return registration
|
|
}
|
|
|
|
func unregister() async throws {
|
|
guard let pushProxyRegistration else {
|
|
return
|
|
}
|
|
var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
|
|
url.path = "/app/v1/registrations/\(pushProxyRegistration.id)"
|
|
var request = URLRequest(url: url.url!)
|
|
request.httpMethod = "DELETE"
|
|
let (data, resp) = try await URLSession.shared.data(for: request)
|
|
let status = (resp as! HTTPURLResponse).statusCode
|
|
if (200...299).contains(status) {
|
|
self.pushProxyRegistration = nil
|
|
logger.debug("Unregistered from proxy")
|
|
} else {
|
|
logger.error("Unregistering: unexpected status \(status)")
|
|
let error = (try? JSONDecoder().decode(ProxyRegistrationError.self, from: data)) ?? ProxyRegistrationError(error: "Unknown error", fields: nil)
|
|
throw PushRegistrationError.unregistering(error)
|
|
}
|
|
}
|
|
|
|
func updateIfNecessary() async {
|
|
guard let pushProxyRegistration else {
|
|
return
|
|
}
|
|
logger.debug("Push proxy registration: \(pushProxyRegistration.id, privacy: .public)")
|
|
do {
|
|
let token = try await getDeviceToken().hexEncodedString()
|
|
guard token != pushProxyRegistration.deviceToken else {
|
|
// already up-to-date, nothing to do
|
|
return
|
|
}
|
|
let newRegistration = try await update(registration: pushProxyRegistration, deviceToken: token)
|
|
if pushProxyRegistration.endpoint != newRegistration.endpoint {
|
|
// TODO: update subscriptions if the endpoint's changed
|
|
}
|
|
} catch {
|
|
logger.error("Failed to update push registration: \(String(describing: error), privacy: .public)")
|
|
#if canImport(Sentry)
|
|
SentrySDK.capture(error: error)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private func getDeviceToken() async throws -> Data {
|
|
defer {
|
|
remoteNotificationsRegistrationContinuation = nil
|
|
}
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
remoteNotificationsRegistrationContinuation = continuation
|
|
UIApplication.shared.registerForRemoteNotifications()
|
|
}
|
|
}
|
|
|
|
func didRegisterForRemoteNotifications(deviceToken: Data) {
|
|
remoteNotificationsRegistrationContinuation?.resume(returning: deviceToken)
|
|
}
|
|
|
|
func didFailToRegisterForRemoteNotifications(error: any Error) {
|
|
remoteNotificationsRegistrationContinuation?.resume(throwing: PushRegistrationError.registeringForRemoteNotifications(error))
|
|
}
|
|
|
|
private func register(deviceToken: String) async throws -> PushProxyRegistration {
|
|
var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
|
|
url.path = "/app/v1/registrations"
|
|
var request = URLRequest(url: url.url!)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "content-type")
|
|
request.httpBody = try! JSONEncoder().encode(PushRegistrationParams(transactionID: "TODO", environment: apnsEnvironment, deviceToken: deviceToken, pushVersion: 1))
|
|
let (data, resp) = try await URLSession.shared.data(for: request)
|
|
let status = (resp as! HTTPURLResponse).statusCode
|
|
guard (200...299).contains(status) else {
|
|
logger.error("Registering: unexpected status \(status)")
|
|
let error = (try? JSONDecoder().decode(ProxyRegistrationError.self, from: data)) ?? ProxyRegistrationError(error: "Unknown error", fields: [])
|
|
throw error
|
|
}
|
|
return try JSONDecoder().decode(PushProxyRegistration.self, from: data)
|
|
}
|
|
|
|
private func update(registration: PushProxyRegistration, deviceToken: String) async throws -> PushProxyRegistration {
|
|
var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
|
|
url.path = "/app/v1/registrations/\(registration.id)"
|
|
var request = URLRequest(url: url.url!)
|
|
request.httpMethod = "PUT"
|
|
request.setValue("application/json", forHTTPHeaderField: "content-type")
|
|
request.httpBody = try! JSONEncoder().encode(PushUpdateParams(environment: apnsEnvironment, deviceToken: deviceToken, pushVersion: 1))
|
|
let (data, resp) = try await URLSession.shared.data(for: request)
|
|
let status = (resp as! HTTPURLResponse).statusCode
|
|
guard (200...299).contains(status) else {
|
|
logger.error("Updating: unexpected status \(status)")
|
|
let error = (try? JSONDecoder().decode(ProxyRegistrationError.self, from: data)) ?? ProxyRegistrationError(error: "Unknown error", fields: [])
|
|
throw error
|
|
}
|
|
return try JSONDecoder().decode(PushProxyRegistration.self, from: data)
|
|
}
|
|
}
|
|
|
|
enum PushRegistrationError: LocalizedError {
|
|
case alreadyRegistering
|
|
case registeringForRemoteNotifications(any Error)
|
|
case registeringWithProxy(any Error)
|
|
case unregistering(any Error)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .alreadyRegistering:
|
|
"Already registering"
|
|
case .registeringForRemoteNotifications(let error):
|
|
"Remote notifications: \(error.localizedDescription)"
|
|
case .registeringWithProxy(let error):
|
|
"Proxy: \(error.localizedDescription)"
|
|
case .unregistering(let error):
|
|
"Unregistering: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ProxyRegistrationError: LocalizedError, Decodable {
|
|
let error: String
|
|
let fields: [Field]?
|
|
|
|
var errorDescription: String? {
|
|
if let fields,
|
|
!fields.isEmpty {
|
|
error + ": " + fields.map { "\($0.key): \($0.reason)" }.joined(separator: ", ")
|
|
} else {
|
|
error
|
|
}
|
|
}
|
|
|
|
struct Field: Decodable {
|
|
let key: String
|
|
let reason: String
|
|
}
|
|
}
|
|
|
|
private struct PushRegistrationParams: Encodable {
|
|
let transactionID: String
|
|
let environment: String
|
|
let deviceToken: String
|
|
let pushVersion: Int
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case transactionID = "transaction_id"
|
|
case environment
|
|
case deviceToken = "device_token"
|
|
case pushVersion = "push_version"
|
|
}
|
|
}
|
|
|
|
private struct PushUpdateParams: Encodable {
|
|
let environment: String
|
|
let deviceToken: String
|
|
let pushVersion: Int
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case environment
|
|
case deviceToken = "device_token"
|
|
case pushVersion = "push_version"
|
|
}
|
|
}
|
|
|
|
struct PushProxyRegistration: Decodable {
|
|
let id: String
|
|
let endpoint: URL
|
|
let deviceToken: String
|
|
|
|
fileprivate var defaultsDict: [String: String] {
|
|
[
|
|
"id": id,
|
|
"endpoint": endpoint.absoluteString,
|
|
"deviceToken": deviceToken,
|
|
]
|
|
}
|
|
|
|
fileprivate init?(defaultsDict: [String: String]) {
|
|
guard let id = defaultsDict["id"],
|
|
let endpoint = defaultsDict["endpoint"].flatMap(URL.init(string:)),
|
|
let deviceToken = defaultsDict["deviceToken"] else {
|
|
return nil
|
|
}
|
|
self.id = id
|
|
self.endpoint = endpoint
|
|
self.deviceToken = deviceToken
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id
|
|
case endpoint
|
|
case deviceToken = "device_token"
|
|
}
|
|
}
|
|
|
|
private extension Data {
|
|
func hexEncodedString() -> String {
|
|
String(unsafeUninitializedCapacity: count * 2) { buffer in
|
|
let chars = Array("0123456789ABCDEF".utf8)
|
|
for (i, x) in enumerated() {
|
|
let (upper, lower) = x.quotientAndRemainder(dividingBy: 16)
|
|
buffer[i * 2] = chars[Int(upper)]
|
|
buffer[i * 2 + 1] = chars[Int(lower)]
|
|
}
|
|
return count * 2
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PushSubscription {
|
|
let accountID: String
|
|
let endpoint: URL
|
|
let alerts: Alerts
|
|
let policy: Policy
|
|
|
|
fileprivate var defaultsDict: [String: Any] {
|
|
[
|
|
"accountID": accountID,
|
|
"endpoint": endpoint.absoluteString,
|
|
"alerts": alerts.rawValue,
|
|
"policy": policy.rawValue
|
|
]
|
|
}
|
|
|
|
init?(defaultsDict: [String: Any]) {
|
|
guard let accountID = defaultsDict["accountID"] as? String,
|
|
let endpoint = (defaultsDict["endpoint"] as? String).flatMap(URL.init(string:)),
|
|
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.alerts = Alerts(rawValue: alerts)
|
|
self.policy = policy
|
|
}
|
|
|
|
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)
|
|
|
|
let rawValue: Int
|
|
}
|
|
}
|