// // 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? 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 } }