From 71c4a9f2d1d3ca4c153e2da1eaf09b47e19f2964 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 19 Jun 2026 14:13:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20iOS=20UpdateSDK=20=E5=A2=9E=E5=BC=BA=20?= =?UTF-8?q?-=20=E5=AF=B9=E9=BD=90=20Android/RN=20=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: - 设备信息上报(deviceId, model, osVersion, vendor=APNS) - 更新缓存(30分钟 TTL,UserDefaults) - 版本忽略机制(ignoreVersion, clearIgnoredVersions, isVersionIgnored) - bypassIgnore 参数 - requiresLogin 字段 - downloadUrl/marketUrl 字段 - currentVersionName 上报 - UpdateListener 协议 - Bundle 扩展(versionCode, versionName) Co-Authored-By: Claude --- Sources/XuqmUpdateSDK/UpdateSDK.swift | 190 +++++++++++++++++++++++++- 1 file changed, 186 insertions(+), 4 deletions(-) diff --git a/Sources/XuqmUpdateSDK/UpdateSDK.swift b/Sources/XuqmUpdateSDK/UpdateSDK.swift index 9952a1b..4c11903 100644 --- a/Sources/XuqmUpdateSDK/UpdateSDK.swift +++ b/Sources/XuqmUpdateSDK/UpdateSDK.swift @@ -4,43 +4,225 @@ import XuqmCoreSDK 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 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() {} - public func checkAppUpdate(currentVersionCode: Int) async throws -> AppUpdateInfo { + 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: "\(currentVersionCode)"), + 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)) } - return try await ApiClient.shared.request( + 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" + } }