import Foundation import XuqmCoreSDK #if canImport(UIKit) import UIKit #endif /// 更新信息 public struct AppUpdateInfo: Decodable, Sendable { public let needsUpdate: Bool public let versionName: String? public let versionCode: Int? public let downloadUrl: String? public let changeLog: String? public let forceUpdate: Bool public let appStoreUrl: String? public let marketUrl: String? public let requiresLogin: Bool public let alreadyDownloaded: Bool public let apkHash: String? enum CodingKeys: String, CodingKey { case needsUpdate, versionName, versionCode, downloadUrl, changeLog case forceUpdate, appStoreUrl, marketUrl, requiresLogin, apkHash } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) needsUpdate = try container.decodeIfPresent(Bool.self, forKey: .needsUpdate) ?? false versionName = try container.decodeIfPresent(String.self, forKey: .versionName) versionCode = try container.decodeIfPresent(Int.self, forKey: .versionCode) downloadUrl = try container.decodeIfPresent(String.self, forKey: .downloadUrl) changeLog = try container.decodeIfPresent(String.self, forKey: .changeLog) forceUpdate = try container.decodeIfPresent(Bool.self, forKey: .forceUpdate) ?? false appStoreUrl = try container.decodeIfPresent(String.self, forKey: .appStoreUrl) marketUrl = try container.decodeIfPresent(String.self, forKey: .marketUrl) requiresLogin = try container.decodeIfPresent(Bool.self, forKey: .requiresLogin) ?? false apkHash = try container.decodeIfPresent(String.self, forKey: .apkHash) alreadyDownloaded = false } public init(needsUpdate: Bool, versionName: String? = nil, versionCode: Int? = nil, downloadUrl: String? = nil, changeLog: String? = nil, forceUpdate: Bool = false, appStoreUrl: String? = nil, marketUrl: String? = nil, requiresLogin: Bool = false, alreadyDownloaded: Bool = false, apkHash: String? = nil) { self.needsUpdate = needsUpdate self.versionName = versionName self.versionCode = versionCode self.downloadUrl = downloadUrl self.changeLog = changeLog self.forceUpdate = forceUpdate self.appStoreUrl = appStoreUrl self.marketUrl = marketUrl self.requiresLogin = requiresLogin self.alreadyDownloaded = alreadyDownloaded self.apkHash = apkHash } } /// 更新监听器 public protocol UpdateListener: AnyObject, Sendable { func onUpdateAvailable(_ updateInfo: AppUpdateInfo) func onNoUpdate() func onError(_ error: Error) } /// 更新 SDK @MainActor public final class UpdateSDK { public static let shared = UpdateSDK() private init() {} private static let cacheKey = "xuqm_update_cache" private static let ignoredPrefix = "xuqm_ignored_v" private static let cacheTtl: TimeInterval = 30 * 60 // 30 minutes /// 检查更新 /// - Parameters: /// - bypassIgnore: 是否忽略已忽略的版本 /// - currentVersionCode: 当前版本号(可选,默认从 Bundle 读取) public func checkAppUpdate(bypassIgnore: Bool = false, currentVersionCode: Int? = nil) async throws -> AppUpdateInfo { await XuqmSDK.shared.awaitInitialization() let config = XuqmSDK.shared.requireConfig() let userId = XuqmSDK.shared.currentUserId let versionCode = currentVersionCode ?? Bundle.main.versionCode // Check cache (skip if bypassIgnore) if !bypassIgnore { if let cached = readCache(appKey: config.appKey, versionCode: versionCode, userId: userId) { return cached } } let deviceInfo = getDeviceInfo() let versionName = Bundle.main.versionName var queryItems = [ URLQueryItem(name: "appKey", value: config.appKey), URLQueryItem(name: "platform", value: "IOS"), URLQueryItem(name: "currentVersionCode", value: "\(versionCode)"), URLQueryItem(name: "currentVersionName", value: versionName), URLQueryItem(name: "deviceId", value: deviceInfo.deviceId), URLQueryItem(name: "model", value: deviceInfo.model), URLQueryItem(name: "osVersion", value: deviceInfo.osVersion), URLQueryItem(name: "vendor", value: "APNS"), ] if let userId, !userId.isEmpty { queryItems.append(URLQueryItem(name: "userId", value: userId)) } if bypassIgnore { queryItems.append(URLQueryItem(name: "bypassIgnore", value: "true")) } var info: AppUpdateInfo = try await ApiClient.shared.request( path: "/api/v1/updates/app/check", queryItems: queryItems ) // Apply version ignore if !bypassIgnore && info.needsUpdate && !info.forceUpdate, let vc = info.versionCode { if isVersionIgnored(vc) { return AppUpdateInfo(needsUpdate: false) } } // Write cache writeCache(appKey: config.appKey, versionCode: versionCode, userId: userId, info: info) return info } /// 打开 App Store public func openAppStore(url: String) { guard let storeURL = URL(string: url) else { return } #if canImport(UIKit) UIApplication.shared.open(storeURL) #endif } /// 忽略版本 public func ignoreVersion(_ versionCode: Int) { UserDefaults.standard.set(true, forKey: "\(Self.ignoredPrefix)\(versionCode)") } /// 清除所有忽略的版本 public func clearIgnoredVersions() { let keys = UserDefaults.standard.dictionaryRepresentation().keys for key in keys where key.hasPrefix(Self.ignoredPrefix) { UserDefaults.standard.removeObject(forKey: key) } } /// 检查版本是否被忽略 public func isVersionIgnored(_ versionCode: Int) -> Bool { UserDefaults.standard.bool(forKey: "\(Self.ignoredPrefix)\(versionCode)") } // MARK: - Device Info private struct DeviceInfo { let deviceId: String let model: String let osVersion: String } private func getDeviceInfo() -> DeviceInfo { let device = UIDevice.current let deviceId = device.identifierForVendor?.uuidString ?? "" let model = device.model let osVersion = "iOS \(device.systemVersion)" return DeviceInfo(deviceId: deviceId, model: model, osVersion: osVersion) } // MARK: - Cache private func readCache(appKey: String, versionCode: Int, userId: String?) -> AppUpdateInfo? { let key = "\(Self.cacheKey)_\(appKey)_\(versionCode)_\(userId ?? "")" guard let data = UserDefaults.standard.dictionary(forKey: key) else { return nil } guard let ts = data["ts"] as? TimeInterval else { return nil } guard Date().timeIntervalSince1970 - ts < Self.cacheTtl else { return nil } guard let infoData = data["data"] as? [String: Any] else { return nil } return AppUpdateInfo( needsUpdate: infoData["needsUpdate"] as? Bool ?? false, versionName: infoData["versionName"] as? String, versionCode: infoData["versionCode"] as? Int, downloadUrl: infoData["downloadUrl"] as? String, changeLog: infoData["changeLog"] as? String, forceUpdate: infoData["forceUpdate"] as? Bool ?? false, appStoreUrl: infoData["appStoreUrl"] as? String, marketUrl: infoData["marketUrl"] as? String, requiresLogin: infoData["requiresLogin"] as? Bool ?? false, apkHash: infoData["apkHash"] as? String ) } private func writeCache(appKey: String, versionCode: Int, userId: String?, info: AppUpdateInfo) { let key = "\(Self.cacheKey)_\(appKey)_\(versionCode)_\(userId ?? "")" let data: [String: Any] = [ "ts": Date().timeIntervalSince1970, "data": [ "needsUpdate": info.needsUpdate, "versionName": info.versionName as Any, "versionCode": info.versionCode as Any, "downloadUrl": info.downloadUrl as Any, "changeLog": info.changeLog as Any, "forceUpdate": info.forceUpdate, "appStoreUrl": info.appStoreUrl as Any, "marketUrl": info.marketUrl as Any, "requiresLogin": info.requiresLogin, "apkHash": info.apkHash as Any, ] as [String: Any] ] UserDefaults.standard.set(data, forKey: key) } } // MARK: - Bundle Extension private extension Bundle { var versionCode: Int { Int(infoDictionary?["CFBundleVersion"] as? String ?? "0") ?? 0 } var versionName: String { infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" } }