import Foundation import UserNotifications #if canImport(UIKit) import UIKit #endif public enum PushVendor: String { case apns = "APNS" case fcm = "FCM" } public struct PushMessage: Sendable { public let title: String? public let body: String? public let payload: [String: Any] public let rawUserInfo: [AnyHashable: Any] } public protocol PushMessageDelegate: AnyObject, Sendable { func pushSDK(_ sdk: PushSDK, didReceiveMessage message: PushMessage) func pushSDK(_ sdk: PushSDK, didTapNotification message: PushMessage) } @MainActor public final class PushSDK: NSObject, UNUserNotificationCenterDelegate { public static let shared = PushSDK() public weak var delegate: PushMessageDelegate? private override init() { super.init() } @MainActor public func requestAuthorization(options: UNAuthorizationOptions = [.alert, .badge, .sound]) async throws -> Bool { let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: options) #if canImport(UIKit) if granted { await MainActor.run { UIApplication.shared.registerForRemoteNotifications() } } #endif return granted } public func registerDeviceToken(_ deviceToken: Data, userId: String) async throws { let token = deviceToken.map { String(format: "%02x", $0) }.joined() try await registerToken(token, userId: userId) } public func registerToken(_ token: String, userId: String, vendor: PushVendor = .apns) async throws { let config = XuqmSDK.shared.requireConfig() let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/push/register", method: "POST", queryItems: [ URLQueryItem(name: "appId", value: config.appId), URLQueryItem(name: "userId", value: userId), URLQueryItem(name: "vendor", value: vendor.rawValue), URLQueryItem(name: "token", value: token), ] ) } public func registerFcmToken(_ token: String, userId: String) async throws { try await registerToken(token, userId: userId, vendor: .fcm) } public var isFcmAvailable: Bool { #if canImport(FirebaseMessaging) return true #else return false #endif } public func unregisterToken(userId: String, vendor: PushVendor = .apns) async throws { let config = XuqmSDK.shared.requireConfig() let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/push/unregister", method: "DELETE", queryItems: [ URLQueryItem(name: "appId", value: config.appId), URLQueryItem(name: "userId", value: userId), URLQueryItem(name: "vendor", value: vendor.rawValue), ] ) } public func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { let message = parseMessage(notification.request.content) if let message = message { delegate?.pushSDK(self, didReceiveMessage: message) } completionHandler([.banner, .sound, .badge]) } public func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { let message = parseMessage(response.notification.request.content) if let message = message { delegate?.pushSDK(self, didTapNotification: message) } completionHandler() } private func parseMessage(_ content: UNNotificationContent) -> PushMessage? { let userInfo = content.userInfo let aps = userInfo["aps"] as? [String: Any] let alert = aps?["alert"] as? [String: Any] let title = content.title.isEmpty ? (alert?["title"] as? String) : content.title let body = content.body.isEmpty ? (alert?["body"] as? String) : content.body return PushMessage( title: title, body: body, payload: userInfo.compactMapKeys { $0 as? String }, rawUserInfo: userInfo ) } } private extension Dictionary { func compactMapKeys(_ transform: (Key) -> T?) -> [T: Value] { var result: [T: Value] = [:] for (key, value) in self { if let newKey = transform(key) { result[newKey] = value } } return result } }