import Foundation import UserNotifications #if canImport(UIKit) import UIKit #endif #if canImport(ObjectiveC) import ObjectiveC #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 var cachedToken: String? private var cachedPushConfigAt: Date? private var cachedPushConfig: PushRuntimeConfig? private override init() { super.init() } public func start(options: UNAuthorizationOptions = [.alert, .badge, .sound]) async throws { UNUserNotificationCenter.current().delegate = self #if canImport(UIKit) && canImport(ObjectiveC) PushAppDelegateInterceptor.install() #endif try? await applyTenantConfiguration() _ = try await requestAuthorization(options: options) } @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() try? await applyTenantConfiguration() cachedToken = token let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/push/register", method: "POST", queryItems: [ URLQueryItem(name: "appKey", value: config.appKey), URLQueryItem(name: "userId", value: userId), URLQueryItem(name: "vendor", value: vendor.rawValue), URLQueryItem(name: "token", value: token), URLQueryItem(name: "deviceId", value: deviceId), URLQueryItem(name: "brand", value: "Apple"), URLQueryItem(name: "model", value: deviceModel), URLQueryItem(name: "osVersion", value: osVersion), URLQueryItem(name: "platform", value: "IOS"), URLQueryItem(name: "appVersion", value: appVersion), ] ) } 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 categoryIdentifier(for routeType: String) async -> String? { if cachedPushConfig == nil { try? await applyTenantConfiguration() } return cachedPushConfig?.routing[routeType]?.category } 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: "appKey", value: config.appKey), URLQueryItem(name: "userId", value: userId), URLQueryItem(name: "vendor", value: vendor.rawValue), URLQueryItem(name: "deviceId", value: deviceId), ] ) } 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 func applyTenantConfiguration(force: Bool = false) async throws { if !force, let cachedPushConfigAt, Date().timeIntervalSince(cachedPushConfigAt) < 300 { return } let config = XuqmSDK.shared.requireConfig() let response: SdkRuntimeConfigResponse = try await ApiClient.shared.request( path: "/api/sdk/config", queryItems: [ URLQueryItem(name: "appKey", value: config.appKey), URLQueryItem(name: "platform", value: "IOS"), ] ) cachedPushConfig = response.pushConfig cachedPushConfigAt = Date() registerNotificationCategories(response.pushConfig) } private func registerNotificationCategories(_ config: PushRuntimeConfig?) { let categoryIds = Set(config?.routing.values.compactMap { $0.category.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0.category } ?? []) guard !categoryIds.isEmpty else { return } let categories = categoryIds.map { categoryId in UNNotificationCategory( identifier: categoryId, actions: [], intentIdentifiers: [], options: [] ) } UNUserNotificationCenter.current().setNotificationCategories(Set(categories)) } } private struct SdkRuntimeConfigResponse: Decodable { let pushConfig: PushRuntimeConfig? } private struct PushRuntimeConfig: Decodable { let channels: [PushChannelConfig] let routing: [String: PushRouteConfig] init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) channels = try container.decodeIfPresent([PushChannelConfig].self, forKey: .channels) ?? [] routing = try container.decodeIfPresent([String: PushRouteConfig].self, forKey: .routing) ?? [:] } private enum CodingKeys: String, CodingKey { case channels case routing } } private struct PushChannelConfig: Decodable { let key: String let channelId: String let version: Int let name: String let description: String? let importance: String let sound: Bool let vibration: Bool let badge: Bool } private struct PushRouteConfig: Decodable { let channel: String let category: String let priority: String } #if canImport(UIKit) && canImport(ObjectiveC) private final class OriginalImpBox { let imp: IMP init(_ imp: IMP) { self.imp = imp } } private var didRegisterOriginalImpKey: UInt8 = 0 private final class PushAppDelegateInterceptor: NSObject { private static let lock = NSLock() private static var installedClassNames: Set = [] static func install() { guard let delegate = UIApplication.shared.delegate else { return } let delegateClass: AnyClass = object_getClass(delegate) ?? type(of: delegate) let className = NSStringFromClass(delegateClass) lock.lock() let alreadyInstalled = installedClassNames.contains(className) if !alreadyInstalled { installedClassNames.insert(className) } lock.unlock() guard !alreadyInstalled else { return } installDidRegisterHandler(on: delegateClass) } private static func installDidRegisterHandler(on delegateClass: AnyClass) { let originalSelector = #selector( UIApplicationDelegate.application( _:didRegisterForRemoteNotificationsWithDeviceToken: ) ) let interceptorSelector = #selector( PushAppDelegateInterceptor.xuqm_application( _:didRegisterForRemoteNotificationsWithDeviceToken: ) ) guard let interceptorMethod = class_getInstanceMethod(Self.self, interceptorSelector) else { return } if let originalMethod = class_getInstanceMethod(delegateClass, originalSelector) { objc_setAssociatedObject( delegateClass, &didRegisterOriginalImpKey, OriginalImpBox(method_getImplementation(originalMethod)), .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) } class_replaceMethod( delegateClass, originalSelector, method_getImplementation(interceptorMethod), method_getTypeEncoding(interceptorMethod) ) } @objc private func xuqm_application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { Task { @MainActor in XuqmSDK.shared.registerDeviceToken(deviceToken) } callOriginalDidRegister(application, deviceToken: deviceToken) } private func callOriginalDidRegister(_ application: UIApplication, deviceToken: Data) { guard let box = objc_getAssociatedObject( object_getClass(self) ?? type(of: self), &didRegisterOriginalImpKey ) as? OriginalImpBox else { return } typealias DidRegisterImp = @convention(c) ( AnyObject, Selector, UIApplication, Data ) -> Void let original = unsafeBitCast(box.imp, to: DidRegisterImp.self) original( self, #selector( UIApplicationDelegate.application( _:didRegisterForRemoteNotificationsWithDeviceToken: ) ), application, deviceToken ) } } #endif private extension PushSDK { var deviceId: String { #if canImport(UIKit) return UIDevice.current.identifierForVendor?.uuidString ?? "ios-device" #else return "ios-device" #endif } var deviceModel: String { #if canImport(UIKit) return UIDevice.current.model #else return "Apple" #endif } var osVersion: String { #if canImport(UIKit) return "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)" #else return "iOS" #endif } var appVersion: String? { let info = Bundle.main.infoDictionary let version = info?["CFBundleShortVersionString"] as? String let build = info?["CFBundleVersion"] as? String switch (version, build) { case let (version?, build?): return "\(version)(\(build))" case let (version?, nil): return version default: return nil } } } 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 } }