XuqmGroup-iOSSDK/Sources/XuqmCoreSDK/XuqmSDK.swift
XuqmGroup 85dd1b2b99 feat: iOS SDK 完善 — XWebView 双桥接 + BugCollect/Update macOS 兼容 + CI 流水线
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>
2026-06-29 11:28:18 +08:00

224 行
7.3 KiB
Swift

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

import Foundation
#if canImport(UIKit)
import UIKit
#endif
/// Decrypted content of the init config file (xuqm/config.xuqm).
/// Contains the appKey and optional server URL needed to bootstrap the SDK.
/// This is separate from XuqmLicenseSDK's LicenseFile (device activation).
public struct ConfigFileData: Codable, Sendable {
public let appKey: String
public let appName: String?
public let companyName: String?
public let packageName: String?
public let iosBundleId: String?
public let harmonyBundleName: String?
public let baseUrl: String?
public let serverUrl: String?
public let issuedAt: String?
public let expiresAt: String?
public init(
appKey: String,
appName: String? = nil,
companyName: String? = nil,
packageName: String? = nil,
iosBundleId: String? = nil,
harmonyBundleName: String? = nil,
baseUrl: String? = nil,
serverUrl: String? = nil,
issuedAt: String? = nil,
expiresAt: String? = nil
) {
self.appKey = appKey
self.appName = appName
self.companyName = companyName
self.packageName = packageName
self.iosBundleId = iosBundleId
self.harmonyBundleName = harmonyBundleName
self.baseUrl = baseUrl
self.serverUrl = serverUrl
self.issuedAt = issuedAt
self.expiresAt = expiresAt
}
}
@MainActor
public final class XuqmSDK: NSObject {
public static let shared = XuqmSDK()
public private(set) var config: SDKConfig?
public private(set) var tokenStore: TokenStore?
public private(set) var currentUserId: String?
public private(set) var currentNickname: String?
public private(set) var currentAvatar: String?
private var userSig: String?
public private(set) var cachedDeviceToken: String?
private var lastInitializedAppKey: String?
private var isInitialized = false
private var initContinuations: [CheckedContinuation<Void, Never>] = []
// Module hooks set by the convenience XuqmSDK module to wire feature modules
public var onInitialize: (@MainActor @Sendable (SDKConfig) -> Void)?
public var onLogin: (@MainActor @Sendable (String, String) async throws -> Void)?
public var onLogout: (@MainActor @Sendable (String) async throws -> Void)?
public var onDeviceToken: (@MainActor @Sendable (String, String) async throws -> Void)?
private override init() {
super.init()
}
/// Auto-initialize from the embedded config file (xuqm/config.xuqm).
/// Reads and decrypts the config file directly no external hooks needed.
public func autoInitialize(debug: Bool = false) throws {
guard let file = ConfigFileReader.read() else {
throw XuqmSDKError.noConfigFile
}
let bundleId = Bundle.main.bundleIdentifier ?? ""
if let configBundle = file.iosBundleId, !configBundle.isEmpty, configBundle != bundleId {
throw XuqmSDKError.packageMismatch(license: configBundle, local: bundleId)
}
if let baseUrl = file.baseUrl {
SDKEndpoints.configure(apiBaseURL: baseUrl)
}
if let serverUrl = file.serverUrl {
SDKEndpoints.configure(imWebSocketURL: serverUrl + "/ws/im")
}
let cfg = SDKConfig(appKey: file.appKey, debug: debug)
initialize(config: cfg)
}
public func initialize(config: SDKConfig) {
if let lastInitializedAppKey, lastInitializedAppKey == config.appKey {
return
}
self.config = config
self.lastInitializedAppKey = config.appKey
self.tokenStore = TokenStore()
ApiClient.shared.configure(with: config)
isInitialized = true
let continuations = initContinuations
initContinuations.removeAll()
for cont in continuations {
cont.resume()
}
onInitialize?(config)
}
public func requireConfig() -> SDKConfig {
guard let config else {
fatalError("XuqmSDK not initialized. Call XuqmSDK.shared.initialize() first.")
}
return config
}
public var initialized: Bool { isInitialized }
// MARK: - Convenience accessors (used by BugCollectSDK / WebViewSDK)
/// Alias for `currentUserId` expected name in BugCollectSDK and WebViewSDK.
public var userId: String? { currentUserId }
/// Auth token stored in the Keychain.
/// `nonisolated` so it can be read from background threads (e.g. LogUploader).
public nonisolated var token: String? { TokenStore().get() }
/// Persistent device identifier (IDFV on iOS, UserDefaults-backed UUID on macOS).
public var deviceId: String {
#if canImport(UIKit)
return UIDevice.current.identifierForVendor?.uuidString ?? persistedDeviceId()
#else
return persistedDeviceId()
#endif
}
private func persistedDeviceId() -> String {
let key = "xuqm_sdk_device_id"
if let saved = UserDefaults.standard.string(forKey: key) { return saved }
let new = UUID().uuidString
UserDefaults.standard.set(new, forKey: key)
return new
}
public func awaitInitialization() async {
guard !isInitialized else { return }
await withCheckedContinuation { cont in
initContinuations.append(cont)
}
}
public func login(userId: String, userSig: String) async {
if currentUserId == userId && self.userSig == userSig {
return
}
self.currentUserId = userId
self.userSig = userSig
do {
try await onLogin?(userId, userSig)
} catch {
// Module login failed; silently ignored per facade pattern
}
if let cachedDeviceToken {
do {
try await onDeviceToken?(cachedDeviceToken, userId)
} catch {
// Device token registration failed
}
}
}
public func logout() async {
if let userId = currentUserId {
do {
try await onLogout?(userId)
} catch {
// Module logout failed
}
}
tokenStore?.clear()
currentUserId = nil
userSig = nil
cachedDeviceToken = nil
}
///
///
/// - Parameters:
/// - userId:
/// - nickname:
/// - avatar: URL
public func setUserInfo(userId: String, nickname: String? = nil, avatar: String? = nil) {
self.currentUserId = userId
self.currentNickname = nickname
self.currentAvatar = avatar
}
///
public func clearUserInfo() {
self.currentUserId = nil
self.currentNickname = nil
self.currentAvatar = nil
}
public func registerDeviceToken(_ deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
self.cachedDeviceToken = token
Task { @MainActor in
if let userId = self.currentUserId {
try? await self.onDeviceToken?(token, userId)
}
}
}
}
public enum XuqmSDKError: Error {
case noConfigFile
case noLicenseFile
case packageMismatch(license: String, local: String)
}