feat: 添加 iOS BugCollect 崩溃收集模块
新增模块:XuqmBugCollectSDK - BugCollectSDK: 主入口,错误捕获 API - CrashCapture: 崩溃捕获(NSException + POSIX 信号) - IssueEvent/LogEvent: 数据模型 - Fingerprint: 错误指纹计算(SHA-256) - LogQueue: 内存队列 + 定时上传 - LogStorage: 崩溃文件持久化 - LogUploader: HTTP 上传到服务端 - FunnelTracker: 漏斗追踪 功能: - captureError/captureCrash 捕获错误 - addBreadcrumb 添加面包屑 - event 记录自定义事件 - warn/info 日志级别记录 - 自动初始化(onInitialize hook) Co-Authored-By: Claude <noreply@anthropic.com>
这个提交包含在:
父节点
ae9d033c85
当前提交
227bc66bfd
@ -6,15 +6,16 @@ let package = Package(
|
|||||||
platforms: [.iOS(.v16), .macOS(.v13)],
|
platforms: [.iOS(.v16), .macOS(.v13)],
|
||||||
products: [
|
products: [
|
||||||
// Individual modules — use these for per-module consumption
|
// Individual modules — use these for per-module consumption
|
||||||
.library(name: "XuqmCoreSDK", targets: ["XuqmCoreSDK"]),
|
.library(name: "XuqmCoreSDK", targets: ["XuqmCoreSDK"]),
|
||||||
.library(name: "XuqmImSDK", targets: ["XuqmImSDK"]),
|
.library(name: "XuqmImSDK", targets: ["XuqmImSDK"]),
|
||||||
.library(name: "XuqmPushSDK", targets: ["XuqmPushSDK"]),
|
.library(name: "XuqmPushSDK", targets: ["XuqmPushSDK"]),
|
||||||
.library(name: "XuqmUpdateSDK", targets: ["XuqmUpdateSDK"]),
|
.library(name: "XuqmUpdateSDK", targets: ["XuqmUpdateSDK"]),
|
||||||
.library(name: "XuqmLicenseSDK", targets: ["XuqmLicenseSDK"]),
|
.library(name: "XuqmLicenseSDK", targets: ["XuqmLicenseSDK"]),
|
||||||
.library(name: "XuqmFileSDK", targets: ["XuqmFileSDK"]),
|
.library(name: "XuqmFileSDK", targets: ["XuqmFileSDK"]),
|
||||||
.library(name: "XuqmWebViewSDK", targets: ["XuqmWebViewSDK"]),
|
.library(name: "XuqmWebViewSDK", targets: ["XuqmWebViewSDK"]),
|
||||||
|
.library(name: "XuqmBugCollectSDK", targets: ["XuqmBugCollectSDK"]),
|
||||||
// Convenience — backward compatible, includes all modules
|
// Convenience — backward compatible, includes all modules
|
||||||
.library(name: "XuqmSDK", targets: ["XuqmSDK"]),
|
.library(name: "XuqmSDK", targets: ["XuqmSDK"]),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
@ -58,11 +59,18 @@ let package = Package(
|
|||||||
path: "Sources/XuqmWebViewSDK",
|
path: "Sources/XuqmWebViewSDK",
|
||||||
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
|
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
|
||||||
),
|
),
|
||||||
|
.target(
|
||||||
|
name: "XuqmBugCollectSDK",
|
||||||
|
dependencies: ["XuqmCoreSDK"],
|
||||||
|
path: "Sources/XuqmBugCollectSDK",
|
||||||
|
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
|
||||||
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "XuqmSDK",
|
name: "XuqmSDK",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"XuqmCoreSDK", "XuqmImSDK", "XuqmPushSDK",
|
"XuqmCoreSDK", "XuqmImSDK", "XuqmPushSDK",
|
||||||
"XuqmUpdateSDK", "XuqmLicenseSDK", "XuqmFileSDK",
|
"XuqmUpdateSDK", "XuqmLicenseSDK", "XuqmFileSDK",
|
||||||
|
"XuqmBugCollectSDK",
|
||||||
],
|
],
|
||||||
path: "Sources/XuqmSDK",
|
path: "Sources/XuqmSDK",
|
||||||
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
|
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
|
||||||
|
|||||||
@ -0,0 +1,290 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
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 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: [],
|
||||||
|
release: appVersion(),
|
||||||
|
environment: environment,
|
||||||
|
userId: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId,
|
||||||
|
user: IssueEvent.UserInfo(id: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId),
|
||||||
|
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 var isReady: Bool {
|
||||||
|
XuqmSDK.shared.isInitialized && 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 device = UIDevice.current
|
||||||
|
let processInfo = ProcessInfo.processInfo
|
||||||
|
|
||||||
|
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: Int(processInfo.physicalMemory / 1024 / 1024),
|
||||||
|
buildType: isDebug() ? "debug" : "release"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// 错误指纹计算
|
||||||
|
public enum Fingerprint {
|
||||||
|
|
||||||
|
/// 计算错误指纹
|
||||||
|
public static func compute(type: String, message: String, stacktrace: String) -> String {
|
||||||
|
let normalizedMessage = normalizeMessage(message)
|
||||||
|
let topFrames = extractTopFrames(stacktrace, count: 3)
|
||||||
|
|
||||||
|
let payload = "\(type):\(normalizedMessage):\(topFrames)"
|
||||||
|
let data = Data(payload.utf8)
|
||||||
|
let hash = SHA256.hash(data: data)
|
||||||
|
return hash.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 归一化消息(数字替换为 N,长 hex 替换为 H)
|
||||||
|
private static func normalizeMessage(_ message: String) -> String {
|
||||||
|
var result = message
|
||||||
|
// 替换数字
|
||||||
|
result = result.replacingOccurrences(of: #"\d+"#, with: "N", options: .regularExpression)
|
||||||
|
// 替换长 hex 字符串
|
||||||
|
result = result.replacingOccurrences(of: #"[0-9a-fA-F]{8,}"#, with: "H", options: .regularExpression)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提取堆栈顶部帧
|
||||||
|
private static func extractTopFrames(_ stacktrace: String, count: Int) -> String {
|
||||||
|
let lines = stacktrace.split(separator: "\n").prefix(count)
|
||||||
|
return lines.joined(separator: "|")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,228 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Issue 事件数据模型
|
||||||
|
public struct IssueEvent: Codable, Sendable {
|
||||||
|
public let level: String
|
||||||
|
public let platform: String
|
||||||
|
public let fingerprint: String
|
||||||
|
public let appKey: String
|
||||||
|
public let exception: ExceptionInfo?
|
||||||
|
public let breadcrumbs: [Breadcrumb]
|
||||||
|
public let release: String
|
||||||
|
public let environment: String
|
||||||
|
public let userId: String?
|
||||||
|
public let user: UserInfo?
|
||||||
|
public let device: DeviceInfo?
|
||||||
|
public let tags: [String: AnyCodable]?
|
||||||
|
public let sdk: SdkInfo?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
level: String,
|
||||||
|
platform: String,
|
||||||
|
fingerprint: String,
|
||||||
|
appKey: String,
|
||||||
|
exception: ExceptionInfo? = nil,
|
||||||
|
breadcrumbs: [Breadcrumb] = [],
|
||||||
|
release: String,
|
||||||
|
environment: String,
|
||||||
|
userId: String? = nil,
|
||||||
|
user: UserInfo? = nil,
|
||||||
|
device: DeviceInfo? = nil,
|
||||||
|
tags: [String: Any?]? = nil,
|
||||||
|
sdk: SdkInfo? = nil
|
||||||
|
) {
|
||||||
|
self.level = level
|
||||||
|
self.platform = platform
|
||||||
|
self.fingerprint = fingerprint
|
||||||
|
self.appKey = appKey
|
||||||
|
self.exception = exception
|
||||||
|
self.breadcrumbs = breadcrumbs
|
||||||
|
self.release = release
|
||||||
|
self.environment = environment
|
||||||
|
self.userId = userId
|
||||||
|
self.user = user
|
||||||
|
self.device = device
|
||||||
|
self.tags = tags?.mapValues { AnyCodable($0) }
|
||||||
|
self.sdk = sdk
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ExceptionInfo: Codable, Sendable {
|
||||||
|
public let type: String
|
||||||
|
public let value: String
|
||||||
|
public let stacktrace: String?
|
||||||
|
|
||||||
|
public init(type: String, value: String, stacktrace: String? = nil) {
|
||||||
|
self.type = type
|
||||||
|
self.value = value
|
||||||
|
self.stacktrace = stacktrace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct UserInfo: Codable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
|
||||||
|
public init(id: String) {
|
||||||
|
self.id = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DeviceInfo: Codable, Sendable {
|
||||||
|
public let model: String
|
||||||
|
public let manufacturer: String
|
||||||
|
public let osName: String
|
||||||
|
public let osVersion: String
|
||||||
|
public let locale: String
|
||||||
|
public let timezone: String
|
||||||
|
public let isEmulator: Bool
|
||||||
|
public let freeMemoryMb: Int
|
||||||
|
public let buildType: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
model: String,
|
||||||
|
manufacturer: String,
|
||||||
|
osName: String,
|
||||||
|
osVersion: String,
|
||||||
|
locale: String,
|
||||||
|
timezone: String,
|
||||||
|
isEmulator: Bool,
|
||||||
|
freeMemoryMb: Int,
|
||||||
|
buildType: String
|
||||||
|
) {
|
||||||
|
self.model = model
|
||||||
|
self.manufacturer = manufacturer
|
||||||
|
self.osName = osName
|
||||||
|
self.osVersion = osVersion
|
||||||
|
self.locale = locale
|
||||||
|
self.timezone = timezone
|
||||||
|
self.isEmulator = isEmulator
|
||||||
|
self.freeMemoryMb = freeMemoryMb
|
||||||
|
self.buildType = buildType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SdkInfo: Codable, Sendable {
|
||||||
|
public let name: String
|
||||||
|
public let version: String
|
||||||
|
|
||||||
|
public init(name: String, version: String) {
|
||||||
|
self.name = name
|
||||||
|
self.version = version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 面包屑
|
||||||
|
public struct Breadcrumb: Codable, Sendable {
|
||||||
|
public let timestamp: Int
|
||||||
|
public let category: String
|
||||||
|
public let message: String
|
||||||
|
public let level: String
|
||||||
|
public let data: [String: AnyCodable]?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
timestamp: Int,
|
||||||
|
category: String,
|
||||||
|
message: String,
|
||||||
|
level: String,
|
||||||
|
data: [String: Any?]? = nil
|
||||||
|
) {
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.category = category
|
||||||
|
self.message = message
|
||||||
|
self.level = level
|
||||||
|
self.data = data?.mapValues { AnyCodable($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日志事件
|
||||||
|
public struct LogEvent: Codable, Sendable {
|
||||||
|
public let name: String
|
||||||
|
public let properties: [String: AnyCodable]?
|
||||||
|
public let appKey: String
|
||||||
|
public let userId: String
|
||||||
|
public let platform: String
|
||||||
|
public let release: String
|
||||||
|
public let environment: String
|
||||||
|
public let sdk: IssueEvent.SdkInfo
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String,
|
||||||
|
properties: [String: Any?]? = nil,
|
||||||
|
appKey: String,
|
||||||
|
userId: String,
|
||||||
|
platform: String,
|
||||||
|
release: String,
|
||||||
|
environment: String,
|
||||||
|
sdk: IssueEvent.SdkInfo
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.properties = properties?.mapValues { AnyCodable($0) }
|
||||||
|
self.appKey = appKey
|
||||||
|
self.userId = userId
|
||||||
|
self.platform = platform
|
||||||
|
self.release = release
|
||||||
|
self.environment = environment
|
||||||
|
self.sdk = sdk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日志级别
|
||||||
|
public enum LogLevel: Int, Sendable {
|
||||||
|
case debug = 0
|
||||||
|
case info = 1
|
||||||
|
case warn = 2
|
||||||
|
case error = 3
|
||||||
|
case none = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AnyCodable 包装器
|
||||||
|
public struct AnyCodable: Codable, Sendable {
|
||||||
|
public let value: Any
|
||||||
|
|
||||||
|
public init(_ value: Any?) {
|
||||||
|
self.value = value ?? ()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if container.decodeNil() {
|
||||||
|
self.value = ()
|
||||||
|
} else if let bool = try? container.decode(Bool.self) {
|
||||||
|
self.value = bool
|
||||||
|
} else if let int = try? container.decode(Int.self) {
|
||||||
|
self.value = int
|
||||||
|
} else if let double = try? container.decode(Double.self) {
|
||||||
|
self.value = double
|
||||||
|
} else if let string = try? container.decode(String.self) {
|
||||||
|
self.value = string
|
||||||
|
} else if let array = try? container.decode([AnyCodable].self) {
|
||||||
|
self.value = array.map { $0.value }
|
||||||
|
} else if let dict = try? container.decode([String: AnyCodable].self) {
|
||||||
|
self.value = dict.mapValues { $0.value }
|
||||||
|
} else {
|
||||||
|
self.value = ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch value {
|
||||||
|
case is Void:
|
||||||
|
try container.encodeNil()
|
||||||
|
case let bool as Bool:
|
||||||
|
try container.encode(bool)
|
||||||
|
case let int as Int:
|
||||||
|
try container.encode(int)
|
||||||
|
case let double as Double:
|
||||||
|
try container.encode(double)
|
||||||
|
case let string as String:
|
||||||
|
try container.encode(string)
|
||||||
|
case let array as [Any]:
|
||||||
|
try container.encode(array.map { AnyCodable($0) })
|
||||||
|
case let dict as [String: Any]:
|
||||||
|
try container.encode(dict.mapValues { AnyCodable($0) })
|
||||||
|
default:
|
||||||
|
try container.encodeNil()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 崩溃捕获器
|
||||||
|
final class CrashCapture {
|
||||||
|
|
||||||
|
typealias CrashHandler = (NSException, String) -> Void
|
||||||
|
|
||||||
|
private let handler: CrashHandler
|
||||||
|
private var previousHandler: (@convention(c) (NSException) -> Void)?
|
||||||
|
|
||||||
|
init(handler: @escaping CrashHandler) {
|
||||||
|
self.handler = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安装崩溃捕获
|
||||||
|
func install() {
|
||||||
|
// 捕获 Objective-C 异常
|
||||||
|
previousHandler = NSGetUncaughtExceptionHandler()
|
||||||
|
NSSetUncaughtExceptionHandler { exception in
|
||||||
|
let stacktrace = exception.callStackSymbols.joined(separator: "\n")
|
||||||
|
|
||||||
|
// 保存到磁盘
|
||||||
|
CrashCapture.handleCrash(exception: exception, stacktrace: stacktrace)
|
||||||
|
|
||||||
|
// 调用之前的 handler
|
||||||
|
CrashCapture.shared?.previousHandler?(exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 捕获 POSIX 信号
|
||||||
|
installSignalHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var shared: CrashCapture?
|
||||||
|
|
||||||
|
private static func handleCrash(exception: NSException, stacktrace: String) {
|
||||||
|
shared?.handler(exception, stacktrace)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安装信号处理器
|
||||||
|
private func installSignalHandlers() {
|
||||||
|
let signals = [SIGABRT, SIGSEGV, SIGBUS, SIGFPE, SIGILL, SIGTRAP]
|
||||||
|
|
||||||
|
for sig in signals {
|
||||||
|
let prev = signal(sig) { sig in
|
||||||
|
let exception = NSException(
|
||||||
|
name: NSExceptionName("Signal \(sig)"),
|
||||||
|
reason: "Received signal \(sig)",
|
||||||
|
userInfo: nil
|
||||||
|
)
|
||||||
|
let stacktrace = Thread.callStackSymbols.joined(separator: "\n")
|
||||||
|
|
||||||
|
CrashCapture.handleCrash(exception: exception, stacktrace: stacktrace)
|
||||||
|
|
||||||
|
// 恢复默认处理
|
||||||
|
signal(sig, SIG_DFL)
|
||||||
|
raise(sig)
|
||||||
|
}
|
||||||
|
_ = prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 漏斗追踪器
|
||||||
|
final class FunnelTracker {
|
||||||
|
|
||||||
|
static let shared = FunnelTracker()
|
||||||
|
|
||||||
|
private var sessions: [String: [String]] = [:]
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
/// 追踪事件
|
||||||
|
func track(name: String, properties: [String: Any?]) {
|
||||||
|
// 简化实现,后续可扩展
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前会话的事件序列
|
||||||
|
func getSessionEvents(sessionId: String) -> [String] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return sessions[sessionId] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除会话
|
||||||
|
func clearSession(_ sessionId: String) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
sessions.removeValue(forKey: sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 日志队列 - 内存队列 + 定时上传
|
||||||
|
final class LogQueue {
|
||||||
|
|
||||||
|
private let appKey: String
|
||||||
|
private let sdkName: String
|
||||||
|
private let sdkVersion: String
|
||||||
|
private let uploader: LogUploader
|
||||||
|
private let storage: LogStorage
|
||||||
|
|
||||||
|
private var queue: [Any] = []
|
||||||
|
private let queueLock = NSLock()
|
||||||
|
private var timer: Timer?
|
||||||
|
private let flushInterval: TimeInterval = 30
|
||||||
|
private let maxQueueSize = 50
|
||||||
|
|
||||||
|
init(appKey: String, sdkName: String, sdkVersion: String) {
|
||||||
|
self.appKey = appKey
|
||||||
|
self.sdkName = sdkName
|
||||||
|
self.sdkVersion = sdkVersion
|
||||||
|
self.uploader = LogUploader(appKey: appKey)
|
||||||
|
self.storage = LogStorage(appKey: appKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动定时上传
|
||||||
|
func start() {
|
||||||
|
// 上传待处理的崩溃
|
||||||
|
uploadPendingCrashes()
|
||||||
|
|
||||||
|
// 启动定时器
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: flushInterval, repeats: true) { [weak self] _ in
|
||||||
|
self?.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止队列
|
||||||
|
func stop() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 推送事件
|
||||||
|
func push(_ event: Any) {
|
||||||
|
queueLock.lock()
|
||||||
|
queue.append(event)
|
||||||
|
let count = queue.count
|
||||||
|
queueLock.unlock()
|
||||||
|
|
||||||
|
// 达到阈值时自动上传
|
||||||
|
if count >= maxQueueSize {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刷新队列
|
||||||
|
private func flush() {
|
||||||
|
queueLock.lock()
|
||||||
|
let events = queue
|
||||||
|
queue = []
|
||||||
|
queueLock.unlock()
|
||||||
|
|
||||||
|
guard !events.isEmpty else { return }
|
||||||
|
|
||||||
|
// 分离 issues 和 events
|
||||||
|
var issues: [IssueEvent] = []
|
||||||
|
var logEvents: [LogEvent] = []
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
if let issue = event as? IssueEvent {
|
||||||
|
issues.append(issue)
|
||||||
|
} else if let logEvent = event as? LogEvent {
|
||||||
|
logEvents.append(logEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传 issues
|
||||||
|
if !issues.isEmpty {
|
||||||
|
uploader.uploadIssues(issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传 events
|
||||||
|
if !logEvents.isEmpty {
|
||||||
|
uploader.uploadEvents(logEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传待处理的崩溃
|
||||||
|
private func uploadPendingCrashes() {
|
||||||
|
let crashes = storage.loadPendingCrashes()
|
||||||
|
guard !crashes.isEmpty else { return }
|
||||||
|
|
||||||
|
uploader.uploadIssues(crashes) { [weak self] success in
|
||||||
|
if success {
|
||||||
|
self?.storage.clearPendingCrashes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 崩溃文件持久化
|
||||||
|
enum LogStorage {
|
||||||
|
|
||||||
|
private static let crashDir = "xuqm_bugcollect_crashes"
|
||||||
|
private static let encoder: JSONEncoder = {
|
||||||
|
let e = JSONEncoder()
|
||||||
|
e.dateEncodingStrategy = .iso8601
|
||||||
|
return e
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// 保存崩溃到磁盘
|
||||||
|
static func saveCrash(_ event: IssueEvent) {
|
||||||
|
let dir = crashDirectory()
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let filename = "\(Int(Date().timeIntervalSince1970))_\(event.fingerprint.prefix(8)).json"
|
||||||
|
let url = dir.appendingPathComponent(filename)
|
||||||
|
|
||||||
|
if let data = try? encoder.encode(event) {
|
||||||
|
try? data.write(to: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载待处理的崩溃
|
||||||
|
static func loadPendingCrashes() -> [IssueEvent] {
|
||||||
|
let dir = crashDirectory()
|
||||||
|
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||||
|
at: dir,
|
||||||
|
includingPropertiesForKeys: nil
|
||||||
|
) else { return [] }
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
|
||||||
|
return files
|
||||||
|
.filter { $0.pathExtension == "json" }
|
||||||
|
.compactMap { url -> IssueEvent? in
|
||||||
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||||
|
return try? decoder.decode(IssueEvent.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除已上传的崩溃
|
||||||
|
static func clearPendingCrashes() {
|
||||||
|
let dir = crashDirectory()
|
||||||
|
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||||
|
at: dir,
|
||||||
|
includingPropertiesForKeys: nil
|
||||||
|
) else { return }
|
||||||
|
|
||||||
|
for file in files {
|
||||||
|
try? FileManager.default.removeItem(at: file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func crashDirectory() -> URL {
|
||||||
|
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
||||||
|
return caches.appendingPathComponent(crashDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
import Foundation
|
||||||
|
import XuqmCoreSDK
|
||||||
|
|
||||||
|
/// 日志上传器
|
||||||
|
final class LogUploader {
|
||||||
|
|
||||||
|
private let appKey: String
|
||||||
|
private let encoder: JSONEncoder = {
|
||||||
|
let e = JSONEncoder()
|
||||||
|
e.dateEncodingStrategy = .iso8601
|
||||||
|
return e
|
||||||
|
}()
|
||||||
|
|
||||||
|
init(appKey: String) {
|
||||||
|
self.appKey = appKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传 Issue 事件
|
||||||
|
func uploadIssues(_ issues: [IssueEvent], completion: ((Bool) -> Void)? = nil) {
|
||||||
|
let baseURL = SDKEndpoints.bugCollectApiURL ?? SDKEndpoints.apiBaseURL
|
||||||
|
guard let url = URL(string: "\(baseURL)/bugcollect/v1/issues/batch") else {
|
||||||
|
completion?(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: [String: Any] = [
|
||||||
|
"sentAt": ISO8601DateFormatter().string(from: Date()),
|
||||||
|
"sdk": ["name": "bugcollect.ios", "version": "1.0.0"],
|
||||||
|
"events": issues.map { issue in
|
||||||
|
var event: [String: Any] = [
|
||||||
|
"type": "issue",
|
||||||
|
"level": issue.level,
|
||||||
|
"platform": issue.platform,
|
||||||
|
"fingerprint": issue.fingerprint,
|
||||||
|
"appKey": issue.appKey,
|
||||||
|
"release": issue.release,
|
||||||
|
"environment": issue.environment
|
||||||
|
]
|
||||||
|
if let exception = issue.exception {
|
||||||
|
event["exception"] = [
|
||||||
|
"type": exception.type,
|
||||||
|
"value": exception.value,
|
||||||
|
"stacktrace": exception.stacktrace ?? ""
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if let userId = issue.userId {
|
||||||
|
event["userId"] = userId
|
||||||
|
}
|
||||||
|
if let user = issue.user {
|
||||||
|
event["user"] = ["id": user.id]
|
||||||
|
}
|
||||||
|
if let device = issue.device {
|
||||||
|
event["device"] = [
|
||||||
|
"model": device.model,
|
||||||
|
"manufacturer": device.manufacturer,
|
||||||
|
"osName": device.osName,
|
||||||
|
"osVersion": device.osVersion,
|
||||||
|
"locale": device.locale,
|
||||||
|
"timezone": device.timezone,
|
||||||
|
"isEmulator": device.isEmulator,
|
||||||
|
"freeMemoryMb": device.freeMemoryMb,
|
||||||
|
"buildType": device.buildType
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
upload(url: url, body: body, completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传日志事件
|
||||||
|
func uploadEvents(_ events: [LogEvent], completion: ((Bool) -> Void)? = nil) {
|
||||||
|
let baseURL = SDKEndpoints.bugCollectApiURL ?? SDKEndpoints.apiBaseURL
|
||||||
|
guard let url = URL(string: "\(baseURL)/bugcollect/v1/events/batch") else {
|
||||||
|
completion?(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: [String: Any] = [
|
||||||
|
"sentAt": ISO8601DateFormatter().string(from: Date()),
|
||||||
|
"sdk": ["name": "bugcollect.ios", "version": "1.0.0"],
|
||||||
|
"events": events.map { event in
|
||||||
|
var dict: [String: Any] = [
|
||||||
|
"type": "event",
|
||||||
|
"name": event.name,
|
||||||
|
"appKey": event.appKey,
|
||||||
|
"userId": event.userId,
|
||||||
|
"platform": event.platform,
|
||||||
|
"release": event.release,
|
||||||
|
"environment": event.environment
|
||||||
|
]
|
||||||
|
if let props = event.properties {
|
||||||
|
dict["properties"] = props.mapValues { $0.value }
|
||||||
|
}
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
upload(url: url, body: body, completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func upload(url: URL, body: [String: Any], completion: ((Bool) -> Void)?) {
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
// 添加认证 token
|
||||||
|
if let token = XuqmSDK.shared.token {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
} catch {
|
||||||
|
completion?(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
URLSession.shared.dataTask(with: request) { _, response, error in
|
||||||
|
let success = error == nil &&
|
||||||
|
(response as? HTTPURLResponse)?.statusCode ?? 0 < 400
|
||||||
|
completion?(success)
|
||||||
|
}.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,6 +33,7 @@ public extension SDKConfig {
|
|||||||
public enum SDKEndpoints {
|
public enum SDKEndpoints {
|
||||||
private static var _apiBaseURL: URL?
|
private static var _apiBaseURL: URL?
|
||||||
private static var _imWebSocketURL: URL?
|
private static var _imWebSocketURL: URL?
|
||||||
|
private static var _bugCollectApiURL: URL?
|
||||||
|
|
||||||
public static var apiBaseURL: URL {
|
public static var apiBaseURL: URL {
|
||||||
_apiBaseURL ?? URL(string: "https://dev.xuqinmin.com")!
|
_apiBaseURL ?? URL(string: "https://dev.xuqinmin.com")!
|
||||||
@ -42,9 +43,14 @@ public enum SDKEndpoints {
|
|||||||
_imWebSocketURL ?? URL(string: "wss://dev.xuqinmin.com/ws/im")!
|
_imWebSocketURL ?? URL(string: "wss://dev.xuqinmin.com/ws/im")!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static var bugCollectApiURL: URL? {
|
||||||
|
_bugCollectApiURL ?? _apiBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
public static func configure(
|
public static func configure(
|
||||||
apiBaseURL: String? = nil,
|
apiBaseURL: String? = nil,
|
||||||
imWebSocketURL: String? = nil
|
imWebSocketURL: String? = nil,
|
||||||
|
bugCollectApiURL: String? = nil
|
||||||
) {
|
) {
|
||||||
if let api = apiBaseURL, let url = URL(string: api) {
|
if let api = apiBaseURL, let url = URL(string: api) {
|
||||||
_apiBaseURL = url
|
_apiBaseURL = url
|
||||||
@ -52,10 +58,14 @@ public enum SDKEndpoints {
|
|||||||
if let ws = imWebSocketURL, let url = URL(string: ws) {
|
if let ws = imWebSocketURL, let url = URL(string: ws) {
|
||||||
_imWebSocketURL = url
|
_imWebSocketURL = url
|
||||||
}
|
}
|
||||||
|
if let bc = bugCollectApiURL, let url = URL(string: bc) {
|
||||||
|
_bugCollectApiURL = url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func reset() {
|
public static func reset() {
|
||||||
_apiBaseURL = nil
|
_apiBaseURL = nil
|
||||||
_imWebSocketURL = nil
|
_imWebSocketURL = nil
|
||||||
|
_bugCollectApiURL = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
@_exported import XuqmUpdateSDK
|
@_exported import XuqmUpdateSDK
|
||||||
@_exported import XuqmLicenseSDK
|
@_exported import XuqmLicenseSDK
|
||||||
@_exported import XuqmFileSDK
|
@_exported import XuqmFileSDK
|
||||||
|
@_exported import XuqmBugCollectSDK
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@ -15,13 +16,16 @@ extension XuqmSDK {
|
|||||||
public func registerModuleHooks() {
|
public func registerModuleHooks() {
|
||||||
// Note: licenseReader hook removed — autoInitialize now reads config.xuqm directly via ConfigFileReader.
|
// Note: licenseReader hook removed — autoInitialize now reads config.xuqm directly via ConfigFileReader.
|
||||||
|
|
||||||
// Initialize hook — auto-start push if configured
|
// Initialize hook — auto-start push and bugcollect if configured
|
||||||
onInitialize = { @MainActor config in
|
onInitialize = { @MainActor config in
|
||||||
if config.autoRegisterPush {
|
if config.autoRegisterPush {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await PushSDK.shared.start()
|
try? await PushSDK.shared.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 启动 BugCollect 崩溃捕获
|
||||||
|
BugCollectSDK.shared.startCrashCapture()
|
||||||
|
BugCollectSDK.shared.startQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login hook — connect IM and register push token
|
// Login hook — connect IM and register push token
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户