XuqmCoreSDK - 新增 userId / token(nonisolated) / deviceId 公开属性,对齐 BugCollectSDK / WebViewSDK 引用 XuqmWebViewSDK - XWebViewTypes:新增 jsBridgeName / onClose / onPageError / onProgressChanged / onNavigationChanged - XWebViewView:注册双 bridge(ReactNativeWebView 内部 + 自定义 jsBridgeName)、KVO progress、nav delegate - XWebViewStandardHandlers:@MainActor,injectedJavaScript 改 nonisolated,兼容 macOS - 新增 import XuqmCoreSDK XuqmBugCollectSDK - 全局 #if canImport(UIKit) 兼容 macOS 编译 - isReady 从 computed var 改为 func 消除歧义 - LogQueue 修正 LogStorage 为 enum 静态方法调用 XuqmUpdateSDK - getDeviceInfo() #if canImport(UIKit) 兼容 macOS XuqmLicenseSDK - ensureInitializedFromSDK() 用 DispatchQueue.main.sync 访问 @MainActor 属性 CI - 重写 Jenkinsfile:增加 SNAPSHOT/Release 模式,统一版本号管理,修正 git URL,对齐 Android 流水线 - 新增 version.properties:统一追踪 SDK_VERSION - 更新 .gitignore:移除 JS/Java 噪音,补 Swift/Xcode 规则 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
234 行
9.0 KiB
Swift
234 行
9.0 KiB
Swift
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 {
|
||
#if canImport(UIKit)
|
||
let device = UIDevice.current
|
||
return DeviceInfo(
|
||
deviceId: device.identifierForVendor?.uuidString ?? "",
|
||
model: device.model,
|
||
osVersion: "iOS \(device.systemVersion)"
|
||
)
|
||
#else
|
||
return DeviceInfo(deviceId: XuqmSDK.shared.deviceId, model: "Mac", osVersion: ProcessInfo.processInfo.operatingSystemVersionString)
|
||
#endif
|
||
}
|
||
|
||
// 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"
|
||
}
|
||
}
|