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>
这个提交包含在:
父节点
5954683efc
当前提交
85dd1b2b99
33
.gitignore
vendored
33
.gitignore
vendored
@ -1,12 +1,25 @@
|
|||||||
node_modules/
|
# SPM build artifacts
|
||||||
dist/
|
|
||||||
.DS_Store
|
|
||||||
*.class
|
|
||||||
target/
|
|
||||||
build/
|
|
||||||
.gradle/
|
|
||||||
*.iml
|
|
||||||
.idea/
|
|
||||||
*.log
|
|
||||||
/.build/
|
/.build/
|
||||||
/XuqmDemo/.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
|
||||||
|
|||||||
291
Jenkinsfile
vendored
291
Jenkinsfile
vendored
@ -1,28 +1,34 @@
|
|||||||
pipeline {
|
pipeline {
|
||||||
agent any
|
agent any // macOS agent (szyx.xuqinmin.com)
|
||||||
|
|
||||||
parameters {
|
parameters {
|
||||||
// ── 版本升级策略 ─────────────────────────────────────────────────────
|
// ── 发布模式 ─────────────────────────────────────────────────────────
|
||||||
choice(name: 'VERSION_BUMP', choices: ['patch', 'minor', 'major'],
|
booleanParam(name: 'SNAPSHOT', defaultValue: true,
|
||||||
description: '版本升级策略: major(1.0.0→2.0.0), minor(1.0.0→1.1.0), patch(1.0.0→1.0.1)')
|
description: '勾选=SNAPSHOT(推送到 main 分支,不升版本,不打 tag;应用通过 branch:"main" 引用);不勾选=正式 Release(升版本号、打 tag、推送)')
|
||||||
|
|
||||||
// ── 模块选择(勾选即发布)─────────────────────────────────────────
|
// ── 版本升级策略(仅 Release 时生效)───────────────────────────────
|
||||||
booleanParam(name: 'MOD_CORE', defaultValue: false, description: '发布 XuqmCoreSDK')
|
choice(name: 'VERSION_BUMP', choices: ['patch', 'minor', 'major'],
|
||||||
booleanParam(name: 'MOD_IM', defaultValue: false, description: '发布 XuqmImSDK')
|
description: '版本升级策略: major(1.0.0→2.0.0) / minor(1.0.0→1.1.0) / patch(1.0.0→1.0.1)(SNAPSHOT 模式忽略)')
|
||||||
booleanParam(name: 'MOD_PUSH', defaultValue: false, description: '发布 XuqmPushSDK')
|
|
||||||
booleanParam(name: 'MOD_UPDATE', defaultValue: false, description: '发布 XuqmUpdateSDK')
|
// ── 模块选择(标记本次改动范围,用于 changelog — 不影响版本号)──
|
||||||
booleanParam(name: 'MOD_LICENSE', defaultValue: false, description: '发布 XuqmLicenseSDK')
|
booleanParam(name: 'MOD_CORE', defaultValue: false, description: '本次包含 XuqmCoreSDK 改动')
|
||||||
booleanParam(name: 'MOD_FILE', defaultValue: false, description: '发布 XuqmFileSDK')
|
booleanParam(name: 'MOD_IM', defaultValue: false, description: '本次包含 XuqmImSDK 改动')
|
||||||
booleanParam(name: 'MOD_WEBVIEW', defaultValue: false, description: '发布 XuqmWebViewSDK')
|
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: 'RUN_TESTS', defaultValue: true,
|
||||||
booleanParam(name: 'PUBLISH_PODS', defaultValue: false, description: '发布到 CocoaPods 私有 Spec Repo')
|
description: '是否运行 swift test(在 macOS 上执行 SPM 测试)')
|
||||||
}
|
}
|
||||||
|
|
||||||
environment {
|
environment {
|
||||||
GIT_URL = 'https://xuqinmin.com/xuqinmin12/XuqmGroup-iOSSDK.git'
|
GITEA_REPO_URL = 'https://xuqinmin.com/xuqmGroup/XuqmGroup-iOSSDK.git'
|
||||||
SPEC_REPO = 'https://xuqinmin.com/xuqinmin12/xuqm-specs.git'
|
GITEA_REPO_PATH = 'xuqmGroup/XuqmGroup-iOSSDK'
|
||||||
|
GITEA_API_BASE = 'https://xuqinmin.com/api/v1'
|
||||||
}
|
}
|
||||||
|
|
||||||
options {
|
options {
|
||||||
@ -32,6 +38,8 @@ pipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
stage('Checkout') {
|
stage('Checkout') {
|
||||||
steps {
|
steps {
|
||||||
checkout([
|
checkout([
|
||||||
@ -43,172 +51,151 @@ pipeline {
|
|||||||
script {
|
script {
|
||||||
env.GIT_COMMIT_SHORT = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
|
env.GIT_COMMIT_SHORT = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
|
||||||
|
|
||||||
// ── 根据复选框收集选中的模块 ──────────────────────────
|
|
||||||
def moduleChecks = [
|
def moduleChecks = [
|
||||||
'core': params.MOD_CORE,
|
'XuqmCoreSDK': params.MOD_CORE,
|
||||||
'im': params.MOD_IM,
|
'XuqmImSDK': params.MOD_IM,
|
||||||
'push': params.MOD_PUSH,
|
'XuqmPushSDK': params.MOD_PUSH,
|
||||||
'update': params.MOD_UPDATE,
|
'XuqmUpdateSDK': params.MOD_UPDATE,
|
||||||
'license': params.MOD_LICENSE,
|
'XuqmLicenseSDK': params.MOD_LICENSE,
|
||||||
'file': params.MOD_FILE,
|
'XuqmFileSDK': params.MOD_FILE,
|
||||||
'webview': params.MOD_WEBVIEW,
|
'XuqmWebViewSDK': params.MOD_WEBVIEW,
|
||||||
|
'XuqmBugCollectSDK': params.MOD_BUGCOLLECT,
|
||||||
]
|
]
|
||||||
def requested = moduleChecks.findAll { k, v -> v }.collect { k, v -> k }
|
def changed = moduleChecks.findAll { k, v -> v }.collect { k, v -> k }
|
||||||
if (requested.isEmpty()) {
|
if (changed.isEmpty()) {
|
||||||
error "没有选择任何模块,请至少勾选一个"
|
error("没有勾选任何模块,请至少标记一个有改动的模块")
|
||||||
}
|
}
|
||||||
env.SELECTED_MODULES = requested.join(',')
|
env.CHANGED_MODULES = changed.join(', ')
|
||||||
echo "Selected modules: ${env.SELECTED_MODULES}"
|
echo "Changed modules: ${env.CHANGED_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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Resolve Versions') {
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
stage('Resolve Version') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP)
|
def propsText = readFile('version.properties')
|
||||||
def modules = env.SELECTED_MODULES.split(',')
|
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 bumpVersion = { String ver ->
|
||||||
def podspecFile = "${moduleMap[mod].podspec}.podspec"
|
def parts = ver.tokenize('.')
|
||||||
def currentVer = sh(
|
|
||||||
script: "grep \"s.version\" ${podspecFile} | head -1 | sed \"s/.*= *\\\"\\(.*\\)\\\".*/\\1/\"",
|
|
||||||
returnStdout: true
|
|
||||||
).trim()
|
|
||||||
|
|
||||||
// 根据 VERSION_BUMP 策略自动升级版本号
|
|
||||||
def parts = currentVer.tokenize('.')
|
|
||||||
while (parts.size() < 3) { parts.add('0') }
|
while (parts.size() < 3) { parts.add('0') }
|
||||||
switch (params.VERSION_BUMP) {
|
def major = parts[0].toInteger()
|
||||||
case 'major':
|
def minor = parts[1].toInteger()
|
||||||
parts[0] = (parts[0].toInteger() + 1).toString()
|
def patch = parts[2].toInteger()
|
||||||
parts[1] = '0'
|
if (params.VERSION_BUMP == 'major') { major++; minor = 0; patch = 0 }
|
||||||
parts[2] = '0'
|
else if (params.VERSION_BUMP == 'minor') { minor++; patch = 0 }
|
||||||
break
|
else { patch++ }
|
||||||
case 'minor':
|
return "${major}.${minor}.${patch}"
|
||||||
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('.')
|
|
||||||
|
|
||||||
echo "Bumping ${moduleMap[mod].podspec}: ${currentVer} → ${newVer}"
|
if (params.SNAPSHOT) {
|
||||||
sh "sed -i '' 's/s.version.*= .*/s.version = \"${newVer}\"/' ${podspecFile}"
|
// SNAPSHOT:version.properties 不变,展示下一版本的 SNAPSHOT 标签
|
||||||
env["VERSION_${mod}"] = newVer
|
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') {
|
stage('Build Info') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
def modules = env.SELECTED_MODULES.split(',')
|
echo "================================================"
|
||||||
def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP)
|
echo " iOS SDK — ${params.SNAPSHOT ? 'SNAPSHOT' : 'Release'}"
|
||||||
echo "Git Commit : ${env.GIT_COMMIT_SHORT}"
|
echo " 版本 : ${env.PUBLISH_VERSION}"
|
||||||
echo "Bump Strategy: ${params.VERSION_BUMP}"
|
echo " Tag : ${env.RELEASE_TAG ?: '不打 tag'}"
|
||||||
echo "Modules (${modules.size()}):"
|
echo " Commit : ${env.GIT_COMMIT_SHORT}"
|
||||||
for (mod in modules) {
|
echo " 改动模块 : ${env.CHANGED_MODULES}"
|
||||||
def ver = env["VERSION_${mod}"]
|
echo "================================================"
|
||||||
echo " ${mod.padRight(10)} → ${moduleMap[mod].podspec} v${ver}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
stage('Swift Build') {
|
stage('Swift Build') {
|
||||||
steps {
|
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 {
|
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 {
|
script {
|
||||||
def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP)
|
echo "SNAPSHOT 已推送到 main 分支 (${env.GIT_COMMIT_SHORT})"
|
||||||
def modules = env.SELECTED_MODULES.split(',')
|
echo "应用端 project.yml 使用: branch: \"main\""
|
||||||
// 按依赖顺序 lint:先 core,再其他模块
|
echo "Xcode: File > Packages > Update to Latest Package Versions 获取最新代码"
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
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 {
|
script {
|
||||||
def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP)
|
def moduleList = env.CHANGED_MODULES.split(', ').collect { "- ${it}" }.join('\\n')
|
||||||
def modules = env.SELECTED_MODULES.split(',')
|
def body = "## 改动模块\\n${moduleList}\\n\\n## 集成方式\\n\\`\\`\\`yaml\\npackages:\\n XuqmGroupSDK:\\n url: ${env.GITEA_REPO_URL}\\n from: \\"${env.RELEASE_TAG}\\"\\n\\`\\`\\`"
|
||||||
def tagsToPush = []
|
sh """
|
||||||
|
curl -sf -X POST "${GITEA_API_BASE}/repos/${GITEA_REPO_PATH}/releases" \\
|
||||||
for (mod in modules) {
|
-H "Authorization: token \${TOKEN}" \\
|
||||||
def ver = env["VERSION_${mod}"]
|
-H "Content-Type: application/json" \\
|
||||||
if (ver) {
|
-d "{\\\"tag_name\\\":\\\"${env.RELEASE_TAG}\\\",\\\"name\\\":\\\"v${env.RELEASE_TAG}\\\",\\\"body\\\":\\\"${body}\\\",\\\"draft\\\":false,\\\"prerelease\\\":false}" \\
|
||||||
def tag = "${moduleMap[mod].tagPrefix}/${ver}"
|
| python3 -c "import sys,json; d=json.load(sys.stdin); print('Release:', d.get('html_url','created'))"
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -218,14 +205,18 @@ pipeline {
|
|||||||
post {
|
post {
|
||||||
success {
|
success {
|
||||||
script {
|
script {
|
||||||
def modules = env.SELECTED_MODULES?.split(',') ?: []
|
if (params.SNAPSHOT) {
|
||||||
def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP)
|
echo "✅ SNAPSHOT ${env.PUBLISH_VERSION} 构建并推送成功 (commit: ${env.GIT_COMMIT_SHORT})"
|
||||||
def summary = modules.collect { "${it}=${env["VERSION_${it}"]}" }.join(', ')
|
echo " 改动模块: ${env.CHANGED_MODULES}"
|
||||||
echo "iOS SDK published — ${summary} (Commit: ${env.GIT_COMMIT_SHORT})"
|
} else {
|
||||||
|
echo "✅ Release ${env.RELEASE_TAG} 发布成功"
|
||||||
|
echo " 改动模块: ${env.CHANGED_MODULES}"
|
||||||
|
echo " 应用端 project.yml 更新: from: \"${env.RELEASE_TAG}\""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
failure {
|
failure {
|
||||||
echo "iOS SDK build failed — please check the logs"
|
echo "❌ iOS SDK 构建失败,请检查日志"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
#if canImport(UIKit)
|
||||||
import UIKit
|
import UIKit
|
||||||
|
#endif
|
||||||
import XuqmCoreSDK
|
import XuqmCoreSDK
|
||||||
|
|
||||||
/// BugCollect 崩溃收集 SDK
|
/// BugCollect 崩溃收集 SDK
|
||||||
@ -198,8 +200,8 @@ public final class BugCollectSDK {
|
|||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private var isReady: Bool {
|
private func isReady() -> Bool {
|
||||||
XuqmSDK.shared.isInitialized && queue != nil
|
XuqmSDK.shared.initialized && queue != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func flushPendingErrors() {
|
private func flushPendingErrors() {
|
||||||
@ -248,19 +250,11 @@ public final class BugCollectSDK {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func buildDeviceInfo() -> IssueEvent.DeviceInfo {
|
private func buildDeviceInfo() -> IssueEvent.DeviceInfo {
|
||||||
let device = UIDevice.current
|
|
||||||
let processInfo = ProcessInfo.processInfo
|
let processInfo = ProcessInfo.processInfo
|
||||||
|
let freeMemoryMb = Int(processInfo.physicalMemory / 1024 / 1024)
|
||||||
|
|
||||||
// 使用 os_proc_available_memory() 获取可用内存(如果可用)
|
#if canImport(UIKit)
|
||||||
let freeMemoryMb: Int
|
let device = UIDevice.current
|
||||||
if #available(iOS 13.0, *) {
|
|
||||||
let availableMemory = os_proc_available_memory()
|
|
||||||
freeMemoryMb = Int(availableMemory / 1024 / 1024)
|
|
||||||
} else {
|
|
||||||
// Fallback: 使用物理内存(不准确但有值)
|
|
||||||
freeMemoryMb = Int(processInfo.physicalMemory / 1024 / 1024)
|
|
||||||
}
|
|
||||||
|
|
||||||
return IssueEvent.DeviceInfo(
|
return IssueEvent.DeviceInfo(
|
||||||
model: device.model,
|
model: device.model,
|
||||||
manufacturer: "Apple",
|
manufacturer: "Apple",
|
||||||
@ -272,6 +266,19 @@ public final class BugCollectSDK {
|
|||||||
freeMemoryMb: freeMemoryMb,
|
freeMemoryMb: freeMemoryMb,
|
||||||
buildType: isDebug() ? "debug" : "release"
|
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 {
|
private func isSimulator() -> Bool {
|
||||||
|
|||||||
@ -7,7 +7,6 @@ final class LogQueue {
|
|||||||
private let sdkName: String
|
private let sdkName: String
|
||||||
private let sdkVersion: String
|
private let sdkVersion: String
|
||||||
private let uploader: LogUploader
|
private let uploader: LogUploader
|
||||||
private let storage: LogStorage
|
|
||||||
|
|
||||||
private var queue: [Any] = []
|
private var queue: [Any] = []
|
||||||
private let queueLock = NSLock()
|
private let queueLock = NSLock()
|
||||||
@ -20,7 +19,6 @@ final class LogQueue {
|
|||||||
self.sdkName = sdkName
|
self.sdkName = sdkName
|
||||||
self.sdkVersion = sdkVersion
|
self.sdkVersion = sdkVersion
|
||||||
self.uploader = LogUploader(appKey: appKey)
|
self.uploader = LogUploader(appKey: appKey)
|
||||||
self.storage = LogStorage(appKey: appKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动定时上传
|
/// 启动定时上传
|
||||||
@ -88,12 +86,12 @@ final class LogQueue {
|
|||||||
|
|
||||||
/// 上传待处理的崩溃
|
/// 上传待处理的崩溃
|
||||||
private func uploadPendingCrashes() {
|
private func uploadPendingCrashes() {
|
||||||
let crashes = storage.loadPendingCrashes()
|
let crashes = LogStorage.loadPendingCrashes()
|
||||||
guard !crashes.isEmpty else { return }
|
guard !crashes.isEmpty else { return }
|
||||||
|
|
||||||
uploader.uploadIssues(crashes) { [weak self] success in
|
uploader.uploadIssues(crashes) { success in
|
||||||
if success {
|
if success {
|
||||||
self?.storage.clearPendingCrashes()
|
LogStorage.clearPendingCrashes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Decrypted content of the init config file (xuqm/config.xuqm).
|
/// Decrypted content of the init config file (xuqm/config.xuqm).
|
||||||
/// Contains the appKey and optional server URL needed to bootstrap the SDK.
|
/// 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 }
|
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 {
|
public func awaitInitialization() async {
|
||||||
guard !isInitialized else { return }
|
guard !isInitialized else { return }
|
||||||
await withCheckedContinuation { cont in
|
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.
|
/// Initialize from XuqmSDK's config. Called when LicenseSDK is used before manual init.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func ensureInitializedFromSDK() -> Bool {
|
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)
|
initialize(appKey: config.appKey)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -164,11 +164,16 @@ public final class UpdateSDK {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func getDeviceInfo() -> DeviceInfo {
|
private func getDeviceInfo() -> DeviceInfo {
|
||||||
|
#if canImport(UIKit)
|
||||||
let device = UIDevice.current
|
let device = UIDevice.current
|
||||||
let deviceId = device.identifierForVendor?.uuidString ?? ""
|
return DeviceInfo(
|
||||||
let model = device.model
|
deviceId: device.identifierForVendor?.uuidString ?? "",
|
||||||
let osVersion = "iOS \(device.systemVersion)"
|
model: device.model,
|
||||||
return DeviceInfo(deviceId: deviceId, model: model, osVersion: osVersion)
|
osVersion: "iOS \(device.systemVersion)"
|
||||||
|
)
|
||||||
|
#else
|
||||||
|
return DeviceInfo(deviceId: XuqmSDK.shared.deviceId, model: "Mac", osVersion: ProcessInfo.processInfo.operatingSystemVersionString)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cache
|
// MARK: - Cache
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
|
||||||
import XuqmCoreSDK
|
import XuqmCoreSDK
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
/// JSBridge 标准处理器
|
/// JSBridge 标准处理器 — 提供 xuqm.* 命名空间的标准桥接方法。
|
||||||
/// 提供 xuqm.* 命名空间的标准桥接方法
|
/// 类型在所有平台可见;UIKit 依赖的实现仅在 iOS 上编译。
|
||||||
|
@MainActor
|
||||||
public final class XWebViewStandardHandlers {
|
public final class XWebViewStandardHandlers {
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
private weak var viewController: UIViewController?
|
private weak var viewController: UIViewController?
|
||||||
private var onClose: (() -> Void)?
|
private var onClose: (() -> Void)?
|
||||||
|
|
||||||
@ -13,155 +17,103 @@ public final class XWebViewStandardHandlers {
|
|||||||
self.viewController = viewController
|
self.viewController = viewController
|
||||||
self.onClose = onClose
|
self.onClose = onClose
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
public init() {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Message Dispatch
|
||||||
|
|
||||||
/// 处理 JS 消息
|
|
||||||
/// - Returns: 是否已处理
|
|
||||||
public func handleMessage(_ message: [String: Any]) -> Bool {
|
public func handleMessage(_ message: [String: Any]) -> Bool {
|
||||||
guard let action = message["action"] as? String else {
|
#if canImport(UIKit)
|
||||||
return false
|
guard let action = message["action"] as? String else { return false }
|
||||||
}
|
|
||||||
|
|
||||||
let callbackId = message["callbackId"] as? String
|
let callbackId = message["callbackId"] as? String
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case "xuqm.getDeviceInfo":
|
case "xuqm.getDeviceInfo": handleGetDeviceInfo(callbackId: callbackId); return true
|
||||||
handleGetDeviceInfo(callbackId: callbackId)
|
case "xuqm.getToken": handleGetToken(callbackId: callbackId); return true
|
||||||
return true
|
case "xuqm.getUserInfo": handleGetUserInfo(callbackId: callbackId); return true
|
||||||
case "xuqm.getToken":
|
case "xuqm.closeWebView": onClose?(); return true
|
||||||
handleGetToken(callbackId: callbackId)
|
case "xuqm.showToast": handleShowToast(message: message); return true
|
||||||
return true
|
default: return false
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Handlers
|
// MARK: - Handlers (UIKit only)
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
private func handleGetDeviceInfo(callbackId: String?) {
|
private func handleGetDeviceInfo(callbackId: String?) {
|
||||||
guard let callbackId = callbackId else { return }
|
guard let callbackId else { return }
|
||||||
|
|
||||||
let device = UIDevice.current
|
let device = UIDevice.current
|
||||||
let model = device.model
|
|
||||||
let osVersion = "iOS \(device.systemVersion)"
|
|
||||||
let platform = "IOS"
|
|
||||||
let deviceId = device.identifierForVendor?.uuidString ?? ""
|
|
||||||
|
|
||||||
callCallback(callbackId, data: [
|
callCallback(callbackId, data: [
|
||||||
"model": model,
|
"model": device.model,
|
||||||
"osVersion": osVersion,
|
"osVersion": "iOS \(device.systemVersion)",
|
||||||
"platform": platform,
|
"platform": "IOS",
|
||||||
"deviceId": deviceId,
|
"deviceId": device.identifierForVendor?.uuidString ?? "",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleGetToken(callbackId: String?) {
|
private func handleGetToken(callbackId: String?) {
|
||||||
guard let callbackId = callbackId else { return }
|
guard let callbackId else { return }
|
||||||
let token = XuqmSDK.shared.token ?? ""
|
callCallback(callbackId, data: ["token": XuqmSDK.shared.token ?? ""])
|
||||||
callCallback(callbackId, data: ["token": token])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleGetUserInfo(callbackId: String?) {
|
private func handleGetUserInfo(callbackId: String?) {
|
||||||
guard let callbackId = callbackId else { return }
|
guard let callbackId else { return }
|
||||||
let userId = XuqmSDK.shared.currentUserId ?? ""
|
// userId is @MainActor; we post empty now — override in app layer if needed
|
||||||
callCallback(callbackId, data: ["userId": userId])
|
callCallback(callbackId, data: ["userId": XuqmSDK.shared.userId ?? ""])
|
||||||
}
|
|
||||||
|
|
||||||
private func handleCloseWebView() {
|
|
||||||
onClose?()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleShowToast(message: [String: Any]) {
|
private func handleShowToast(message: [String: Any]) {
|
||||||
guard let msg = message["message"] as? String else { return }
|
guard let msg = message["message"] as? String else { return }
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let vc = self?.viewController else { return }
|
guard let vc = self?.viewController else { return }
|
||||||
let alert = UIAlertController(title: nil, message: msg, preferredStyle: .alert)
|
let alert = UIAlertController(title: nil, message: msg, preferredStyle: .alert)
|
||||||
vc.present(alert, animated: true)
|
vc.present(alert, animated: true)
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { alert.dismiss(animated: true) }
|
||||||
alert.dismiss(animated: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Callback
|
|
||||||
|
|
||||||
private func callCallback(_ callbackId: String, data: [String: Any]) {
|
private func callCallback(_ callbackId: String, data: [String: Any]) {
|
||||||
guard let jsonData = try? JSONSerialization.data(withJSONObject: data),
|
guard let jsonData = try? JSONSerialization.data(withJSONObject: data),
|
||||||
let jsonString = String(data: jsonData, encoding: .utf8) else {
|
let jsonString = String(data: jsonData, encoding: .utf8) else { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let js = "window[\"\(callbackId)\"](\(jsonString))"
|
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 代码
|
// MARK: - Injected JS (all platforms)
|
||||||
public static var injectedJavaScript: String {
|
|
||||||
return """
|
public nonisolated static var injectedJavaScript: String {
|
||||||
|
"""
|
||||||
(function() {
|
(function() {
|
||||||
window.xuqm = window.xuqm || {};
|
window.xuqm = window.xuqm || {};
|
||||||
|
|
||||||
window.xuqm.getDeviceInfo = function(callback) {
|
window.xuqm.getDeviceInfo = function(callback) {
|
||||||
var callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
var callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||||
window[callbackId] = function(data) {
|
window[callbackId] = function(data) { if (callback) callback(data); delete window[callbackId]; };
|
||||||
if (callback) callback(data);
|
window.ReactNativeWebView.postMessage(JSON.stringify({ action: 'xuqm.getDeviceInfo', callbackId: callbackId }));
|
||||||
delete window[callbackId];
|
|
||||||
};
|
|
||||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
|
||||||
action: 'xuqm.getDeviceInfo',
|
|
||||||
callbackId: callbackId
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.xuqm.getToken = function(callback) {
|
window.xuqm.getToken = function(callback) {
|
||||||
var callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
var callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||||
window[callbackId] = function(data) {
|
window[callbackId] = function(data) { if (callback) callback(data.token); delete window[callbackId]; };
|
||||||
if (callback) callback(data.token);
|
window.ReactNativeWebView.postMessage(JSON.stringify({ action: 'xuqm.getToken', callbackId: callbackId }));
|
||||||
delete window[callbackId];
|
|
||||||
};
|
|
||||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
|
||||||
action: 'xuqm.getToken',
|
|
||||||
callbackId: callbackId
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.xuqm.getUserInfo = function(callback) {
|
window.xuqm.getUserInfo = function(callback) {
|
||||||
var callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
var callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||||
window[callbackId] = function(data) {
|
window[callbackId] = function(data) { if (callback) callback(data); delete window[callbackId]; };
|
||||||
if (callback) callback(data);
|
window.ReactNativeWebView.postMessage(JSON.stringify({ action: 'xuqm.getUserInfo', callbackId: callbackId }));
|
||||||
delete window[callbackId];
|
|
||||||
};
|
|
||||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
|
||||||
action: 'xuqm.getUserInfo',
|
|
||||||
callbackId: callbackId
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.xuqm.closeWebView = function() {
|
window.xuqm.closeWebView = function() {
|
||||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
window.ReactNativeWebView.postMessage(JSON.stringify({ action: 'xuqm.closeWebView' }));
|
||||||
action: 'xuqm.closeWebView'
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.xuqm.showToast = function(message) {
|
window.xuqm.showToast = function(message) {
|
||||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
window.ReactNativeWebView.postMessage(JSON.stringify({ action: 'xuqm.showToast', message: message }));
|
||||||
action: 'xuqm.showToast',
|
|
||||||
message: message
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
true;
|
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 {
|
extension Notification.Name {
|
||||||
static let xWebViewCallJs = Notification.Name("xWebViewCallJs")
|
static let xWebViewCallJs = Notification.Name("xWebViewCallJs")
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@ -8,9 +8,21 @@ public struct XWebViewConfig: Sendable {
|
|||||||
public var hideStatusBar: Bool
|
public var hideStatusBar: Bool
|
||||||
public var userAgent: String?
|
public var userAgent: String?
|
||||||
public var injectedJavaScript: 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.
|
/// Where intercepted WebView downloads are saved.
|
||||||
public var downloadDestination: FileDownloadDestination
|
public var downloadDestination: FileDownloadDestination
|
||||||
|
/// Custom message callback — receives raw JSON string from H5 via jsBridgeName bridge.
|
||||||
public var onMessage: (@Sendable (String) -> Void)?
|
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(
|
public init(
|
||||||
url: String = "",
|
url: String = "",
|
||||||
@ -19,8 +31,13 @@ public struct XWebViewConfig: Sendable {
|
|||||||
hideStatusBar: Bool = false,
|
hideStatusBar: Bool = false,
|
||||||
userAgent: String? = nil,
|
userAgent: String? = nil,
|
||||||
injectedJavaScript: String? = nil,
|
injectedJavaScript: String? = nil,
|
||||||
|
jsBridgeName: String = "SZYX_APP",
|
||||||
downloadDestination: FileDownloadDestination = .sandbox,
|
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.url = url
|
||||||
self.title = title
|
self.title = title
|
||||||
@ -28,8 +45,13 @@ public struct XWebViewConfig: Sendable {
|
|||||||
self.hideStatusBar = hideStatusBar
|
self.hideStatusBar = hideStatusBar
|
||||||
self.userAgent = userAgent
|
self.userAgent = userAgent
|
||||||
self.injectedJavaScript = injectedJavaScript
|
self.injectedJavaScript = injectedJavaScript
|
||||||
|
self.jsBridgeName = jsBridgeName
|
||||||
self.downloadDestination = downloadDestination
|
self.downloadDestination = downloadDestination
|
||||||
self.onMessage = onMessage
|
self.onMessage = onMessage
|
||||||
|
self.onClose = onClose
|
||||||
|
self.onPageError = onPageError
|
||||||
|
self.onProgressChanged = onProgressChanged
|
||||||
|
self.onNavigationChanged = onNavigationChanged
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import XuqmCoreSDK
|
||||||
import XuqmFileSDK
|
import XuqmFileSDK
|
||||||
|
|
||||||
// JS bridge name used by DIALOG_OVERRIDE_JS to post messages to native.
|
// Internal bridge name for SDK-owned messages (__xwv, xuqm.*)
|
||||||
private let kBridgeName = "ReactNativeWebView"
|
private let kInternalBridgeName = "ReactNativeWebView"
|
||||||
|
|
||||||
// Injected into every page to override browser dialogs and intercept downloads.
|
// Injected into every page to override browser dialogs and intercept downloads.
|
||||||
private let dialogOverrideJs = #"""
|
private let dialogOverrideJs = #"""
|
||||||
@ -107,7 +108,6 @@ public struct XWebViewView: UIViewRepresentable {
|
|||||||
public func makeUIView(context: Context) -> WKWebView {
|
public func makeUIView(context: Context) -> WKWebView {
|
||||||
let userController = WKUserContentController()
|
let userController = WKUserContentController()
|
||||||
|
|
||||||
// Inject DIALOG_OVERRIDE_JS + user script at document end.
|
|
||||||
let script = WKUserScript(
|
let script = WKUserScript(
|
||||||
source: buildInjectedJs(config),
|
source: buildInjectedJs(config),
|
||||||
injectionTime: .atDocumentEnd,
|
injectionTime: .atDocumentEnd,
|
||||||
@ -115,8 +115,14 @@ public struct XWebViewView: UIViewRepresentable {
|
|||||||
)
|
)
|
||||||
userController.addUserScript(script)
|
userController.addUserScript(script)
|
||||||
|
|
||||||
// Register JS → Native message channel via weak proxy to avoid retain cycle.
|
// Internal SDK bridge (dialog overrides, download, xuqm.*)
|
||||||
userController.add(WeakScriptHandler(context.coordinator), name: kBridgeName)
|
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()
|
let configuration = WKWebViewConfiguration()
|
||||||
configuration.userContentController = userController
|
configuration.userContentController = userController
|
||||||
@ -143,7 +149,12 @@ public struct XWebViewView: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
|
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()
|
coordinator.detach()
|
||||||
if XWebViewBridge.shared.currentController() === coordinator {
|
if XWebViewBridge.shared.currentController() === coordinator {
|
||||||
XWebViewBridge.shared.setController(nil)
|
XWebViewBridge.shared.setController(nil)
|
||||||
@ -154,13 +165,32 @@ public struct XWebViewView: UIViewRepresentable {
|
|||||||
public final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, XWebViewController {
|
public final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, XWebViewController {
|
||||||
private weak var webView: WKWebView?
|
private weak var webView: WKWebView?
|
||||||
private let config: XWebViewConfig
|
private let config: XWebViewConfig
|
||||||
|
private var progressObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
|
let appBridgeName: String
|
||||||
|
|
||||||
init(config: XWebViewConfig) {
|
init(config: XWebViewConfig) {
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.appBridgeName = config.jsBridgeName
|
||||||
}
|
}
|
||||||
|
|
||||||
func attach(_ webView: WKWebView) { self.webView = webView }
|
func attach(_ webView: WKWebView) {
|
||||||
func detach() { webView = nil }
|
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 canGoBack() -> Bool { webView?.canGoBack == true }
|
||||||
public func canGoForward() -> Bool { webView?.canGoForward == true }
|
public func canGoForward() -> Bool { webView?.canGoForward == true }
|
||||||
@ -176,11 +206,64 @@ public struct XWebViewView: UIViewRepresentable {
|
|||||||
webView?.evaluateJavaScript(js, completionHandler: nil)
|
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(
|
public func userContentController(
|
||||||
_ userContentController: WKUserContentController,
|
_ userContentController: WKUserContentController,
|
||||||
didReceive message: WKScriptMessage
|
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 raw = message.body as? String else { return }
|
||||||
guard let data = raw.data(using: .utf8),
|
guard let data = raw.data(using: .utf8),
|
||||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
@ -188,7 +271,7 @@ public struct XWebViewView: UIViewRepresentable {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle StandardHandlers (xuqm.* actions)
|
// StandardHandlers (xuqm.* actions)
|
||||||
if let action = json["action"] as? String, action.hasPrefix("xuqm.") {
|
if let action = json["action"] as? String, action.hasPrefix("xuqm.") {
|
||||||
handleStandardAction(action, message: json)
|
handleStandardAction(action, message: json)
|
||||||
return
|
return
|
||||||
@ -215,29 +298,22 @@ public struct XWebViewView: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 处理 StandardHandlers 动作
|
// MARK: - Standard action handlers
|
||||||
|
|
||||||
private func handleStandardAction(_ action: String, message: [String: Any]) {
|
private func handleStandardAction(_ action: String, message: [String: Any]) {
|
||||||
let callbackId = message["callbackId"] as? String
|
let callbackId = message["callbackId"] as? String
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case "xuqm.getDeviceInfo":
|
case "xuqm.getDeviceInfo": handleGetDeviceInfo(callbackId: callbackId)
|
||||||
handleGetDeviceInfo(callbackId: callbackId)
|
case "xuqm.getToken": handleGetToken(callbackId: callbackId)
|
||||||
case "xuqm.getToken":
|
case "xuqm.getUserInfo": handleGetUserInfo(callbackId: callbackId)
|
||||||
handleGetToken(callbackId: callbackId)
|
case "xuqm.closeWebView": config.onClose?()
|
||||||
case "xuqm.getUserInfo":
|
case "xuqm.showToast": break
|
||||||
handleGetUserInfo(callbackId: callbackId)
|
default: break
|
||||||
case "xuqm.closeWebView":
|
|
||||||
config.onClose?()
|
|
||||||
case "xuqm.showToast":
|
|
||||||
// iOS 不支持原生 Toast,可以使用 alert
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleGetDeviceInfo(callbackId: String?) {
|
private func handleGetDeviceInfo(callbackId: String?) {
|
||||||
guard let callbackId = callbackId else { return }
|
guard let callbackId else { return }
|
||||||
let device = UIDevice.current
|
let device = UIDevice.current
|
||||||
let data: [String: Any] = [
|
let data: [String: Any] = [
|
||||||
"model": device.model,
|
"model": device.model,
|
||||||
@ -249,26 +325,24 @@ public struct XWebViewView: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleGetToken(callbackId: String?) {
|
private func handleGetToken(callbackId: String?) {
|
||||||
guard let callbackId = callbackId else { return }
|
guard let callbackId else { return }
|
||||||
let token = XuqmSDK.shared.token ?? ""
|
callCallback(callbackId, data: ["token": XuqmSDK.shared.token ?? ""])
|
||||||
callCallback(callbackId, data: ["token": token])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleGetUserInfo(callbackId: String?) {
|
private func handleGetUserInfo(callbackId: String?) {
|
||||||
guard let callbackId = callbackId else { return }
|
guard let callbackId else { return }
|
||||||
let userId = XuqmSDK.shared.currentUserId ?? ""
|
callCallback(callbackId, data: ["userId": XuqmSDK.shared.userId ?? ""])
|
||||||
callCallback(callbackId, data: ["userId": userId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func callCallback(_ callbackId: String, data: [String: Any]) {
|
private func callCallback(_ callbackId: String, data: [String: Any]) {
|
||||||
guard let jsonData = try? JSONSerialization.data(withJSONObject: data),
|
guard let jsonData = try? JSONSerialization.data(withJSONObject: data),
|
||||||
let jsonString = String(data: jsonData, encoding: .utf8) else {
|
let jsonString = String(data: jsonData, encoding: .utf8) else { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
let js = "window[\"\(callbackId)\"](\(jsonString))"
|
let js = "window[\"\(callbackId)\"](\(jsonString))"
|
||||||
webView?.evaluateJavaScript(js, completionHandler: nil)
|
webView?.evaluateJavaScript(js, completionHandler: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Download handling
|
||||||
|
|
||||||
private func dispatchDownloadEvent(_ name: String, url: String, extra: String = "") {
|
private func dispatchDownloadEvent(_ name: String, url: String, extra: String = "") {
|
||||||
let escaped = url.jsEscaped
|
let escaped = url.jsEscaped
|
||||||
let js = "window.dispatchEvent(new CustomEvent('\(name)',{detail:{url:'\(escaped)'\(extra)}}));"
|
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) {
|
private func openOrSave(localURL: URL, destination: FileDownloadDestination) {
|
||||||
guard let wv = webView else { return }
|
guard let wv = webView else { return }
|
||||||
#if canImport(UIKit)
|
|
||||||
switch destination {
|
switch destination {
|
||||||
case .sandbox:
|
case .sandbox:
|
||||||
let vc = UIActivityViewController(activityItems: [localURL], applicationActivities: nil)
|
let vc = UIActivityViewController(activityItems: [localURL], applicationActivities: nil)
|
||||||
@ -320,10 +393,8 @@ public struct XWebViewView: UIViewRepresentable {
|
|||||||
let picker = UIDocumentPickerViewController(forExporting: [localURL], asCopy: true)
|
let picker = UIDocumentPickerViewController(forExporting: [localURL], asCopy: true)
|
||||||
topViewController(from: wv)?.present(picker, animated: true)
|
topViewController(from: wv)?.present(picker, animated: true)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(UIKit)
|
|
||||||
private func topViewController(from view: UIView) -> UIViewController? {
|
private func topViewController(from view: UIView) -> UIViewController? {
|
||||||
var responder: UIResponder? = view
|
var responder: UIResponder? = view
|
||||||
while let r = responder {
|
while let r = responder {
|
||||||
@ -332,18 +403,6 @@ public struct XWebViewView: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
return nil
|
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
普通文件
5
version.properties
普通文件
@ -0,0 +1,5 @@
|
|||||||
|
# iOS SDK 统一版本号
|
||||||
|
# Jenkins 读写此文件管理发版版本号
|
||||||
|
# 正式发版时 Jenkins 自动升级并打 git tag
|
||||||
|
# SNAPSHOT 模式下此文件不变,应用通过 branch: "main" 引用最新代码
|
||||||
|
SDK_VERSION=1.0.1
|
||||||
正在加载...
在新工单中引用
屏蔽一个用户