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>
这个提交包含在:
XuqmGroup 2026-06-29 11:28:18 +08:00
父节点 5954683efc
当前提交 85dd1b2b99
共有 11 个文件被更改,包括 418 次插入334 次删除

33
.gitignore vendored
查看文件

@ -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

273
Jenkinsfile vendored
查看文件

@ -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,174 +51,153 @@ 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 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}"
}
def newVer = parts.join('.')
echo "Bumping ${moduleMap[mod].podspec}: ${currentVer} → ${newVer}"
sh "sed -i '' 's/s.version.*= .*/s.version = \"${newVer}\"/' ${podspecFile}"
env["VERSION_${mod}"] = newVer
if (params.SNAPSHOT) {
// SNAPSHOTversion.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 {
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"
}
}
}
sh 'swift test 2>&1'
}
}
stage('Publish SPM (Git Tags)') {
when { expression { return params.PUBLISH_SPM } }
// ─────────────────────────────────────────────────────────────────────
// SNAPSHOT 分支:只推送代码到 main,不打 tag,不修改版本文件
stage('Publish SNAPSHOT') {
when { expression { return params.SNAPSHOT } }
steps {
withCredentials([usernamePassword(credentialsId: 'gitlab-credentials', passwordVariable: 'GIT_PASS', usernameVariable: 'GIT_USER')]) {
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) {
withCredentials([string(credentialsId: 'GITEA_TOKEN', variable: 'TOKEN')]) {
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}]"
git push https://xuqinmin12:\${TOKEN}@xuqinmin.com/xuqmGroup/XuqmGroup-iOSSDK.git HEAD:main
"""
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(', ')}"
}
}
script {
echo "SNAPSHOT 已推送到 main 分支 (${env.GIT_COMMIT_SHORT})"
echo "应用端 project.yml 使用: branch: \"main\""
echo "Xcode: File > Packages > Update to Latest Package Versions 获取最新代码"
}
}
}
stage('Publish CocoaPods') {
when { expression { return params.PUBLISH_PODS } }
// ─────────────────────────────────────────────────────────────────────
// Release 分支:升版本号 commit → 打 tag → 推送 commit + tag
stage('Commit & Tag Release') {
when { expression { return !params.SNAPSHOT } }
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"
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 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 构建失败,请检查日志"
}
}
}

查看文件

@ -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 {

查看文件

@ -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()
}
}
}

查看文件

@ -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

查看文件

@ -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
}

查看文件

@ -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

查看文件

@ -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:
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

查看文件

@ -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.<jsBridgeName>.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 0100.
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
}
}

查看文件

@ -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)
}
}
}

5
version.properties 普通文件
查看文件

@ -0,0 +1,5 @@
# iOS SDK 统一版本号
# Jenkins 读写此文件管理发版版本号
# 正式发版时 Jenkins 自动升级并打 git tag
# SNAPSHOT 模式下此文件不变,应用通过 branch: "main" 引用最新代码
SDK_VERSION=1.0.1