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/
|
||||
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
|
||||
|
||||
291
Jenkinsfile
vendored
291
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,172 +51,151 @@ pipeline {
|
||||
script {
|
||||
env.GIT_COMMIT_SHORT = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
|
||||
|
||||
// ── 根据复选框收集选中的模块 ──────────────────────────
|
||||
def moduleChecks = [
|
||||
'core': params.MOD_CORE,
|
||||
'im': params.MOD_IM,
|
||||
'push': params.MOD_PUSH,
|
||||
'update': params.MOD_UPDATE,
|
||||
'license': params.MOD_LICENSE,
|
||||
'file': params.MOD_FILE,
|
||||
'webview': params.MOD_WEBVIEW,
|
||||
'XuqmCoreSDK': params.MOD_CORE,
|
||||
'XuqmImSDK': params.MOD_IM,
|
||||
'XuqmPushSDK': params.MOD_PUSH,
|
||||
'XuqmUpdateSDK': params.MOD_UPDATE,
|
||||
'XuqmLicenseSDK': params.MOD_LICENSE,
|
||||
'XuqmFileSDK': params.MOD_FILE,
|
||||
'XuqmWebViewSDK': params.MOD_WEBVIEW,
|
||||
'XuqmBugCollectSDK': params.MOD_BUGCOLLECT,
|
||||
]
|
||||
def requested = moduleChecks.findAll { k, v -> v }.collect { k, v -> k }
|
||||
if (requested.isEmpty()) {
|
||||
error "没有选择任何模块,请至少勾选一个"
|
||||
def changed = moduleChecks.findAll { k, v -> v }.collect { k, v -> k }
|
||||
if (changed.isEmpty()) {
|
||||
error("没有勾选任何模块,请至少标记一个有改动的模块")
|
||||
}
|
||||
env.SELECTED_MODULES = requested.join(',')
|
||||
echo "Selected modules: ${env.SELECTED_MODULES}"
|
||||
|
||||
// ── 模块 → podspec/tag 映射 ────────────────────────────
|
||||
def moduleMap = [
|
||||
'core': [podspec: 'XuqmCoreSDK', tagPrefix: 'core'],
|
||||
'im': [podspec: 'XuqmImSDK', tagPrefix: 'im'],
|
||||
'push': [podspec: 'XuqmPushSDK', tagPrefix: 'push'],
|
||||
'update': [podspec: 'XuqmUpdateSDK', tagPrefix: 'update'],
|
||||
'license': [podspec: 'XuqmLicenseSDK', tagPrefix: 'license'],
|
||||
'file': [podspec: 'XuqmFileSDK', tagPrefix: 'file'],
|
||||
'webview': [podspec: 'XuqmWebViewSDK', tagPrefix: 'webview'],
|
||||
]
|
||||
env.MODULE_MAP = groovy.json.JsonOutput.toJson(moduleMap)
|
||||
env.CHANGED_MODULES = changed.join(', ')
|
||||
echo "Changed modules: ${env.CHANGED_MODULES}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Resolve Versions') {
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
stage('Resolve Version') {
|
||||
steps {
|
||||
script {
|
||||
def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP)
|
||||
def modules = env.SELECTED_MODULES.split(',')
|
||||
def propsText = readFile('version.properties')
|
||||
def match = (propsText =~ /SDK_VERSION=([0-9][^\s#\r\n]*)/)
|
||||
if (!match) { error("version.properties 中找不到 SDK_VERSION") }
|
||||
def currentVer = match[0][1].replaceAll(/-SNAPSHOT$/, '')
|
||||
|
||||
for (mod in modules) {
|
||||
def podspecFile = "${moduleMap[mod].podspec}.podspec"
|
||||
def currentVer = sh(
|
||||
script: "grep \"s.version\" ${podspecFile} | head -1 | sed \"s/.*= *\\\"\\(.*\\)\\\".*/\\1/\"",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
// 根据 VERSION_BUMP 策略自动升级版本号
|
||||
def parts = currentVer.tokenize('.')
|
||||
def bumpVersion = { String ver ->
|
||||
def parts = ver.tokenize('.')
|
||||
while (parts.size() < 3) { parts.add('0') }
|
||||
switch (params.VERSION_BUMP) {
|
||||
case 'major':
|
||||
parts[0] = (parts[0].toInteger() + 1).toString()
|
||||
parts[1] = '0'
|
||||
parts[2] = '0'
|
||||
break
|
||||
case 'minor':
|
||||
parts[1] = (parts[1].toInteger() + 1).toString()
|
||||
parts[2] = '0'
|
||||
break
|
||||
case 'patch':
|
||||
default:
|
||||
parts[2] = (parts[2].toInteger() + 1).toString()
|
||||
break
|
||||
}
|
||||
def newVer = parts.join('.')
|
||||
def major = parts[0].toInteger()
|
||||
def minor = parts[1].toInteger()
|
||||
def patch = parts[2].toInteger()
|
||||
if (params.VERSION_BUMP == 'major') { major++; minor = 0; patch = 0 }
|
||||
else if (params.VERSION_BUMP == 'minor') { minor++; patch = 0 }
|
||||
else { patch++ }
|
||||
return "${major}.${minor}.${patch}"
|
||||
}
|
||||
|
||||
echo "Bumping ${moduleMap[mod].podspec}: ${currentVer} → ${newVer}"
|
||||
sh "sed -i '' 's/s.version.*= .*/s.version = \"${newVer}\"/' ${podspecFile}"
|
||||
env["VERSION_${mod}"] = newVer
|
||||
if (params.SNAPSHOT) {
|
||||
// SNAPSHOT:version.properties 不变,展示下一版本的 SNAPSHOT 标签
|
||||
def nextVer = bumpVersion(currentVer)
|
||||
env.PUBLISH_VERSION = "${nextVer}-SNAPSHOT"
|
||||
env.RELEASE_TAG = ''
|
||||
echo "SNAPSHOT 模式 — 当前 Release: ${currentVer},本次标记: ${env.PUBLISH_VERSION}"
|
||||
} else {
|
||||
// Release:升级并写回
|
||||
def newVer = bumpVersion(currentVer)
|
||||
env.PUBLISH_VERSION = newVer
|
||||
env.RELEASE_TAG = newVer
|
||||
def newProps = propsText.replaceAll(/SDK_VERSION=[^\s#\r\n]+/, "SDK_VERSION=${newVer}")
|
||||
writeFile(file: 'version.properties', text: newProps)
|
||||
echo "Release 模式 — ${currentVer} → ${newVer}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
stage('Build Info') {
|
||||
steps {
|
||||
script {
|
||||
def modules = env.SELECTED_MODULES.split(',')
|
||||
def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP)
|
||||
echo "Git Commit : ${env.GIT_COMMIT_SHORT}"
|
||||
echo "Bump Strategy: ${params.VERSION_BUMP}"
|
||||
echo "Modules (${modules.size()}):"
|
||||
for (mod in modules) {
|
||||
def ver = env["VERSION_${mod}"]
|
||||
echo " ${mod.padRight(10)} → ${moduleMap[mod].podspec} v${ver}"
|
||||
}
|
||||
echo "================================================"
|
||||
echo " iOS SDK — ${params.SNAPSHOT ? 'SNAPSHOT' : 'Release'}"
|
||||
echo " 版本 : ${env.PUBLISH_VERSION}"
|
||||
echo " Tag : ${env.RELEASE_TAG ?: '不打 tag'}"
|
||||
echo " Commit : ${env.GIT_COMMIT_SHORT}"
|
||||
echo " 改动模块 : ${env.CHANGED_MODULES}"
|
||||
echo "================================================"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
stage('Swift Build') {
|
||||
steps {
|
||||
sh 'swift build'
|
||||
sh 'swift build -c release 2>&1'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Pod Spec Lint') {
|
||||
when { expression { return params.PUBLISH_PODS } }
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
stage('Swift Test') {
|
||||
when { expression { return params.RUN_TESTS } }
|
||||
steps {
|
||||
sh 'swift test 2>&1'
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// SNAPSHOT 分支:只推送代码到 main,不打 tag,不修改版本文件
|
||||
stage('Publish SNAPSHOT') {
|
||||
when { expression { return params.SNAPSHOT } }
|
||||
steps {
|
||||
withCredentials([string(credentialsId: 'GITEA_TOKEN', variable: 'TOKEN')]) {
|
||||
sh """
|
||||
git config user.email "jenkins@xuqm.com"
|
||||
git config user.name "Jenkins CI"
|
||||
git push https://xuqinmin12:\${TOKEN}@xuqinmin.com/xuqmGroup/XuqmGroup-iOSSDK.git HEAD:main
|
||||
"""
|
||||
}
|
||||
script {
|
||||
def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP)
|
||||
def modules = env.SELECTED_MODULES.split(',')
|
||||
// 按依赖顺序 lint:先 core,再其他模块
|
||||
def ordered = ['core'] + modules.findAll { it != 'core' }
|
||||
for (mod in ordered) {
|
||||
def podspec = "${moduleMap[mod].podspec}.podspec"
|
||||
if (fileExists(podspec)) {
|
||||
echo "Linting ${podspec}..."
|
||||
sh "pod spec lint ${podspec} --allow-warnings"
|
||||
}
|
||||
}
|
||||
echo "SNAPSHOT 已推送到 main 分支 (${env.GIT_COMMIT_SHORT})"
|
||||
echo "应用端 project.yml 使用: branch: \"main\""
|
||||
echo "Xcode: File > Packages > Update to Latest Package Versions 获取最新代码"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Publish SPM (Git Tags)') {
|
||||
when { expression { return params.PUBLISH_SPM } }
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Release 分支:升版本号 commit → 打 tag → 推送 commit + tag
|
||||
stage('Commit & Tag Release') {
|
||||
when { expression { return !params.SNAPSHOT } }
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: 'gitlab-credentials', passwordVariable: 'GIT_PASS', usernameVariable: 'GIT_USER')]) {
|
||||
withCredentials([string(credentialsId: 'GITEA_TOKEN', variable: 'TOKEN')]) {
|
||||
sh """
|
||||
git config user.email "jenkins@xuqm.com"
|
||||
git config user.name "Jenkins CI"
|
||||
git add version.properties
|
||||
git commit -m "ci: release ${env.RELEASE_TAG} [${env.CHANGED_MODULES}]"
|
||||
git tag ${env.RELEASE_TAG}
|
||||
git push https://xuqinmin12:\${TOKEN}@xuqinmin.com/xuqmGroup/XuqmGroup-iOSSDK.git HEAD:main
|
||||
git push https://xuqinmin12:\${TOKEN}@xuqinmin.com/xuqmGroup/XuqmGroup-iOSSDK.git ${env.RELEASE_TAG}
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Release 分支:在 Gitea 上创建 Release 记录
|
||||
stage('Create Gitea Release') {
|
||||
when { expression { return !params.SNAPSHOT } }
|
||||
steps {
|
||||
withCredentials([string(credentialsId: 'GITEA_TOKEN', variable: 'TOKEN')]) {
|
||||
script {
|
||||
def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP)
|
||||
def modules = env.SELECTED_MODULES.split(',')
|
||||
def tagsToPush = []
|
||||
|
||||
for (mod in modules) {
|
||||
def ver = env["VERSION_${mod}"]
|
||||
if (ver) {
|
||||
def tag = "${moduleMap[mod].tagPrefix}/${ver}"
|
||||
tagsToPush.push(tag)
|
||||
}
|
||||
}
|
||||
|
||||
if (tagsToPush) {
|
||||
sh """
|
||||
git config user.email "jenkins@xuqm.com"
|
||||
git config user.name "Jenkins CI"
|
||||
git add *.podspec || true
|
||||
git diff --cached --quiet || git commit -m "ci: bump versions [${env.SELECTED_MODULES}]"
|
||||
"""
|
||||
for (tag in tagsToPush) {
|
||||
sh "git tag -f '${tag}'"
|
||||
}
|
||||
sh """
|
||||
git push https://${GIT_USER}:${GIT_PASS}@xuqinmin.com/xuqinmin12/XuqmGroup-iOSSDK.git HEAD:main --tags -f
|
||||
"""
|
||||
echo "Published tags: ${tagsToPush.join(', ')}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Publish CocoaPods') {
|
||||
when { expression { return params.PUBLISH_PODS } }
|
||||
steps {
|
||||
script {
|
||||
def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP)
|
||||
def modules = env.SELECTED_MODULES.split(',')
|
||||
sh "pod repo add xuqm-specs ${SPEC_REPO} 2>/dev/null || true"
|
||||
// 按依赖顺序发布:先 core,再其他模块
|
||||
def ordered = ['core'] + modules.findAll { it != 'core' }
|
||||
for (mod in ordered) {
|
||||
def podspec = "${moduleMap[mod].podspec}.podspec"
|
||||
if (fileExists(podspec)) {
|
||||
echo "Publishing ${podspec}..."
|
||||
sh "pod repo push xuqm-specs ${podspec} --allow-warnings"
|
||||
}
|
||||
def moduleList = env.CHANGED_MODULES.split(', ').collect { "- ${it}" }.join('\\n')
|
||||
def body = "## 改动模块\\n${moduleList}\\n\\n## 集成方式\\n\\`\\`\\`yaml\\npackages:\\n XuqmGroupSDK:\\n url: ${env.GITEA_REPO_URL}\\n from: \\"${env.RELEASE_TAG}\\"\\n\\`\\`\\`"
|
||||
sh """
|
||||
curl -sf -X POST "${GITEA_API_BASE}/repos/${GITEA_REPO_PATH}/releases" \\
|
||||
-H "Authorization: token \${TOKEN}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d "{\\\"tag_name\\\":\\\"${env.RELEASE_TAG}\\\",\\\"name\\\":\\\"v${env.RELEASE_TAG}\\\",\\\"body\\\":\\\"${body}\\\",\\\"draft\\\":false,\\\"prerelease\\\":false}" \\
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print('Release:', d.get('html_url','created'))"
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -218,14 +205,18 @@ pipeline {
|
||||
post {
|
||||
success {
|
||||
script {
|
||||
def modules = env.SELECTED_MODULES?.split(',') ?: []
|
||||
def moduleMap = new groovy.json.JsonSlurper().parseText(env.MODULE_MAP)
|
||||
def summary = modules.collect { "${it}=${env["VERSION_${it}"]}" }.join(', ')
|
||||
echo "iOS SDK published — ${summary} (Commit: ${env.GIT_COMMIT_SHORT})"
|
||||
if (params.SNAPSHOT) {
|
||||
echo "✅ SNAPSHOT ${env.PUBLISH_VERSION} 构建并推送成功 (commit: ${env.GIT_COMMIT_SHORT})"
|
||||
echo " 改动模块: ${env.CHANGED_MODULES}"
|
||||
} else {
|
||||
echo "✅ Release ${env.RELEASE_TAG} 发布成功"
|
||||
echo " 改动模块: ${env.CHANGED_MODULES}"
|
||||
echo " 应用端 project.yml 更新: from: \"${env.RELEASE_TAG}\""
|
||||
}
|
||||
}
|
||||
}
|
||||
failure {
|
||||
echo "iOS SDK build failed — please check the logs"
|
||||
echo "❌ iOS SDK 构建失败,请检查日志"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
return false
|
||||
case "xuqm.getDeviceInfo": handleGetDeviceInfo(callbackId: callbackId); return true
|
||||
case "xuqm.getToken": handleGetToken(callbackId: callbackId); return true
|
||||
case "xuqm.getUserInfo": handleGetUserInfo(callbackId: callbackId); return true
|
||||
case "xuqm.closeWebView": onClose?(); return true
|
||||
case "xuqm.showToast": handleShowToast(message: message); return true
|
||||
default: return false
|
||||
}
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Handlers
|
||||
// MARK: - Handlers (UIKit only)
|
||||
|
||||
#if canImport(UIKit)
|
||||
private func handleGetDeviceInfo(callbackId: String?) {
|
||||
guard let callbackId = callbackId else { return }
|
||||
|
||||
guard let callbackId else { return }
|
||||
let device = UIDevice.current
|
||||
let model = device.model
|
||||
let osVersion = "iOS \(device.systemVersion)"
|
||||
let platform = "IOS"
|
||||
let deviceId = device.identifierForVendor?.uuidString ?? ""
|
||||
|
||||
callCallback(callbackId, data: [
|
||||
"model": model,
|
||||
"osVersion": osVersion,
|
||||
"platform": platform,
|
||||
"deviceId": deviceId,
|
||||
"model": device.model,
|
||||
"osVersion": "iOS \(device.systemVersion)",
|
||||
"platform": "IOS",
|
||||
"deviceId": device.identifierForVendor?.uuidString ?? "",
|
||||
])
|
||||
}
|
||||
|
||||
private func handleGetToken(callbackId: String?) {
|
||||
guard let callbackId = callbackId else { return }
|
||||
let token = XuqmSDK.shared.token ?? ""
|
||||
callCallback(callbackId, data: ["token": token])
|
||||
guard let callbackId else { return }
|
||||
callCallback(callbackId, data: ["token": XuqmSDK.shared.token ?? ""])
|
||||
}
|
||||
|
||||
private func handleGetUserInfo(callbackId: String?) {
|
||||
guard let callbackId = callbackId else { return }
|
||||
let userId = XuqmSDK.shared.currentUserId ?? ""
|
||||
callCallback(callbackId, data: ["userId": userId])
|
||||
}
|
||||
|
||||
private func handleCloseWebView() {
|
||||
onClose?()
|
||||
guard let callbackId else { return }
|
||||
// userId is @MainActor; we post empty now — override in app layer if needed
|
||||
callCallback(callbackId, data: ["userId": XuqmSDK.shared.userId ?? ""])
|
||||
}
|
||||
|
||||
private func handleShowToast(message: [String: Any]) {
|
||||
guard let msg = message["message"] as? String else { return }
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let vc = self?.viewController else { return }
|
||||
let alert = UIAlertController(title: nil, message: msg, preferredStyle: .alert)
|
||||
vc.present(alert, animated: true)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
alert.dismiss(animated: true)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { alert.dismiss(animated: true) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Callback
|
||||
|
||||
private func callCallback(_ callbackId: String, data: [String: Any]) {
|
||||
guard let jsonData = try? JSONSerialization.data(withJSONObject: data),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) else {
|
||||
return
|
||||
}
|
||||
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) else { return }
|
||||
let js = "window[\"\(callbackId)\"](\(jsonString))"
|
||||
// The WebView will execute this JS through its controller
|
||||
NotificationCenter.default.post(
|
||||
name: .xWebViewCallJs,
|
||||
object: nil,
|
||||
userInfo: ["js": js]
|
||||
)
|
||||
NotificationCenter.default.post(name: .xWebViewCallJs, object: nil, userInfo: ["js": js])
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 生成注入的 JS 代码
|
||||
public static var injectedJavaScript: String {
|
||||
return """
|
||||
// MARK: - Injected JS (all platforms)
|
||||
|
||||
public nonisolated static var injectedJavaScript: String {
|
||||
"""
|
||||
(function() {
|
||||
window.xuqm = window.xuqm || {};
|
||||
|
||||
window.xuqm.getDeviceInfo = function(callback) {
|
||||
var callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
window[callbackId] = function(data) {
|
||||
if (callback) callback(data);
|
||||
delete window[callbackId];
|
||||
};
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||
action: 'xuqm.getDeviceInfo',
|
||||
callbackId: callbackId
|
||||
}));
|
||||
window[callbackId] = function(data) { if (callback) callback(data); delete window[callbackId]; };
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ action: 'xuqm.getDeviceInfo', callbackId: callbackId }));
|
||||
};
|
||||
|
||||
window.xuqm.getToken = function(callback) {
|
||||
var callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
window[callbackId] = function(data) {
|
||||
if (callback) callback(data.token);
|
||||
delete window[callbackId];
|
||||
};
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||
action: 'xuqm.getToken',
|
||||
callbackId: callbackId
|
||||
}));
|
||||
window[callbackId] = function(data) { if (callback) callback(data.token); delete window[callbackId]; };
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ action: 'xuqm.getToken', callbackId: callbackId }));
|
||||
};
|
||||
|
||||
window.xuqm.getUserInfo = function(callback) {
|
||||
var callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
window[callbackId] = function(data) {
|
||||
if (callback) callback(data);
|
||||
delete window[callbackId];
|
||||
};
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||
action: 'xuqm.getUserInfo',
|
||||
callbackId: callbackId
|
||||
}));
|
||||
window[callbackId] = function(data) { if (callback) callback(data); delete window[callbackId]; };
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ action: 'xuqm.getUserInfo', callbackId: callbackId }));
|
||||
};
|
||||
|
||||
window.xuqm.closeWebView = function() {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||
action: 'xuqm.closeWebView'
|
||||
}));
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ action: 'xuqm.closeWebView' }));
|
||||
};
|
||||
|
||||
window.xuqm.showToast = function(message) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||
action: 'xuqm.showToast',
|
||||
message: message
|
||||
}));
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ action: 'xuqm.showToast', message: message }));
|
||||
};
|
||||
})();
|
||||
true;
|
||||
@ -169,8 +121,10 @@ public final class XWebViewStandardHandlers {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Name
|
||||
// MARK: - Notification Name (UIKit only, used by WKWebView coordinator)
|
||||
|
||||
#if canImport(UIKit)
|
||||
extension Notification.Name {
|
||||
static let xWebViewCallJs = Notification.Name("xWebViewCallJs")
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -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 0–100.
|
||||
public var onProgressChanged: (@Sendable (Int) -> Void)?
|
||||
/// Called when the navigation stack changes. (canGoBack, canGoForward)
|
||||
public var onNavigationChanged: (@Sendable (Bool, Bool) -> Void)?
|
||||
|
||||
public init(
|
||||
url: String = "",
|
||||
@ -19,8 +31,13 @@ public struct XWebViewConfig: Sendable {
|
||||
hideStatusBar: Bool = false,
|
||||
userAgent: String? = nil,
|
||||
injectedJavaScript: String? = nil,
|
||||
jsBridgeName: String = "SZYX_APP",
|
||||
downloadDestination: FileDownloadDestination = .sandbox,
|
||||
onMessage: (@Sendable (String) -> Void)? = nil
|
||||
onMessage: (@Sendable (String) -> Void)? = nil,
|
||||
onClose: (@Sendable () -> Void)? = nil,
|
||||
onPageError: (@Sendable (String) -> Void)? = nil,
|
||||
onProgressChanged: (@Sendable (Int) -> Void)? = nil,
|
||||
onNavigationChanged: (@Sendable (Bool, Bool) -> Void)? = nil
|
||||
) {
|
||||
self.url = url
|
||||
self.title = title
|
||||
@ -28,8 +45,13 @@ public struct XWebViewConfig: Sendable {
|
||||
self.hideStatusBar = hideStatusBar
|
||||
self.userAgent = userAgent
|
||||
self.injectedJavaScript = injectedJavaScript
|
||||
self.jsBridgeName = jsBridgeName
|
||||
self.downloadDestination = downloadDestination
|
||||
self.onMessage = onMessage
|
||||
self.onClose = onClose
|
||||
self.onPageError = onPageError
|
||||
self.onProgressChanged = onProgressChanged
|
||||
self.onNavigationChanged = onNavigationChanged
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
普通文件
5
version.properties
普通文件
@ -0,0 +1,5 @@
|
||||
# iOS SDK 统一版本号
|
||||
# Jenkins 读写此文件管理发版版本号
|
||||
# 正式发版时 Jenkins 自动升级并打 git tag
|
||||
# SNAPSHOT 模式下此文件不变,应用通过 branch: "main" 引用最新代码
|
||||
SDK_VERSION=1.0.1
|
||||
正在加载...
在新工单中引用
屏蔽一个用户