2026-04-21 22:07:29 +08:00
|
|
|
import Foundation
|
2026-04-30 19:23:22 +08:00
|
|
|
import UserNotifications
|
|
|
|
|
#if canImport(UIKit)
|
|
|
|
|
import UIKit
|
|
|
|
|
#endif
|
2026-05-05 17:54:59 +08:00
|
|
|
#if canImport(ObjectiveC)
|
|
|
|
|
import ObjectiveC
|
|
|
|
|
#endif
|
2026-04-21 22:07:29 +08:00
|
|
|
|
|
|
|
|
public enum PushVendor: String {
|
|
|
|
|
case apns = "APNS"
|
|
|
|
|
case fcm = "FCM"
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 22:57:55 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 22:07:29 +08:00
|
|
|
@MainActor
|
2026-04-30 19:23:22 +08:00
|
|
|
public final class PushSDK: NSObject, UNUserNotificationCenterDelegate {
|
2026-04-21 22:07:29 +08:00
|
|
|
|
|
|
|
|
public static let shared = PushSDK()
|
2026-05-02 22:57:55 +08:00
|
|
|
public weak var delegate: PushMessageDelegate?
|
2026-05-05 17:54:59 +08:00
|
|
|
private var cachedToken: String?
|
2026-05-05 22:26:32 +08:00
|
|
|
private var cachedPushConfigAt: Date?
|
|
|
|
|
private var cachedPushConfig: PushRuntimeConfig?
|
2026-04-30 19:23:22 +08:00
|
|
|
private override init() {
|
|
|
|
|
super.init()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 17:54:59 +08:00
|
|
|
public func start(options: UNAuthorizationOptions = [.alert, .badge, .sound]) async throws {
|
|
|
|
|
UNUserNotificationCenter.current().delegate = self
|
|
|
|
|
#if canImport(UIKit) && canImport(ObjectiveC)
|
|
|
|
|
PushAppDelegateInterceptor.install()
|
|
|
|
|
#endif
|
2026-05-05 22:26:32 +08:00
|
|
|
try? await applyTenantConfiguration()
|
2026-05-05 17:54:59 +08:00
|
|
|
_ = try await requestAuthorization(options: options)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 19:23:22 +08:00
|
|
|
@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)
|
|
|
|
|
}
|
2026-04-21 22:07:29 +08:00
|
|
|
|
|
|
|
|
public func registerToken(_ token: String, userId: String, vendor: PushVendor = .apns) async throws {
|
|
|
|
|
let config = XuqmSDK.shared.requireConfig()
|
2026-05-05 22:26:32 +08:00
|
|
|
try? await applyTenantConfiguration()
|
2026-05-05 17:54:59 +08:00
|
|
|
cachedToken = token
|
2026-04-21 22:07:29 +08:00
|
|
|
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),
|
2026-05-05 17:54:59 +08:00
|
|
|
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),
|
2026-04-21 22:07:29 +08:00
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-04-30 19:23:22 +08:00
|
|
|
|
2026-05-01 21:27:39 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 22:26:32 +08:00
|
|
|
public func categoryIdentifier(for routeType: String) async -> String? {
|
|
|
|
|
if cachedPushConfig == nil {
|
|
|
|
|
try? await applyTenantConfiguration()
|
|
|
|
|
}
|
|
|
|
|
return cachedPushConfig?.routing[routeType]?.category
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 21:27:39 +08:00
|
|
|
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",
|
2026-05-02 22:57:55 +08:00
|
|
|
method: "DELETE",
|
2026-05-01 21:27:39 +08:00
|
|
|
queryItems: [
|
|
|
|
|
URLQueryItem(name: "appId", value: config.appId),
|
|
|
|
|
URLQueryItem(name: "userId", value: userId),
|
|
|
|
|
URLQueryItem(name: "vendor", value: vendor.rawValue),
|
2026-05-05 17:54:59 +08:00
|
|
|
URLQueryItem(name: "deviceId", value: deviceId),
|
2026-05-01 21:27:39 +08:00
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 19:23:22 +08:00
|
|
|
public func userNotificationCenter(
|
|
|
|
|
_ center: UNUserNotificationCenter,
|
|
|
|
|
willPresent notification: UNNotification,
|
|
|
|
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
|
|
|
|
) {
|
2026-05-02 22:57:55 +08:00
|
|
|
let message = parseMessage(notification.request.content)
|
|
|
|
|
if let message = message {
|
|
|
|
|
delegate?.pushSDK(self, didReceiveMessage: message)
|
|
|
|
|
}
|
2026-04-30 19:23:22 +08:00
|
|
|
completionHandler([.banner, .sound, .badge])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func userNotificationCenter(
|
|
|
|
|
_ center: UNUserNotificationCenter,
|
|
|
|
|
didReceive response: UNNotificationResponse,
|
|
|
|
|
withCompletionHandler completionHandler: @escaping () -> Void
|
|
|
|
|
) {
|
2026-05-02 22:57:55 +08:00
|
|
|
let message = parseMessage(response.notification.request.content)
|
|
|
|
|
if let message = message {
|
|
|
|
|
delegate?.pushSDK(self, didTapNotification: message)
|
|
|
|
|
}
|
2026-04-30 19:23:22 +08:00
|
|
|
completionHandler()
|
|
|
|
|
}
|
2026-05-02 22:57:55 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-05-05 22:26:32 +08:00
|
|
|
|
|
|
|
|
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: "appId", value: config.appId),
|
|
|
|
|
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
|
2026-05-02 22:57:55 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-05 17:54:59 +08:00
|
|
|
#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<String> = []
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 22:57:55 +08:00
|
|
|
private extension Dictionary {
|
|
|
|
|
func compactMapKeys<T: Hashable>(_ transform: (Key) -> T?) -> [T: Value] {
|
|
|
|
|
var result: [T: Value] = [:]
|
|
|
|
|
for (key, value) in self {
|
|
|
|
|
if let newKey = transform(key) {
|
|
|
|
|
result[newKey] = value
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
2026-04-21 22:07:29 +08:00
|
|
|
}
|