XuqmGroup-iOSSDK/Sources/XuqmBugCollectSDK/BugCollectSDK.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

310 行
9.8 KiB
Swift

import Foundation
#if canImport(UIKit)
import UIKit
#endif
import XuqmCoreSDK
/// BugCollect SDK
@MainActor
public final class BugCollectSDK {
public static let shared = BugCollectSDK()
private var logLevel: LogLevel = .warn
private var environment: String = "production"
private var queue: LogQueue?
private var crashCapture: CrashCapture?
private var breadcrumbBuffer: [Breadcrumb] = []
private let breadcrumbLock = NSLock()
private let maxBreadcrumbs = 50
private var pendingErrors: [IssueEvent] = []
private let pendingErrorsLock = NSLock()
private let sdkName = "bugcollect.ios"
private let sdkVersion = "1.0.0"
private init() {}
// MARK: - Public API
///
public func setLogLevel(_ level: LogLevel) {
self.logLevel = level
}
///
public func setEnvironment(_ env: String) {
self.environment = env
}
///
public func addBreadcrumb(
category: String,
message: String,
level: String = "info",
data: [String: Any?] = [:]
) {
breadcrumbLock.lock()
defer { breadcrumbLock.unlock() }
if breadcrumbBuffer.count >= maxBreadcrumbs {
breadcrumbBuffer.removeFirst()
}
breadcrumbBuffer.append(Breadcrumb(
timestamp: Int(Date().timeIntervalSince1970 * 1000),
category: category,
message: message,
level: level,
data: data
))
}
///
public func event(name: String, properties: [String: Any?] = [:]) {
guard isReady() else { return }
let logEvent = LogEvent(
name: name,
properties: properties,
appKey: XuqmSDK.shared.requireConfig().appKey,
userId: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId,
platform: "ios",
release: appVersion(),
environment: environment,
sdk: IssueEvent.SdkInfo(name: sdkName, version: sdkVersion)
)
queue?.push(logEvent)
FunnelTracker.shared.track(name: name, properties: properties)
}
///
public func captureError(_ error: Error, tags: [String: Any?] = [:], fingerprint: String? = nil) {
let crumbs = breadcrumbLock.lockAndRun { breadcrumbBuffer }
let fp = fingerprint ?? Fingerprint.compute(
type: String(describing: type(of: error)),
message: error.localizedDescription,
stacktrace: Thread.callStackSymbols.joined(separator: "\n")
)
let issueEvent = IssueEvent(
level: "error",
platform: "ios",
fingerprint: fp,
appKey: XuqmSDK.shared.requireConfig().appKey,
exception: IssueEvent.ExceptionInfo(
type: String(describing: type(of: error)),
value: error.localizedDescription,
stacktrace: Thread.callStackSymbols.joined(separator: "\n")
),
breadcrumbs: crumbs,
release: appVersion(),
environment: environment,
userId: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId,
user: IssueEvent.UserInfo(id: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId),
device: buildDeviceInfo(),
tags: tags
)
if !isReady() {
pendingErrorsLock.lock()
pendingErrors.append(issueEvent)
pendingErrorsLock.unlock()
return
}
flushPendingErrors()
queue?.push(issueEvent)
}
///
public func captureCrash(_ error: Error, tags: [String: Any?] = [:]) {
guard isReady() else { return }
let crumbs = breadcrumbLock.lockAndRun { breadcrumbBuffer }
let issueEvent = IssueEvent(
level: "fatal",
platform: "ios",
fingerprint: Fingerprint.compute(
type: String(describing: type(of: error)),
message: error.localizedDescription,
stacktrace: Thread.callStackSymbols.joined(separator: "\n")
),
appKey: XuqmSDK.shared.requireConfig().appKey,
exception: IssueEvent.ExceptionInfo(
type: String(describing: type(of: error)),
value: error.localizedDescription,
stacktrace: Thread.callStackSymbols.joined(separator: "\n")
),
breadcrumbs: crumbs,
release: appVersion(),
environment: environment,
userId: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId,
user: IssueEvent.UserInfo(id: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId),
device: buildDeviceInfo(),
tags: tags
)
queue?.push(issueEvent)
}
///
public func warn(_ message: String, tags: [String: Any?] = [:]) {
guard logLevel.rawValue <= LogLevel.warn.rawValue else { return }
guard isReady() else { return }
let crumbs = breadcrumbLock.lockAndRun { breadcrumbBuffer }
let issueEvent = IssueEvent(
level: "warning",
platform: "ios",
fingerprint: Fingerprint.compute(type: "Warning", message: message, stacktrace: ""),
appKey: XuqmSDK.shared.requireConfig().appKey,
exception: IssueEvent.ExceptionInfo(type: "Warning", value: message),
breadcrumbs: crumbs,
release: appVersion(),
environment: environment,
userId: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId,
user: IssueEvent.UserInfo(id: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId),
device: buildDeviceInfo(),
tags: tags
)
queue?.push(issueEvent)
}
///
public func info(_ message: String, tags: [String: Any?] = [:]) {
guard logLevel.rawValue <= LogLevel.info.rawValue else { return }
event(name: "__log_info", properties: tags.merging(["message": message]) { _, new in new })
}
// MARK: - Lifecycle
///
public func startCrashCapture() {
guard crashCapture == nil else { return }
crashCapture = CrashCapture { [weak self] exception, stacktrace in
self?.handleCrash(exception: exception, stacktrace: stacktrace)
}
crashCapture?.install()
}
///
public func startQueue() {
guard queue == nil else { return }
let config = XuqmSDK.shared.requireConfig()
queue = LogQueue(appKey: config.appKey, sdkName: sdkName, sdkVersion: sdkVersion)
queue?.start()
}
// MARK: - Private
private func isReady() -> Bool {
XuqmSDK.shared.initialized && queue != nil
}
private func flushPendingErrors() {
pendingErrorsLock.lock()
let errors = pendingErrors
pendingErrors = []
pendingErrorsLock.unlock()
for error in errors {
queue?.push(error)
}
}
private func handleCrash(exception: NSException, stacktrace: String) {
let crumbs = breadcrumbLock.lockAndRun { breadcrumbBuffer }
let issueEvent = IssueEvent(
level: "fatal",
platform: "ios",
fingerprint: Fingerprint.compute(
type: exception.name.rawValue,
message: exception.reason ?? "",
stacktrace: stacktrace
),
appKey: XuqmSDK.shared.requireConfig().appKey,
exception: IssueEvent.ExceptionInfo(
type: exception.name.rawValue,
value: exception.reason ?? "",
stacktrace: stacktrace
),
breadcrumbs: crumbs,
release: appVersion(),
environment: environment,
userId: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId,
user: IssueEvent.UserInfo(id: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId),
device: buildDeviceInfo(),
tags: [:]
)
//
LogStorage.saveCrash(issueEvent)
}
private func appVersion() -> String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
}
private func buildDeviceInfo() -> IssueEvent.DeviceInfo {
let processInfo = ProcessInfo.processInfo
let freeMemoryMb = Int(processInfo.physicalMemory / 1024 / 1024)
#if canImport(UIKit)
let device = UIDevice.current
return IssueEvent.DeviceInfo(
model: device.model,
manufacturer: "Apple",
osName: device.systemName,
osVersion: device.systemVersion,
locale: Locale.current.identifier,
timezone: TimeZone.current.identifier,
isEmulator: isSimulator(),
freeMemoryMb: freeMemoryMb,
buildType: isDebug() ? "debug" : "release"
)
#else
return IssueEvent.DeviceInfo(
model: "Mac",
manufacturer: "Apple",
osName: "macOS",
osVersion: ProcessInfo.processInfo.operatingSystemVersionString,
locale: Locale.current.identifier,
timezone: TimeZone.current.identifier,
isEmulator: false,
freeMemoryMb: freeMemoryMb,
buildType: isDebug() ? "debug" : "release"
)
#endif
}
private func isSimulator() -> Bool {
#if targetEnvironment(simulator)
return true
#else
return false
#endif
}
private func isDebug() -> Bool {
#if DEBUG
return true
#else
return false
#endif
}
}
// MARK: - NSLock Extension
private extension NSLock {
func lockAndRun<T>(_ block: () -> T) -> T {
lock()
defer { unlock() }
return block()
}
}