From 85dd1b2b9937876d0c79c7a2999fa1aae84d43f8 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Mon, 29 Jun 2026 11:28:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20iOS=20SDK=20=E5=AE=8C=E5=96=84=20?= =?UTF-8?q?=E2=80=94=20XWebView=20=E5=8F=8C=E6=A1=A5=E6=8E=A5=20+=20BugCol?= =?UTF-8?q?lect/Update=20macOS=20=E5=85=BC=E5=AE=B9=20+=20CI=20=E6=B5=81?= =?UTF-8?q?=E6=B0=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 33 +- Jenkinsfile | 291 +++++++++--------- Sources/XuqmBugCollectSDK/BugCollectSDK.swift | 33 +- .../XuqmBugCollectSDK/internal/LogQueue.swift | 8 +- Sources/XuqmCoreSDK/XuqmSDK.swift | 29 ++ Sources/XuqmLicenseSDK/LicenseSDK.swift | 3 +- Sources/XuqmUpdateSDK/UpdateSDK.swift | 13 +- .../XWebViewStandardHandlers.swift | 154 ++++----- Sources/XuqmWebViewSDK/XWebViewTypes.swift | 24 +- Sources/XuqmWebViewSDK/XWebViewView.swift | 159 +++++++--- version.properties | 5 + 11 files changed, 418 insertions(+), 334 deletions(-) create mode 100644 version.properties diff --git a/.gitignore b/.gitignore index 6c7312c..7758466 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,25 @@ -node_modules/ -dist/ -.DS_Store -*.class -target/ -build/ -.gradle/ -*.iml -.idea/ -*.log +# SPM build artifacts /.build/ /XuqmDemo/.build/ + +# Xcode +*.xcworkspace/xcuserdata/ +*.xcodeproj/xcuserdata/ +DerivedData/ +*.ipa +*.dSYM.zip +*.dSYM +.DS_Store + +# CocoaPods (optional, not primary) +Pods/ +.cocoapods/ + +# Fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/ +fastlane/test_output/ + +# macOS +.DS_Store diff --git a/Jenkinsfile b/Jenkinsfile index 94c5cec..5af9088 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,28 +1,34 @@ pipeline { - agent any + agent any // macOS agent (szyx.xuqinmin.com) parameters { - // ── 版本升级策略 ───────────────────────────────────────────────────── - choice(name: 'VERSION_BUMP', choices: ['patch', 'minor', 'major'], - description: '版本升级策略: major(1.0.0→2.0.0), minor(1.0.0→1.1.0), patch(1.0.0→1.0.1)') + // ── 发布模式 ───────────────────────────────────────────────────────── + booleanParam(name: 'SNAPSHOT', defaultValue: true, + description: '勾选=SNAPSHOT(推送到 main 分支,不升版本,不打 tag;应用通过 branch:"main" 引用);不勾选=正式 Release(升版本号、打 tag、推送)') - // ── 模块选择(勾选即发布)───────────────────────────────────────── - booleanParam(name: 'MOD_CORE', defaultValue: false, description: '发布 XuqmCoreSDK') - booleanParam(name: 'MOD_IM', defaultValue: false, description: '发布 XuqmImSDK') - booleanParam(name: 'MOD_PUSH', defaultValue: false, description: '发布 XuqmPushSDK') - booleanParam(name: 'MOD_UPDATE', defaultValue: false, description: '发布 XuqmUpdateSDK') - booleanParam(name: 'MOD_LICENSE', defaultValue: false, description: '发布 XuqmLicenseSDK') - booleanParam(name: 'MOD_FILE', defaultValue: false, description: '发布 XuqmFileSDK') - booleanParam(name: 'MOD_WEBVIEW', defaultValue: false, description: '发布 XuqmWebViewSDK') + // ── 版本升级策略(仅 Release 时生效)─────────────────────────────── + choice(name: 'VERSION_BUMP', choices: ['patch', 'minor', 'major'], + description: '版本升级策略: major(1.0.0→2.0.0) / minor(1.0.0→1.1.0) / patch(1.0.0→1.0.1)(SNAPSHOT 模式忽略)') + + // ── 模块选择(标记本次改动范围,用于 changelog — 不影响版本号)── + booleanParam(name: 'MOD_CORE', defaultValue: false, description: '本次包含 XuqmCoreSDK 改动') + booleanParam(name: 'MOD_IM', defaultValue: false, description: '本次包含 XuqmImSDK 改动') + booleanParam(name: 'MOD_PUSH', defaultValue: false, description: '本次包含 XuqmPushSDK 改动') + booleanParam(name: 'MOD_UPDATE', defaultValue: false, description: '本次包含 XuqmUpdateSDK 改动') + booleanParam(name: 'MOD_LICENSE', defaultValue: false, description: '本次包含 XuqmLicenseSDK 改动') + booleanParam(name: 'MOD_FILE', defaultValue: false, description: '本次包含 XuqmFileSDK 改动') + booleanParam(name: 'MOD_WEBVIEW', defaultValue: false, description: '本次包含 XuqmWebViewSDK 改动') + booleanParam(name: 'MOD_BUGCOLLECT', defaultValue: false, description: '本次包含 XuqmBugCollectSDK 改动') // ── 构建选项 ──────────────────────────────────────────────────────── - booleanParam(name: 'PUBLISH_SPM', defaultValue: true, description: '发布 SPM 版本(打 Git Tag)') - booleanParam(name: 'PUBLISH_PODS', defaultValue: false, description: '发布到 CocoaPods 私有 Spec Repo') + booleanParam(name: 'RUN_TESTS', defaultValue: true, + description: '是否运行 swift test(在 macOS 上执行 SPM 测试)') } environment { - GIT_URL = 'https://xuqinmin.com/xuqinmin12/XuqmGroup-iOSSDK.git' - SPEC_REPO = 'https://xuqinmin.com/xuqinmin12/xuqm-specs.git' + GITEA_REPO_URL = 'https://xuqinmin.com/xuqmGroup/XuqmGroup-iOSSDK.git' + GITEA_REPO_PATH = 'xuqmGroup/XuqmGroup-iOSSDK' + GITEA_API_BASE = 'https://xuqinmin.com/api/v1' } options { @@ -32,6 +38,8 @@ pipeline { } stages { + + // ───────────────────────────────────────────────────────────────────── stage('Checkout') { steps { checkout([ @@ -43,172 +51,151 @@ pipeline { script { env.GIT_COMMIT_SHORT = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim() - // ── 根据复选框收集选中的模块 ────────────────────────── def moduleChecks = [ - 'core': params.MOD_CORE, - 'im': params.MOD_IM, - 'push': params.MOD_PUSH, - 'update': params.MOD_UPDATE, - 'license': params.MOD_LICENSE, - 'file': params.MOD_FILE, - 'webview': params.MOD_WEBVIEW, + 'XuqmCoreSDK': params.MOD_CORE, + 'XuqmImSDK': params.MOD_IM, + 'XuqmPushSDK': params.MOD_PUSH, + 'XuqmUpdateSDK': params.MOD_UPDATE, + 'XuqmLicenseSDK': params.MOD_LICENSE, + 'XuqmFileSDK': params.MOD_FILE, + 'XuqmWebViewSDK': params.MOD_WEBVIEW, + 'XuqmBugCollectSDK': params.MOD_BUGCOLLECT, ] - def requested = moduleChecks.findAll { k, v -> v }.collect { k, v -> k } - if (requested.isEmpty()) { - error "没有选择任何模块,请至少勾选一个" + def changed = moduleChecks.findAll { k, v -> v }.collect { k, v -> k } + if (changed.isEmpty()) { + error("没有勾选任何模块,请至少标记一个有改动的模块") } - env.SELECTED_MODULES = requested.join(',') - echo "Selected modules: ${env.SELECTED_MODULES}" - - // ── 模块 → podspec/tag 映射 ──────────────────────────── - def moduleMap = [ - 'core': [podspec: 'XuqmCoreSDK', tagPrefix: 'core'], - 'im': [podspec: 'XuqmImSDK', tagPrefix: 'im'], - 'push': [podspec: 'XuqmPushSDK', tagPrefix: 'push'], - 'update': [podspec: 'XuqmUpdateSDK', tagPrefix: 'update'], - 'license': [podspec: 'XuqmLicenseSDK', tagPrefix: 'license'], - 'file': [podspec: 'XuqmFileSDK', tagPrefix: 'file'], - 'webview': [podspec: 'XuqmWebViewSDK', tagPrefix: 'webview'], - ] - env.MODULE_MAP = groovy.json.JsonOutput.toJson(moduleMap) + env.CHANGED_MODULES = changed.join(', ') + echo "Changed modules: ${env.CHANGED_MODULES}" } } } - stage('Resolve Versions') { + // ───────────────────────────────────────────────────────────────────── + stage('Resolve Version') { steps { script { - def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP) - def modules = env.SELECTED_MODULES.split(',') + def propsText = readFile('version.properties') + def match = (propsText =~ /SDK_VERSION=([0-9][^\s#\r\n]*)/) + if (!match) { error("version.properties 中找不到 SDK_VERSION") } + def currentVer = match[0][1].replaceAll(/-SNAPSHOT$/, '') - for (mod in modules) { - def podspecFile = "${moduleMap[mod].podspec}.podspec" - def currentVer = sh( - script: "grep \"s.version\" ${podspecFile} | head -1 | sed \"s/.*= *\\\"\\(.*\\)\\\".*/\\1/\"", - returnStdout: true - ).trim() - - // 根据 VERSION_BUMP 策略自动升级版本号 - def parts = currentVer.tokenize('.') + def bumpVersion = { String ver -> + def parts = ver.tokenize('.') while (parts.size() < 3) { parts.add('0') } - switch (params.VERSION_BUMP) { - case 'major': - parts[0] = (parts[0].toInteger() + 1).toString() - parts[1] = '0' - parts[2] = '0' - break - case 'minor': - parts[1] = (parts[1].toInteger() + 1).toString() - parts[2] = '0' - break - case 'patch': - default: - parts[2] = (parts[2].toInteger() + 1).toString() - break - } - def newVer = parts.join('.') + def major = parts[0].toInteger() + def minor = parts[1].toInteger() + def patch = parts[2].toInteger() + if (params.VERSION_BUMP == 'major') { major++; minor = 0; patch = 0 } + else if (params.VERSION_BUMP == 'minor') { minor++; patch = 0 } + else { patch++ } + return "${major}.${minor}.${patch}" + } - echo "Bumping ${moduleMap[mod].podspec}: ${currentVer} → ${newVer}" - sh "sed -i '' 's/s.version.*= .*/s.version = \"${newVer}\"/' ${podspecFile}" - env["VERSION_${mod}"] = newVer + if (params.SNAPSHOT) { + // SNAPSHOT:version.properties 不变,展示下一版本的 SNAPSHOT 标签 + def nextVer = bumpVersion(currentVer) + env.PUBLISH_VERSION = "${nextVer}-SNAPSHOT" + env.RELEASE_TAG = '' + echo "SNAPSHOT 模式 — 当前 Release: ${currentVer},本次标记: ${env.PUBLISH_VERSION}" + } else { + // Release:升级并写回 + def newVer = bumpVersion(currentVer) + env.PUBLISH_VERSION = newVer + env.RELEASE_TAG = newVer + def newProps = propsText.replaceAll(/SDK_VERSION=[^\s#\r\n]+/, "SDK_VERSION=${newVer}") + writeFile(file: 'version.properties', text: newProps) + echo "Release 模式 — ${currentVer} → ${newVer}" } } } } + // ───────────────────────────────────────────────────────────────────── stage('Build Info') { steps { script { - def modules = env.SELECTED_MODULES.split(',') - def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP) - echo "Git Commit : ${env.GIT_COMMIT_SHORT}" - echo "Bump Strategy: ${params.VERSION_BUMP}" - echo "Modules (${modules.size()}):" - for (mod in modules) { - def ver = env["VERSION_${mod}"] - echo " ${mod.padRight(10)} → ${moduleMap[mod].podspec} v${ver}" - } + echo "================================================" + echo " iOS SDK — ${params.SNAPSHOT ? 'SNAPSHOT' : 'Release'}" + echo " 版本 : ${env.PUBLISH_VERSION}" + echo " Tag : ${env.RELEASE_TAG ?: '不打 tag'}" + echo " Commit : ${env.GIT_COMMIT_SHORT}" + echo " 改动模块 : ${env.CHANGED_MODULES}" + echo "================================================" } } } + // ───────────────────────────────────────────────────────────────────── stage('Swift Build') { steps { - sh 'swift build' + sh 'swift build -c release 2>&1' } } - stage('Pod Spec Lint') { - when { expression { return params.PUBLISH_PODS } } + // ───────────────────────────────────────────────────────────────────── + stage('Swift Test') { + when { expression { return params.RUN_TESTS } } steps { + sh 'swift test 2>&1' + } + } + + // ───────────────────────────────────────────────────────────────────── + // SNAPSHOT 分支:只推送代码到 main,不打 tag,不修改版本文件 + stage('Publish SNAPSHOT') { + when { expression { return params.SNAPSHOT } } + steps { + withCredentials([string(credentialsId: 'GITEA_TOKEN', variable: 'TOKEN')]) { + sh """ + git config user.email "jenkins@xuqm.com" + git config user.name "Jenkins CI" + git push https://xuqinmin12:\${TOKEN}@xuqinmin.com/xuqmGroup/XuqmGroup-iOSSDK.git HEAD:main + """ + } script { - def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP) - def modules = env.SELECTED_MODULES.split(',') - // 按依赖顺序 lint:先 core,再其他模块 - def ordered = ['core'] + modules.findAll { it != 'core' } - for (mod in ordered) { - def podspec = "${moduleMap[mod].podspec}.podspec" - if (fileExists(podspec)) { - echo "Linting ${podspec}..." - sh "pod spec lint ${podspec} --allow-warnings" - } - } + echo "SNAPSHOT 已推送到 main 分支 (${env.GIT_COMMIT_SHORT})" + echo "应用端 project.yml 使用: branch: \"main\"" + echo "Xcode: File > Packages > Update to Latest Package Versions 获取最新代码" } } } - stage('Publish SPM (Git Tags)') { - when { expression { return params.PUBLISH_SPM } } + // ───────────────────────────────────────────────────────────────────── + // Release 分支:升版本号 commit → 打 tag → 推送 commit + tag + stage('Commit & Tag Release') { + when { expression { return !params.SNAPSHOT } } steps { - withCredentials([usernamePassword(credentialsId: 'gitlab-credentials', passwordVariable: 'GIT_PASS', usernameVariable: 'GIT_USER')]) { + withCredentials([string(credentialsId: 'GITEA_TOKEN', variable: 'TOKEN')]) { + sh """ + git config user.email "jenkins@xuqm.com" + git config user.name "Jenkins CI" + git add version.properties + git commit -m "ci: release ${env.RELEASE_TAG} [${env.CHANGED_MODULES}]" + git tag ${env.RELEASE_TAG} + git push https://xuqinmin12:\${TOKEN}@xuqinmin.com/xuqmGroup/XuqmGroup-iOSSDK.git HEAD:main + git push https://xuqinmin12:\${TOKEN}@xuqinmin.com/xuqmGroup/XuqmGroup-iOSSDK.git ${env.RELEASE_TAG} + """ + } + } + } + + // ───────────────────────────────────────────────────────────────────── + // Release 分支:在 Gitea 上创建 Release 记录 + stage('Create Gitea Release') { + when { expression { return !params.SNAPSHOT } } + steps { + withCredentials([string(credentialsId: 'GITEA_TOKEN', variable: 'TOKEN')]) { script { - def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP) - def modules = env.SELECTED_MODULES.split(',') - def tagsToPush = [] - - for (mod in modules) { - def ver = env["VERSION_${mod}"] - if (ver) { - def tag = "${moduleMap[mod].tagPrefix}/${ver}" - tagsToPush.push(tag) - } - } - - if (tagsToPush) { - sh """ - git config user.email "jenkins@xuqm.com" - git config user.name "Jenkins CI" - git add *.podspec || true - git diff --cached --quiet || git commit -m "ci: bump versions [${env.SELECTED_MODULES}]" - """ - for (tag in tagsToPush) { - sh "git tag -f '${tag}'" - } - sh """ - git push https://${GIT_USER}:${GIT_PASS}@xuqinmin.com/xuqinmin12/XuqmGroup-iOSSDK.git HEAD:main --tags -f - """ - echo "Published tags: ${tagsToPush.join(', ')}" - } - } - } - } - } - - stage('Publish CocoaPods') { - when { expression { return params.PUBLISH_PODS } } - steps { - script { - def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP) - def modules = env.SELECTED_MODULES.split(',') - sh "pod repo add xuqm-specs ${SPEC_REPO} 2>/dev/null || true" - // 按依赖顺序发布:先 core,再其他模块 - def ordered = ['core'] + modules.findAll { it != 'core' } - for (mod in ordered) { - def podspec = "${moduleMap[mod].podspec}.podspec" - if (fileExists(podspec)) { - echo "Publishing ${podspec}..." - sh "pod repo push xuqm-specs ${podspec} --allow-warnings" - } + def moduleList = env.CHANGED_MODULES.split(', ').collect { "- ${it}" }.join('\\n') + def body = "## 改动模块\\n${moduleList}\\n\\n## 集成方式\\n\\`\\`\\`yaml\\npackages:\\n XuqmGroupSDK:\\n url: ${env.GITEA_REPO_URL}\\n from: \\"${env.RELEASE_TAG}\\"\\n\\`\\`\\`" + sh """ + curl -sf -X POST "${GITEA_API_BASE}/repos/${GITEA_REPO_PATH}/releases" \\ + -H "Authorization: token \${TOKEN}" \\ + -H "Content-Type: application/json" \\ + -d "{\\\"tag_name\\\":\\\"${env.RELEASE_TAG}\\\",\\\"name\\\":\\\"v${env.RELEASE_TAG}\\\",\\\"body\\\":\\\"${body}\\\",\\\"draft\\\":false,\\\"prerelease\\\":false}" \\ + | python3 -c "import sys,json; d=json.load(sys.stdin); print('Release:', d.get('html_url','created'))" + """ } } } @@ -218,14 +205,18 @@ pipeline { post { success { script { - def modules = env.SELECTED_MODULES?.split(',') ?: [] - def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP) - def summary = modules.collect { "${it}=${env["VERSION_${it}"]}" }.join(', ') - echo "iOS SDK published — ${summary} (Commit: ${env.GIT_COMMIT_SHORT})" + if (params.SNAPSHOT) { + echo "✅ SNAPSHOT ${env.PUBLISH_VERSION} 构建并推送成功 (commit: ${env.GIT_COMMIT_SHORT})" + echo " 改动模块: ${env.CHANGED_MODULES}" + } else { + echo "✅ Release ${env.RELEASE_TAG} 发布成功" + echo " 改动模块: ${env.CHANGED_MODULES}" + echo " 应用端 project.yml 更新: from: \"${env.RELEASE_TAG}\"" + } } } failure { - echo "iOS SDK build failed — please check the logs" + echo "❌ iOS SDK 构建失败,请检查日志" } } } diff --git a/Sources/XuqmBugCollectSDK/BugCollectSDK.swift b/Sources/XuqmBugCollectSDK/BugCollectSDK.swift index 180013f..48476b9 100644 --- a/Sources/XuqmBugCollectSDK/BugCollectSDK.swift +++ b/Sources/XuqmBugCollectSDK/BugCollectSDK.swift @@ -1,5 +1,7 @@ import Foundation +#if canImport(UIKit) import UIKit +#endif import XuqmCoreSDK /// BugCollect 崩溃收集 SDK @@ -198,8 +200,8 @@ public final class BugCollectSDK { // MARK: - Private - private var isReady: Bool { - XuqmSDK.shared.isInitialized && queue != nil + private func isReady() -> Bool { + XuqmSDK.shared.initialized && queue != nil } private func flushPendingErrors() { @@ -248,19 +250,11 @@ public final class BugCollectSDK { } private func buildDeviceInfo() -> IssueEvent.DeviceInfo { - let device = UIDevice.current let processInfo = ProcessInfo.processInfo + let freeMemoryMb = Int(processInfo.physicalMemory / 1024 / 1024) - // 使用 os_proc_available_memory() 获取可用内存(如果可用) - let freeMemoryMb: Int - if #available(iOS 13.0, *) { - let availableMemory = os_proc_available_memory() - freeMemoryMb = Int(availableMemory / 1024 / 1024) - } else { - // Fallback: 使用物理内存(不准确但有值) - freeMemoryMb = Int(processInfo.physicalMemory / 1024 / 1024) - } - + #if canImport(UIKit) + let device = UIDevice.current return IssueEvent.DeviceInfo( model: device.model, manufacturer: "Apple", @@ -272,6 +266,19 @@ public final class BugCollectSDK { 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 { diff --git a/Sources/XuqmBugCollectSDK/internal/LogQueue.swift b/Sources/XuqmBugCollectSDK/internal/LogQueue.swift index dc63150..348c8ca 100644 --- a/Sources/XuqmBugCollectSDK/internal/LogQueue.swift +++ b/Sources/XuqmBugCollectSDK/internal/LogQueue.swift @@ -7,7 +7,6 @@ final class LogQueue { private let sdkName: String private let sdkVersion: String private let uploader: LogUploader - private let storage: LogStorage private var queue: [Any] = [] private let queueLock = NSLock() @@ -20,7 +19,6 @@ final class LogQueue { self.sdkName = sdkName self.sdkVersion = sdkVersion self.uploader = LogUploader(appKey: appKey) - self.storage = LogStorage(appKey: appKey) } /// 启动定时上传 @@ -88,12 +86,12 @@ final class LogQueue { /// 上传待处理的崩溃 private func uploadPendingCrashes() { - let crashes = storage.loadPendingCrashes() + let crashes = LogStorage.loadPendingCrashes() guard !crashes.isEmpty else { return } - uploader.uploadIssues(crashes) { [weak self] success in + uploader.uploadIssues(crashes) { success in if success { - self?.storage.clearPendingCrashes() + LogStorage.clearPendingCrashes() } } } diff --git a/Sources/XuqmCoreSDK/XuqmSDK.swift b/Sources/XuqmCoreSDK/XuqmSDK.swift index 444000f..134e346 100644 --- a/Sources/XuqmCoreSDK/XuqmSDK.swift +++ b/Sources/XuqmCoreSDK/XuqmSDK.swift @@ -1,4 +1,7 @@ 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. @@ -113,6 +116,32 @@ public final class XuqmSDK: NSObject { 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 diff --git a/Sources/XuqmLicenseSDK/LicenseSDK.swift b/Sources/XuqmLicenseSDK/LicenseSDK.swift index 20baf8b..630b71c 100644 --- a/Sources/XuqmLicenseSDK/LicenseSDK.swift +++ b/Sources/XuqmLicenseSDK/LicenseSDK.swift @@ -110,7 +110,8 @@ public final class LicenseSDK: @unchecked Sendable { /// Initialize from XuqmSDK's config. Called when LicenseSDK is used before manual init. @discardableResult private func ensureInitializedFromSDK() -> Bool { - guard XuqmSDK.shared.initialized, let config = XuqmSDK.shared.config else { return false } + let isInit = DispatchQueue.main.sync { XuqmSDK.shared.initialized } + guard isInit, let config = DispatchQueue.main.sync(execute: { XuqmSDK.shared.config }) else { return false } initialize(appKey: config.appKey) return true } diff --git a/Sources/XuqmUpdateSDK/UpdateSDK.swift b/Sources/XuqmUpdateSDK/UpdateSDK.swift index 4c11903..6511280 100644 --- a/Sources/XuqmUpdateSDK/UpdateSDK.swift +++ b/Sources/XuqmUpdateSDK/UpdateSDK.swift @@ -164,11 +164,16 @@ public final class UpdateSDK { } private func getDeviceInfo() -> DeviceInfo { + #if canImport(UIKit) 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) + 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 diff --git a/Sources/XuqmWebViewSDK/XWebViewStandardHandlers.swift b/Sources/XuqmWebViewSDK/XWebViewStandardHandlers.swift index 4b627b4..0849646 100644 --- a/Sources/XuqmWebViewSDK/XWebViewStandardHandlers.swift +++ b/Sources/XuqmWebViewSDK/XWebViewStandardHandlers.swift @@ -1,11 +1,15 @@ import Foundation -import UIKit import XuqmCoreSDK +#if canImport(UIKit) +import UIKit +#endif -/// JSBridge 标准处理器 -/// 提供 xuqm.* 命名空间的标准桥接方法 +/// JSBridge 标准处理器 — 提供 xuqm.* 命名空间的标准桥接方法。 +/// 类型在所有平台可见;UIKit 依赖的实现仅在 iOS 上编译。 +@MainActor public final class XWebViewStandardHandlers { + #if canImport(UIKit) private weak var viewController: UIViewController? private var onClose: (() -> Void)? @@ -13,155 +17,103 @@ public final class XWebViewStandardHandlers { self.viewController = viewController self.onClose = onClose } + #else + public init() {} + #endif + + // MARK: - Message Dispatch - /// 处理 JS 消息 - /// - Returns: 是否已处理 public func handleMessage(_ message: [String: Any]) -> Bool { - guard let action = message["action"] as? String else { - return false - } - + #if canImport(UIKit) + guard let action = message["action"] as? String else { return false } let callbackId = message["callbackId"] as? String - switch action { - case "xuqm.getDeviceInfo": - handleGetDeviceInfo(callbackId: callbackId) - return true - case "xuqm.getToken": - handleGetToken(callbackId: callbackId) - return true - case "xuqm.getUserInfo": - handleGetUserInfo(callbackId: callbackId) - return true - case "xuqm.closeWebView": - handleCloseWebView() - return true - case "xuqm.showToast": - handleShowToast(message: message) - return true - default: - return false + case "xuqm.getDeviceInfo": handleGetDeviceInfo(callbackId: callbackId); return true + case "xuqm.getToken": handleGetToken(callbackId: callbackId); return true + case "xuqm.getUserInfo": handleGetUserInfo(callbackId: callbackId); return true + case "xuqm.closeWebView": onClose?(); return true + case "xuqm.showToast": handleShowToast(message: message); return true + default: return false } + #else + return false + #endif } - // MARK: - Handlers + // MARK: - Handlers (UIKit only) + #if canImport(UIKit) private func handleGetDeviceInfo(callbackId: String?) { - guard let callbackId = callbackId else { return } - + guard let callbackId else { return } let device = UIDevice.current - let model = device.model - let osVersion = "iOS \(device.systemVersion)" - let platform = "IOS" - let deviceId = device.identifierForVendor?.uuidString ?? "" - callCallback(callbackId, data: [ - "model": model, - "osVersion": osVersion, - "platform": platform, - "deviceId": deviceId, + "model": device.model, + "osVersion": "iOS \(device.systemVersion)", + "platform": "IOS", + "deviceId": device.identifierForVendor?.uuidString ?? "", ]) } private func handleGetToken(callbackId: String?) { - guard let callbackId = callbackId else { return } - let token = XuqmSDK.shared.token ?? "" - callCallback(callbackId, data: ["token": token]) + guard let callbackId else { return } + callCallback(callbackId, data: ["token": XuqmSDK.shared.token ?? ""]) } private func handleGetUserInfo(callbackId: String?) { - guard let callbackId = callbackId else { return } - let userId = XuqmSDK.shared.currentUserId ?? "" - callCallback(callbackId, data: ["userId": userId]) - } - - private func handleCloseWebView() { - onClose?() + guard let callbackId else { return } + // userId is @MainActor; we post empty now — override in app layer if needed + callCallback(callbackId, data: ["userId": XuqmSDK.shared.userId ?? ""]) } private func handleShowToast(message: [String: Any]) { guard let msg = message["message"] as? String else { return } - DispatchQueue.main.async { [weak self] in guard let vc = self?.viewController else { return } let alert = UIAlertController(title: nil, message: msg, preferredStyle: .alert) vc.present(alert, animated: true) - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - alert.dismiss(animated: true) - } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { alert.dismiss(animated: true) } } } - // MARK: - Callback - private func callCallback(_ callbackId: String, data: [String: Any]) { guard let jsonData = try? JSONSerialization.data(withJSONObject: data), - let jsonString = String(data: jsonData, encoding: .utf8) else { - return - } - + let jsonString = String(data: jsonData, encoding: .utf8) else { return } let js = "window[\"\(callbackId)\"](\(jsonString))" - // The WebView will execute this JS through its controller - NotificationCenter.default.post( - name: .xWebViewCallJs, - object: nil, - userInfo: ["js": js] - ) + NotificationCenter.default.post(name: .xWebViewCallJs, object: nil, userInfo: ["js": js]) } + #endif - /// 生成注入的 JS 代码 - public static var injectedJavaScript: String { - return """ + // MARK: - Injected JS (all platforms) + + public nonisolated static var injectedJavaScript: String { + """ (function() { window.xuqm = window.xuqm || {}; window.xuqm.getDeviceInfo = function(callback) { var callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); - window[callbackId] = function(data) { - if (callback) callback(data); - delete window[callbackId]; - }; - window.ReactNativeWebView.postMessage(JSON.stringify({ - action: 'xuqm.getDeviceInfo', - callbackId: callbackId - })); + window[callbackId] = function(data) { if (callback) callback(data); delete window[callbackId]; }; + window.ReactNativeWebView.postMessage(JSON.stringify({ action: 'xuqm.getDeviceInfo', callbackId: callbackId })); }; window.xuqm.getToken = function(callback) { var callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); - window[callbackId] = function(data) { - if (callback) callback(data.token); - delete window[callbackId]; - }; - window.ReactNativeWebView.postMessage(JSON.stringify({ - action: 'xuqm.getToken', - callbackId: callbackId - })); + window[callbackId] = function(data) { if (callback) callback(data.token); delete window[callbackId]; }; + window.ReactNativeWebView.postMessage(JSON.stringify({ action: 'xuqm.getToken', callbackId: callbackId })); }; window.xuqm.getUserInfo = function(callback) { var callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); - window[callbackId] = function(data) { - if (callback) callback(data); - delete window[callbackId]; - }; - window.ReactNativeWebView.postMessage(JSON.stringify({ - action: 'xuqm.getUserInfo', - callbackId: callbackId - })); + window[callbackId] = function(data) { if (callback) callback(data); delete window[callbackId]; }; + window.ReactNativeWebView.postMessage(JSON.stringify({ action: 'xuqm.getUserInfo', callbackId: callbackId })); }; window.xuqm.closeWebView = function() { - window.ReactNativeWebView.postMessage(JSON.stringify({ - action: 'xuqm.closeWebView' - })); + window.ReactNativeWebView.postMessage(JSON.stringify({ action: 'xuqm.closeWebView' })); }; window.xuqm.showToast = function(message) { - window.ReactNativeWebView.postMessage(JSON.stringify({ - action: 'xuqm.showToast', - message: message - })); + window.ReactNativeWebView.postMessage(JSON.stringify({ action: 'xuqm.showToast', message: message })); }; })(); true; @@ -169,8 +121,10 @@ public final class XWebViewStandardHandlers { } } -// MARK: - Notification Name +// MARK: - Notification Name (UIKit only, used by WKWebView coordinator) +#if canImport(UIKit) extension Notification.Name { static let xWebViewCallJs = Notification.Name("xWebViewCallJs") } +#endif diff --git a/Sources/XuqmWebViewSDK/XWebViewTypes.swift b/Sources/XuqmWebViewSDK/XWebViewTypes.swift index 3c2aa07..da5f87b 100644 --- a/Sources/XuqmWebViewSDK/XWebViewTypes.swift +++ b/Sources/XuqmWebViewSDK/XWebViewTypes.swift @@ -8,9 +8,21 @@ public struct XWebViewConfig: Sendable { public var hideStatusBar: Bool public var userAgent: String? public var injectedJavaScript: String? + /// Native message handler name registered in WKUserContentController. + /// H5 side calls `window.webkit.messageHandlers..postMessage(json)`. + public var jsBridgeName: String /// Where intercepted WebView downloads are saved. public var downloadDestination: FileDownloadDestination + /// Custom message callback — receives raw JSON string from H5 via jsBridgeName bridge. public var onMessage: (@Sendable (String) -> Void)? + /// Called when the WebView container should close (xuqm.closeWebView JSBridge command). + public var onClose: (@Sendable () -> Void)? + /// Called on page load error. Receives a human-readable error description. + public var onPageError: (@Sendable (String) -> Void)? + /// Called as the page loads. Progress is 0–100. + public var onProgressChanged: (@Sendable (Int) -> Void)? + /// Called when the navigation stack changes. (canGoBack, canGoForward) + public var onNavigationChanged: (@Sendable (Bool, Bool) -> Void)? public init( url: String = "", @@ -19,8 +31,13 @@ public struct XWebViewConfig: Sendable { hideStatusBar: Bool = false, userAgent: String? = nil, injectedJavaScript: String? = nil, + jsBridgeName: String = "SZYX_APP", downloadDestination: FileDownloadDestination = .sandbox, - onMessage: (@Sendable (String) -> Void)? = nil + onMessage: (@Sendable (String) -> Void)? = nil, + onClose: (@Sendable () -> Void)? = nil, + onPageError: (@Sendable (String) -> Void)? = nil, + onProgressChanged: (@Sendable (Int) -> Void)? = nil, + onNavigationChanged: (@Sendable (Bool, Bool) -> Void)? = nil ) { self.url = url self.title = title @@ -28,8 +45,13 @@ public struct XWebViewConfig: Sendable { self.hideStatusBar = hideStatusBar self.userAgent = userAgent self.injectedJavaScript = injectedJavaScript + self.jsBridgeName = jsBridgeName self.downloadDestination = downloadDestination self.onMessage = onMessage + self.onClose = onClose + self.onPageError = onPageError + self.onProgressChanged = onProgressChanged + self.onNavigationChanged = onNavigationChanged } } diff --git a/Sources/XuqmWebViewSDK/XWebViewView.swift b/Sources/XuqmWebViewSDK/XWebViewView.swift index 7ff5165..207def6 100644 --- a/Sources/XuqmWebViewSDK/XWebViewView.swift +++ b/Sources/XuqmWebViewSDK/XWebViewView.swift @@ -1,8 +1,9 @@ import SwiftUI +import XuqmCoreSDK import XuqmFileSDK -// JS bridge name used by DIALOG_OVERRIDE_JS to post messages to native. -private let kBridgeName = "ReactNativeWebView" +// Internal bridge name for SDK-owned messages (__xwv, xuqm.*) +private let kInternalBridgeName = "ReactNativeWebView" // Injected into every page to override browser dialogs and intercept downloads. private let dialogOverrideJs = #""" @@ -107,7 +108,6 @@ public struct XWebViewView: UIViewRepresentable { public func makeUIView(context: Context) -> WKWebView { let userController = WKUserContentController() - // Inject DIALOG_OVERRIDE_JS + user script at document end. let script = WKUserScript( source: buildInjectedJs(config), injectionTime: .atDocumentEnd, @@ -115,8 +115,14 @@ public struct XWebViewView: UIViewRepresentable { ) userController.addUserScript(script) - // Register JS → Native message channel via weak proxy to avoid retain cycle. - userController.add(WeakScriptHandler(context.coordinator), name: kBridgeName) + // Internal SDK bridge (dialog overrides, download, xuqm.*) + userController.add(WeakScriptHandler(context.coordinator), name: kInternalBridgeName) + + // Custom app bridge (routes to onMessage callback) + let appBridgeName = config.jsBridgeName + if !appBridgeName.isEmpty && appBridgeName != kInternalBridgeName { + userController.add(WeakScriptHandler(context.coordinator), name: appBridgeName) + } let configuration = WKWebViewConfiguration() configuration.userContentController = userController @@ -143,7 +149,12 @@ public struct XWebViewView: UIViewRepresentable { } public static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { - uiView.configuration.userContentController.removeScriptMessageHandler(forName: kBridgeName) + let uc = uiView.configuration.userContentController + uc.removeScriptMessageHandler(forName: kInternalBridgeName) + let appBridgeName = coordinator.appBridgeName + if !appBridgeName.isEmpty && appBridgeName != kInternalBridgeName { + uc.removeScriptMessageHandler(forName: appBridgeName) + } coordinator.detach() if XWebViewBridge.shared.currentController() === coordinator { XWebViewBridge.shared.setController(nil) @@ -154,13 +165,32 @@ public struct XWebViewView: UIViewRepresentable { public final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, XWebViewController { private weak var webView: WKWebView? private let config: XWebViewConfig + private var progressObservation: NSKeyValueObservation? + + let appBridgeName: String init(config: XWebViewConfig) { self.config = config + self.appBridgeName = config.jsBridgeName } - func attach(_ webView: WKWebView) { self.webView = webView } - func detach() { webView = nil } + func attach(_ webView: WKWebView) { + guard self.webView !== webView else { return } + progressObservation?.invalidate() + self.webView = webView + progressObservation = webView.observe(\.estimatedProgress, options: .new) { [weak self] wv, _ in + let pct = Int(wv.estimatedProgress * 100) + Task { @MainActor [weak self] in + self?.config.onProgressChanged?(pct) + } + } + } + + func detach() { + progressObservation?.invalidate() + progressObservation = nil + webView = nil + } public func canGoBack() -> Bool { webView?.canGoBack == true } public func canGoForward() -> Bool { webView?.canGoForward == true } @@ -176,11 +206,64 @@ public struct XWebViewView: UIViewRepresentable { webView?.evaluateJavaScript(js, completionHandler: nil) } - // WKScriptMessageHandler — routes window.webkit.messageHandlers.ReactNativeWebView.postMessage() + // MARK: - WKNavigationDelegate + + public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + config.onProgressChanged?(0) + notifyNavigationChanged(webView) + } + + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + config.onProgressChanged?(100) + notifyNavigationChanged(webView) + } + + public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + let nsError = error as NSError + if nsError.code != NSURLErrorCancelled { + config.onPageError?(error.localizedDescription) + } + notifyNavigationChanged(webView) + } + + public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + let nsError = error as NSError + if nsError.code != NSURLErrorCancelled { + config.onPageError?(error.localizedDescription) + } + notifyNavigationChanged(webView) + } + + private func notifyNavigationChanged(_ webView: WKWebView) { + config.onNavigationChanged?(webView.canGoBack, webView.canGoForward) + } + + @available(iOS 15.0, *) + public func webView( + _ webView: WKWebView, + requestMediaCapturePermissionFor origin: WKSecurityOrigin, + initiatedByFrame frame: WKFrameInfo, + type: WKMediaCaptureType, + decisionHandler: @escaping (WKPermissionDecision) -> Void + ) { + decisionHandler(.grant) + } + + // MARK: - WKScriptMessageHandler + public func userContentController( _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { + // Route custom app messages to onMessage + if message.name == appBridgeName { + if let body = message.body as? String { + config.onMessage?(body) + } + return + } + + // Internal bridge (ReactNativeWebView) guard let raw = message.body as? String else { return } guard let data = raw.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { @@ -188,7 +271,7 @@ public struct XWebViewView: UIViewRepresentable { return } - // Handle StandardHandlers (xuqm.* actions) + // StandardHandlers (xuqm.* actions) if let action = json["action"] as? String, action.hasPrefix("xuqm.") { handleStandardAction(action, message: json) return @@ -215,29 +298,22 @@ public struct XWebViewView: UIViewRepresentable { } } - /// 处理 StandardHandlers 动作 + // MARK: - Standard action handlers + private func handleStandardAction(_ action: String, message: [String: Any]) { let callbackId = message["callbackId"] as? String - switch action { - case "xuqm.getDeviceInfo": - handleGetDeviceInfo(callbackId: callbackId) - case "xuqm.getToken": - handleGetToken(callbackId: callbackId) - case "xuqm.getUserInfo": - handleGetUserInfo(callbackId: callbackId) - case "xuqm.closeWebView": - config.onClose?() - case "xuqm.showToast": - // iOS 不支持原生 Toast,可以使用 alert - break - default: - break + case "xuqm.getDeviceInfo": handleGetDeviceInfo(callbackId: callbackId) + case "xuqm.getToken": handleGetToken(callbackId: callbackId) + case "xuqm.getUserInfo": handleGetUserInfo(callbackId: callbackId) + case "xuqm.closeWebView": config.onClose?() + case "xuqm.showToast": break + default: break } } private func handleGetDeviceInfo(callbackId: String?) { - guard let callbackId = callbackId else { return } + guard let callbackId else { return } let device = UIDevice.current let data: [String: Any] = [ "model": device.model, @@ -249,26 +325,24 @@ public struct XWebViewView: UIViewRepresentable { } private func handleGetToken(callbackId: String?) { - guard let callbackId = callbackId else { return } - let token = XuqmSDK.shared.token ?? "" - callCallback(callbackId, data: ["token": token]) + guard let callbackId else { return } + callCallback(callbackId, data: ["token": XuqmSDK.shared.token ?? ""]) } private func handleGetUserInfo(callbackId: String?) { - guard let callbackId = callbackId else { return } - let userId = XuqmSDK.shared.currentUserId ?? "" - callCallback(callbackId, data: ["userId": userId]) + guard let callbackId else { return } + callCallback(callbackId, data: ["userId": XuqmSDK.shared.userId ?? ""]) } private func callCallback(_ callbackId: String, data: [String: Any]) { guard let jsonData = try? JSONSerialization.data(withJSONObject: data), - let jsonString = String(data: jsonData, encoding: .utf8) else { - return - } + let jsonString = String(data: jsonData, encoding: .utf8) else { return } let js = "window[\"\(callbackId)\"](\(jsonString))" webView?.evaluateJavaScript(js, completionHandler: nil) } + // MARK: - Download handling + private func dispatchDownloadEvent(_ name: String, url: String, extra: String = "") { let escaped = url.jsEscaped let js = "window.dispatchEvent(new CustomEvent('\(name)',{detail:{url:'\(escaped)'\(extra)}}));" @@ -311,7 +385,6 @@ public struct XWebViewView: UIViewRepresentable { private func openOrSave(localURL: URL, destination: FileDownloadDestination) { guard let wv = webView else { return } - #if canImport(UIKit) switch destination { case .sandbox: let vc = UIActivityViewController(activityItems: [localURL], applicationActivities: nil) @@ -320,10 +393,8 @@ public struct XWebViewView: UIViewRepresentable { let picker = UIDocumentPickerViewController(forExporting: [localURL], asCopy: true) topViewController(from: wv)?.present(picker, animated: true) } - #endif } - #if canImport(UIKit) private func topViewController(from view: UIView) -> UIViewController? { var responder: UIResponder? = view while let r = responder { @@ -332,18 +403,6 @@ public struct XWebViewView: UIViewRepresentable { } return nil } - #endif - - @available(iOS 15.0, *) - public func webView( - _ webView: WKWebView, - requestMediaCapturePermissionFor origin: WKSecurityOrigin, - initiatedByFrame frame: WKFrameInfo, - type: WKMediaCaptureType, - decisionHandler: @escaping (WKPermissionDecision) -> Void - ) { - decisionHandler(.grant) - } } } diff --git a/version.properties b/version.properties new file mode 100644 index 0000000..008314d --- /dev/null +++ b/version.properties @@ -0,0 +1,5 @@ +# iOS SDK 统一版本号 +# Jenkins 读写此文件管理发版版本号 +# 正式发版时 Jenkins 自动升级并打 git tag +# SNAPSHOT 模式下此文件不变,应用通过 branch: "main" 引用最新代码 +SDK_VERSION=1.0.1