feat: rebuild RN sdk runtime and plugin updates
这个提交包含在:
父节点
ac7e941744
当前提交
a902e30e0a
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,10 +1,12 @@
|
||||
node_modules/
|
||||
dist/
|
||||
**/dist-test/
|
||||
.DS_Store
|
||||
*.class
|
||||
target/
|
||||
build/
|
||||
.gradle/
|
||||
.native-gradle/
|
||||
*.iml
|
||||
.idea/
|
||||
*.log
|
||||
|
||||
6
.npmrc
6
.npmrc
@ -1,2 +1,4 @@
|
||||
registry=https://nexus.xuqinmin.com/repository/npm/
|
||||
legacy-peer-deps=true
|
||||
registry=https://registry.npmjs.org/
|
||||
@xuqm:registry=https://nexus.xuqinmin.com/repository/npm/
|
||||
auto-install-peers=false
|
||||
strict-peer-dependencies=true
|
||||
|
||||
8
.prettierignore
普通文件
8
.prettierignore
普通文件
@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
packages/*/android/build/
|
||||
packages/*/ios/build/
|
||||
*.bundle
|
||||
*.map
|
||||
pnpm-lock.yaml
|
||||
8
.prettierrc.json
普通文件
8
.prettierrc.json
普通文件
@ -0,0 +1,8 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"bracketSpacing": true,
|
||||
"printWidth": 100,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
29
AGENTS.md
普通文件
29
AGENTS.md
普通文件
@ -0,0 +1,29 @@
|
||||
# Repository Working Agreement
|
||||
|
||||
## Authorization gate
|
||||
|
||||
- For a newly requested change, first inspect requirements and produce a plan.
|
||||
- Do not modify files, publish artifacts, trigger Jenkins, or change external state until the user explicitly says `开始实施`.
|
||||
- Once implementation is authorized, keep work inside the frozen scope. A material scope expansion requires a new confirmation.
|
||||
|
||||
## Product goal
|
||||
|
||||
- A host that installs the Xuqm update SDK must be able to enable plugin architecture, create plugins, build, publish, update, and roll back with minimal configuration and the smallest practical amount of application code.
|
||||
- Tooling must be generic and must not depend on YiwangxinApp4 paths, aliases, global variables, or private runtime bridges.
|
||||
|
||||
## Architecture constraints
|
||||
|
||||
- Shared behavior must have one canonical implementation across common/update/xwebview/bugcollect.
|
||||
- Plugin runtime state, bundle manifests, compatibility checks, atomic activation, crash-safe rollback, and embedded recovery belong to the SDK/tooling rather than each host app.
|
||||
- CLI operations must support interactive local use and deterministic non-interactive CI use.
|
||||
- Do not integrate or expand `@xuqm/rn-push` for the current scope.
|
||||
- Public API changes must update package exports, native files where applicable, tests, examples, CLI help, README, and docs together.
|
||||
|
||||
## Delivery constraints
|
||||
|
||||
- Published npm packages must include every declared native file, CLI script, config template, and type required by documented usage.
|
||||
- Snapshot/alpha publication precedes host integration.
|
||||
- All XuqmGroup artifact publication and deployment must run through `https://jenkins.xuqinmin.com/`; do not publish npm packages, Maven artifacts, or services directly from a developer machine.
|
||||
- Jenkins typecheck/tests/builds are hard gates; do not swallow failures.
|
||||
- Before handoff, run formatting, lint, typecheck, tests, package-content verification, CLI fixture tests, Android builds, bundle/update/rollback integration, and documentation checks.
|
||||
- Keep `docs/IMPLEMENTATION_HANDOFF.md` synchronized while work is in progress so another agent can resume from the current verified state without reconstructing context from chat history.
|
||||
105
CLAUDE.md
105
CLAUDE.md
@ -1,99 +1,8 @@
|
||||
# XuqmGroup-RNSDK — Claude 项目上下文
|
||||
# XuqmGroup-RNSDK 开发约束
|
||||
|
||||
## 项目定位
|
||||
|
||||
XuqmGroup React Native SDK monorepo。为集成宿主 App(如 YiwangxinApp4)提供核心能力:初始化、HTTP、设备信息、XWebView、OTA 更新、推送、IM、证书、日志追踪。
|
||||
|
||||
- Git 远端:`https://xuqinmin.com/xuqmGroup/XuqmGroup-RNSDK.git`
|
||||
- Nexus npm 发布:`https://nexus.xuqinmin.com/repository/npm-hosted/`
|
||||
- 规范文档:`XuqmGroup-Docs/design/06-sdk-cross-platform-spec.md`
|
||||
|
||||
## 包结构
|
||||
|
||||
| 包名 | 目录 | 版本 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `@xuqm/rn-common` | packages/common | 0.4.0 | 核心层:初始化、HTTP、设备、XWebView Bridge、API hooks |
|
||||
| `@xuqm/rn-update` | packages/update | 0.4.0 | OTA 热更新、APK 更新 |
|
||||
| `@xuqm/rn-push` | packages/push | 0.2.2 | 厂商推送(代码冻结,仅文档) |
|
||||
| `@xuqm/rn-im` | packages/im | 0.2.2 | IM 会话、消息(代码冻结,仅文档) |
|
||||
| `@xuqm/rn-xwebview` | packages/xwebview | 0.2.2 | WebView 容器 + JSBridge UI 组件 |
|
||||
| `@xuqm/rn-license` | packages/license | 0.3.0 | 证书授权(代码冻结,仅文档) |
|
||||
| `@xuqm/rn-bugcollect` | packages/bugcollect | 0.1.0 | 日志、错误追踪、漏斗分析(新) |
|
||||
|
||||
**代码冻结模块(push/im/license):不修改代码,仅补充文档。**
|
||||
|
||||
## 技术栈
|
||||
|
||||
- React Native >= 0.76(Bridgeless Mode,New Architecture)
|
||||
- TypeScript 5.x strict — `noImplicitAny: true`,禁止 `any`
|
||||
- yarn workspaces monorepo
|
||||
- 发布到 Nexus npm 私有仓库
|
||||
|
||||
## 初始化(两种方式)
|
||||
|
||||
**方式 A — 配置文件自动初始化(推荐)**
|
||||
宿主配置 Babel alias `@xuqm/autoinit-config` → 加密配置文件(XUQM-CONFIG-V1 格式)。SDK 自动读取并初始化,App 无需调用任何 init。
|
||||
|
||||
**方式 B — 手动初始化**
|
||||
```ts
|
||||
import { XuqmSDK } from '@xuqm/rn-common'
|
||||
|
||||
// 公有平台
|
||||
await XuqmSDK.initialize({ appKey: 'xxx' })
|
||||
|
||||
// 私有化平台
|
||||
await XuqmSDK.initialize({ appKey: 'xxx', platformUrl: 'https://xxx' })
|
||||
|
||||
// 等待异步平台配置拉取
|
||||
await XuqmSDK.awaitInitialization()
|
||||
```
|
||||
|
||||
**两种平台互相独立,不允许自动降级。**
|
||||
|
||||
## 用户信息
|
||||
|
||||
```ts
|
||||
XuqmSDK.setUserInfo({ userId: 'u001', userSig: 'sig', name: '张三' })
|
||||
XuqmSDK.setUserInfo(null) // 登出,触发所有子 SDK 登出
|
||||
```
|
||||
|
||||
子 SDK 自动订阅 `setUserInfo` 事件:Push 自动注册/解绑 token,IM 自动登录/断开,Update 更新 userId。
|
||||
|
||||
## 日志 SDK(@xuqm/rn-bugcollect)
|
||||
|
||||
```ts
|
||||
import { BugCollect } from '@xuqm/rn-bugcollect'
|
||||
|
||||
BugCollect.setLogLevel('debug')
|
||||
BugCollect.setEnvironment('production')
|
||||
BugCollect.startCapture() // 开启全局错误捕获
|
||||
BugCollect.event('page_view', { page: 'home' })
|
||||
BugCollect.captureError(new Error('xxx'))
|
||||
BugCollect.defineFunnel('checkout', ['cart_view', 'checkout_start', 'payment_done'])
|
||||
```
|
||||
|
||||
`bugCollectApiUrl` 由 SDK 在 init 后从 `/api/sdk/config` 自动获取,无需 App 传入。
|
||||
|
||||
## 关键约束
|
||||
|
||||
- `XuqmSDK.initialize()` 签名不得更改
|
||||
- `XuqmSDK.setUserInfo()` 现有字段不得删除
|
||||
- 新增字段一律为可选,有合理默认值
|
||||
- 不允许静默降级(autoInit 失败时 `__DEV__` 模式 throw)
|
||||
- rn-common 与 rn-bugcollect 解耦:rn-common 可独立使用;rn-bugcollect peerDep rn-common
|
||||
|
||||
## XWebView JSBridge 标准 Handler
|
||||
|
||||
见 `docs/XWebView-JSBridge.md`。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
yarn workspace @xuqm/rn-common typecheck
|
||||
yarn workspace @xuqm/rn-bugcollect typecheck
|
||||
yarn workspace @xuqm/rn-update typecheck
|
||||
yarn workspace @xuqm/rn-push typecheck
|
||||
yarn workspace @xuqm/rn-im typecheck
|
||||
yarn workspace @xuqm/rn-xwebview typecheck
|
||||
yarn workspace @xuqm/rn-license typecheck
|
||||
```
|
||||
- pnpm 11 workspace;提交前运行 `corepack pnpm validate`。
|
||||
- `@xuqm/rn-common` 是唯一公共基座和唯一可独立使用的包。
|
||||
- 其余官方包必须 peer-depend common,并通过 `@xuqm/rn-common/internal` 订阅生命周期。
|
||||
- 宿主只调用一次自动初始化和一次 `XuqmSDK.login()` / `logout()`;禁止恢复 `setUserInfo`、独立 token、独立登录或多级传参。
|
||||
- 通用网络、文件、时间、加密和设备能力只能在 common 实现一次。
|
||||
- 使用当前稳定工具链和第三方依赖;升级后必须通过类型、测试、包内容和宿主集成验证。
|
||||
|
||||
60
Jenkinsfile
vendored
60
Jenkinsfile
vendored
@ -4,6 +4,7 @@ pipeline {
|
||||
environment {
|
||||
NEXUS_REGISTRY = 'https://nexus.xuqinmin.com/repository/npm-hosted/'
|
||||
GIT_URL = 'https://xuqinmin.com/xuqmGroup/XuqmGroup-RNSDK.git'
|
||||
ANDROID_SDK_GIT_URL = 'https://xuqinmin.com/xuqmGroup/XuqmGroup-AndroidSDK.git'
|
||||
}
|
||||
|
||||
options {
|
||||
@ -23,11 +24,8 @@ pipeline {
|
||||
|
||||
// ── 模块选择(勾选即发布)──────────────────────────────────────────
|
||||
booleanParam(name: 'MOD_COMMON', defaultValue: false, description: '发布 @xuqm/rn-common')
|
||||
booleanParam(name: 'MOD_IM', defaultValue: false, description: '发布 @xuqm/rn-im')
|
||||
booleanParam(name: 'MOD_PUSH', defaultValue: false, description: '发布 @xuqm/rn-push')
|
||||
booleanParam(name: 'MOD_UPDATE', defaultValue: false, description: '发布 @xuqm/rn-update')
|
||||
booleanParam(name: 'MOD_XWEBVIEW', defaultValue: false, description: '发布 @xuqm/rn-xwebview')
|
||||
booleanParam(name: 'MOD_LICENSE', defaultValue: false, description: '发布 @xuqm/rn-license')
|
||||
booleanParam(name: 'MOD_BUGCOLLECT', defaultValue: false, description: '发布 @xuqm/rn-bugcollect')
|
||||
}
|
||||
|
||||
@ -51,21 +49,15 @@ pipeline {
|
||||
// ── 根据复选框收集选中的模块 ──────────────────────────────
|
||||
def moduleMap = [
|
||||
common: ['packages/common', '@xuqm/rn-common'],
|
||||
im: ['packages/im', '@xuqm/rn-im'],
|
||||
push: ['packages/push', '@xuqm/rn-push'],
|
||||
update: ['packages/update', '@xuqm/rn-update'],
|
||||
xwebview: ['packages/xwebview', '@xuqm/rn-xwebview'],
|
||||
license: ['packages/license', '@xuqm/rn-license'],
|
||||
bugcollect: ['packages/bugcollect', '@xuqm/rn-bugcollect'],
|
||||
]
|
||||
|
||||
def moduleChecks = [
|
||||
'common': params.MOD_COMMON,
|
||||
'im': params.MOD_IM,
|
||||
'push': params.MOD_PUSH,
|
||||
'update': params.MOD_UPDATE,
|
||||
'xwebview': params.MOD_XWEBVIEW,
|
||||
'license': params.MOD_LICENSE,
|
||||
'bugcollect': params.MOD_BUGCOLLECT,
|
||||
]
|
||||
def requestedModules = moduleChecks.findAll { k, v -> v }.collect { k, v -> k }
|
||||
@ -134,11 +126,8 @@ pipeline {
|
||||
def selected = env.SELECTED_MODULES.split(',')
|
||||
def dirMap = [
|
||||
common: 'packages/common',
|
||||
im: 'packages/im',
|
||||
push: 'packages/push',
|
||||
update: 'packages/update',
|
||||
xwebview: 'packages/xwebview',
|
||||
license: 'packages/license',
|
||||
bugcollect: 'packages/bugcollect',
|
||||
]
|
||||
echo "Git Commit : ${env.GIT_COMMIT_SHORT}"
|
||||
@ -163,7 +152,9 @@ pipeline {
|
||||
usernameVariable: 'NPM_USER')]) {
|
||||
bat """
|
||||
powershell -NonInteractive -Command "\$auth=[Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(\$env:NPM_USER+':'+\$env:NPM_PASS)); Set-Content .npmrc ('@xuqm:registry=https://nexus.xuqinmin.com/repository/npm-hosted/' + [char]10 + '//nexus.xuqinmin.com/repository/npm-hosted/:_auth=' + \$auth) -Encoding ASCII"
|
||||
npm install --legacy-peer-deps
|
||||
corepack enable
|
||||
corepack prepare pnpm@11.13.1 --activate
|
||||
pnpm install --frozen-lockfile
|
||||
"""
|
||||
}
|
||||
}
|
||||
@ -173,15 +164,13 @@ pipeline {
|
||||
stage('Type Check') {
|
||||
steps {
|
||||
script {
|
||||
// 在每个选中的包目录运行类型检查(允许失败)
|
||||
bat "pnpm format:check"
|
||||
// 在每个选中的包目录运行类型检查;失败必须阻断发布。
|
||||
def selected = env.SELECTED_MODULES.split(',')
|
||||
def dirMap = [
|
||||
common: 'packages/common',
|
||||
im: 'packages/im',
|
||||
push: 'packages/push',
|
||||
update: 'packages/update',
|
||||
xwebview: 'packages/xwebview',
|
||||
license: 'packages/license',
|
||||
bugcollect: 'packages/bugcollect',
|
||||
]
|
||||
for (mod in selected) {
|
||||
@ -191,7 +180,10 @@ pipeline {
|
||||
continue
|
||||
}
|
||||
echo "Type-checking ${mod} (${dir})"
|
||||
bat "cd ${dir} && npm run typecheck || ver>nul"
|
||||
bat "pnpm --dir ${dir} typecheck"
|
||||
if (mod == 'update') {
|
||||
bat "pnpm --dir ${dir} pack:check"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -200,7 +192,7 @@ pipeline {
|
||||
// ── 5. 测试 ────────────────────────────────────────────────────────────
|
||||
stage('Tests') {
|
||||
steps {
|
||||
bat 'npm test > test-results.txt 2>&1 || ver>nul'
|
||||
bat 'pnpm test > test-results.txt 2>&1'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
@ -209,7 +201,30 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. 发布到 Nexus ─────────────────────────────────────────────────────
|
||||
// ── 6. Android 原生桥编译 ───────────────────────────────────────────────
|
||||
stage('Android Native Compile') {
|
||||
when {
|
||||
expression { params.MOD_UPDATE }
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
dir('.native-gradle') {
|
||||
checkout([
|
||||
$class: 'GitSCM',
|
||||
branches: [[name: 'main']],
|
||||
extensions: [[$class: 'CleanBeforeCheckout']],
|
||||
userRemoteConfigs: [[
|
||||
url: env.ANDROID_SDK_GIT_URL,
|
||||
credentialsId: scm.userRemoteConfigs[0].credentialsId
|
||||
]]
|
||||
])
|
||||
}
|
||||
bat '.native-gradle\\gradlew.bat -p native-test :updateBridge:compileDebugJavaWithJavac --no-daemon --stacktrace'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. 发布到 Nexus ─────────────────────────────────────────────────────
|
||||
stage('Publish to Nexus') {
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: 'NEXUS_CREDS',
|
||||
@ -224,11 +239,8 @@ pipeline {
|
||||
def selected = env.SELECTED_MODULES.split(',')
|
||||
def dirMap = [
|
||||
common: 'packages/common',
|
||||
im: 'packages/im',
|
||||
push: 'packages/push',
|
||||
update: 'packages/update',
|
||||
xwebview: 'packages/xwebview',
|
||||
license: 'packages/license',
|
||||
bugcollect: 'packages/bugcollect',
|
||||
]
|
||||
|
||||
@ -241,7 +253,7 @@ pipeline {
|
||||
echo "Publishing ${mod} from ${dir} (tag: ${env.NPM_TAG})"
|
||||
bat """
|
||||
copy .npmrc ${dir}\\.npmrc
|
||||
cd ${dir} && npm publish --tag ${env.NPM_TAG} --registry %NEXUS_REGISTRY%
|
||||
pnpm --dir ${dir} publish --tag ${env.NPM_TAG} --registry %NEXUS_REGISTRY% --no-git-checks
|
||||
del ${dir}\\.npmrc 2>nul || exit 0
|
||||
"""
|
||||
}
|
||||
|
||||
373
README.md
373
README.md
@ -1,370 +1,95 @@
|
||||
# XuqmGroup React Native SDK
|
||||
|
||||
Modular React Native SDK providing IM, Push, App/Plugin Update, WebView, BugCollect, and License management for XuqmGroup platform applications.
|
||||
XuqmGroup 通用 React Native SDK 工作区。`@xuqm/rn-common` 是唯一基础包;其余扩展包都复用 common 的配置、初始化、会话、网络和通用能力。仅使用 common 时不放配置文件、不包装 Metro,也不要求初始化或登录;使用任一官方扩展时,由 `withXuqmConfig()` 在业务入口前自动启动一次共享上下文。
|
||||
|
||||
## Table of Contents
|
||||
## 包结构
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Package Structure](#package-structure)
|
||||
- [Installation](#installation)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Package Details](#package-details)
|
||||
- [Development](#development)
|
||||
- [Plugin Scaffolding](#plugin-scaffolding)
|
||||
| 包 | 用途 | 可独立使用 |
|
||||
| --- | --- | --- |
|
||||
| `@xuqm/rn-common` | 可选 SDK 上下文、网络、文件、时间、加密、设备与通用 UI | 是,且无需初始化或登录 |
|
||||
| `@xuqm/rn-update` | App 更新、RN 插件注册、下载、校验与运行 | 否,依赖 common |
|
||||
| `@xuqm/rn-xwebview` | WebView、JSBridge、权限与下载 | 否,依赖 common |
|
||||
| `@xuqm/rn-bugcollect` | 错误、事件、漏斗和批量上报 | 否,依赖 common |
|
||||
| `@xuqm/rn-im` | IM 连接、消息和本地数据 | 否,依赖 common |
|
||||
| `@xuqm/rn-push` | 多厂商原生推送桥接 | 否,依赖 common |
|
||||
|
||||
---
|
||||
根包 `@xuqm/rn-sdk` 只用于仓库内聚合开发,不对外发布。应用应按需安装具体包,避免引入未使用的原生能力。
|
||||
|
||||
## Overview
|
||||
## 安装
|
||||
|
||||
`@xuqm/rn-sdk` is the meta-package that re-exports all sub-modules. It provides a unified `XuqmSDK` entry point with coordinated login/logout that wires up IM, Push, and token management in a single call.
|
||||
项目 `.npmrc`:
|
||||
|
||||
You can install the meta-package for full functionality, or pick individual packages as needed.
|
||||
|
||||
**Peer Dependencies** (required in host app):
|
||||
|
||||
| Package | Version |
|
||||
|---------|---------|
|
||||
| `react` | >= 18.0.0 |
|
||||
| `react-native` | >= 0.76.0 |
|
||||
| `@react-native-async-storage/async-storage` | >= 1.21.0 |
|
||||
|
||||
---
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
XuqmGroup-RNSDK/ # @xuqm/rn-sdk (meta-package, private)
|
||||
├── src/
|
||||
│ ├── index.ts # Re-exports all sub-packages
|
||||
│ └── sdk.ts # Unified login / logout
|
||||
├── packages/
|
||||
│ ├── common/ @xuqm/rn-common # Init, network, device, token, HTTP, auto-init, Metro plugins
|
||||
│ ├── im/ @xuqm/rn-im # IM messaging (WebSocket/STOMP + WatermelonDB)
|
||||
│ ├── push/ @xuqm/rn-push # Multi-vendor push registration
|
||||
│ ├── update/ @xuqm/rn-update # App update check + RN plugin hot-update
|
||||
│ ├── xwebview/ @xuqm/rn-xwebview # Enhanced WebView with JSBridge
|
||||
│ └── bugcollect/ @xuqm/rn-bugcollect # Error capture, crash reporting, funnel analytics
|
||||
```ini
|
||||
registry=https://registry.npmjs.org/
|
||||
@xuqm:registry=https://nexus.xuqinmin.com/repository/npm/
|
||||
```
|
||||
|
||||
All packages are at version **0.2.2**.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Configure the npm registry
|
||||
最小安装:
|
||||
|
||||
```bash
|
||||
# In your host project root
|
||||
cat > .npmrc << 'EOF'
|
||||
registry=https://nexus.xuqinmin.com/repository/npm/
|
||||
legacy-peer-deps=true
|
||||
EOF
|
||||
pnpm add @xuqm/rn-common axios \
|
||||
@react-native-async-storage/async-storage
|
||||
```
|
||||
|
||||
### 2. Install
|
||||
|
||||
**Option A -- Meta-package (all modules):**
|
||||
按需增加扩展包,例如:
|
||||
|
||||
```bash
|
||||
yarn add @xuqm/rn-sdk
|
||||
yarn add @react-native-async-storage/async-storage
|
||||
pnpm add @xuqm/rn-update @xuqm/rn-xwebview @xuqm/rn-bugcollect
|
||||
```
|
||||
|
||||
**Option B -- Individual packages:**
|
||||
## common 独立使用
|
||||
|
||||
```bash
|
||||
yarn add @xuqm/rn-common @xuqm/rn-update @xuqm/rn-xwebview @xuqm/rn-bugcollect
|
||||
yarn add @react-native-async-storage/async-storage
|
||||
```
|
||||
只安装 common 时,可以直接调用文件、时间、网络、加密、设备和 UI 能力,不要求配置、初始化或登录。`apiRequest` 使用绝对 URL,或先调用 `configureHttp` 配置基础地址即可。
|
||||
|
||||
### 3. Install optional peer dependencies
|
||||
## 扩展包一次初始化、一次登录
|
||||
|
||||
```bash
|
||||
# IM module (if using @xuqm/rn-im)
|
||||
yarn add @nozbe/watermelondb
|
||||
|
||||
# XWebView module
|
||||
yarn add react-native-webview react-native-blob-util react-native-svg
|
||||
|
||||
# Config file decryption (auto-init)
|
||||
yarn add react-native-quick-crypto
|
||||
|
||||
# BugCollect module (if using @xuqm/rn-bugcollect)
|
||||
yarn add @xuqm/rn-bugcollect
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Zero-config initialization (recommended)
|
||||
|
||||
Place the encrypted `.xuqmconfig` file (XUQM-CONFIG-V1 format) downloaded from the tenant platform into `src/assets/config/`, then wrap your Metro config with `withXuqmConfig`:
|
||||
把平台生成的 `config.xuqmconfig` 放在 `src/assets/config/`,然后只包装一次 Metro 配置:
|
||||
|
||||
```js
|
||||
// metro.config.js
|
||||
const { getDefaultConfig } = require('@react-native/metro-config');
|
||||
const { withXuqmConfig } = require('@xuqm/rn-common/metro');
|
||||
const baseConfig = getDefaultConfig(__dirname);
|
||||
module.exports = withXuqmConfig(baseConfig);
|
||||
const {getDefaultConfig} = require('@react-native/metro-config');
|
||||
const {withXuqmConfig} = require('@xuqm/rn-common/metro');
|
||||
|
||||
module.exports = withXuqmConfig(getDefaultConfig(__dirname));
|
||||
```
|
||||
|
||||
That is it. `withXuqmConfig` automatically discovers the config file from `src/assets/config/` (or `src/assets/xuqm/`) and wires up the virtual module. When `@xuqm/rn-common` is imported, it silently calls `XuqmSDK.initWithConfigFile()` using the encrypted file. No explicit init code needed.
|
||||
|
||||
**Compatible fallback:** You can also manually configure a Babel/Metro alias for `@xuqm/autoinit-config` pointing to a `.ts` wrapper file. See `docs/配置文件规范.md` for details.
|
||||
|
||||
### Manual initialization
|
||||
`withXuqmConfig()` 会把初始化模块注册为 Metro pre-main module,避免 `inlineRequires` 延迟扩展包加载。应用登录成功后只更新一次公共会话:
|
||||
|
||||
```ts
|
||||
import { XuqmSDK } from '@xuqm/rn-sdk';
|
||||
import {XuqmSDK} from '@xuqm/rn-common';
|
||||
|
||||
// Init with appKey (fetches config from server)
|
||||
await XuqmSDK.initialize({ appKey: 'your-app-key' });
|
||||
await XuqmSDK.awaitInitialization();
|
||||
await XuqmSDK.login({
|
||||
userId: 'user-id',
|
||||
accessToken: 'http-access-token',
|
||||
userSig: 'optional-im-credential',
|
||||
});
|
||||
|
||||
// Login (wires up IM + Push automatically)
|
||||
await XuqmSDK.login({ userId: 'user123', userSig: '...' });
|
||||
|
||||
// Logout
|
||||
await XuqmSDK.logout();
|
||||
```
|
||||
|
||||
### Using individual modules
|
||||
扩展包通过 `@xuqm/rn-common/internal` 接收同一次会话变化。宿主业务代码不得导入该内部子路径,也不得逐级传递公共 token 或用户状态。
|
||||
|
||||
无法使用配置文件自动初始化时,才调用一次:
|
||||
|
||||
```ts
|
||||
import { ImSDK } from '@xuqm/rn-im';
|
||||
import { PushSDK } from '@xuqm/rn-push';
|
||||
import { UpdateSDK } from '@xuqm/rn-update';
|
||||
import { XWebViewScreen } from '@xuqm/rn-xwebview';
|
||||
await XuqmSDK.initialize({appKey: 'app-key'});
|
||||
```
|
||||
|
||||
---
|
||||
## 开发与验证
|
||||
|
||||
## Package Details
|
||||
|
||||
### @xuqm/rn-common
|
||||
|
||||
Core module. Handles SDK initialization, HTTP requests, token persistence, device identification, and auto-initialization from encrypted config files.
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `XuqmSDK.initialize(opts)` | Init with `appKey`, fetches remote config |
|
||||
| `XuqmSDK.initWithConfigFile(encrypted)` | Init from `XUQM-CONFIG-V1` encrypted file |
|
||||
| `XuqmSDK.initializeFromLicense(file)` | Init from decrypted config data |
|
||||
| `XuqmSDK.awaitInitialization()` | Wait for async init to complete |
|
||||
| `XuqmSDK.setUserId / getUserId` | Manage current user ID |
|
||||
| `XuqmSDK.setUserInfo / getUserInfo` | Manage current user profile |
|
||||
| `apiRequest(url, options?)` | HTTP request with Bearer token auth |
|
||||
| `configureHttp(opts)` | Override HTTP base URL or headers |
|
||||
| `getDeviceId()` | Stable per-install UUID |
|
||||
| `getDeviceInfo()` | Device brand/model/OS info |
|
||||
| `detectPushVendor()` | Detect push vendor from device brand |
|
||||
| `ScaledImage` | Image component with aspect-ratio scaling |
|
||||
| `isInitialized()` | Check if SDK is ready |
|
||||
| `getConfig()` | Get resolved config |
|
||||
|
||||
**Auto-init mechanism:** On import, `autoInit.ts` tries `require('@xuqm/autoinit-config')`. If the Metro alias is configured, it decrypts the file and calls `initWithConfigFile` silently. If the alias is not configured, it skips without error.
|
||||
|
||||
**Encrypted config format:** `XUQM-CONFIG-V1.{base64url-salt}.{base64url-iv}.{base64url-ciphertext}` -- decrypted via PBKDF2 (120k iterations, SHA-256) + AES-256-GCM using `react-native-quick-crypto`.
|
||||
|
||||
---
|
||||
|
||||
### @xuqm/rn-im
|
||||
|
||||
Full-featured IM module. WebSocket/STOMP transport with WatermelonDB local persistence. Supports single and group chat with 15 message types.
|
||||
|
||||
**Message types:** `TEXT`, `IMAGE`, `VIDEO`, `AUDIO`, `FILE`, `CUSTOM`, `LOCATION`, `NOTIFY`, `RICH_TEXT`, `CALL_AUDIO`, `CALL_VIDEO`, `QUOTE`, `MERGE`, `REVOKED`, `FORWARD`
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `ImSDK.login(userId, userSig)` | Connect to IM server |
|
||||
| `ImSDK.disconnect()` | Disconnect |
|
||||
| `ImSDK.sendMessage(params)` | Send a message |
|
||||
| `ImSDK.getConversations()` | List conversations |
|
||||
| `ImSDK.getHistory(query)` | Fetch message history |
|
||||
| `ImSDK.createGroup(...)` | Create a group |
|
||||
| `ImSDK.joinGroup(groupId)` | Join a group |
|
||||
| `ImSDK.getGroupInfo(groupId)` | Get group details |
|
||||
| `ImSDK.getGroupMembers(groupId)` | List group members |
|
||||
| `listFriends / addFriend / removeFriend` | Friend management |
|
||||
| `setFriendGroup / listFriendGroups` | Friend grouping |
|
||||
| `checkBlacklist` | Blacklist check |
|
||||
| `searchUsers / searchGroups / searchMessages` | Search |
|
||||
| `editMessage` | Edit a sent message |
|
||||
| `setConversationHidden / setConversationGroup` | Conversation management |
|
||||
| `locateHistoryPage / locateGroupHistoryPage` | Jump to specific history page |
|
||||
| `syncOfflineMessages / offlineMessageCount` | Offline message handling |
|
||||
| `ImClient` | Low-level STOMP client |
|
||||
| `ImDatabase` | WatermelonDB wrapper |
|
||||
| `uploadFile` | File upload for IM |
|
||||
|
||||
**Additional peer dependency:** `@nozbe/watermelondb >= 0.27.0`
|
||||
|
||||
---
|
||||
|
||||
### @xuqm/rn-push
|
||||
|
||||
Multi-vendor push registration. Automatically detects the device vendor (Huawei, Xiaomi, OPPO, vivo, Honor, FCM, APNS) and bridges to the native push module.
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `PushSDK.initialize(userId?)` | Initialize push and register device token |
|
||||
| `PushSDK.registerToken(userId, token, vendor?)` | Register a push token with server |
|
||||
| `PushSDK.unregisterToken(userId)` | Unregister push token |
|
||||
| `PushSDK.setDeviceToken(token, vendor?)` | Manually set device token |
|
||||
| `PushSDK.onPushToken(callback)` | Listen for push token updates |
|
||||
| `PushSDK.logout(userId?)` | Unregister and clean up |
|
||||
| `isNativePushAvailable()` | Check if native push module is linked |
|
||||
| `detectVendorNative()` | Detect vendor via native module |
|
||||
| `registerPushNative()` | Trigger native vendor registration |
|
||||
|
||||
**Native module:** Requires `NativeModules.XuqmPushModule` to be linked in the host app.
|
||||
|
||||
---
|
||||
|
||||
### @xuqm/rn-update
|
||||
|
||||
App update check and RN plugin (bundle) hot-update. Checks for new app versions and downloads/caches RN bundles for OTA delivery.
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `UpdateSDK.registerPlugin(meta)` | Register plugin metadata at bundle load time |
|
||||
| `UpdateSDK.checkAppUpdate(bypassIgnore?)` | Check for app-level update |
|
||||
| `UpdateSDK.openStore(url?, marketUrl?)` | Open app store page |
|
||||
| `UpdateSDK.checkPluginUpdate(moduleId)` | Check if a plugin bundle has an update |
|
||||
| `UpdateSDK.downloadPluginBundle(url)` | Download bundle source text |
|
||||
| `UpdateSDK.cachePluginBundle(id, ver, md5, src)` | Cache bundle to AsyncStorage |
|
||||
| `UpdateSDK.getCachedPluginBundle(id)` | Read cached bundle |
|
||||
| `UpdateSDK.checkAndCachePlugin(id)` | One-step check + download + cache |
|
||||
| `UpdateSDK.getRegisteredPlugins()` | List all registered plugins |
|
||||
| `UpdateSDK.getAppVersionCode / getAppVersionName` | Get host app version |
|
||||
|
||||
**Types:** `PluginMeta`, `AppUpdateInfo`, `PluginUpdateInfo`, `CachedRnBundle`
|
||||
|
||||
---
|
||||
|
||||
### @xuqm/rn-xwebview
|
||||
|
||||
Enhanced WebView with JSBridge for bidirectional communication between RN and web content. Includes progress bar, inline view, and full-screen screen components.
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `XWebViewScreen` | Full-screen WebView (React Navigation screen) |
|
||||
| `XWebViewView` | Inline WebView component |
|
||||
| `XWebViewProgress` | Progress bar component |
|
||||
| `openXWebView(url, options?)` | Open WebView programmatically |
|
||||
| `setXWebViewController(controller)` | Set global WebView controller |
|
||||
| `getXWebViewConfig()` | Get current WebView config |
|
||||
|
||||
**Additional dependencies:** `react-native-webview`, `react-native-blob-util`, `react-native-svg`
|
||||
|
||||
**Additional peer dependency:** `@react-navigation/native >= 7.0.0`
|
||||
|
||||
---
|
||||
|
||||
### @xuqm/rn-bugcollect
|
||||
|
||||
Error capture, crash reporting, and funnel analytics. Provides global JS error capture, custom event tracking, and batched async upload to the BugCollect service.
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `BugCollect.setLogLevel(level)` | Set minimum log level (`debug` / `info` / `warn` / `error`) |
|
||||
| `BugCollect.setEnvironment(env)` | Set environment tag (`development` / `staging` / `production`) |
|
||||
| `BugCollect.startCapture()` | Enable global JS error and unhandled Promise rejection capture |
|
||||
| `BugCollect.event(name, properties?)` | Record a custom analytics event |
|
||||
| `BugCollect.captureError(error, metadata?)` | Report a JS exception |
|
||||
| `BugCollect.warn(message, metadata?)` | Log a warning |
|
||||
| `BugCollect.info(message, metadata?)` | Log an info event |
|
||||
| `BugCollect.defineFunnel(id, steps)` | Define a conversion funnel |
|
||||
| `BugCollect.getFunnelProgress(funnelId)` | Get client-side funnel progress |
|
||||
| `BugCollect.flush()` | Immediately flush all pending events |
|
||||
|
||||
**Metro plugin:** `@xuqm/rn-bugcollect/metro` exports `withBugCollect` for automatic SourceMap upload on Release builds.
|
||||
|
||||
```js
|
||||
const { withBugCollect } = require('@xuqm/rn-bugcollect/metro');
|
||||
module.exports = withBugCollect(baseConfig);
|
||||
```
|
||||
|
||||
**Config:** `bugCollectApiUrl` and `bugCollectEnabled` are automatically obtained from the platform config (`/api/sdk/config`) after `@xuqm/rn-common` init. No app-level configuration needed.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 18
|
||||
- Yarn (workspaces)
|
||||
- React Native development environment
|
||||
|
||||
### Commands
|
||||
要求 Node.js 22、Corepack 和 pnpm 11:
|
||||
|
||||
```bash
|
||||
# Type-check all packages
|
||||
yarn typecheck
|
||||
|
||||
# Type-check a single package
|
||||
cd packages/im && yarn typecheck
|
||||
corepack pnpm install
|
||||
corepack pnpm validate
|
||||
```
|
||||
|
||||
### Publishing
|
||||
|
||||
Packages are published to the private npm registry at `https://nexus.xuqinmin.com/repository/npm-hosted/`.
|
||||
`validate` 会执行全部包的 TypeScript 校验和测试。开发版本发布到 Nexus hosted 仓库:
|
||||
|
||||
```bash
|
||||
# Publish a single package
|
||||
cd packages/common
|
||||
npm publish
|
||||
|
||||
# Publish all packages (from each package directory)
|
||||
for pkg in common im push update xwebview bugcollect; do
|
||||
cd packages/$pkg && npm publish && cd ../..
|
||||
done
|
||||
corepack pnpm --filter @xuqm/rn-common publish --tag next --no-git-checks
|
||||
```
|
||||
|
||||
### Project layout
|
||||
集成 update 的宿主由 `xuqm-rn start` 和 `xuqm-rn run android` 统一代理 Metro 与 Android Debug 启动;宿主只保留简短的 `start/android` package script。
|
||||
|
||||
```
|
||||
packages/<name>/
|
||||
├── src/
|
||||
│ └── index.ts # Public API
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
Each package has its own `package.json`, `tsconfig.json`, and `publishConfig` pointing to the hosted registry. The root `package.json` is a private meta-package that depends on all sub-packages.
|
||||
|
||||
---
|
||||
|
||||
## Plugin Scaffolding
|
||||
|
||||
The `create-plugin.mjs` script generates a new UpdateSDK plugin skeleton and auto-registers it into the host project.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Interactive mode
|
||||
node packages/update/scripts/create-plugin.mjs
|
||||
|
||||
# CLI mode
|
||||
node packages/update/scripts/create-plugin.mjs <moduleId> [title] [subtitle] [accentColor]
|
||||
```
|
||||
|
||||
### What it generates
|
||||
|
||||
- `bundle.ts` -- Plugin entry point
|
||||
- `{PascalCase}Screen.tsx` -- Screen component
|
||||
- `plugin.json` -- Plugin metadata
|
||||
|
||||
### What it auto-registers
|
||||
|
||||
- `pluginCatalog.ts` -- Adds plugin to catalog
|
||||
- `debugPlugins.ts` -- Adds to debug plugin list
|
||||
- `package.json` -- Adds build script for the new bundle
|
||||
- `metro.split.config.js` -- Adds bundle entry
|
||||
- `babel.config.js` -- Adds module alias
|
||||
- `tsconfig.json` -- Adds path mapping
|
||||
|
||||
**Module ID rules:** Lowercase alphanumeric, must be unique across all plugins.
|
||||
每个扩展包的安装要求与 API 以对应 `packages/<name>/README.md` 为准。整体设计见 `docs/架构总览.md`,公共接口见 `docs/SDK-API参考.md`。
|
||||
|
||||
143
docs/IMPLEMENTATION_HANDOFF.md
普通文件
143
docs/IMPLEMENTATION_HANDOFF.md
普通文件
@ -0,0 +1,143 @@
|
||||
# RN SDK 重构实施接管文档
|
||||
|
||||
> 状态更新时间:2026-07-17
|
||||
> 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview`
|
||||
> 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。
|
||||
|
||||
## 1. 本轮目标
|
||||
|
||||
1. `rn-common` 可以完全独立使用公共工具,不要求初始化或登录。
|
||||
2. 使用 bugcollect、update、xwebview 等扩展时,只执行一次共享 SDK 初始化和一次共享登录。
|
||||
3. 配置文件放入约定位置后由构建工具自动接入初始化,不要求宿主逐个初始化子 SDK。
|
||||
4. common、update、bugcollect、xwebview 之间只保留一份网络、文件、时间、配置、会话和错误定义。
|
||||
5. update SDK 统一承担插件 manifest、兼容性、原子激活、启动确认、崩溃回滚和内嵌恢复;宿主不得再实现平行版本管理器。
|
||||
6. Android 整包更新必须具备应用内下载、SHA-256 校验、安装权限处理、进度、取消、重试和确定性错误码。
|
||||
7. 宿主通过最少配置完成插件创建、开发运行、打包、随 APK 内嵌、发布和回滚。
|
||||
|
||||
## 2. 已冻结的设计约束
|
||||
|
||||
- `startup` 是内嵌恢复入口,只随完整安装包更新。
|
||||
- `common`、`app`、`buz` 可以作为插件版本管理对象;common/app 更新必须冷启动生效。
|
||||
- 插件声明 `appVersionRange`、`commonVersionRange` 和 `minNativeApiLevel`;完整包登记 `nativeBaselineId`,插件记录 `builtAgainstNativeBaselineId`。不能只靠一个最大 App 版本或最低 common 字段推断兼容性;配置 schema 已统一为 v3。
|
||||
- 一次依赖更新必须作为一个 release set:全部下载、全部校验、全部 staging、原子激活、整组确认或整组回滚。
|
||||
- common 同一主版本遵守向后兼容;破坏性 API 变更必须提升主版本。
|
||||
- 插件版本比较必须使用 SemVer;“不相等”不能等价为“需要升级”。
|
||||
- 远程 Bundle 和 manifest 使用 SHA-256,并预留签名 manifest;MD5 不作为最终安全校验。
|
||||
- 现阶段不扩展 `@xuqm/rn-push`,医网信宿主继续使用腾讯 Push。
|
||||
- 差分 APK 本轮只保留协议扩展位,不实现差分生成与合并。
|
||||
- 启动更新顺序固定为:准备/恢复未确认 release set → 整包检查 → 无整包更新时检查 `app + common` 依赖闭包。
|
||||
- buz 只在进入前检查“当前 buz + common”依赖闭包,不扫描或顺带更新其它 buz;common 不作为业务入口独立检查。
|
||||
|
||||
## 3. 当前可信基线
|
||||
|
||||
### 3.1 质量门禁
|
||||
|
||||
- Android SDK Jenkins `sdk-android-publish #152`:成功,已发布
|
||||
`com.xuqm:sdk-core:1.1.6-SNAPSHOT` 与 `com.xuqm:sdk-update:2.0.0-SNAPSHOT`。
|
||||
- `native-test` 是 RN update Android Bridge 的唯一 Gradle 编译夹具;Jenkins 发布 update 前必须执行
|
||||
`:updateBridge:compileDebugJavaWithJavac`。
|
||||
- 本地已使用 JDK 21 + Gradle 9.3.1 编译通过;RN Bridge 明确输出 Java 17 字节码,不再隐式回落到 Java 8。
|
||||
- RN 包不再内置第二份 AGP buildscript;宿主负责插件版本,独立夹具使用与 Android SDK 一致的 AGP 9.1.0。
|
||||
- `pnpm validate`:通过。
|
||||
- common:15 个测试通过。
|
||||
- bugcollect:4 个独立测试通过。
|
||||
- xwebview:2 个独立测试通过。
|
||||
- update CLI:6 个测试通过;release-set:6 个测试通过。
|
||||
- IM 现有测试:3 个通过;IM 不在本轮开发范围。
|
||||
- 所有 workspace TypeScript typecheck:通过。
|
||||
|
||||
### 3.2 已存在能力
|
||||
|
||||
- common 已提供配置、共享会话、HTTP、日期、文件、加密和 XWebView bridge。
|
||||
- common 已新增统一流式下载、SHA-256、RFC 文件名解析和 SemVer/版本范围能力;update/xwebview 后续必须复用,不再保留私有实现。
|
||||
- common-only 宿主缺少配置文件时不会触发自动初始化,已有测试覆盖。
|
||||
- bugcollect、update、xwebview 通过 `@xuqm/rn-common/internal` 触发一次自动初始化入口。
|
||||
- update CLI 已能从 schema v3 `xuqm.config.json` 构建 startup/common/app/buz,生成携带 SemVer 兼容范围和原生 API 等级的内嵌 manifest。
|
||||
- RN update 已有 release-set 纯领域规划器;Android 原生模块已改为整组 staging/激活/确认/回滚,并从 APK assets 恢复内嵌基线。
|
||||
|
||||
### 3.3 已确认缺口
|
||||
|
||||
- RN update 已编码 `XuqmAppUpdateModule`,只桥接 XuqmGroup Android `sdk-update:2.0.0-SNAPSHOT`;待 Jenkins 发布 Android Snapshot 后完成宿主 Gradle 编译和模拟器验证。
|
||||
- release-set 已统一使用 SHA-256,但签名 manifest 尚待实现。
|
||||
- 原生 state 已成为 Bundle 版本与事务状态唯一真相;App 更新提示缓存仍可使用 AsyncStorage,但不保存 Bundle 版本。
|
||||
- 客户端已改为 `/api/v1/rn/release-set/check` 单次请求目标入口依赖闭包;租户平台服务端接口尚待同步实现。
|
||||
- `nativeBaselineId` 已自动计算并进入内嵌 manifest/插件上传:覆盖 Android 原生源码、Gradle 配置、RN 与含原生代码的 npm 依赖,普通业务 JS 不影响指纹;Jenkins/服务端登记校验尚待实现。
|
||||
- 插件 API 已拆分为纯检查 `checkPluginRelease`、确认后安装 `installPluginRelease` 和自动场景 `checkAndInstallPlugin`;检查阶段不下载、不写本地状态。
|
||||
- bugcollect 的采样、限频、fatal 绕过采样和 fatal/error 持久化策略已有纯逻辑测试覆盖。
|
||||
- xwebview 的相册保存仍是可选宿主能力;其余文件下载能力已经下沉到 common。
|
||||
- Jenkins 发布参数已收敛到 common、update、bugcollect、xwebview;仍需增加依赖版本存在性与发布顺序硬校验。
|
||||
|
||||
## 4. 实施进度
|
||||
|
||||
| 工作项 | 状态 | 说明 |
|
||||
| ----------------------------- | ------ | --------------------------------------------------- |
|
||||
| 基线审计与门禁 | 完成 | 2026-07-17 本地 validate 全通过 |
|
||||
| 实时接管文档 | 进行中 | 本文件为唯一实施状态入口 |
|
||||
| common 公共上下文收敛 | 完成 | 公共基础与共享生命周期 15 测试 |
|
||||
| bugcollect 依赖 common 与测试 | 完成 | 自动初始化、SHA、版本与策略已统一,4 测试 |
|
||||
| xwebview 文件能力与测试 | 完成 | 文件能力下沉 common,2 测试通过 |
|
||||
| update release set 与原生事务 | 进行中 | Android Snapshot 已发布;待 Bridge 编译和模拟器验证 |
|
||||
| package 内容校验 | 待实施 | 四包完成后执行 |
|
||||
| Jenkins alpha 发布 | 待实施 | 仅在全部门禁通过后触发 |
|
||||
| App4 接入 | 暂缓 | 用户要求先完成 RN SDK |
|
||||
|
||||
## 5. 下一步操作
|
||||
|
||||
1. 用仓库内 `native-test` 编译 RN Java Bridge,再在 Android 宿主补充 release-set crash/recovery 仪器测试。
|
||||
2. Jenkins 发布 RN 四个 alpha 包后,执行模拟器整包更新与插件更新验证。
|
||||
3. 收敛 Jenkins 四包依赖发布顺序和 package 内容检查。
|
||||
4. 后续租户平台实现 `/api/v1/rn/release-set/check`,服务端只返回目标入口与 common 的兼容闭包。
|
||||
|
||||
## 6. 常用验证命令
|
||||
|
||||
```bash
|
||||
pnpm validate
|
||||
pnpm --dir packages/update pack:check
|
||||
pnpm --dir packages/common test
|
||||
pnpm --dir packages/update test
|
||||
```
|
||||
|
||||
不得通过忽略错误、`|| true` 或跳过测试让 Jenkins 变绿。
|
||||
|
||||
## 7. 本轮变更记录
|
||||
|
||||
### 2026-07-17 / common 第一轮
|
||||
|
||||
- 引入 `semver@7.8.5` 与 `@types/semver@7.7.1`,不自行重复实现版本算法。
|
||||
- 新增 `downloadBytes`、`downloadText`、`DownloadError` 和统一下载进度结构。
|
||||
- 新增 `sha256Hex`,作为后续 Bundle/APK 校验的公共实现。
|
||||
- 新增纯 JS 文件名模块,Node/CI 测试不会加载 `react-native` 原生模块。
|
||||
- 新增格式化硬门禁,范围限定为本轮四个 SDK 包,避免改动不在范围内的 IM/Push。
|
||||
- 验证:common typecheck、15 个测试、Prettier 全部通过。
|
||||
|
||||
### 2026-07-17 / bugcollect 与 xwebview 第一轮
|
||||
|
||||
- common 增加扩展初始化订阅;所有扩展共享同一次配置初始化。
|
||||
- bugcollect 在远程配置启用后自动启动采集,不要求宿主第二次初始化或手工启动。
|
||||
- bugcollect 删除私有 SHA-256 实现并复用 common;SDK 版本改为读取发布包版本。
|
||||
- xwebview 删除重复的 Content-Disposition/URL 文件名实现并复用 common。
|
||||
- xwebview 删除直接的 blob-util 依赖,大文件下载、进度、取消和 Android 下载登记统一下沉 common。
|
||||
- bugcollect 的采样/限频窗口、fatal 绕过采样和 fatal/error 持久化策略抽成纯领域逻辑,避免采集器与队列各自重复判断。
|
||||
- 验证:bugcollect typecheck + 4 测试、xwebview typecheck + 2 测试通过。
|
||||
|
||||
### 2026-07-17 / update release-set 第一轮
|
||||
|
||||
- 删除 Bundle 版本的 AsyncStorage 副本和 MD5 实现,原生 state 为唯一版本真相,远程 Bundle 强制 SHA-256。
|
||||
- 新增 release-set 规划器,验证 SemVer 升级、common 范围、原生 API 等级和最终已安装集合兼容性。
|
||||
- Android 原生模块使用单一事务日志完成整组 staging/激活/确认/回滚;首次运行可从 APK `assets/rn-bundles` 恢复内嵌版本。
|
||||
- 未确认 release set 首次启动获得一次确认机会;再次启动仍未确认才整组回滚,避免冷启动更新在加载前被误回滚。
|
||||
- 更新检查改为按入口依赖闭包:启动时整包优先,其后 app+common;buz 进入前只检查当前 buz+common。
|
||||
- SDK 不决定宿主 UI:整包与插件均提供独立检查/安装 API;App4 进入 buz 使用 `checkAndInstallPlugin`,需要弹窗的宿主使用 `checkPluginRelease` 后再调用 `installPluginRelease`。
|
||||
- release-set 记录检查时的全部本地基线版本;弹窗停留期间状态变化会触发 `StaleReleaseSetError`,不会安装过期计划。
|
||||
- `xuqm.config.json` 统一升级到 schema v3:`commonVersionRange` + `minNativeApiLevel`,删除 `minCommonVersion`/`minNativeVersion` 双重语义。
|
||||
- 验证:update typecheck、CLI 6 测试、release-set 6 测试通过;Android 原生代码尚待宿主工程编译验证。
|
||||
|
||||
### 2026-07-17 / Android 整包安装桥接
|
||||
|
||||
- 新增 `XuqmAppUpdateModule`,桥接 AndroidSDK 的唯一 APK 更新实现,不在 RN 包重复下载、哈希或 FileProvider 逻辑。
|
||||
- 原生事件提供下载字节数、总字节数和百分比;支持 AbortSignal 取消、有限重试及确定错误码。
|
||||
- SHA-256 为安装硬门禁;缺失哈希直接拒绝,不降级为未校验安装。
|
||||
- 增加 `openInstallPermissionSettings()`,宿主收到 `INSTALL_PERMISSION_REQUIRED` 后可自行展示 UI 并跳转授权。
|
||||
- Android SDK Jenkins `#152` 已发布 `com.xuqm:sdk-update:2.0.0-SNAPSHOT`。
|
||||
- 新增 `native-test` 最小 Gradle 工程;Jenkins 发布 update 前必须真实编译 Java Bridge,不允许只做 TypeScript/打包校验。
|
||||
- 首次门禁发现独立工程未启用 AndroidX,补充 `native-test/gradle.properties` 后编译通过;该失败不得通过关闭检查规避。
|
||||
@ -1,431 +1,48 @@
|
||||
# XuqmGroup RN SDK API 参考
|
||||
# XuqmGroup RN SDK API
|
||||
|
||||
> 版本:v0.4.0(待发布) · 最后更新 2026-06-15
|
||||
> 包名:`@xuqm/rn-common` · `@xuqm/rn-update` · `@xuqm/rn-push` · `@xuqm/rn-im` · `@xuqm/rn-license`
|
||||
> 设计规范:`XuqmGroup-Docs/design/06-sdk-cross-platform-spec.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. XuqmSDK(common)
|
||||
|
||||
> `import { XuqmSDK } from '@xuqm/rn-common'`
|
||||
|
||||
SDK 核心模块。负责从平台拉取服务配置,并作为所有子 SDK 用户状态的统一分发中心。
|
||||
|
||||
### 类型定义
|
||||
## 公共生命周期
|
||||
|
||||
```ts
|
||||
interface XuqmInitOptions {
|
||||
appKey: string // 应用标识(从租户平台获取)
|
||||
platformUrl?: string // 平台地址;不传则使用内置默认公有平台地址
|
||||
debug?: boolean
|
||||
}
|
||||
import {XuqmSDK} from '@xuqm/rn-common';
|
||||
|
||||
interface XuqmConfig {
|
||||
appKey: string
|
||||
apiUrl: string // 通用 API 地址
|
||||
imWsUrl: string // IM WebSocket 地址
|
||||
fileServiceUrl: string // 文件服务地址
|
||||
licenseUrl: string // License 服务地址
|
||||
debug: boolean
|
||||
imEnabled: boolean // 是否开通 IM 服务
|
||||
pushEnabled: boolean // 是否开通推送服务
|
||||
licenseEnabled: boolean // 是否开通 License 服务
|
||||
}
|
||||
await XuqmSDK.awaitInitialization();
|
||||
|
||||
interface XuqmUserInfo {
|
||||
userId: string // 必填
|
||||
userSig?: string // IM 服务必填;未开通 IM 时可不传
|
||||
name?: string
|
||||
phone?: string
|
||||
avatar?: string
|
||||
}
|
||||
await XuqmSDK.login({
|
||||
userId: 'user-id',
|
||||
accessToken: 'http-token',
|
||||
userSig: 'optional-im-credential',
|
||||
name: '姓名',
|
||||
phone: '手机号',
|
||||
});
|
||||
|
||||
await XuqmSDK.logout();
|
||||
```
|
||||
|
||||
### 初始化 API
|
||||
- `initialize(options)`:仅在不能使用配置文件自动初始化时调用。
|
||||
- `awaitInitialization()`:等待唯一初始化完成;未配置且未手动初始化会抛出明确错误。
|
||||
- `login(options)`:唯一登录入口;同一会话重复调用幂等,不同会话串行切换。
|
||||
- `logout()`:唯一登出入口。
|
||||
- `getUserId()` / `getUserInfo()`:只读获取当前会话。
|
||||
|
||||
#### `XuqmSDK.initialize(options): Promise<void>`
|
||||
不提供 `setUserInfo`、`setUserId` 或公开的配置文件初始化入口。
|
||||
|
||||
手动初始化(方式 B)。请求平台获取 appKey 专属服务配置(各服务 URL 和开通状态)。
|
||||
## common 通用能力
|
||||
|
||||
- `platformUrl` 未传时使用内置默认公有平台地址
|
||||
- **失败时直接抛出错误,不降级**,调用方必须处理异常
|
||||
下列能力可以零初始化、零登录独立使用。`apiRequest` 未初始化时不会生成 SDK 签名头;可使用绝对 URL 或 `configureHttp` 设置基础地址。
|
||||
|
||||
```ts
|
||||
// 公有平台
|
||||
await XuqmSDK.initialize({ appKey: 'your-app-key', debug: __DEV__ })
|
||||
- `apiRequest`、`configureHttp`、`useRequest`、`useApi`、`usePageApi`
|
||||
- `expirationStatus`、`toTimestamp`、`millisecondsUntil`、`formatDateTime`
|
||||
- `fileExists`、`ensureDirectory`、`readFileAsBase64`、`writeBase64File`、`openLocalFile`
|
||||
- `decryptXuqmFile`、`hmacSha256Base64Url`
|
||||
- `getDeviceId`、`getDeviceInfo`、`detectPushVendor`
|
||||
- `showToast`、`showAlert`、`showConfirm`、`ScaledImage`
|
||||
|
||||
// 私有化部署
|
||||
await XuqmSDK.initialize({
|
||||
appKey: 'your-app-key',
|
||||
platformUrl: 'https://your-server.com',
|
||||
})
|
||||
```
|
||||
## 扩展包
|
||||
|
||||
> 方式 A(配置文件自动初始化):将加密 `.xuqmconfig` 文件放置到 `src/assets/xuqm/config.xuqmconfig`,SDK 自动读取、解密(AES-GCM)并调用 `initialize()`,App 无需任何初始化代码。
|
||||
- `@xuqm/rn-update`:`UpdateSDK`、`XuqmRuntime`、`definePlugin` 和 `xuqm-rn` CLI。
|
||||
- `@xuqm/rn-xwebview`:`XWebViewView`、`XWebViewScreen`、JSBridge 与统一下载。
|
||||
- `@xuqm/rn-bugcollect`:`BugCollect` 错误、事件、漏斗和队列上报。
|
||||
- `@xuqm/rn-im`:在 common 会话含 `userSig` 且服务启用时连接。
|
||||
- `@xuqm/rn-push`:在 common 会话变化时同步原生推送绑定。
|
||||
|
||||
#### `XuqmSDK.awaitInitialization(): Promise<void>`
|
||||
|
||||
等待初始化完成。子 SDK 内部调用,确保配置就绪后再使用。
|
||||
|
||||
### 用户认证 API
|
||||
|
||||
#### `XuqmSDK.setUserInfo(info): void`
|
||||
|
||||
用户认证核心枢纽。**登录后调用一次,所有子 SDK 自动同步**;传 `null` 触发全局登出。
|
||||
|
||||
```ts
|
||||
// 登录
|
||||
XuqmSDK.setUserInfo({
|
||||
userId: 'user_001',
|
||||
userSig: 'sig_from_server', // IM 必填
|
||||
name: '张三',
|
||||
phone: '13800138000',
|
||||
})
|
||||
|
||||
// 登出
|
||||
XuqmSDK.setUserInfo(null)
|
||||
```
|
||||
|
||||
| 子 SDK | `setUserInfo(info)` 触发动作 | `setUserInfo(null)` 触发动作 |
|
||||
|--------|------------------------------|------------------------------|
|
||||
| PushSDK | 自动检测厂商 → 获取配置 → 注册设备 → 上报 token | 解绑 token |
|
||||
| ImSDK | 若 `userSig` 存在且 IM 已开通,自动登录 | 断开 WebSocket 连接 |
|
||||
| UpdateSDK | 更新 userId(用于定向更新) | — |
|
||||
| LicenseSDK | 更新用户上下文 | — |
|
||||
|
||||
#### `XuqmSDK.getUserId(): string | null`
|
||||
|
||||
#### `XuqmSDK.getUserInfo(): XuqmUserInfo | null`
|
||||
|
||||
### 已移除 API
|
||||
|
||||
| 方法 | 移除原因 |
|
||||
|------|---------|
|
||||
| `XuqmSDK.init(options): void` | 同步 init 不拉取服务配置,行为不完整 |
|
||||
| `XuqmSDK.initializeFromLicense(file)` | License 不承担 SDK 初始化职责 |
|
||||
| `XuqmSDK.initWithConfigFile(encrypted)` | 由自动初始化机制内部调用,不对外暴露 |
|
||||
|
||||
---
|
||||
|
||||
## 2. UpdateSDK(update)
|
||||
|
||||
> `import { UpdateSDK } from '@xuqm/rn-update'`
|
||||
|
||||
### 类型定义
|
||||
|
||||
```ts
|
||||
interface PluginRegistration {
|
||||
moduleId: string // 版本号由 SDK 自动从缓存读取,不需要传入
|
||||
}
|
||||
|
||||
interface AppUpdateInfo {
|
||||
needsUpdate: boolean
|
||||
versionName?: string
|
||||
versionCode?: number
|
||||
downloadUrl?: string // APK 直接下载地址(Android)
|
||||
changeLog?: string
|
||||
forceUpdate?: boolean
|
||||
appStoreUrl?: string // iOS App Store 地址
|
||||
marketUrl?: string // Android 应用商店地址
|
||||
apkHash?: string | null // APK SHA-256
|
||||
}
|
||||
|
||||
interface PluginUpdateInfo {
|
||||
needsUpdate: boolean
|
||||
latestVersion: string
|
||||
currentVersion: string // SDK 自动填入当前版本
|
||||
downloadUrl: string
|
||||
md5: string
|
||||
minCommonVersion: string
|
||||
note: string
|
||||
forceUpdate?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
### 插件注册
|
||||
|
||||
#### `UpdateSDK.registerPlugins(plugins): void`
|
||||
|
||||
批量注册插件(推荐)。版本号由 SDK 自动从本地缓存读取(首次为 `0.0.0`)。
|
||||
|
||||
```ts
|
||||
UpdateSDK.registerPlugins([
|
||||
{ moduleId: 'buz1' },
|
||||
{ moduleId: 'buz2' },
|
||||
{ moduleId: 'buz3' },
|
||||
])
|
||||
```
|
||||
|
||||
#### `UpdateSDK.registerPlugin(meta): void`
|
||||
|
||||
单个注册(向后兼容)。`version` 字段已废弃,传入无效。
|
||||
|
||||
#### `UpdateSDK.setBundleCallbacks(callbacks): void`
|
||||
|
||||
注入宿主 BundleRuntime 的写入和重载能力。宿主在初始化时调用一次。
|
||||
|
||||
```ts
|
||||
import { writeBundleFile, loadBundle } from '@native/BundleRuntime'
|
||||
|
||||
UpdateSDK.setBundleCallbacks({
|
||||
writeBundle: writeBundleFile,
|
||||
reloadBundle: loadBundle,
|
||||
})
|
||||
```
|
||||
|
||||
### 插件热更新
|
||||
|
||||
#### `UpdateSDK.checkPluginUpdate(moduleId): Promise<PluginUpdateInfo>`
|
||||
|
||||
检查指定插件是否有更新,返回版本信息(不执行更新)。
|
||||
|
||||
#### `UpdateSDK.updatePlugin(moduleId, options?): Promise<void>`
|
||||
|
||||
执行插件更新(一步完成)。SDK 内部自动:检查 → 下载 → 写文件 → 重载。
|
||||
|
||||
```ts
|
||||
// 静默后台更新(下次启动生效)
|
||||
await UpdateSDK.updatePlugin('buz1', { silent: true })
|
||||
|
||||
// 带进度的前台更新(立即重载)
|
||||
const info = await UpdateSDK.checkPluginUpdate('buz1')
|
||||
if (info.needsUpdate) {
|
||||
const confirmed = info.forceUpdate || (await showConfirm(`发现 ${info.latestVersion},是否更新?`))
|
||||
if (confirmed) {
|
||||
await UpdateSDK.updatePlugin('buz1', { onProgress: setProgress })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### App 整包更新
|
||||
|
||||
#### `UpdateSDK.checkAppUpdate(bypassIgnore?): Promise<AppUpdateInfo>`
|
||||
|
||||
检查 App 整包更新。自动携带当前 versionCode 和 userId(定向更新)。
|
||||
|
||||
#### `UpdateSDK.downloadAndInstallApk(url, options?): Promise<void>`
|
||||
|
||||
Android 专用。下载 APK 并调起系统安装器。
|
||||
|
||||
```ts
|
||||
await UpdateSDK.downloadAndInstallApk(info.downloadUrl, {
|
||||
onProgress: (p) => setProgress(p),
|
||||
sha256: info.apkHash ?? undefined,
|
||||
})
|
||||
```
|
||||
|
||||
#### `UpdateSDK.openStore(appStoreUrl?, marketUrl?): Promise<void>`
|
||||
|
||||
跳转 iOS App Store 或 Android 应用商店。
|
||||
|
||||
### 已移除 API
|
||||
|
||||
| 方法 | 替代 |
|
||||
|------|------|
|
||||
| `UpdateSDK.checkAndCachePlugin(moduleId)` | `updatePlugin(moduleId, { silent: true })` |
|
||||
| `UpdateSDK.downloadPluginBundle(url)` | 内部实现,不对外暴露 |
|
||||
| `UpdateSDK.cachePluginBundle(...)` | 内部实现 |
|
||||
| `UpdateSDK.getCachedPluginBundle(moduleId)` | 内部实现 |
|
||||
| `PluginMeta.version`(注册时传 version)| SDK 自动从缓存读取 |
|
||||
|
||||
---
|
||||
|
||||
## 3. PushSDK(push)
|
||||
|
||||
> `import { PushSDK } from '@xuqm/rn-push'`
|
||||
|
||||
**无需任何初始化**。`XuqmSDK.setUserInfo(info)` 被调用时,SDK 自动完成:
|
||||
1. 检测设备厂商(华为/小米/OPPO/vivo/荣耀/FCM/APNs)
|
||||
2. 从平台获取该厂商推送配置
|
||||
3. 调用厂商 SDK 完成设备注册
|
||||
4. 收到 token 后自动上报绑定到平台
|
||||
|
||||
### API
|
||||
|
||||
#### `PushSDK.setOfflinePushEnabled(enabled): Promise<void>`
|
||||
|
||||
设置是否接收离线推送。
|
||||
|
||||
#### `PushSDK.setQuietHours(start, end): Promise<void>`
|
||||
|
||||
设置免打扰时间段(24 小时制)。
|
||||
|
||||
```ts
|
||||
await PushSDK.setQuietHours('22:00', '08:00')
|
||||
```
|
||||
|
||||
#### `PushSDK.clearQuietHours(): Promise<void>`
|
||||
|
||||
清除免打扰设置。
|
||||
|
||||
#### `PushSDK.logout(): Promise<void>`
|
||||
|
||||
主动解绑推送 token。通常不需要手动调用,`XuqmSDK.setUserInfo(null)` 会自动触发。
|
||||
|
||||
### 已移除 API
|
||||
|
||||
| 方法 | 移除原因 |
|
||||
|------|---------|
|
||||
| `PushSDK.initialize(userId?)` | 由 `setUserInfo` 触发 |
|
||||
| `PushSDK.requestNativeRegistration()` | 内部流程 |
|
||||
| `PushSDK.registerToken(userId, token, vendor?)` | 内部流程 |
|
||||
| `PushSDK.onPushToken(callback)` | 内部流程 |
|
||||
| `PushSDK.unregisterToken(userId)` | 由 `setUserInfo(null)` 触发 |
|
||||
| `PushSDK.setPendingToken / getPendingToken` | 内部状态 |
|
||||
|
||||
---
|
||||
|
||||
## 4. ImSDK(im)
|
||||
|
||||
> `import { ImSDK } from '@xuqm/rn-im'`
|
||||
|
||||
**无需初始化,通常不需要直接调用 `login()`**。`setUserInfo({ userId, userSig })` 自动触发 IM 登录(若租户开通 IM)。
|
||||
|
||||
### 初始化相关
|
||||
|
||||
#### `ImSDK.login(userId, userSig): Promise<void>`
|
||||
|
||||
手动登录(保留,通常不需要直接调用)。
|
||||
|
||||
#### `ImSDK.refreshToken(userSig): Promise<void>`
|
||||
|
||||
刷新 IM userSig(过期时调用)。
|
||||
|
||||
```ts
|
||||
const newSig = await api.refreshUserSig()
|
||||
await ImSDK.refreshToken(newSig)
|
||||
```
|
||||
|
||||
#### `ImSDK.disconnect(): void`
|
||||
|
||||
断开 WebSocket 连接(通常不需要直接调用,`setUserInfo(null)` 会自动触发)。
|
||||
|
||||
### 消息 API
|
||||
|
||||
```ts
|
||||
await ImSDK.sendTextMessage(toId, chatType, content, mentionedUserIds?)
|
||||
await ImSDK.sendImageMessage(toId, chatType, localUri, width?, height?)
|
||||
await ImSDK.sendVideoMessage(toId, chatType, localUri, thumbnailUri?, duration?)
|
||||
await ImSDK.sendAudioMessage(toId, chatType, localUri, duration)
|
||||
await ImSDK.sendFileMessage(toId, chatType, localUri, filename, size)
|
||||
await ImSDK.revokeMessage(messageId)
|
||||
await ImSDK.editMessage(messageId, content)
|
||||
```
|
||||
|
||||
### 会话 API
|
||||
|
||||
```ts
|
||||
await ImSDK.listConversations()
|
||||
ImSDK.subscribeConversations(callback) // 返回 unsubscribe 函数
|
||||
await ImSDK.markRead(targetId, chatType)
|
||||
await ImSDK.deleteConversation(targetId, chatType)
|
||||
await ImSDK.getTotalUnreadCount()
|
||||
```
|
||||
|
||||
### 历史消息 API
|
||||
|
||||
```ts
|
||||
await ImSDK.fetchHistory(toId, page, size)
|
||||
await ImSDK.fetchGroupHistory(groupId, page, size)
|
||||
await ImSDK.searchMessages({ keyword, msgType, startTime, endTime })
|
||||
```
|
||||
|
||||
### 群组 API
|
||||
|
||||
```ts
|
||||
await ImSDK.createGroup(name, memberIds, groupType)
|
||||
await ImSDK.listGroups()
|
||||
await ImSDK.addGroupMember(groupId, userId)
|
||||
await ImSDK.removeGroupMember(groupId, userId)
|
||||
await ImSDK.setGroupRole(groupId, userId, role)
|
||||
```
|
||||
|
||||
### 关系链 API
|
||||
|
||||
```ts
|
||||
await ImSDK.listFriends()
|
||||
await ImSDK.addFriend(friendId)
|
||||
await ImSDK.removeFriend(friendId)
|
||||
await ImSDK.sendFriendRequest(toUserId, remark?)
|
||||
await ImSDK.acceptFriendRequest(requestId)
|
||||
await ImSDK.addToBlacklist(blockedUserId)
|
||||
await ImSDK.checkBlacklist(targetUserId)
|
||||
```
|
||||
|
||||
### 事件监听
|
||||
|
||||
```ts
|
||||
ImSDK.addListener({
|
||||
onConnected: () => {},
|
||||
onDisconnected: () => {},
|
||||
onMessage: (msg) => {},
|
||||
onGroupMessage: (msg) => {},
|
||||
onRevoke: ({ msgId }) => {},
|
||||
})
|
||||
ImSDK.removeListener(listener)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. LicenseSDK(license)
|
||||
|
||||
> `import { checkLicense, getStatus } from '@xuqm/rn-license'`
|
||||
|
||||
**无需独立初始化**。内部自动调用 `awaitInitialization()` 等待 `XuqmSDK` 就绪,使用公共 `appKey` 和 `licenseUrl`。
|
||||
|
||||
### API
|
||||
|
||||
#### `checkLicense(userInfo?): Promise<LicenseResult>`
|
||||
|
||||
验证设备 License。
|
||||
|
||||
```ts
|
||||
const result = await checkLicense()
|
||||
if (result.type === 'success') {
|
||||
console.log('License 验证通过:', result.reason)
|
||||
} else {
|
||||
console.error('License 验证失败:', result.message)
|
||||
}
|
||||
```
|
||||
|
||||
#### `getStatus(): Promise<LicenseStatus>`
|
||||
|
||||
获取当前 License 状态(`'ok' | 'denied' | 'unknown'`)。
|
||||
|
||||
#### `clear(): Promise<void>`
|
||||
|
||||
清除本地 License 缓存(调试用)。
|
||||
|
||||
### 已移除 API
|
||||
|
||||
| 方法 | 移除原因 |
|
||||
|------|---------|
|
||||
| `initialize(appKey, options)` | 依赖 XuqmSDK,无需独立初始化 |
|
||||
| `initializeFromFile(encrypted)` | License 文件不承担 SDK 初始化职责 |
|
||||
|
||||
---
|
||||
|
||||
## 内部机制说明
|
||||
|
||||
### 用户信息分发(_registerUserInfoHandler)
|
||||
|
||||
`XuqmSDK.setUserInfo()` 内部维护一个订阅者列表。各子 SDK 在模块加载时注册处理器:
|
||||
|
||||
```
|
||||
setUserInfo(info)
|
||||
├── PushSDK:检测厂商 → 获取厂商配置 → 注册 token
|
||||
├── ImSDK:若 userSig 存在且 imEnabled,自动登录
|
||||
├── UpdateSDK:更新 userId
|
||||
└── LicenseSDK:更新用户上下文
|
||||
```
|
||||
|
||||
该机制为内部实现,业务侧只需调用 `setUserInfo`,无需关心具体分发逻辑。
|
||||
|
||||
### 配置文件解密
|
||||
|
||||
`.xuqmconfig` 文件格式:`XUQM-CONFIG-V1.{salt}.{iv}.{ciphertext}`(Base64URL 编码)
|
||||
|
||||
SDK 使用 `react-native-quick-crypto` 的 SubtleCrypto 进行 PBKDF2 派生密钥 + AES-GCM 解密,详见 `packages/common/src/configCrypto.ts`。
|
||||
|
||||
解密后包含:`{ appKey, platformUrl/serverUrl }`,SDK 自动调用 `initialize()` 完成远程配置拉取。
|
||||
所有扩展包都不提供第二套初始化或登录 API。
|
||||
|
||||
201
docs/插件脚手架.md
201
docs/插件脚手架.md
@ -1,164 +1,91 @@
|
||||
# 插件脚手架工具
|
||||
# RN 插件脚手架
|
||||
|
||||
> 脚本位置:`packages/update/scripts/create-plugin.mjs`
|
||||
> 最后更新:2026-06-15
|
||||
插件创建、构建、嵌入和发布统一由 `@xuqm/rn-update` 提供的 `xuqm-rn` CLI 完成。已删除独立 `create-plugin.mjs`,宿主不得复制 SDK 脚本或为每个插件维护一套构建命令。
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
`create-plugin.mjs` 是 UpdateSDK 提供的插件脚手架工具,用于一键创建完整的插件骨架项目。
|
||||
|
||||
**功能**:
|
||||
|
||||
- 交互式或命令行输入插件参数
|
||||
- 校验 moduleId 唯一性
|
||||
- 自动生成插件文件(bundle.ts / Screen / plugin.json)
|
||||
- 自动注册到宿主(pluginCatalog / debugPlugins / build scripts / metro config / babel alias / tsconfig)
|
||||
|
||||
---
|
||||
|
||||
## 用法
|
||||
|
||||
### 命令行模式(推荐)
|
||||
## 初始化宿主
|
||||
|
||||
```bash
|
||||
node scripts/create-plugin.mjs <moduleId> [title] [subtitle] [accentColor]
|
||||
pnpm exec xuqm-rn init
|
||||
pnpm xuqm:doctor
|
||||
```
|
||||
|
||||
示例:
|
||||
`init` 只创建缺失的统一配置和必要脚本,不覆盖宿主已有代码。唯一插件配置文件为根目录 `xuqm.config.json`。
|
||||
|
||||
开发启动也使用同一 CLI,不由宿主复制 Node 包装脚本:
|
||||
|
||||
```bash
|
||||
# 完整参数
|
||||
node scripts/create-plugin.mjs buz4 "IM 消息" "即时通讯业务组件" "#E74C3C"
|
||||
|
||||
# 最简(title/subtitle/accentColor 使用默认值)
|
||||
node scripts/create-plugin.mjs buz5
|
||||
pnpm start
|
||||
pnpm android -- --device emulator-5554
|
||||
```
|
||||
|
||||
### 交互模式
|
||||
`xuqm-rn start/run` 始终调用宿主本地的 React Native CLI,并为子进程归一化 `NO_COLOR/FORCE_COLOR` 环境变量。
|
||||
|
||||
## 创建插件
|
||||
|
||||
```bash
|
||||
node scripts/create-plugin.mjs
|
||||
pnpm exec xuqm-rn plugin create prescription
|
||||
```
|
||||
|
||||
按提示依次输入 moduleId、title、subtitle、accentColor。
|
||||
`moduleId` 必须:
|
||||
|
||||
---
|
||||
- 以小写字母开头
|
||||
- 只包含小写字母、数字和连字符
|
||||
- 在 `xuqm.config.json` 中唯一
|
||||
|
||||
## 参数说明
|
||||
CLI 会生成:
|
||||
|
||||
| 参数 | 必填 | 格式 | 默认值 | 说明 |
|
||||
| ----------- | ---- | ------------------ | ----------------- | ----------------------- |
|
||||
| moduleId | ✅ | `^[a-z][a-z0-9]*$` | — | 插件唯一标识,如 `buz4` |
|
||||
| title | ❌ | 任意文本 | = moduleId | 插件标题 |
|
||||
| subtitle | ❌ | 任意文本 | `{title}业务组件` | 插件副标题 |
|
||||
| accentColor | ❌ | CSS 颜色值 | `#0E84FA` | 主题色 |
|
||||
|
||||
---
|
||||
|
||||
## 自动生成的文件
|
||||
|
||||
### 插件目录 `src/plugins/{moduleId}/`
|
||||
|
||||
#### `bundle.ts` — 入口文件
|
||||
|
||||
```typescript
|
||||
import { UpdateSDK } from "@xuqm/rn-update";
|
||||
import { registerPluginFromBridge } from "@plugins/runtimeBridge";
|
||||
import { Buz4Screen } from "@buz4/Buz4Screen";
|
||||
|
||||
// 自动注册插件版本(bundle 加载时执行)
|
||||
UpdateSDK.registerPlugin({ moduleId: "buz4", version: "1.0.0" });
|
||||
|
||||
// 注册 UI 组件到宿主运行时
|
||||
registerPluginFromBridge({
|
||||
id: "buz4",
|
||||
title: "IM 消息",
|
||||
subtitle: "即时通讯业务组件",
|
||||
accentColor: "#E74C3C",
|
||||
Component: Buz4Screen,
|
||||
});
|
||||
```text
|
||||
src/plugins/prescription/
|
||||
bundle.ts
|
||||
PrescriptionScreen.tsx
|
||||
```
|
||||
|
||||
#### `{ModuleId}Screen.tsx` — Screen 骨架
|
||||
并向 `xuqm.config.json.modules` 增加唯一模块声明。不会修改 Babel alias、TypeScript paths、Metro offset 或增加模块专属 package script。
|
||||
|
||||
```typescript
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
## 插件入口
|
||||
|
||||
export function Buz4Screen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>buz4</Text>
|
||||
<Text style={styles.subtitle}>TODO: 实现buz4功能</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
生成的入口使用统一运行时定义:
|
||||
|
||||
```ts
|
||||
import { definePlugin } from '@xuqm/rn-update'
|
||||
|
||||
import { PrescriptionScreen } from './PrescriptionScreen'
|
||||
|
||||
definePlugin({
|
||||
id: 'prescription',
|
||||
Component: PrescriptionScreen,
|
||||
})
|
||||
```
|
||||
|
||||
#### `plugin.json` — 插件元数据
|
||||
插件入口不声明版本。默认 `moduleVersion`、`commonVersionRange` 和 `minNativeApiLevel` 由 CLI 从宿主 `package.json.version`、common 模块版本和 SDK 原生 API 等级一次性解析。
|
||||
|
||||
```json
|
||||
{ "moduleId": "buz4", "version": "1.0.0" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自动更新的宿主文件
|
||||
|
||||
| 文件 | 变更内容 |
|
||||
| ------------------------------- | --------------------------------------------------------------- |
|
||||
| `src/app/pluginCatalog.ts` | 添加插件目录条目(id/title/summary/accentColor) |
|
||||
| `src/bootstrap/debugPlugins.ts` | 注册 debug loader(`require('../plugins/{id}/bundle')`) |
|
||||
| `package.json` | 添加 `build:android:{id}` / `build:ios:{id}` 脚本,更新聚合脚本 |
|
||||
| `metro.split.config.js` | 添加 moduleId 的 offset 分支 |
|
||||
| `babel.config.js` | 添加 `@{id}` alias |
|
||||
| `tsconfig.json` | 添加 `@{id}/*` path mapping |
|
||||
|
||||
---
|
||||
|
||||
## 唯一性校验
|
||||
|
||||
脚本会检查两处来确保 moduleId 唯一:
|
||||
|
||||
1. `src/app/pluginCatalog.ts` 中已注册的 id
|
||||
2. `src/plugins/` 目录下已存在的目录名
|
||||
|
||||
如果 moduleId 已存在,脚本会报错并退出:
|
||||
|
||||
```
|
||||
❌ moduleId "buz4" 已存在,请使用其他 ID
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## moduleId 命名规范
|
||||
|
||||
- 必须以**小写字母**开头
|
||||
- 只能包含**小写字母和数字**
|
||||
- 推荐格式:`buz{N}`(如 `buz4`、`buz5`)
|
||||
|
||||
Metro module ID offset 自动计算:`buz1` → 11M,`buz2` → 12M,`buzN` → (10+N)M。
|
||||
|
||||
---
|
||||
|
||||
## 创建后的开发流程
|
||||
## 构建与发布
|
||||
|
||||
```bash
|
||||
# 1. 生成插件骨架
|
||||
node scripts/create-plugin.mjs buz4 "IM 消息" "即时通讯业务组件" "#E74C3C"
|
||||
|
||||
# 2. 实现业务 UI
|
||||
# 编辑 src/plugins/buz4/Buz4Screen.tsx
|
||||
|
||||
# 3. 创建子目录
|
||||
mkdir -p src/plugins/buz4/services
|
||||
mkdir -p src/plugins/buz4/pages
|
||||
|
||||
# 4. 验证
|
||||
yarn validate
|
||||
|
||||
# 5. 构建
|
||||
yarn build:android:buz4
|
||||
yarn build:ios:buz4
|
||||
pnpm exec xuqm-rn doctor
|
||||
pnpm exec xuqm-rn build android --module prescription
|
||||
pnpm exec xuqm-rn publish android --module prescription
|
||||
```
|
||||
|
||||
构建全部模块并嵌入宿主:
|
||||
|
||||
```bash
|
||||
pnpm build:android
|
||||
```
|
||||
|
||||
生成 Android Release 安装包:
|
||||
|
||||
```bash
|
||||
pnpm release:android
|
||||
pnpm release:android -- --apk
|
||||
```
|
||||
|
||||
Release 构建会先重新生成全部插件,不使用仓库内历史 bundle。
|
||||
|
||||
## 强制约束
|
||||
|
||||
- 不得在业务入口硬编码插件版本。
|
||||
- 不得给同一插件同时维护 JSON、TS 常量和 package script 三套定义。
|
||||
- 不得逐层传递 SDK 初始化、用户、token 或宿主运行时参数。
|
||||
- 共通逻辑放在 common 或 update 运行时,不复制到各插件。
|
||||
- 插件发布前必须通过 `xuqm-rn doctor`、宿主测试和 Release 构建。
|
||||
|
||||
129
docs/架构总览.md
129
docs/架构总览.md
@ -1,108 +1,41 @@
|
||||
# XuqmGroup RN SDK 架构总览
|
||||
# XuqmGroup RN SDK 架构
|
||||
|
||||
## 整体架构
|
||||
## 唯一基座
|
||||
|
||||
```
|
||||
宿主 App(YiwangxinApp4 等)
|
||||
└── 初始化(方式A: 配置文件自动 / 方式B: 手动 initialize)
|
||||
`@xuqm/rn-common` 是唯一可以独立使用的基础包,负责:
|
||||
|
||||
- 为官方扩展提供从 `src/assets/config/config.xuqmconfig` 自动初始化的共享上下文;common 单独使用时不启动初始化;
|
||||
- 维护唯一的 SDK 配置和用户会话;
|
||||
- 网络、访问令牌、签名、文件、时间、加密、设备信息和通用 UI;
|
||||
- 将一次 `XuqmSDK.login()` / `logout()` 传播给官方扩展包。
|
||||
|
||||
common 的文件、时间、网络、加密、设备、UI 和 hooks 不要求初始化或登录。配置与会话只在启用扩展包时形成统一 SDK 上下文。
|
||||
|
||||
`rn-update`、`rn-xwebview`、`rn-bugcollect`、`rn-im`、`rn-push` 都以 peer dependency 依赖同一个 common 实例。扩展包不得再实现独立初始化、独立 token 或独立用户状态。
|
||||
|
||||
## 生命周期
|
||||
|
||||
```text
|
||||
配置文件 + withXuqmConfig
|
||||
↓
|
||||
@xuqm/rn-common (核心层 v0.4.0)
|
||||
├── XuqmSDK.initialize() → 拉取 /api/sdk/config
|
||||
├── XuqmSDK.setUserInfo() → 通知所有子 SDK(订阅者模式)
|
||||
├── api/(useApi / usePageApi / apiRequest)
|
||||
├── device(设备信息 / 厂商检测)
|
||||
├── xwebview(JSBridge 桥接控制)
|
||||
├── ui/(toast / alert / confirm)
|
||||
└── logApiUrl / logEnabled(从平台配置获取)
|
||||
↓ peerDep / 依赖
|
||||
子 SDK(各自独立)
|
||||
├── @xuqm/rn-update → OTA 热更新 / APK 更新
|
||||
├── @xuqm/rn-push → 厂商推送(peerDep: rn-common)
|
||||
├── @xuqm/rn-im → IM 会话 / 消息(peerDep: rn-common)
|
||||
├── @xuqm/rn-xwebview → WebView UI 组件(peerDep: rn-common)
|
||||
├── @xuqm/rn-license → 证书授权(peerDep: rn-common)
|
||||
└── @xuqm/rn-bugcollect → 错误采集 / Crash 捕获 / 漏斗分析(peerDep: rn-common)
|
||||
common 自动初始化(同配置并发复用一个 Promise)
|
||||
↓
|
||||
宿主登录成功后调用一次 XuqmSDK.login
|
||||
↓
|
||||
common 串行更新 accessToken / userId
|
||||
↓
|
||||
已安装扩展包接收同一次会话通知
|
||||
↓
|
||||
宿主退出时调用一次 XuqmSDK.logout
|
||||
```
|
||||
|
||||
## 依赖关系
|
||||
内部订阅入口位于 `@xuqm/rn-common/internal`,仅供官方扩展包使用。应用代码只能使用 common 顶层公共 API。
|
||||
|
||||
```
|
||||
rn-common (核心,无 SDK 内部依赖)
|
||||
↑ peerDep
|
||||
├── rn-update (直接依赖 rn-common)
|
||||
├── rn-push (peerDep rn-common)
|
||||
├── rn-im (peerDep rn-common + watermelondb)
|
||||
├── rn-xwebview (peerDep rn-common + webview + navigation)
|
||||
├── rn-license (peerDep rn-common + quick-crypto)
|
||||
└── rn-bugcollect(peerDep rn-common)
|
||||
```
|
||||
## 发布与集成
|
||||
|
||||
## 用户状态分发机制
|
||||
|
||||
```
|
||||
XuqmSDK.setUserInfo(info)
|
||||
├── PushSDK: 检测厂商 → 获取厂商配置 → 注册 token
|
||||
├── ImSDK: 若 userSig 存在且 imEnabled → 自动登录 WebSocket
|
||||
├── UpdateSDK: 更新 userId(用于定向更新)
|
||||
└── LicenseSDK: 更新用户上下文
|
||||
|
||||
XuqmSDK.setUserInfo(null)
|
||||
├── PushSDK: 解绑 token
|
||||
├── ImSDK: 断开 WebSocket
|
||||
└── 其他子 SDK: 清理用户状态
|
||||
```
|
||||
|
||||
各子 SDK 在模块加载时通过 `_registerUserInfoHandler()` 注册处理器,无需 App 手动协调。
|
||||
|
||||
## 初始化流程
|
||||
|
||||
### 方式 A(自动,推荐)
|
||||
|
||||
```
|
||||
withXuqmConfig(metroConfig) 自动发现 src/assets/config/*.xuqmconfig
|
||||
↓ 生成虚拟模块 @xuqm/autoinit-config
|
||||
import '@xuqm/rn-common' 时触发
|
||||
↓ autoInit.ts → require('@xuqm/autoinit-config')
|
||||
↓ 解密(PBKDF2 + AES-256-GCM)
|
||||
initWithConfigFile() → initialize({ appKey, platformUrl })
|
||||
↓ HTTP GET
|
||||
/api/sdk/config → 返回 { apiUrl, imWsUrl, fileServiceUrl, bugCollectApiUrl, ... }
|
||||
```
|
||||
|
||||
兼容方式:手动配置 Babel/Metro alias `@xuqm/autoinit-config` → `.ts` 包装文件。
|
||||
|
||||
### 方式 B(手动)
|
||||
|
||||
```
|
||||
App 代码调用 XuqmSDK.initialize({ appKey })
|
||||
↓ HTTP GET
|
||||
/api/sdk/config → 解析平台配置
|
||||
↓
|
||||
各子 SDK 就绪(等待 setUserInfo 触发具体功能)
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
App 代码
|
||||
↓ 调用 API
|
||||
子 SDK(update/push/im/license/bugcollect)
|
||||
↓ 使用 apiRequest()
|
||||
rn-common http.ts
|
||||
↓ 附加 Bearer Token + 处理响应
|
||||
平台服务(tenant-service / im-service / push-service / update-service / bugcollect-service)
|
||||
```
|
||||
|
||||
## 包发布
|
||||
|
||||
所有包发布到 Nexus npm 私有仓库:`https://nexus.xuqinmin.com/repository/npm-hosted/`
|
||||
包管理统一使用 pnpm 11。开发版本发布到 `https://nexus.xuqinmin.com/repository/npm-hosted/`;宿主从 npm group 仓库安装精确版本,禁止使用跨仓库 `file:` 依赖。
|
||||
|
||||
```bash
|
||||
# 发布单个包
|
||||
cd packages/common && npm publish
|
||||
|
||||
# 发布所有包
|
||||
for pkg in common update push im xwebview license bugcollect; do
|
||||
cd packages/$pkg && npm publish && cd ../..
|
||||
done
|
||||
corepack pnpm validate
|
||||
corepack pnpm --filter @xuqm/rn-common publish --tag next --no-git-checks
|
||||
```
|
||||
|
||||
@ -1,85 +0,0 @@
|
||||
# 模块待开发说明
|
||||
|
||||
> 以下模块当前代码冻结,仅补充文档。后续开发需单独评估。
|
||||
|
||||
---
|
||||
|
||||
## rn-push(厂商推送)
|
||||
|
||||
### 当前状态
|
||||
|
||||
- FCM/APNS 基础注册已实现
|
||||
- HMS/VIVO/OPPO 厂商推送集成框架已建
|
||||
- 自动厂商检测(通过原生模块 `NativePush`)
|
||||
- Token 注册/解绑 API 完整
|
||||
- 离线推送开关、免打扰时间段 API 已实现
|
||||
|
||||
### 后续待做
|
||||
|
||||
- 厂商推送 token 注册完整流程(华为 HMS 需要 AppGallery Connect 配置)
|
||||
- 设备绑定/解绑统一 API(当前分散在各厂商 SDK)
|
||||
- 角标管理(badge count 同步)
|
||||
- 厂商推送通道选择(高优先级 vs 普通通知)
|
||||
- 推送数据分析(到达率、点击率统计)
|
||||
|
||||
### 技术要点
|
||||
|
||||
- 原生模块 `XuqmPushModule` 需要在宿主 App 中链接
|
||||
- 各厂商 SDK 初始化需要对应的 App ID / App Secret
|
||||
- `setUserInfo(info)` 触发自动注册,`setUserInfo(null)` 触发自动解绑
|
||||
|
||||
---
|
||||
|
||||
## rn-im(即时通讯)
|
||||
|
||||
### 当前状态
|
||||
|
||||
- 腾讯云 IM SDK 封装(WebSocket/STOMP 传输)
|
||||
- WatermelonDB 本地消息持久化
|
||||
- 基础会话/消息 API 完整(15 种消息类型)
|
||||
- 群组管理 API 完整
|
||||
- 关系链(好友/黑名单)API 完整
|
||||
- 离线消息同步、消息搜索(本地)
|
||||
|
||||
### 后续待做
|
||||
|
||||
- 消息已读回执(单聊已读状态同步)
|
||||
- 群组消息已读回执统计
|
||||
- 离线消息补全(当前为 best-effort 同步)
|
||||
- 消息撤回时间窗口控制(服务端策略)
|
||||
- 语音消息录制组件(当前仅发送本地音频文件)
|
||||
- 图片/视频消息缩略图生成
|
||||
- 消息转发、合并转发 UI
|
||||
- @所有人(@all)支持
|
||||
|
||||
### 技术要点
|
||||
|
||||
- 需要 `@nozbe/watermelondb` 依赖
|
||||
- DB 名称由 `appKey + userId` 派生,多账号隔离
|
||||
- 断线重连由 `ImClient` 内部处理(指数退避)
|
||||
- `setUserInfo` 自动触发 IM 登录(若 `imEnabled` 为 true)
|
||||
|
||||
---
|
||||
|
||||
## rn-license(证书授权)
|
||||
|
||||
### 当前状态
|
||||
|
||||
- 证书授权基础流程已实现
|
||||
- 设备 License 验证(`checkLicense`)
|
||||
- License 状态查询(`getStatus`)
|
||||
- 本地缓存(AsyncStorage)
|
||||
|
||||
### 后续待做
|
||||
|
||||
- 证书到期提醒(服务端推送 + 本地定时检查)
|
||||
- 证书自动刷新机制
|
||||
- 多设备 License 管理
|
||||
- 离线 License 验证(降级策略)
|
||||
- License 使用量统计
|
||||
|
||||
### 技术要点
|
||||
|
||||
- 需要 `react-native-quick-crypto` 依赖
|
||||
- 内部自动等待 `XuqmSDK` 初始化完成
|
||||
- License 服务地址从平台配置获取(`licenseUrl`)
|
||||
248
docs/配置文件规范.md
248
docs/配置文件规范.md
@ -1,203 +1,119 @@
|
||||
# 配置文件规范
|
||||
# Xuqm RN 配置文件规范
|
||||
|
||||
> 最后更新:2026-06-15
|
||||
Xuqm RN 扩展 SDK 只支持一种自动初始化方案:宿主保存一份平台签发的 `.xuqmconfig` 原始加密文件,并用 `withXuqmConfig()` 包装 Metro。不得创建 TypeScript 配置副本、手动 alias 或在业务代码硬编码 appKey。
|
||||
|
||||
---
|
||||
## common-only 项目
|
||||
|
||||
## 概述
|
||||
只使用 `@xuqm/rn-common` 的文件、时间、加密、设备、UI 或自定义网络能力时:
|
||||
|
||||
XuqmGroup SDK 使用加密配置文件完成自动初始化,对齐 Android SDK 的 ContentProvider 模式。
|
||||
- 不放 `.xuqmconfig`
|
||||
- 不使用 `withXuqmConfig()`
|
||||
- 不调用 `XuqmSDK.initialize()`
|
||||
- 不调用 `XuqmSDK.login()`
|
||||
|
||||
**核心流程**:
|
||||
common-only 模式保持零初始化、零登录。
|
||||
|
||||
1. 宿主把加密配置文件放到标准位置
|
||||
2. SDK 在 common bundle 加载时自动读取、解密、初始化
|
||||
3. 宿主代码零初始化调用
|
||||
## 扩展 SDK 项目
|
||||
|
||||
---
|
||||
安装 update、bugcollect、xwebview 等扩展后,将平台签发的原始文件放到:
|
||||
|
||||
## 配置文件格式
|
||||
|
||||
### 加密格式
|
||||
|
||||
```
|
||||
XUQM-CONFIG-V1.{base64urlSalt}.{base64urlIV}.{base64urlCiphertext}
|
||||
```text
|
||||
src/assets/config/config.xuqmconfig
|
||||
```
|
||||
|
||||
| 部分 | 说明 |
|
||||
| --------------------- | ------------------------------------------- |
|
||||
| `XUQM-CONFIG-V1` | 固定 magic header |
|
||||
| `base64urlSalt` | PBKDF2 盐值(16 字节,base64url 编码) |
|
||||
| `base64urlIV` | AES-GCM 初始向量(12 字节,base64url 编码) |
|
||||
| `base64urlCiphertext` | AES-256-GCM 密文 + 16 字节 auth tag |
|
||||
文件名也可以是其他 `*.xuqmconfig`。扫描优先级为:
|
||||
|
||||
### 加密参数
|
||||
1. `config.xuqmconfig`
|
||||
2. `config.xuqm`
|
||||
3. 目录中的第一个 `*.xuqmconfig`
|
||||
|
||||
| 参数 | 值 |
|
||||
| -------- | ------------------ |
|
||||
| 算法 | AES-256-GCM |
|
||||
| 密钥派生 | PBKDF2-HMAC-SHA256 |
|
||||
| 迭代次数 | 120,000 |
|
||||
| 密钥长度 | 256 位 |
|
||||
| Auth Tag | 128 位(16 字节) |
|
||||
Metro 配置只包装一次:
|
||||
|
||||
### 解密后 JSON 结构
|
||||
```js
|
||||
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
|
||||
const {withXuqmConfig} = require('@xuqm/rn-common/metro');
|
||||
|
||||
```json
|
||||
{
|
||||
"appKey": "yiwangxin",
|
||||
"appName": "医网信",
|
||||
"companyName": "BJCA",
|
||||
"baseUrl": "https://www.51trust.com",
|
||||
"serverUrl": "https://www.51trust.com",
|
||||
"packageName": "cn.org.bjca.wcert.ywq",
|
||||
"iosBundleId": "com.bjca.ywq",
|
||||
"issuedAt": "2026-01-01T00:00:00Z",
|
||||
"expiresAt": "2028-01-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
const baseConfig = mergeConfig(getDefaultConfig(__dirname), {
|
||||
// 宿主自己的 Metro 配置
|
||||
});
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| ------------- | ---- | -------------------------------- |
|
||||
| `appKey` | ✅ | 应用唯一标识 |
|
||||
| `appName` | ❌ | 应用名称 |
|
||||
| `companyName` | ❌ | 公司名称 |
|
||||
| `baseUrl` | ❌ | API 服务地址(覆盖默认值) |
|
||||
| `serverUrl` | ❌ | 私有部署地址(覆盖所有服务端点) |
|
||||
| `packageName` | ❌ | Android 包名(用于校验) |
|
||||
| `iosBundleId` | ❌ | iOS Bundle ID |
|
||||
| `issuedAt` | ❌ | 签发时间 |
|
||||
| `expiresAt` | ❌ | 过期时间 |
|
||||
|
||||
---
|
||||
|
||||
## 宿主集成方式
|
||||
|
||||
### React Native(Metro bundler)
|
||||
|
||||
#### 推荐方式:withXuqmConfig Metro 插件
|
||||
|
||||
将平台下载的 `.xuqmconfig` 加密文件直接放入 `src/assets/config/`(推荐)或 `src/assets/xuqm/`,然后用 `withXuqmConfig` 包裹 Metro 配置:
|
||||
|
||||
**文件位置**:`src/assets/config/config.xuqmconfig`(原始加密文件,无需改名或创建 `.ts` 包装)
|
||||
|
||||
**Metro 配置**(`metro.config.js`):
|
||||
|
||||
```javascript
|
||||
const { getDefaultConfig } = require('@react-native/metro-config');
|
||||
const { withXuqmConfig } = require('@xuqm/rn-common/metro');
|
||||
const baseConfig = getDefaultConfig(__dirname);
|
||||
module.exports = withXuqmConfig(baseConfig);
|
||||
```
|
||||
|
||||
`withXuqmConfig` 会自动扫描 `src/assets/config/` 和 `src/assets/xuqm/` 目录,优先查找 `config.xuqmconfig` 或 `config.xuqm`,否则取第一个 `.xuqmconfig` 文件。找到后生成虚拟模块并注入 Metro 解析链,SDK import 时自动读取并初始化。
|
||||
## 自动初始化时序
|
||||
|
||||
#### 兼容方式:手动 alias
|
||||
|
||||
如果无法使用 Metro 插件,也可以手动配置 alias。由于 Metro 只能 bundle JS/TS 模块,配置文件使用 `.ts` 扩展名。
|
||||
|
||||
**文件位置**:`src/assets/xuqm/config.xuqmconfig.ts`
|
||||
|
||||
```typescript
|
||||
export const ENCRYPTED_CONFIG = "XUQM-CONFIG-V1.{salt}.{iv}.{ciphertext}";
|
||||
```text
|
||||
Metro 读取唯一 .xuqmconfig
|
||||
→ 生成构建期传输模块
|
||||
→ 注册 @xuqm/rn-common/internal 为 pre-main module
|
||||
→ 解密配置
|
||||
→ 使用 appKey 请求远程 SDK 配置
|
||||
→ 初始化唯一 HTTP/扩展上下文
|
||||
→ 执行业务入口
|
||||
```
|
||||
|
||||
**Metro alias**(`metro.config.js`):
|
||||
pre-main 机制保证初始化不受 Metro `inlineRequires` 和业务模块加载顺序影响。解密开始后,所有 `awaitInitialization()` 调用都等待同一个 Promise。
|
||||
|
||||
```javascript
|
||||
const alias = {
|
||||
"@xuqm/autoinit-config": path.resolve(
|
||||
projectRoot,
|
||||
"src/assets/xuqm/config.xuqmconfig",
|
||||
),
|
||||
};
|
||||
## 原生配置同步
|
||||
|
||||
`withXuqmConfig()` 会把同一份源文件幂等同步到:
|
||||
|
||||
```text
|
||||
android/app/src/main/assets/config/config.xuqmconfig
|
||||
ios/<App>/config/config.xuqmconfig
|
||||
```
|
||||
|
||||
**Babel alias**(`babel.config.js`):
|
||||
这些位置是构建目标,不是第二配置源。宿主只维护 `src/assets/config` 下的原文件。
|
||||
|
||||
```javascript
|
||||
'@xuqm/autoinit-config': './src/assets/xuqm/config.xuqmconfig'
|
||||
## 加密文件
|
||||
|
||||
文件格式:
|
||||
|
||||
```text
|
||||
XUQM-CONFIG-V1.{salt}.{iv}.{ciphertextAndTag}
|
||||
```
|
||||
|
||||
**TypeScript path**(`tsconfig.json`):
|
||||
- PBKDF2-HMAC-SHA256
|
||||
- AES-256-GCM
|
||||
- 12 字节 IV
|
||||
- 16 字节认证标签
|
||||
|
||||
```json
|
||||
"@xuqm/autoinit-config": ["src/assets/xuqm/config.xuqmconfig"]
|
||||
解密后的必要字段为 `appKey`,可选字段包括 `serverUrl`、`baseUrl` 和 `signingKey`。明文结构由平台负责签发,业务仓库不得自行构造。
|
||||
|
||||
## 登录
|
||||
|
||||
初始化与业务登录是两个动作。App 登录完成后只同步一次公共会话:
|
||||
|
||||
```ts
|
||||
import {XuqmSDK} from '@xuqm/rn-common';
|
||||
|
||||
await XuqmSDK.awaitInitialization();
|
||||
await XuqmSDK.login({
|
||||
userId: 'user-id',
|
||||
accessToken: 'access-token',
|
||||
name: '姓名',
|
||||
phone: '手机号',
|
||||
});
|
||||
```
|
||||
|
||||
### Android(原生)
|
||||
退出时只调用:
|
||||
|
||||
配置文件位置:`src/main/assets/config/*.xuqmconfig`(推荐)或 `assets/xuqm/*.xuqmconfig`
|
||||
|
||||
从平台下载 `.xuqmconfig` 文件(例如 `识校宝.xuqmconfig`),放入上述任一目录。文件名不限,扩展名为 `.xuqmconfig` 即可。Android SDK 通过 ContentProvider 自动扫描并读取,无需额外配置。
|
||||
|
||||
### iOS(原生)
|
||||
|
||||
配置文件位置:`{app}/xuqm/*.xuqmconfig`
|
||||
|
||||
从平台下载 `.xuqmconfig` 文件,放入 App Bundle 的 `xuqm/` 目录。文件名不限,扩展名为 `.xuqmconfig` 即可。SDK 启动时自动扫描并读取。
|
||||
|
||||
---
|
||||
|
||||
## 自动初始化流程
|
||||
|
||||
```
|
||||
common bundle 加载
|
||||
→ import '@xuqm/rn-common'
|
||||
→ packages/common/src/index.ts
|
||||
→ import './autoInit' (副作用)
|
||||
→ tryAutoInit()
|
||||
→ require('@xuqm/autoinit-config')
|
||||
→ Metro 解析 alias → 宿主 config.xuqmconfig.ts
|
||||
→ XuqmSDK.initWithConfigFile(encrypted)
|
||||
→ configCrypto.decryptConfigFile(encrypted)
|
||||
→ PBKDF2 派生密钥
|
||||
→ AES-256-GCM 解密
|
||||
→ initializeFromLicense(decrypted)
|
||||
→ configureHttp(baseUrl || serverUrl)
|
||||
→ markInitialized()
|
||||
```ts
|
||||
await XuqmSDK.logout();
|
||||
```
|
||||
|
||||
### 失败处理
|
||||
扩展包不得各自初始化或登录,业务代码不得导入 `@xuqm/rn-common/internal`。
|
||||
|
||||
对齐 Android SDK 的 `runCatching + Log.w` 行为:
|
||||
## 失败规则
|
||||
|
||||
- 配置文件不存在 → 静默跳过
|
||||
- 解密失败 → 静默跳过
|
||||
- SDK 保持未初始化状态
|
||||
- `awaitInitialization()` 会超时
|
||||
- 不崩溃、不阻塞 app 启动
|
||||
- 扩展项目缺少配置:`awaitInitialization()` 明确报错。
|
||||
- 解密失败或远程配置失败:初始化 Promise 拒绝并保留原始错误。
|
||||
- 不自动切换到硬编码配置,不静默创建默认租户,不提供多级兼容回退。
|
||||
- common-only 项目没有配置时不会执行初始化,也不会报错。
|
||||
|
||||
---
|
||||
## 安全约束
|
||||
|
||||
## 降级机制
|
||||
|
||||
如果自动初始化失败,宿主可以手动调用:
|
||||
|
||||
```typescript
|
||||
import { XuqmSDK } from "@xuqm/rn-common";
|
||||
|
||||
// 使用默认 URL 初始化
|
||||
XuqmSDK.init({ appKey: "yiwangxin", debug: __DEV__ });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **不要提交解密后的明文配置**到版本控制
|
||||
2. **不要在日志中打印**配置文件内容或解密后的数据
|
||||
3. 配置文件的 `appKey` 用于 API 调用,**不要硬编码**在业务代码中
|
||||
4. `serverUrl` 字段会覆盖所有服务端点,确保指向正确的环境
|
||||
|
||||
---
|
||||
|
||||
## 与 Android SDK 的对齐
|
||||
|
||||
| 环节 | Android SDK | RNSDK |
|
||||
| ------------ | ---------------------------------- | --------------------------------------------- |
|
||||
| 配置文件位置 | `assets/config/*.xuqmconfig` | `src/assets/config/*.xuqmconfig` |
|
||||
| 自动初始化 | ContentProvider.onCreate() | common 包 import 时 autoInit |
|
||||
| 解密 | ConfigFileCrypto (Java) | configCrypto (JS + react-native-quick-crypto) |
|
||||
| 失败处理 | runCatching + Log.w | try/catch + 静默跳过 |
|
||||
| 服务端点覆盖 | configurePrivateServer() | initializeFromLicense(serverUrl) |
|
||||
- 不打印密文或解密后的配置。
|
||||
- 不提交明文 appKey、signingKey 或租户密钥。
|
||||
- 不在多个目录手工维护配置副本。
|
||||
- `.xuqmconfig` 变更必须通过平台重新签发。
|
||||
|
||||
31
native-test/build.gradle
普通文件
31
native-test/build.gradle
普通文件
@ -0,0 +1,31 @@
|
||||
import groovy.json.JsonSlurper
|
||||
|
||||
plugins {
|
||||
id "com.android.library" version "9.1.0" apply false
|
||||
}
|
||||
|
||||
def reactNativePackage = file("../node_modules/react-native/package.json")
|
||||
if (!reactNativePackage.isFile()) {
|
||||
throw new GradleException("Run pnpm install before compiling the RN Android bridge")
|
||||
}
|
||||
|
||||
def reactNativeVersion = new JsonSlurper().parse(reactNativePackage).version
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url "https://nexus.xuqinmin.com/repository/android/" }
|
||||
}
|
||||
|
||||
configurations.configureEach {
|
||||
resolutionStrategy.eachDependency { details ->
|
||||
if (details.requested.group == "com.facebook.react" &&
|
||||
details.requested.name == "react-android" &&
|
||||
!details.requested.version) {
|
||||
details.useVersion(reactNativeVersion)
|
||||
details.because("The host React Native Gradle plugin normally supplies this version")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
android.useAndroidX=true
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
12
native-test/settings.gradle
普通文件
12
native-test/settings.gradle
普通文件
@ -0,0 +1,12 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "xuqm-rn-native-test"
|
||||
|
||||
include(":updateBridge")
|
||||
project(":updateBridge").projectDir = file("../packages/update/android")
|
||||
3601
package-lock.json
自动生成的
3601
package-lock.json
自动生成的
文件差异内容过多而无法显示
加载差异
30
package.json
30
package.json
@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@xuqm/rn-sdk",
|
||||
"version": "0.4.0",
|
||||
"description": "XuqmGroup React Native SDK — meta-package (IM, Push, Update, Common)",
|
||||
"version": "0.5.0-alpha.1",
|
||||
"description": "XuqmGroup React Native SDK workspace — Common, Update, BugCollect and XWebView",
|
||||
"license": "UNLICENSED",
|
||||
"main": "src/index.ts",
|
||||
"react-native": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.13.1",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
@ -17,8 +18,12 @@
|
||||
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --write AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json packages/common packages/bugcollect packages/update packages/xwebview",
|
||||
"format:check": "prettier --check AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json packages/common packages/bugcollect packages/update packages/xwebview",
|
||||
"test": "pnpm --recursive --if-present test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:all": "yarn workspaces run typecheck"
|
||||
"typecheck:all": "pnpm --recursive --if-present typecheck",
|
||||
"validate": "pnpm format:check && pnpm typecheck:all && pnpm test"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-native-async-storage/async-storage": ">=1.21.0",
|
||||
@ -26,18 +31,19 @@
|
||||
"react-native": ">=0.76.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xuqm/rn-common": ">=0.4.0",
|
||||
"@xuqm/rn-im": ">=0.2.0",
|
||||
"@xuqm/rn-license": ">=0.3.0",
|
||||
"@xuqm/rn-bugcollect": ">=0.1.0",
|
||||
"@xuqm/rn-push": ">=0.2.0",
|
||||
"@xuqm/rn-update": ">=0.4.0",
|
||||
"@xuqm/rn-xwebview": ">=0.2.0"
|
||||
"@xuqm/rn-common": "workspace:*",
|
||||
"@xuqm/rn-bugcollect": "workspace:*",
|
||||
"@xuqm/rn-update": "workspace:*",
|
||||
"@xuqm/rn-xwebview": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-native-async-storage/async-storage": "^3.1.1",
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"axios": "^1.18.0",
|
||||
"typescript": "^5.9.3",
|
||||
"zod": "3.23.8"
|
||||
"prettier": "3.9.5",
|
||||
"react": "19.2.7",
|
||||
"react-native": "0.86.0",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,109 +1,31 @@
|
||||
# @xuqm/rn-bugcollect
|
||||
|
||||
XuqmGroup RN SDK 错误采集模块。提供日志采集、错误追踪、漏斗分析能力。
|
||||
JS 错误、业务日志、API 异常和漏斗采集模块。它依赖 `@xuqm/rn-common` 获取远程配置和当前会话,不提供独立初始化或登录入口。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
yarn add @xuqm/rn-bugcollect
|
||||
pnpm add @xuqm/rn-common @xuqm/rn-bugcollect \
|
||||
@react-native-async-storage/async-storage
|
||||
```
|
||||
|
||||
Peer dependencies:`@xuqm/rn-common >= 0.4.0`,`@react-native-async-storage/async-storage >= 1.21.0`,`react-native >= 0.76`
|
||||
|
||||
## 快速开始
|
||||
## 使用
|
||||
|
||||
```ts
|
||||
import { BugCollect } from '@xuqm/rn-bugcollect'
|
||||
|
||||
// 1. 设置日志级别
|
||||
BugCollect.setLogLevel('debug') // 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
// 2. 设置环境标签
|
||||
BugCollect.setEnvironment('production') // 'development' | 'staging' | 'production'
|
||||
|
||||
// 3. 开启全局错误捕获(App 启动时调用一次)
|
||||
BugCollect.startCapture()
|
||||
|
||||
// 4. 记录自定义事件
|
||||
BugCollect.setEnvironment(__DEV__ ? 'development' : 'production')
|
||||
BugCollect.event('page_view', { page: 'home' })
|
||||
|
||||
// 5. 上报错误
|
||||
try { ... } catch (e) { BugCollect.captureError(e) }
|
||||
try {
|
||||
// business
|
||||
} catch (error) {
|
||||
BugCollect.captureError(error)
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
初始化只由 common 自动完成,登录只由 `XuqmSDK.login()` 完成。配置中启用 bugcollect 后,SDK 在共享初始化完成时自动执行一次 `startCapture()`;手工调用仍然幂等,但正常集成不需要调用。`BugCollect.flush()` 可在 App 转入后台前主动刷新队列。
|
||||
|
||||
### BugCollect 对象
|
||||
错误指纹使用 common 的统一 SHA-256 实现,事件里的 SDK 版本直接读取当前发布包版本,不再维护第二份硬编码版本号。
|
||||
|
||||
| API | 说明 |
|
||||
|-----|------|
|
||||
| `BugCollect.setLogLevel(level)` | 设置最低日志级别(低于此级别的事件被丢弃) |
|
||||
| `BugCollect.setEnvironment(env)` | 设置环境标签(附加到每个事件) |
|
||||
| `BugCollect.startCapture()` | 开启全局 JS 错误和未处理 Promise rejection 捕获 |
|
||||
| `BugCollect.event(name, properties?)` | 记录自定义分析事件 |
|
||||
| `BugCollect.captureError(error, metadata?)` | 上报 JS 异常 |
|
||||
| `BugCollect.warn(message, metadata?)` | 记录警告(需级别允许) |
|
||||
| `BugCollect.info(message, metadata?)` | 记录信息事件(需级别允许) |
|
||||
| `BugCollect.defineFunnel(id, steps)` | 定义漏斗(按步骤顺序追踪转化) |
|
||||
| `BugCollect.getFunnelProgress(funnelId)` | 获取客户端漏斗进度 |
|
||||
| `BugCollect.flush()` | 立即刷新所有待发送事件(如 App 进后台前调用) |
|
||||
|
||||
### 事件类型
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| `LogEvent` | 自定义事件(`type: 'event'`) |
|
||||
| `IssueEvent` | 错误事件(`type: 'js_error'` / `'native_crash'` / `'api_error'` / `'warning'`) |
|
||||
|
||||
### 日志级别
|
||||
|
||||
`'debug'` (0) < `'info'` (1) < `'warn'` (2) < `'error'` (3)
|
||||
|
||||
### 漏斗分析示例
|
||||
|
||||
```ts
|
||||
import { BugCollect } from '@xuqm/rn-bugcollect'
|
||||
|
||||
// 定义漏斗
|
||||
BugCollect.defineFunnel('checkout', ['cart_view', 'checkout_start', 'payment_done'])
|
||||
|
||||
// 在业务代码中记录步骤事件(SDK 自动推进漏斗)
|
||||
BugCollect.event('cart_view')
|
||||
BugCollect.event('checkout_start')
|
||||
BugCollect.event('payment_done')
|
||||
|
||||
// 查询进度
|
||||
const progress = BugCollect.getFunnelProgress('checkout')
|
||||
console.log(progress?.completedSteps) // ['cart_view', 'checkout_start', 'payment_done']
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. **LogQueue**:事件先进入内存队列,按批次异步上传到 `bugCollectApiUrl`
|
||||
2. **ErrorCapture**:`startCapture()` 注册全局 `ErrorUtils` handler,自动捕获未处理异常
|
||||
3. **Fingerprint**:为每个错误生成指纹(基于 message + stack),用于服务端去重聚合
|
||||
4. **FunnelTracker**:客户端维护漏斗进度,服务端跨 session 聚合
|
||||
|
||||
## Metro 插件(SourceMap 自动上传)
|
||||
|
||||
`@xuqm/rn-bugcollect/metro` 导出 `withBugCollect`,包裹 Metro 配置后,打 Release 包时自动上传 SourceMap:
|
||||
|
||||
```js
|
||||
// metro.config.js
|
||||
const { getDefaultConfig } = require('@react-native/metro-config');
|
||||
const { withXuqmConfig } = require('@xuqm/rn-common/metro');
|
||||
const { withBugCollect } = require('@xuqm/rn-bugcollect/metro');
|
||||
const baseConfig = getDefaultConfig(__dirname);
|
||||
module.exports = withBugCollect(withXuqmConfig(baseConfig));
|
||||
```
|
||||
|
||||
## 导出清单
|
||||
|
||||
| 导出 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| `BugCollect` | `@xuqm/rn-bugcollect` | 主对象(setLogLevel / setEnvironment / startCapture / event / captureError / warn / info / defineFunnel / getFunnelProgress / flush) |
|
||||
| `withBugCollect` | `@xuqm/rn-bugcollect/metro` | Metro 插件,自动上传 SourceMap |
|
||||
|
||||
## 配置
|
||||
|
||||
`bugCollectApiUrl` 和 `bugCollectEnabled` 由 `@xuqm/rn-common` 在 init 后从 `/api/sdk/config` 自动获取,无需 App 传入。
|
||||
SourceMap 上传使用独立打包后处理流程;不要再叠加基于 Metro `customSerializer` 的旧插件方案,以免破坏新版 Metro 的 exports 与序列化流程。
|
||||
|
||||
@ -63,16 +63,22 @@ function uploadSourceMap(apiUrl, appKey, platform, appVersion, sourceMapPath) {
|
||||
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="appKey"\r\n\r\n${appKey}`)
|
||||
|
||||
// platform
|
||||
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="platform"\r\n\r\n${platform}`)
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="platform"\r\n\r\n${platform}`,
|
||||
)
|
||||
|
||||
// appVersion
|
||||
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="appVersion"\r\n\r\n${appVersion}`)
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="appVersion"\r\n\r\n${appVersion}`,
|
||||
)
|
||||
|
||||
// bundleName
|
||||
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="bundleName"\r\n\r\nindex`)
|
||||
|
||||
// file
|
||||
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/json\r\n\r\n`)
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/json\r\n\r\n`,
|
||||
)
|
||||
|
||||
const header = Buffer.from(parts.join('\r\n') + '\r\n')
|
||||
const footer = Buffer.from(`\r\n--${boundary}--\r\n`)
|
||||
@ -89,9 +95,9 @@ function uploadSourceMap(apiUrl, appKey, platform, appVersion, sourceMapPath) {
|
||||
},
|
||||
}
|
||||
|
||||
const req = client.request(options, (res) => {
|
||||
const req = client.request(options, res => {
|
||||
let data = ''
|
||||
res.on('data', chunk => data += chunk)
|
||||
res.on('data', chunk => (data += chunk))
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
console.log(`[BugCollect] SourceMap uploaded successfully: ${fileName}`)
|
||||
@ -103,7 +109,7 @@ function uploadSourceMap(apiUrl, appKey, platform, appVersion, sourceMapPath) {
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', (err) => {
|
||||
req.on('error', err => {
|
||||
console.warn('[BugCollect] SourceMap upload error:', err.message)
|
||||
reject(err)
|
||||
})
|
||||
@ -156,9 +162,7 @@ function withBugCollect(metroConfig) {
|
||||
}
|
||||
|
||||
// 从 graph 中获取 appKey
|
||||
const appKey = graph.transformer?.config?.xuqmAppKey
|
||||
|| process.env.XUQM_APP_KEY
|
||||
|| ''
|
||||
const appKey = graph.transformer?.config?.xuqmAppKey || process.env.XUQM_APP_KEY || ''
|
||||
|
||||
if (!appKey) {
|
||||
console.warn('[BugCollect] No appKey configured, skipping SourceMap upload')
|
||||
@ -199,11 +203,9 @@ function withBugCollect(metroConfig) {
|
||||
}
|
||||
|
||||
// 异步上传(不阻塞构建)
|
||||
uploadSourceMap(apiUrl, appKey, platform, appVersion, sourceMapPath)
|
||||
.catch(err => {
|
||||
console.warn('[BugCollect] SourceMap upload failed:', err.message)
|
||||
})
|
||||
|
||||
uploadSourceMap(apiUrl, appKey, platform, appVersion, sourceMapPath).catch(err => {
|
||||
console.warn('[BugCollect] SourceMap upload failed:', err.message)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[BugCollect] SourceMap upload error:', err.message)
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xuqm/rn-bugcollect",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0-alpha.2",
|
||||
"description": "XuqmGroup RN SDK — log collection, error tracking, funnel analysis",
|
||||
"license": "UNLICENSED",
|
||||
"main": "src/index.ts",
|
||||
@ -16,14 +16,16 @@
|
||||
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --import tsx --test tests/*.test.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xuqm/rn-common": ">=0.4.0",
|
||||
"@xuqm/rn-common": "workspace:>=0.6.0-alpha.4",
|
||||
"@react-native-async-storage/async-storage": ">=1.21.0",
|
||||
"react-native": ">=0.76.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@ import { ErrorCapture } from './capture/ErrorCapture'
|
||||
import { HttpInterceptor } from './interceptor/HttpInterceptor'
|
||||
import { computeFingerprint } from './fingerprint'
|
||||
import { FunnelTracker } from './funnel/FunnelTracker'
|
||||
import { BUGCOLLECT_SDK_NAME, BUGCOLLECT_SDK_VERSION } from './sdkInfo'
|
||||
import { ReportPolicy } from './reportPolicy'
|
||||
import type {
|
||||
LogLevel,
|
||||
Environment,
|
||||
@ -18,8 +20,6 @@ import type {
|
||||
|
||||
// ─── Internal state ───────────────────────────────────────────────────────────
|
||||
|
||||
const SDK_NAME = 'bugcollect.rn'
|
||||
const SDK_VERSION = '0.2.0'
|
||||
const MAX_BREADCRUMBS = 100
|
||||
|
||||
let _logLevel: LogLevel = 'warn'
|
||||
@ -29,12 +29,7 @@ let _errorCaptureStarted = false
|
||||
let _httpInterceptorStarted = false
|
||||
let _breadcrumbs: BreadcrumbItem[] = []
|
||||
|
||||
// ─── 上报频率控制 / 采样(降低服务端存储与流量压力)───────────────────────────
|
||||
// 同一指纹在滑动窗口内最多上报 N 次,避免相同错误刷屏;fatal 不受限、不抽样。
|
||||
const RATE_WINDOW_MS = 60_000
|
||||
const RATE_MAX_PER_FINGERPRINT = 5
|
||||
let _sampleRate = 1 // 1=全量;0~1 对非致命事件抽样
|
||||
const _fingerprintHits = new Map<string, number[]>()
|
||||
const reportPolicy = new ReportPolicy()
|
||||
|
||||
function _isFatal(event: BugCollectEvent): boolean {
|
||||
return event.type === 'issue' && event.level === 'fatal'
|
||||
@ -46,18 +41,7 @@ function _eventKey(event: BugCollectEvent): string {
|
||||
|
||||
/** 是否允许上报:fatal 必报;否则先采样、再按指纹滑动窗口限频。 */
|
||||
function _shouldReport(event: BugCollectEvent): boolean {
|
||||
if (_isFatal(event)) return true
|
||||
if (_sampleRate < 1 && Math.random() > _sampleRate) return false
|
||||
const key = _eventKey(event)
|
||||
const now = Date.now()
|
||||
const hits = (_fingerprintHits.get(key) ?? []).filter(t => now - t < RATE_WINDOW_MS)
|
||||
if (hits.length >= RATE_MAX_PER_FINGERPRINT) {
|
||||
_fingerprintHits.set(key, hits)
|
||||
return false
|
||||
}
|
||||
hits.push(now)
|
||||
_fingerprintHits.set(key, hits)
|
||||
return true
|
||||
return reportPolicy.shouldReport(_eventKey(event), _isFatal(event))
|
||||
}
|
||||
|
||||
const _sessionId: string = _generateSessionId()
|
||||
@ -72,11 +56,15 @@ function _levelNum(level: LogLevel): number {
|
||||
|
||||
function _getPlatform(): 'ios' | 'android' | 'harmonyos' | 'web' | 'react-native' {
|
||||
switch (Platform.OS) {
|
||||
case 'ios': return 'ios'
|
||||
case 'android': return 'android'
|
||||
case 'ios':
|
||||
return 'ios'
|
||||
case 'android':
|
||||
return 'android'
|
||||
// @ts-expect-error HarmonyOS is not in the official RN types
|
||||
case 'harmony': return 'harmonyos'
|
||||
default: return 'react-native'
|
||||
case 'harmony':
|
||||
return 'harmonyos'
|
||||
default:
|
||||
return 'react-native'
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,12 +80,14 @@ function _getDeviceInfo(): DeviceInfo {
|
||||
return {
|
||||
model: typeof constants['Model'] === 'string' ? constants['Model'] : undefined,
|
||||
brand: typeof constants['Brand'] === 'string' ? constants['Brand'] : undefined,
|
||||
manufacturer: typeof constants['Manufacturer'] === 'string' ? constants['Manufacturer'] : undefined,
|
||||
manufacturer:
|
||||
typeof constants['Manufacturer'] === 'string' ? constants['Manufacturer'] : undefined,
|
||||
osName: Platform.OS,
|
||||
osVersion: typeof Platform.Version === 'string' ? Platform.Version : String(Platform.Version),
|
||||
sdkVersion: Platform.OS === 'android' && typeof constants['Release'] === 'string'
|
||||
? constants['Release']
|
||||
: undefined,
|
||||
sdkVersion:
|
||||
Platform.OS === 'android' && typeof constants['Release'] === 'string'
|
||||
? constants['Release']
|
||||
: undefined,
|
||||
screenWidth: width,
|
||||
screenHeight: height,
|
||||
}
|
||||
@ -134,7 +124,7 @@ function _buildIssue(level: Level, error: unknown, metadata?: Record<string, unk
|
||||
sessionId: _sessionId,
|
||||
tags: metadata,
|
||||
device: _getDeviceInfo(),
|
||||
sdk: { name: SDK_NAME, version: SDK_VERSION },
|
||||
sdk: { name: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION },
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,7 +141,7 @@ export const BugCollect = {
|
||||
|
||||
/** 设置非致命事件的采样率(0~1,默认 1=全量)。fatal 始终全量上报。 */
|
||||
setSampleRate(rate: number): void {
|
||||
_sampleRate = Math.max(0, Math.min(1, rate))
|
||||
reportPolicy.setSampleRate(rate)
|
||||
},
|
||||
|
||||
/** Add a breadcrumb to the in-memory circular buffer (max 100). */
|
||||
@ -176,7 +166,7 @@ export const BugCollect = {
|
||||
release: _getAppVersion(),
|
||||
environment: _environment,
|
||||
device: _getDeviceInfo(),
|
||||
sdk: { name: SDK_NAME, version: SDK_VERSION },
|
||||
sdk: { name: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION },
|
||||
}
|
||||
_enqueue(event)
|
||||
FunnelTracker.track(name, properties)
|
||||
@ -212,7 +202,7 @@ export const BugCollect = {
|
||||
userId: getUserId() ?? undefined,
|
||||
sessionId: _sessionId,
|
||||
tags: metadata,
|
||||
sdk: { name: SDK_NAME, version: SDK_VERSION },
|
||||
sdk: { name: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION },
|
||||
}
|
||||
_enqueue(issue)
|
||||
},
|
||||
@ -234,7 +224,7 @@ export const BugCollect = {
|
||||
userId: getUserId() ?? undefined,
|
||||
sessionId: _sessionId,
|
||||
tags: metadata,
|
||||
sdk: { name: SDK_NAME, version: SDK_VERSION },
|
||||
sdk: { name: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION },
|
||||
}
|
||||
_enqueue(issue)
|
||||
},
|
||||
@ -271,7 +261,7 @@ export const BugCollect = {
|
||||
HttpInterceptor.stop()
|
||||
_httpInterceptorStarted = false
|
||||
}
|
||||
_fingerprintHits.clear()
|
||||
reportPolicy.clear()
|
||||
_breadcrumbs = []
|
||||
},
|
||||
}
|
||||
|
||||
@ -15,8 +15,7 @@ type UnhandledRejectionHandler = ((event: { reason: unknown }) => void) | null |
|
||||
export const ErrorCapture = {
|
||||
start(onError: (error: unknown, meta?: Record<string, unknown>) => void): void {
|
||||
// JS global error handler
|
||||
const prevError =
|
||||
typeof ErrorUtils !== 'undefined' ? ErrorUtils.getGlobalHandler() : null
|
||||
const prevError = typeof ErrorUtils !== 'undefined' ? ErrorUtils.getGlobalHandler() : null
|
||||
if (typeof ErrorUtils !== 'undefined') {
|
||||
ErrorUtils.setGlobalHandler((error, isFatal) => {
|
||||
onError(error, { isFatal: isFatal ?? false })
|
||||
|
||||
@ -1,149 +1,19 @@
|
||||
/**
|
||||
* Pure-JS SHA-256 implementation.
|
||||
* Hermes does not support Node.js `crypto` module, so we inline a minimal
|
||||
* SHA-256 implementation compatible with the Hermes JS engine.
|
||||
*/
|
||||
import { sha256Hex } from '@xuqm/rn-common/crypto'
|
||||
|
||||
// ─── SHA-256 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const K: number[] = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
|
||||
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
||||
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
|
||||
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
|
||||
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
|
||||
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]
|
||||
|
||||
function rotr32(x: number, n: number): number {
|
||||
return ((x >>> n) | (x << (32 - n))) >>> 0
|
||||
}
|
||||
|
||||
function sha256(message: string): string {
|
||||
// Encode to UTF-8 bytes
|
||||
const bytes: number[] = []
|
||||
for (let i = 0; i < message.length; i++) {
|
||||
let c = message.charCodeAt(i)
|
||||
if (c < 0x80) {
|
||||
bytes.push(c)
|
||||
} else if (c < 0x800) {
|
||||
bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f))
|
||||
} else if (c < 0xd800 || c >= 0xe000) {
|
||||
bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f))
|
||||
} else {
|
||||
// Surrogate pair
|
||||
i++
|
||||
c = 0x10000 + (((c & 0x3ff) << 10) | (message.charCodeAt(i) & 0x3ff))
|
||||
bytes.push(
|
||||
0xf0 | (c >> 18),
|
||||
0x80 | ((c >> 12) & 0x3f),
|
||||
0x80 | ((c >> 6) & 0x3f),
|
||||
0x80 | (c & 0x3f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const msgLen = bytes.length
|
||||
bytes.push(0x80)
|
||||
while ((bytes.length % 64) !== 56) bytes.push(0)
|
||||
|
||||
// Append length as 64-bit big-endian (we only support messages < 2^32 bytes)
|
||||
const bitLen = msgLen * 8
|
||||
bytes.push(0, 0, 0, 0)
|
||||
bytes.push((bitLen >>> 24) & 0xff, (bitLen >>> 16) & 0xff, (bitLen >>> 8) & 0xff, bitLen & 0xff)
|
||||
|
||||
let h0 = 0x6a09e667
|
||||
let h1 = 0xbb67ae85
|
||||
let h2 = 0x3c6ef372
|
||||
let h3 = 0xa54ff53a
|
||||
let h4 = 0x510e527f
|
||||
let h5 = 0x9b05688c
|
||||
let h6 = 0x1f83d9ab
|
||||
let h7 = 0x5be0cd19
|
||||
|
||||
const w = new Array<number>(64)
|
||||
|
||||
for (let i = 0; i < bytes.length; i += 64) {
|
||||
for (let j = 0; j < 16; j++) {
|
||||
w[j] =
|
||||
((bytes[i + j * 4] << 24) |
|
||||
(bytes[i + j * 4 + 1] << 16) |
|
||||
(bytes[i + j * 4 + 2] << 8) |
|
||||
bytes[i + j * 4 + 3]) >>>
|
||||
0
|
||||
}
|
||||
for (let j = 16; j < 64; j++) {
|
||||
const s0 = (rotr32(w[j - 15], 7) ^ rotr32(w[j - 15], 18) ^ (w[j - 15] >>> 3)) >>> 0
|
||||
const s1 = (rotr32(w[j - 2], 17) ^ rotr32(w[j - 2], 19) ^ (w[j - 2] >>> 10)) >>> 0
|
||||
w[j] = (w[j - 16] + s0 + w[j - 7] + s1) >>> 0
|
||||
}
|
||||
|
||||
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7
|
||||
|
||||
for (let j = 0; j < 64; j++) {
|
||||
const S1 = (rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25)) >>> 0
|
||||
const ch = ((e & f) ^ (~e & g)) >>> 0
|
||||
const temp1 = (h + S1 + ch + K[j] + w[j]) >>> 0
|
||||
const S0 = (rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22)) >>> 0
|
||||
const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0
|
||||
const temp2 = (S0 + maj) >>> 0
|
||||
|
||||
h = g; g = f; f = e
|
||||
e = (d + temp1) >>> 0
|
||||
d = c; c = b; b = a
|
||||
a = (temp1 + temp2) >>> 0
|
||||
}
|
||||
|
||||
h0 = (h0 + a) >>> 0
|
||||
h1 = (h1 + b) >>> 0
|
||||
h2 = (h2 + c) >>> 0
|
||||
h3 = (h3 + d) >>> 0
|
||||
h4 = (h4 + e) >>> 0
|
||||
h5 = (h5 + f) >>> 0
|
||||
h6 = (h6 + g) >>> 0
|
||||
h7 = (h7 + h) >>> 0
|
||||
}
|
||||
|
||||
return [h0, h1, h2, h3, h4, h5, h6, h7]
|
||||
.map(v => v.toString(16).padStart(8, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
function normalizeMessage(msg: string): string {
|
||||
return msg.replace(/\b\d{4,}\b/g, 'N').replace(/[a-f0-9]{32,}/gi, 'H')
|
||||
function normalizeMessage(message: string): string {
|
||||
return message.replace(/\b\d{4,}\b/g, 'N').replace(/[a-f0-9]{32,}/gi, 'H')
|
||||
}
|
||||
|
||||
function extractTop3Frames(stack?: string): string {
|
||||
if (!stack) return ''
|
||||
return stack
|
||||
.split('\n')
|
||||
.filter(l => l.includes('at '))
|
||||
.filter(line => line.includes('at '))
|
||||
.slice(0, 3)
|
||||
.join('|')
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a SHA-256 fingerprint for deduplication.
|
||||
* fingerprint = SHA-256(type + ":" + normalizedMessage + ":" + top3Frames)
|
||||
*/
|
||||
export function computeFingerprint(
|
||||
type: string,
|
||||
message: string,
|
||||
stack?: string,
|
||||
): string {
|
||||
const top3 = extractTop3Frames(stack)
|
||||
const raw = `${type}:${normalizeMessage(message)}:${top3}`
|
||||
return sha256(raw)
|
||||
/** Compute a stable SHA-256 fingerprint using the canonical common implementation. */
|
||||
export function computeFingerprint(type: string, message: string, stack?: string): string {
|
||||
return sha256Hex(`${type}:${normalizeMessage(message)}:${extractTop3Frames(stack)}`)
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
export interface FunnelDefinition {
|
||||
id: string
|
||||
steps: string[] // event names in order
|
||||
steps: string[] // event names in order
|
||||
}
|
||||
|
||||
export interface FunnelProgress {
|
||||
|
||||
@ -1,2 +1,12 @@
|
||||
export { BugCollect } from './BugCollect'
|
||||
import { _registerInitializationHandler } from '@xuqm/rn-common/internal'
|
||||
|
||||
import { BugCollect } from './BugCollect'
|
||||
|
||||
_registerInitializationHandler('rn-bugcollect', config => {
|
||||
if (config.bugCollectEnabled && config.bugCollectApiUrl) {
|
||||
BugCollect.startCapture()
|
||||
}
|
||||
})
|
||||
|
||||
export { BugCollect }
|
||||
export type { LogLevel, Environment, LogEvent, IssueEvent, BugCollectEvent } from './types'
|
||||
|
||||
@ -12,7 +12,7 @@ let _originalFetch: typeof fetch | null = null
|
||||
|
||||
export const HttpInterceptor = {
|
||||
start(onError: OnError): void {
|
||||
if (_originalFetch) return // already installed
|
||||
if (_originalFetch) return // already installed
|
||||
|
||||
_originalFetch = globalThis.fetch
|
||||
|
||||
@ -25,7 +25,8 @@ export const HttpInterceptor = {
|
||||
try {
|
||||
const res = await original(input, init)
|
||||
if (!res.ok) {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
|
||||
const url =
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
|
||||
onError(new Error(`HTTP ${res.status} ${res.statusText}`), {
|
||||
type: 'api_error',
|
||||
url,
|
||||
@ -34,7 +35,8 @@ export const HttpInterceptor = {
|
||||
}
|
||||
return res
|
||||
} catch (e) {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
|
||||
const url =
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
|
||||
onError(e, { type: 'api_error', url })
|
||||
throw e
|
||||
}
|
||||
|
||||
@ -1,19 +1,14 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { BugCollectEvent, IssueEvent } from '../types'
|
||||
import type { BugCollectEvent } from '../types'
|
||||
import { BUGCOLLECT_SDK_NAME, BUGCOLLECT_SDK_VERSION } from '../sdkInfo'
|
||||
import { shouldPersist } from './queuePolicy'
|
||||
|
||||
const STORED_QUEUE_KEY = '@xuqm_bugcollect:queue'
|
||||
const BATCH_SIZE = 30
|
||||
const FLUSH_INTERVAL_MS = 10_000 // 10 seconds
|
||||
const FLUSH_INTERVAL_MS = 10_000 // 10 seconds
|
||||
const MAX_QUEUE_SIZE = 500
|
||||
const MAX_RETRY = 3
|
||||
|
||||
/** Only fatal and error issues are persisted to AsyncStorage (survive restarts). */
|
||||
function shouldPersist(event: BugCollectEvent): boolean {
|
||||
if (event.type !== 'issue') return false
|
||||
const level = (event as IssueEvent).level
|
||||
return level === 'fatal' || level === 'error'
|
||||
}
|
||||
|
||||
export class LogQueue {
|
||||
private _memory: BugCollectEvent[] = []
|
||||
private flushTimer: ReturnType<typeof setInterval> | null = null
|
||||
@ -80,7 +75,7 @@ export class LogQueue {
|
||||
private async _post(path: string, data: BugCollectEvent[]): Promise<void> {
|
||||
const body = JSON.stringify({
|
||||
sentAt: new Date().toISOString(),
|
||||
sdk: { name: 'bugcollect.rn', version: '0.2.0' },
|
||||
sdk: { name: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION },
|
||||
events: data,
|
||||
})
|
||||
const res = await fetch(`${this.cfg.bugCollectApiUrl}${path}`, {
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import type { BugCollectEvent } from '../types'
|
||||
|
||||
export function shouldPersist(event: BugCollectEvent): boolean {
|
||||
return event.type === 'issue' && (event.level === 'fatal' || event.level === 'error')
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
export class ReportPolicy {
|
||||
private sampleRate = 1
|
||||
private readonly hits = new Map<string, number[]>()
|
||||
|
||||
constructor(
|
||||
private readonly windowMs = 60_000,
|
||||
private readonly maxPerFingerprint = 5,
|
||||
private readonly now: () => number = Date.now,
|
||||
private readonly random: () => number = Math.random,
|
||||
) {}
|
||||
|
||||
setSampleRate(rate: number): void {
|
||||
this.sampleRate = Math.max(0, Math.min(1, rate))
|
||||
}
|
||||
|
||||
shouldReport(key: string, fatal: boolean): boolean {
|
||||
if (fatal) return true
|
||||
if (this.sampleRate < 1 && this.random() > this.sampleRate) return false
|
||||
|
||||
const timestamp = this.now()
|
||||
const recent = (this.hits.get(key) ?? []).filter(value => timestamp - value < this.windowMs)
|
||||
if (recent.length >= this.maxPerFingerprint) {
|
||||
this.hits.set(key, recent)
|
||||
return false
|
||||
}
|
||||
recent.push(timestamp)
|
||||
this.hits.set(key, recent)
|
||||
return true
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.hits.clear()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
// Metro includes package.json in the published package and supports JSON requires.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const packageMetadata = require('../package.json') as { version?: string }
|
||||
|
||||
export const BUGCOLLECT_SDK_NAME = 'bugcollect.rn'
|
||||
export const BUGCOLLECT_SDK_VERSION = packageMetadata.version ?? '0.0.0'
|
||||
@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { computeFingerprint } from '../src/fingerprint'
|
||||
|
||||
test('fingerprint uses common SHA-256 and normalizes volatile identifiers', () => {
|
||||
const first = computeFingerprint(
|
||||
'error',
|
||||
'request 123456 failed for aabbccddeeff00112233445566778899',
|
||||
'Error\n at first (a.ts:1)\n at second (b.ts:2)',
|
||||
)
|
||||
const second = computeFingerprint(
|
||||
'error',
|
||||
'request 987654 failed for 00112233445566778899aabbccddeeff',
|
||||
'Error\n at first (a.ts:1)\n at second (b.ts:2)',
|
||||
)
|
||||
|
||||
assert.equal(first, second)
|
||||
assert.match(first, /^[a-f0-9]{64}$/)
|
||||
})
|
||||
@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { ReportPolicy } from '../src/reportPolicy'
|
||||
import { shouldPersist } from '../src/queue/queuePolicy'
|
||||
import type { BugCollectEvent } from '../src/types'
|
||||
|
||||
test('fatal reports bypass sampling and rate limiting', () => {
|
||||
const policy = new ReportPolicy(
|
||||
60_000,
|
||||
1,
|
||||
() => 100,
|
||||
() => 1,
|
||||
)
|
||||
policy.setSampleRate(0)
|
||||
assert.equal(policy.shouldReport('fatal', true), true)
|
||||
assert.equal(policy.shouldReport('normal', false), false)
|
||||
})
|
||||
|
||||
test('fingerprint limit expires with the configured window', () => {
|
||||
let now = 1_000
|
||||
const policy = new ReportPolicy(
|
||||
100,
|
||||
2,
|
||||
() => now,
|
||||
() => 0,
|
||||
)
|
||||
assert.equal(policy.shouldReport('same', false), true)
|
||||
assert.equal(policy.shouldReport('same', false), true)
|
||||
assert.equal(policy.shouldReport('same', false), false)
|
||||
now += 101
|
||||
assert.equal(policy.shouldReport('same', false), true)
|
||||
})
|
||||
|
||||
test('fatal and error issues are the only events persisted for restart recovery', () => {
|
||||
const base = {
|
||||
type: 'issue',
|
||||
platform: 'android',
|
||||
fingerprint: 'f',
|
||||
timestamp: 1,
|
||||
appKey: 'app',
|
||||
release: '1',
|
||||
environment: 'test',
|
||||
exception: { type: 'Error', value: 'value' },
|
||||
sessionId: 'session',
|
||||
sdk: { name: 'bugcollect.rn', version: '1.0.0' },
|
||||
} as const
|
||||
assert.equal(shouldPersist({ ...base, level: 'fatal' } as BugCollectEvent), true)
|
||||
assert.equal(shouldPersist({ ...base, level: 'error' } as BugCollectEvent), true)
|
||||
assert.equal(shouldPersist({ ...base, level: 'warning' } as BugCollectEvent), false)
|
||||
})
|
||||
@ -2,14 +2,6 @@
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-native",
|
||||
"baseUrl": "../../",
|
||||
"paths": {
|
||||
"@xuqm/rn-common": ["packages/common/src"],
|
||||
"@react-native-async-storage/async-storage": ["src/shims/async-storage.ts"],
|
||||
"@nozbe/watermelondb": ["src/shims/watermelondb.ts"],
|
||||
"@nozbe/watermelondb/decorators": ["src/shims/watermelondb.ts"],
|
||||
"@nozbe/watermelondb/adapters/sqlite": ["src/shims/watermelondb.ts"]
|
||||
},
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*", "specs/**/*"],
|
||||
|
||||
@ -1,118 +1,83 @@
|
||||
# @xuqm/rn-common
|
||||
|
||||
XuqmGroup RN SDK 核心模块。负责 SDK 初始化、HTTP 请求、Token 管理、设备信息、XWebView Bridge、API hooks。
|
||||
所有 `@xuqm` React Native SDK 的唯一基础包,也可以零初始化、零登录单独使用。文件、时间、加密、设备、UI、API hooks,以及使用绝对 URL 或 `configureHttp` 配置地址的网络请求,都不依赖 SDK 上下文。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
yarn add @xuqm/rn-common @react-native-async-storage/async-storage
|
||||
pnpm add @xuqm/rn-common axios \
|
||||
@react-native-async-storage/async-storage
|
||||
```
|
||||
|
||||
Peer dependencies:`react >= 18`,`react-native >= 0.76`,`axios >= 1.0.0`
|
||||
## 独立使用
|
||||
|
||||
## 初始化
|
||||
只使用 common 的通用能力时,不放配置文件、不调用 `initialize`,也不调用 `login`:
|
||||
|
||||
### 方式 A — 配置文件自动初始化(推荐)
|
||||
```ts
|
||||
import { apiRequest, configureHttp, expirationStatus } from '@xuqm/rn-common'
|
||||
|
||||
将平台下载的 `.xuqmconfig` 文件放入 `src/assets/config/`,然后用 `withXuqmConfig` Metro 插件自动发现:
|
||||
configureHttp({ baseUrl: 'https://api.example.com' })
|
||||
const result = await apiRequest('/health', { skipAuth: true })
|
||||
const state = expirationStatus('2027-01-01T00:00:00Z')
|
||||
```
|
||||
|
||||
没有 SDK 配置时,`apiRequest` 不添加 appKey、用户和签名请求头。
|
||||
|
||||
## 扩展包自动初始化
|
||||
|
||||
把平台生成的加密配置放到 `src/assets/config/config.xuqmconfig`,并只包装一次 Metro 配置:
|
||||
|
||||
```js
|
||||
// metro.config.js
|
||||
const { getDefaultConfig } = require('@react-native/metro-config');
|
||||
const { withXuqmConfig } = require('@xuqm/rn-common/metro');
|
||||
const baseConfig = getDefaultConfig(__dirname);
|
||||
module.exports = withXuqmConfig(baseConfig);
|
||||
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config')
|
||||
const { withXuqmConfig } = require('@xuqm/rn-common/metro')
|
||||
|
||||
module.exports = withXuqmConfig(
|
||||
mergeConfig(getDefaultConfig(__dirname), {
|
||||
// 宿主自己的 Metro 配置
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
SDK 在 import 时自动调用 `initWithConfigFile()`,App 无需任何 init 代码。
|
||||
使用 update、bugcollect、xwebview 等扩展包时,`withXuqmConfig()` 会把 common 的初始化入口注册为 Metro pre-main module,确保初始化早于业务入口且不受 `inlineRequires` 影响。Metro 插件还会把同一份配置同步到 Android assets;宿主不需要在多个位置维护副本。
|
||||
|
||||
**兼容方式**:也可手动配置 Babel alias `@xuqm/autoinit-config` → `.ts` 包装文件。
|
||||
common-only 项目不要放置 `.xuqmconfig`,也不要使用 `withXuqmConfig()`,即可保持零初始化。是否初始化只由这一条规则决定,不提供额外 alias 或多套配置约定。
|
||||
|
||||
### 方式 B — 手动初始化
|
||||
无法使用配置文件时,才调用一次手动初始化:
|
||||
|
||||
```ts
|
||||
await XuqmSDK.initialize({ appKey: 'app-key' })
|
||||
```
|
||||
|
||||
同一配置的并发初始化会复用同一个 Promise;重复使用不同配置会直接报错。
|
||||
|
||||
## 扩展包的唯一登录入口
|
||||
|
||||
应用登录成功后只调用一次:
|
||||
|
||||
```ts
|
||||
import { XuqmSDK } from '@xuqm/rn-common'
|
||||
|
||||
await XuqmSDK.initialize({ appKey: 'xxx' }) // 公有平台
|
||||
await XuqmSDK.initialize({ appKey: 'xxx', platformUrl: 'https://xxx' }) // 私有化
|
||||
await XuqmSDK.awaitInitialization() // 等待配置拉取
|
||||
await XuqmSDK.login({
|
||||
userId: 'user-id',
|
||||
accessToken: 'http-access-token',
|
||||
userSig: 'optional-im-user-sig',
|
||||
name: '姓名',
|
||||
phone: '手机号',
|
||||
})
|
||||
|
||||
await XuqmSDK.logout()
|
||||
```
|
||||
|
||||
## API
|
||||
`accessToken` 仅用于公共 HTTP 鉴权,`userSig` 仅用于 IM。所有已安装的官方子 SDK 订阅这一次会话变更,不得各自初始化或登录。
|
||||
|
||||
### 初始化
|
||||
## 通用能力
|
||||
|
||||
| API | 说明 |
|
||||
|-----|------|
|
||||
| `XuqmSDK.initialize(opts)` | 初始化,拉取平台配置 |
|
||||
| `XuqmSDK.awaitInitialization()` | 等待异步初始化完成 |
|
||||
| `isInitialized()` | 检查 SDK 是否就绪 |
|
||||
| `getConfig()` | 获取已解析的平台配置 |
|
||||
- `apiRequest`:common-only 模式可直接使用;存在 SDK 配置时使用当前会话的 userId、access token 和请求签名。需要配置的扩展包会先调用共享 `awaitInitialization()`,common 不会擅自要求初始化。
|
||||
- `downloadBytes`、`downloadText`、`downloadFileToPath`:统一小文件流式读取与大文件落盘、进度和取消能力。
|
||||
- `compareVersions`、`satisfiesVersion`、`compatibleMajorRange`:统一 SemVer 与插件兼容范围判断。
|
||||
- `expirationStatus`、`toTimestamp`、`millisecondsUntil`、`formatDateTime`:统一时间解析与过期判断。
|
||||
- `fileExists`、`ensureDirectory`、`readFileAsBase64`、`writeBase64File`、`openLocalFile`、`registerAndroidDownloadedFile`:统一文件与 Android 下载登记操作。
|
||||
- `decryptXuqmFile`、`hmacSha256Base64Url`:基于 `@noble/*` 的跨平台加密实现。
|
||||
- `getDeviceInfo`、`getDeviceId`、`useApi`、`usePageApi`、`showToast` 等项目通用能力。
|
||||
|
||||
### 用户信息
|
||||
|
||||
| API | 说明 |
|
||||
|-----|------|
|
||||
| `XuqmSDK.setUserInfo(info)` | 设置用户信息,触发所有子 SDK 登录 |
|
||||
| `XuqmSDK.setUserInfo(null)` | 登出,触发全局登出 |
|
||||
| `getUserId()` | 获取当前 userId |
|
||||
| `getUserInfo()` | 获取当前用户信息 |
|
||||
| `setUserId(id)` | 直接设置 userId(内部用) |
|
||||
|
||||
### HTTP
|
||||
|
||||
| API | 说明 |
|
||||
|-----|------|
|
||||
| `apiRequest(url, options?)` | 带 Bearer token 的 HTTP 请求 |
|
||||
| `configureHttp(opts)` | 覆盖 HTTP 基础配置 |
|
||||
| `_getToken()` | 获取当前 token(内部用) |
|
||||
| `_saveToken(token)` | 保存 token(内部用) |
|
||||
| `_clearToken()` | 清除 token(内部用) |
|
||||
|
||||
### 设备
|
||||
|
||||
| API | 说明 |
|
||||
|-----|------|
|
||||
| `getDeviceId()` | 获取设备稳定 UUID |
|
||||
| `getDeviceInfo()` | 获取设备品牌/型号/系统信息 |
|
||||
| `detectPushVendor()` | 检测设备推送厂商 |
|
||||
|
||||
### XWebView Bridge
|
||||
|
||||
| API | 说明 |
|
||||
|-----|------|
|
||||
| `openXWebView(url, options?)` | 打开 WebView |
|
||||
| `setXWebViewController(controller)` | 设置全局 WebView 控制器 |
|
||||
| `getXWebViewConfig()` | 获取当前 WebView 配置 |
|
||||
| `XWebViewControl` | WebView 控制器类 |
|
||||
|
||||
### API Hooks
|
||||
|
||||
| API | 说明 |
|
||||
|-----|------|
|
||||
| `useApi(method, url, opts?)` | React hook:带 loading/error 的 API 调用 |
|
||||
| `usePageApi(method, url, opts?)` | React hook:分页 API |
|
||||
| `useRequest(opts?)` | React hook:通用请求 |
|
||||
| `setGlobalApiErrorHandler(handler)` | 设置全局 API 错误处理 |
|
||||
|
||||
### UI
|
||||
|
||||
| API | 说明 |
|
||||
|-----|------|
|
||||
| `showToast(msg)` | 显示 toast |
|
||||
| `showAlert(title, msg)` | 显示 alert |
|
||||
| `showConfirm(title, msg)` | 显示确认对话框 |
|
||||
| `ScaledImage` | 等比缩放图片组件 |
|
||||
|
||||
### Metro 插件
|
||||
|
||||
| 导出 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| `withXuqmConfig` | `@xuqm/rn-common/metro` | 自动发现 `.xuqmconfig` 配置文件并注入虚拟模块,推荐方式 |
|
||||
|
||||
### 常量
|
||||
|
||||
| 常量 | 说明 |
|
||||
|------|------|
|
||||
| `DEFAULT_TENANT_PLATFORM_URL` | 默认公有平台地址 |
|
||||
| `DEFAULT_IM_WS_URL` | 默认 IM WebSocket 地址 |
|
||||
业务代码不得导入 `@xuqm/rn-common/internal`。该子路径仅供官方 `@xuqm` 扩展包接入公共生命周期。
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
'use strict';
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* Metro 插件:自动发现 .xuqmconfig 配置文件。
|
||||
@ -12,60 +12,58 @@
|
||||
* @param {import('@react-native/metro-config').MetroConfig} metroConfig
|
||||
* @returns {import('@react-native/metro-config').MetroConfig}
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const VIRTUAL_MODULE_ID = '@xuqm/autoinit-config';
|
||||
const VIRTUAL_MODULE_ID = '@xuqm/autoinit-config'
|
||||
const AUTO_INIT_MODULE = path.resolve(__dirname, '../src/internal.ts')
|
||||
const CONFIG_DIR_CANDIDATES = [
|
||||
'src/assets/config',
|
||||
'src/assets/xuqm',
|
||||
'assets/config',
|
||||
'assets/xuqm',
|
||||
];
|
||||
]
|
||||
|
||||
function findConfigFile(projectRoot) {
|
||||
for (const dir of CONFIG_DIR_CANDIDATES) {
|
||||
const absDir = path.resolve(projectRoot, dir);
|
||||
if (!fs.existsSync(absDir)) continue;
|
||||
const absDir = path.resolve(projectRoot, dir)
|
||||
if (!fs.existsSync(absDir)) continue
|
||||
|
||||
const files = fs.readdirSync(absDir);
|
||||
const files = fs.readdirSync(absDir)
|
||||
// 优先 config.xuqmconfig 或 config.xuqm
|
||||
const preferred = files.find(
|
||||
(f) => f === 'config.xuqmconfig' || f === 'config.xuqm',
|
||||
);
|
||||
const preferred = files.find(f => f === 'config.xuqmconfig' || f === 'config.xuqm')
|
||||
if (preferred) {
|
||||
return path.join(absDir, preferred);
|
||||
return path.join(absDir, preferred)
|
||||
}
|
||||
// 否则取第一个 .xuqmconfig
|
||||
const fallback = files.find((f) => f.endsWith('.xuqmconfig'));
|
||||
const fallback = files.find(f => f.endsWith('.xuqmconfig'))
|
||||
if (fallback) {
|
||||
return path.join(absDir, fallback);
|
||||
return path.join(absDir, fallback)
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
function applyBugCollect(metroConfig) {
|
||||
// 不再自动链式 withBugCollect:基于 customSerializer 的 sourcemap 上传在 Metro 0.84
|
||||
// 下不可靠(exports 限制无法获取默认 serializer,会破坏 dev 打包)。
|
||||
// sourcemap 上传改由独立后处理脚本(react-native bundle 后单独上传)实现。
|
||||
return metroConfig;
|
||||
return metroConfig
|
||||
}
|
||||
|
||||
/** 写入目标文件(仅在缺失或内容不一致时),返回是否发生写入。 */
|
||||
function syncFile(content, destPath, label) {
|
||||
try {
|
||||
if (fs.existsSync(destPath) && fs.readFileSync(destPath, 'utf-8').trim() === content) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
fs.writeFileSync(destPath, content, 'utf-8');
|
||||
console.log(`[XuqmConfig] 已同步 .xuqmconfig → ${label}`);
|
||||
return true;
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
fs.writeFileSync(destPath, content, 'utf-8')
|
||||
console.log(`[XuqmConfig] 已同步 .xuqmconfig → ${label}`)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.warn(`[XuqmConfig] 同步 .xuqmconfig 到 ${label} 失败:${e.message}`);
|
||||
return false;
|
||||
console.warn(`[XuqmConfig] 同步 .xuqmconfig 到 ${label} 失败:${e.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,7 +73,7 @@ function syncFile(content, destPath, label) {
|
||||
* 缺失或内容不一致才复制,幂等。
|
||||
*/
|
||||
function syncNativeConfig(projectRoot, content) {
|
||||
const fileName = 'config.xuqmconfig';
|
||||
const fileName = 'config.xuqmconfig'
|
||||
|
||||
// Android:原生 ContentProvider 扫描 assets/config 下的 .xuqmconfig
|
||||
if (fs.existsSync(path.resolve(projectRoot, 'android'))) {
|
||||
@ -83,21 +81,19 @@ function syncNativeConfig(projectRoot, content) {
|
||||
content,
|
||||
path.resolve(projectRoot, 'android/app/src/main/assets/config', fileName),
|
||||
'android assets/config',
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// iOS:复制到 app 资源目录(需在 Xcode「Copy Bundle Resources」中引用一次)
|
||||
const iosDir = path.resolve(projectRoot, 'ios');
|
||||
const iosDir = path.resolve(projectRoot, 'ios')
|
||||
if (fs.existsSync(iosDir)) {
|
||||
try {
|
||||
const appDir = fs
|
||||
.readdirSync(iosDir, { withFileTypes: true })
|
||||
.filter(
|
||||
(d) => d.isDirectory() && fs.existsSync(path.join(iosDir, d.name, 'Info.plist')),
|
||||
)
|
||||
.map((d) => d.name)[0];
|
||||
.filter(d => d.isDirectory() && fs.existsSync(path.join(iosDir, d.name, 'Info.plist')))
|
||||
.map(d => d.name)[0]
|
||||
if (appDir) {
|
||||
syncFile(content, path.join(iosDir, appDir, 'config', fileName), `ios ${appDir}/config`);
|
||||
syncFile(content, path.join(iosDir, appDir, 'config', fileName), `ios ${appDir}/config`)
|
||||
}
|
||||
} catch {
|
||||
// 忽略 iOS 同步异常,不阻断打包
|
||||
@ -106,32 +102,37 @@ function syncNativeConfig(projectRoot, content) {
|
||||
}
|
||||
|
||||
function withXuqmConfig(metroConfig) {
|
||||
const projectRoot = metroConfig.projectRoot ?? process.cwd();
|
||||
const configFile = findConfigFile(projectRoot);
|
||||
const projectRoot = metroConfig.projectRoot ?? process.cwd()
|
||||
const configFile = findConfigFile(projectRoot)
|
||||
|
||||
if (!configFile) {
|
||||
// 未找到配置文件,返回原配置(autoInit 会静默跳过)
|
||||
return applyBugCollect(metroConfig);
|
||||
return applyBugCollect(metroConfig)
|
||||
}
|
||||
|
||||
// 读取加密配置内容
|
||||
const encryptedContent = fs.readFileSync(configFile, 'utf-8').trim();
|
||||
const encryptedContent = fs.readFileSync(configFile, 'utf-8').trim()
|
||||
|
||||
// 单点放置:自动同步到原生 Android/iOS(缺失或不一致才复制)
|
||||
syncNativeConfig(projectRoot, encryptedContent);
|
||||
syncNativeConfig(projectRoot, encryptedContent)
|
||||
|
||||
// 生成临时 TS 文件作为虚拟模块
|
||||
const tmpDir = path.join(os.tmpdir(), 'xuqm-sdk-config');
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const virtualFile = path.join(tmpDir, 'autoinit-config.ts');
|
||||
// Metro 0.84+ only hashes files under projectRoot/watchFolders. Keep the
|
||||
// generated transport module in the ignored package-manager cache instead
|
||||
// of the system temp directory; the .xuqmconfig file remains the sole source.
|
||||
const generatedDir = path.join(projectRoot, 'node_modules', '.cache', 'xuqm-sdk')
|
||||
fs.mkdirSync(generatedDir, { recursive: true })
|
||||
const virtualFile = path.join(generatedDir, 'autoinit-config.ts')
|
||||
fs.writeFileSync(
|
||||
virtualFile,
|
||||
`// Auto-generated by @xuqm/rn-common/metro — do not edit\nexport const ENCRYPTED_CONFIG = ${JSON.stringify(encryptedContent)};\n`,
|
||||
'utf-8',
|
||||
);
|
||||
)
|
||||
|
||||
// 拦截模块解析,将 @xuqm/autoinit-config 指向虚拟文件
|
||||
const existingResolveRequest = metroConfig.resolver?.resolveRequest;
|
||||
const existingResolveRequest = metroConfig.resolver?.resolveRequest
|
||||
const existingGetModulesRunBeforeMainModule =
|
||||
metroConfig.serializer?.getModulesRunBeforeMainModule
|
||||
|
||||
return applyBugCollect({
|
||||
...metroConfig,
|
||||
@ -142,17 +143,26 @@ function withXuqmConfig(metroConfig) {
|
||||
return {
|
||||
type: 'sourceFile',
|
||||
filePath: virtualFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (existingResolveRequest) {
|
||||
return existingResolveRequest(context, moduleName, platform);
|
||||
return existingResolveRequest(context, moduleName, platform)
|
||||
}
|
||||
|
||||
return context.resolveRequest(context, moduleName, platform);
|
||||
return context.resolveRequest(context, moduleName, platform)
|
||||
},
|
||||
},
|
||||
});
|
||||
serializer: {
|
||||
...metroConfig.serializer,
|
||||
getModulesRunBeforeMainModule: entryFilePath => {
|
||||
const existingModules = existingGetModulesRunBeforeMainModule
|
||||
? existingGetModulesRunBeforeMainModule(entryFilePath)
|
||||
: []
|
||||
return [...new Set([...existingModules, AUTO_INIT_MODULE])]
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { withXuqmConfig };
|
||||
module.exports = { withXuqmConfig }
|
||||
|
||||
@ -1,11 +1,21 @@
|
||||
{
|
||||
"name": "@xuqm/rn-common",
|
||||
"version": "0.4.0",
|
||||
"description": "XuqmGroup RN SDK — core: init, network, token management",
|
||||
"version": "0.6.0-alpha.9",
|
||||
"description": "XuqmGroup RN SDK — standalone utilities and shared extension context",
|
||||
"license": "UNLICENSED",
|
||||
"main": "src/index.ts",
|
||||
"react-native": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./crypto": "./src/crypto.ts",
|
||||
"./download": "./src/download.ts",
|
||||
"./file-name": "./src/fileName.ts",
|
||||
"./internal": "./src/internal.ts",
|
||||
"./metro": "./metro/index.js",
|
||||
"./package.json": "./package.json",
|
||||
"./version": "./src/version.ts"
|
||||
},
|
||||
"metro": "metro/index.js",
|
||||
"files": [
|
||||
"src",
|
||||
@ -16,8 +26,16 @@
|
||||
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --import tsx --test tests/*.test.ts tests/*.test.cjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "2.2.0",
|
||||
"@noble/hashes": "2.2.0",
|
||||
"react-native-blob-util": "0.24.10",
|
||||
"semver": "7.8.5",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-native": ">=0.76.0",
|
||||
@ -25,12 +43,13 @@
|
||||
"axios": ">=1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"typescript": "catalog:",
|
||||
"@types/react": "^19.2.14",
|
||||
"@react-native-async-storage/async-storage": "^3.1.1",
|
||||
"axios": "^1.18.0",
|
||||
"react": "^19.2.0",
|
||||
"react-native": "^0.85.3",
|
||||
"zod": "^3.25.76"
|
||||
"react": "19.2.7",
|
||||
"react-native": "0.86.0",
|
||||
"tsx": "^4.23.1"
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,11 +20,7 @@ type ApiResponse<T> = {
|
||||
|
||||
export type ApiMethod = 'GET' | 'POSTJSON' | 'POSTFORM' | 'PUT' | 'UPLOAD'
|
||||
|
||||
export const useApi = <
|
||||
S extends z.ZodTypeAny,
|
||||
T extends z.infer<S> = z.infer<S>,
|
||||
D = unknown,
|
||||
>(
|
||||
export const useApi = <S extends z.ZodTypeAny, T extends z.infer<S> = z.infer<S>, D = unknown>(
|
||||
url: string,
|
||||
method: ApiMethod,
|
||||
params?: D,
|
||||
@ -90,9 +86,7 @@ export const useApi = <
|
||||
},
|
||||
(thrownError: unknown) => {
|
||||
if (isCancel(thrownError)) {
|
||||
return Promise.reject(
|
||||
new RequestError('网络请求已取消', 'Cancel', thrownError),
|
||||
)
|
||||
return Promise.reject(new RequestError('网络请求已取消', 'Cancel', thrownError))
|
||||
}
|
||||
|
||||
if (thrownError instanceof ValidationError) {
|
||||
@ -114,25 +108,17 @@ export const useApi = <
|
||||
)
|
||||
|
||||
if (e.issues.length <= 0) {
|
||||
return Promise.reject(
|
||||
new RequestError('数据格式校验错误', 'ValidationError', e),
|
||||
)
|
||||
return Promise.reject(new RequestError('数据格式校验错误', 'ValidationError', e))
|
||||
}
|
||||
|
||||
const firstIssue = e.issues[0]
|
||||
if (firstIssue.path.length > 0 && firstIssue.path[0] === 'status') {
|
||||
return Promise.reject(
|
||||
new RequestError(
|
||||
e.response.data?.message ?? '接口返回失败',
|
||||
'ValidationError',
|
||||
e,
|
||||
),
|
||||
new RequestError(e.response.data?.message ?? '接口返回失败', 'ValidationError', e),
|
||||
)
|
||||
}
|
||||
|
||||
return Promise.reject(
|
||||
new RequestError(firstIssue.message, 'ValidationError', e),
|
||||
)
|
||||
return Promise.reject(new RequestError(firstIssue.message, 'ValidationError', e))
|
||||
}
|
||||
|
||||
if (isAxiosError(thrownError)) {
|
||||
@ -140,17 +126,11 @@ export const useApi = <
|
||||
JSON.stringify(thrownError, null, 2),
|
||||
`${thrownError.config?.baseURL}${thrownError.config?.url}`,
|
||||
)
|
||||
return Promise.reject(
|
||||
new RequestError('网络请求失败', 'AxiosError', thrownError),
|
||||
)
|
||||
return Promise.reject(new RequestError('网络请求失败', 'AxiosError', thrownError))
|
||||
}
|
||||
|
||||
console.error(
|
||||
JSON.stringify(thrownError, null, 2),
|
||||
)
|
||||
return Promise.reject(
|
||||
new RequestError('网络请求失败', 'OtherError', thrownError),
|
||||
)
|
||||
console.error(JSON.stringify(thrownError, null, 2))
|
||||
return Promise.reject(new RequestError('网络请求失败', 'OtherError', thrownError))
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
@ -177,8 +157,12 @@ export const useApi = <
|
||||
fetch,
|
||||
fetchAsync: transformedFetchAsync,
|
||||
cancel,
|
||||
requestInterceptors: requestInterceptors as AxiosInterceptorManager<AxiosRequestConfig<unknown>>,
|
||||
responseInterceptors: responseInterceptors as AxiosInterceptorManager<AxiosResponse<ApiResponse<T>, unknown>>,
|
||||
requestInterceptors: requestInterceptors as AxiosInterceptorManager<
|
||||
AxiosRequestConfig<unknown>
|
||||
>,
|
||||
responseInterceptors: responseInterceptors as AxiosInterceptorManager<
|
||||
AxiosResponse<ApiResponse<T>, unknown>
|
||||
>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -47,10 +47,7 @@ export const usePageApi = <
|
||||
})
|
||||
const rootSchema: z.ZodSchema<Root<T>> = z.array(validationSchema ?? z.any())
|
||||
|
||||
const { error, loading, fetchAsync, cancel } = useApi<
|
||||
typeof listSchema | typeof rootSchema,
|
||||
P
|
||||
>(
|
||||
const { error, loading, fetchAsync, cancel } = useApi<typeof listSchema | typeof rootSchema, P>(
|
||||
url,
|
||||
method,
|
||||
options?.pagination === 'offset'
|
||||
@ -84,8 +81,7 @@ export const usePageApi = <
|
||||
if (
|
||||
status === 'loaded' ||
|
||||
(status === 'loadmore' &&
|
||||
((data ?? []).length === 0 ||
|
||||
(data ?? []).length % (options?.pageSize ?? 10) !== 0))
|
||||
((data ?? []).length === 0 || (data ?? []).length % (options?.pageSize ?? 10) !== 0))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -3,12 +3,7 @@
|
||||
* 接口与原版完全兼容,底层使用 axios.create() + AbortController。
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
|
||||
import axios, {
|
||||
type AxiosRequestConfig,
|
||||
type AxiosResponse,
|
||||
isAxiosError,
|
||||
isCancel,
|
||||
} from 'axios'
|
||||
import axios, { type AxiosRequestConfig, type AxiosResponse, isAxiosError, isCancel } from 'axios'
|
||||
import { z } from 'zod'
|
||||
import { RequestError } from './errors'
|
||||
import { _notifyApiError } from './globalErrorHandler'
|
||||
@ -45,9 +40,7 @@ interface State<R> {
|
||||
}
|
||||
|
||||
type Action<R> =
|
||||
| { type: 'start' }
|
||||
| { type: 'resolve'; response: R }
|
||||
| { type: 'reject'; error: RequestError }
|
||||
{ type: 'start' } | { type: 'resolve'; response: R } | { type: 'reject'; error: RequestError }
|
||||
|
||||
function reducer<R>(state: State<R>, action: Action<R>): State<R> {
|
||||
switch (action.type) {
|
||||
@ -95,27 +88,35 @@ export function useRequest<
|
||||
const abortControllerRef = useRef<AbortController | undefined>(undefined)
|
||||
const validationSchemaRef = useRef(validationSchema)
|
||||
const optionsRef = useRef(options)
|
||||
const requestInputRef = useRef({ url, method, params, config })
|
||||
|
||||
// 保持 ref 同步(避免闭包陈旧引用)
|
||||
useEffect(() => { validationSchemaRef.current = validationSchema })
|
||||
useEffect(() => { optionsRef.current = options })
|
||||
useEffect(() => {
|
||||
validationSchemaRef.current = validationSchema
|
||||
})
|
||||
useEffect(() => {
|
||||
optionsRef.current = options
|
||||
})
|
||||
useEffect(() => {
|
||||
requestInputRef.current = { url, method, params, config }
|
||||
}, [config, method, params, url])
|
||||
|
||||
// Zod 校验响应拦截器(对齐 useValidatedAxios 行为)
|
||||
useEffect(() => {
|
||||
const id = instance.interceptors.response.use(
|
||||
(r: AxiosResponse) => {
|
||||
const schema = validationSchemaRef.current
|
||||
if (schema) {
|
||||
const result = schema.safeParse(r.data)
|
||||
if (!result.success) {
|
||||
return Promise.reject(new ValidationError(result.error.issues, r))
|
||||
}
|
||||
r.data = result.data
|
||||
const id = instance.interceptors.response.use((r: AxiosResponse) => {
|
||||
const schema = validationSchemaRef.current
|
||||
if (schema) {
|
||||
const result = schema.safeParse(r.data)
|
||||
if (!result.success) {
|
||||
return Promise.reject(new ValidationError(result.error.issues, r))
|
||||
}
|
||||
return r
|
||||
},
|
||||
)
|
||||
return () => { instance.interceptors.response.eject(id) }
|
||||
r.data = result.data
|
||||
}
|
||||
return r
|
||||
})
|
||||
return () => {
|
||||
instance.interceptors.response.eject(id)
|
||||
}
|
||||
}, [instance])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
@ -125,9 +126,12 @@ export function useRequest<
|
||||
// axios 方法映射
|
||||
function resolveAxiosMethod(m: typeof method): string {
|
||||
switch (m) {
|
||||
case 'GET': return 'get'
|
||||
case 'PUT': return 'put'
|
||||
default: return 'post'
|
||||
case 'GET':
|
||||
return 'get'
|
||||
case 'PUT':
|
||||
return 'put'
|
||||
default:
|
||||
return 'post'
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,7 +151,7 @@ export function useRequest<
|
||||
!(override instanceof ArrayBuffer) &&
|
||||
!Array.isArray(override)
|
||||
) {
|
||||
return {...base as Record<string, unknown>, ...override as Record<string, unknown>}
|
||||
return { ...(base as Record<string, unknown>), ...(override as Record<string, unknown>) }
|
||||
}
|
||||
|
||||
return override
|
||||
@ -155,40 +159,42 @@ export function useRequest<
|
||||
|
||||
const fetchAsync = useCallback(
|
||||
async (overrideConfig?: AxiosRequestConfig<D>): Promise<R> => {
|
||||
const current = requestInputRef.current
|
||||
const loadingDelay = optionsRef.current?.loadingDelay ?? 0
|
||||
const timer = setTimeout(() => { dispatch({ type: 'start' }) }, loadingDelay)
|
||||
const timer = setTimeout(() => {
|
||||
dispatch({ type: 'start' })
|
||||
}, loadingDelay)
|
||||
|
||||
cancel()
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
// Content-Type 按 method 设置
|
||||
const methodHeaders: Record<string, string> = {}
|
||||
if (method === 'POSTFORM') {
|
||||
if (current.method === 'POSTFORM') {
|
||||
methodHeaders['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
} else if (method === 'UPLOAD') {
|
||||
} else if (current.method === 'UPLOAD') {
|
||||
methodHeaders['Content-Type'] = 'multipart/form-data'
|
||||
}
|
||||
|
||||
const axiosMethod = resolveAxiosMethod(method)
|
||||
const isGet = method === 'GET'
|
||||
const axiosMethod = resolveAxiosMethod(current.method)
|
||||
const isGet = current.method === 'GET'
|
||||
|
||||
const mergedConfig: AxiosRequestConfig<D> = {
|
||||
timeout: 30_000,
|
||||
...config,
|
||||
...current.config,
|
||||
...overrideConfig,
|
||||
url,
|
||||
url: current.url,
|
||||
method: axiosMethod,
|
||||
params: isGet
|
||||
? (overrideConfig?.params ?? config?.params ?? params)
|
||||
: (overrideConfig?.params ?? config?.params),
|
||||
data: (
|
||||
isGet
|
||||
? (overrideConfig?.data ?? config?.data)
|
||||
: mergeRequestData(config?.data, overrideConfig?.data ?? params)
|
||||
) as D | undefined,
|
||||
? (overrideConfig?.params ?? current.config?.params ?? current.params)
|
||||
: (overrideConfig?.params ?? current.config?.params),
|
||||
data: (isGet
|
||||
? (overrideConfig?.data ?? current.config?.data)
|
||||
: mergeRequestData(current.config?.data, overrideConfig?.data ?? current.params)) as
|
||||
D | undefined,
|
||||
headers: {
|
||||
...methodHeaders,
|
||||
...(config?.headers as Record<string, string> | undefined),
|
||||
...(current.config?.headers as Record<string, string> | undefined),
|
||||
...(overrideConfig?.headers as Record<string, string> | undefined),
|
||||
},
|
||||
signal: abortControllerRef.current.signal,
|
||||
@ -241,13 +247,14 @@ export function useRequest<
|
||||
return Promise.reject(wrapped)
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[cancel, config, instance, method, params, url],
|
||||
[cancel, instance],
|
||||
)
|
||||
|
||||
const fetch = useCallback(
|
||||
(overrideConfig?: AxiosRequestConfig<D>) => {
|
||||
fetchAsync(overrideConfig).catch(() => { /* error already in state */ })
|
||||
fetchAsync(overrideConfig).catch(() => {
|
||||
/* error already in state */
|
||||
})
|
||||
},
|
||||
[fetchAsync],
|
||||
)
|
||||
@ -262,7 +269,11 @@ export function useRequest<
|
||||
}, [])
|
||||
|
||||
// unmount 时取消请求
|
||||
useEffect(() => { return () => { cancel() } }, [cancel])
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancel()
|
||||
}
|
||||
}, [cancel])
|
||||
|
||||
return {
|
||||
response: state.response,
|
||||
|
||||
@ -6,25 +6,21 @@
|
||||
* 推荐方式(withXuqmConfig Metro 插件):
|
||||
* 1. 宿主将 .xuqmconfig 文件放入 src/assets/config/
|
||||
* 2. metro.config.js 使用 withXuqmConfig(metroConfig) 包装
|
||||
* 3. 本模块 tryRequire('@xuqm/autoinit-config') 自动读取并初始化
|
||||
*
|
||||
* 兼容方式(手动 alias):
|
||||
* 1. 宿主把加密配置放到 src/assets/xuqm/config.xuqmconfig.ts
|
||||
* 2. 宿主 babel/metro config 添加 alias: '@xuqm/autoinit-config' → 该文件
|
||||
* 3. Metro 在业务入口前加载本模块并完成一次共享初始化
|
||||
*
|
||||
* 如果配置未找到(require 失败),静默跳过,不崩溃。
|
||||
*/
|
||||
|
||||
import {isInitialized} from './config'
|
||||
import {XuqmSDK} from './sdk'
|
||||
import { isInitialized } from './config'
|
||||
import { _initializeFromConfigFile } from './sdk'
|
||||
|
||||
let _autoInitAttempted = false
|
||||
|
||||
function tryRequireConfig(): string | null {
|
||||
try {
|
||||
// Metro moduleNameMapper 将此路径映射到宿主的配置文件
|
||||
// withXuqmConfig 将此模块映射到构建期生成的配置传输模块
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require('@xuqm/autoinit-config') as {ENCRYPTED_CONFIG?: string; default?: string}
|
||||
const mod = require('@xuqm/autoinit-config') as { ENCRYPTED_CONFIG?: string; default?: string }
|
||||
return mod?.ENCRYPTED_CONFIG ?? mod?.default ?? null
|
||||
} catch {
|
||||
return null
|
||||
@ -43,8 +39,9 @@ export function tryAutoInit(): void {
|
||||
const encrypted = tryRequireConfig()
|
||||
if (!encrypted) return
|
||||
|
||||
XuqmSDK.initWithConfigFile(encrypted, {debug: __DEV__}).catch((e: unknown) => {
|
||||
_initializeFromConfigFile(encrypted, { debug: __DEV__ }).catch((e: unknown) => {
|
||||
console.error('[XuqmSDK] Auto-init failed:', e)
|
||||
if (__DEV__) throw e
|
||||
})
|
||||
}
|
||||
|
||||
tryAutoInit()
|
||||
|
||||
@ -69,7 +69,14 @@ export function ScaledImage(props: Props): React.ReactElement {
|
||||
|
||||
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(() => {
|
||||
if (originalWidth !== undefined && originalHeight !== undefined) {
|
||||
return computeDimensions(originalWidth, originalHeight, minWidth, maxWidth, minHeight, maxHeight)
|
||||
return computeDimensions(
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
)
|
||||
}
|
||||
return null
|
||||
})
|
||||
@ -97,7 +104,11 @@ export function ScaledImage(props: Props): React.ReactElement {
|
||||
|
||||
if (!dimensions) {
|
||||
// Placeholder while size is being resolved
|
||||
return <View style={[styles.placeholder, { width: minWidth, height: minHeight, borderRadius }, style]} />
|
||||
return (
|
||||
<View
|
||||
style={[styles.placeholder, { width: minWidth, height: minHeight, borderRadius }, style]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export interface XuqmInitOptions {
|
||||
appKey: string
|
||||
platformUrl?: string // 不传则使用内置默认公有平台地址
|
||||
platformUrl?: string // 不传则使用内置默认公有平台地址
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
@ -9,12 +9,10 @@ export interface XuqmConfig {
|
||||
apiUrl: string
|
||||
imWsUrl: string
|
||||
fileServiceUrl: string
|
||||
licenseUrl: string
|
||||
debug: boolean
|
||||
// 服务开通状态(由平台远程配置决定)
|
||||
imEnabled: boolean
|
||||
pushEnabled: boolean
|
||||
licenseEnabled: boolean
|
||||
// 崩溃采集服务(rn-bugcollect 使用)
|
||||
bugCollectApiUrl: string
|
||||
bugCollectEnabled: boolean
|
||||
@ -24,19 +22,27 @@ export interface XuqmConfig {
|
||||
|
||||
export interface XuqmUserInfo {
|
||||
userId: string
|
||||
userSig?: string // IM 登录凭证;未开通 IM 时可不传
|
||||
userSig?: string // IM 登录凭证;未开通 IM 时可不传
|
||||
name?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface XuqmLoginOptions extends XuqmUserInfo {
|
||||
/** 公共 HTTP 访问令牌;与 IM userSig 分开存储、分开使用。 */
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
export type XuqmUserInfoHandler = (info: XuqmUserInfo | null) => void | Promise<void>
|
||||
export type XuqmInitializationHandler = (config: Readonly<XuqmConfig>) => void | Promise<void>
|
||||
|
||||
// ─── Internal state ────────────────────────────────────────────────────────────
|
||||
|
||||
let _config: XuqmConfig | null = null
|
||||
let _userId: string | null = null
|
||||
let _userInfo: XuqmUserInfo | null = null
|
||||
const _userInfoHandlers: Array<(info: XuqmUserInfo | null) => void> = []
|
||||
const _userInfoHandlers = new Map<string, XuqmUserInfoHandler>()
|
||||
const _initializationHandlers = new Map<string, XuqmInitializationHandler>()
|
||||
|
||||
// ─── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -45,10 +51,8 @@ export interface XuqmRemoteConfig {
|
||||
apiUrl?: string
|
||||
imWsUrl?: string
|
||||
fileServiceUrl?: string
|
||||
licenseUrl?: string
|
||||
imEnabled?: boolean
|
||||
pushEnabled?: boolean
|
||||
licenseEnabled?: boolean
|
||||
bugCollectApiUrl?: string
|
||||
bugCollectEnabled?: boolean
|
||||
}
|
||||
@ -58,19 +62,17 @@ export function initConfigFromRemote(
|
||||
remote: XuqmRemoteConfig,
|
||||
signingKey?: string,
|
||||
): void {
|
||||
const apiUrl = remote.imApiUrl ?? remote.apiUrl ?? ''
|
||||
const apiUrl = remote.apiUrl ?? remote.imApiUrl ?? ''
|
||||
_config = {
|
||||
appKey: options.appKey,
|
||||
appKey: options.appKey,
|
||||
apiUrl,
|
||||
imWsUrl: remote.imWsUrl ?? '',
|
||||
fileServiceUrl: remote.fileServiceUrl ?? apiUrl,
|
||||
licenseUrl: remote.licenseUrl ?? apiUrl,
|
||||
debug: options.debug ?? false,
|
||||
imEnabled: remote.imEnabled ?? !!remote.imWsUrl,
|
||||
pushEnabled: remote.pushEnabled ?? true,
|
||||
licenseEnabled: remote.licenseEnabled ?? false,
|
||||
bugCollectApiUrl: remote.bugCollectApiUrl ?? '',
|
||||
bugCollectEnabled: remote.bugCollectEnabled ?? false,
|
||||
imWsUrl: remote.imWsUrl ?? '',
|
||||
fileServiceUrl: remote.fileServiceUrl ?? apiUrl,
|
||||
debug: options.debug ?? false,
|
||||
imEnabled: remote.imEnabled ?? !!remote.imWsUrl,
|
||||
pushEnabled: remote.pushEnabled ?? true,
|
||||
bugCollectApiUrl: remote.bugCollectApiUrl ?? '',
|
||||
bugCollectEnabled: remote.bugCollectEnabled ?? false,
|
||||
signingKey,
|
||||
}
|
||||
}
|
||||
@ -80,31 +82,86 @@ export function getConfig(): XuqmConfig {
|
||||
return _config
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional SDK context for common capabilities that also work standalone.
|
||||
* Extension packages should use getConfig() so a missing initialization remains explicit.
|
||||
*/
|
||||
export function getOptionalConfig(): XuqmConfig | null {
|
||||
return _config
|
||||
}
|
||||
|
||||
export function isInitialized(): boolean {
|
||||
return _config !== null
|
||||
}
|
||||
|
||||
// ─── UserId ────────────────────────────────────────────────────────────────────
|
||||
export function _registerInitializationHandler(
|
||||
name: string,
|
||||
handler: XuqmInitializationHandler,
|
||||
): () => void {
|
||||
if (_initializationHandlers.has(name)) {
|
||||
throw new Error(`[XuqmSDK] Initialization handler "${name}" is already registered.`)
|
||||
}
|
||||
_initializationHandlers.set(name, handler)
|
||||
|
||||
export function setUserId(userId: string | null): void {
|
||||
_userId = userId
|
||||
const current = _config
|
||||
if (current) {
|
||||
Promise.resolve(handler(current)).catch(error => {
|
||||
console.error(`[XuqmSDK] Late initialization handler failed: ${name}`, error)
|
||||
})
|
||||
}
|
||||
return () => _initializationHandlers.delete(name)
|
||||
}
|
||||
|
||||
export async function _notifyInitialized(): Promise<void> {
|
||||
const config = getConfig()
|
||||
const results = await Promise.allSettled(
|
||||
Array.from(_initializationHandlers.entries(), ([name, handler]) =>
|
||||
Promise.resolve(handler(config)).catch(error => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`${name}: ${message}`)
|
||||
}),
|
||||
),
|
||||
)
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
console.error('[XuqmSDK] Extension initialization failed:', result.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── UserId ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getUserId(): string | null {
|
||||
return _userId
|
||||
return _userInfo?.userId ?? null
|
||||
}
|
||||
|
||||
// ─── UserInfo + subscribers ────────────────────────────────────────────────────
|
||||
|
||||
export function _registerUserInfoHandler(handler: (info: XuqmUserInfo | null) => void): void {
|
||||
_userInfoHandlers.push(handler)
|
||||
export function _registerUserInfoHandler(name: string, handler: XuqmUserInfoHandler): () => void {
|
||||
if (_userInfoHandlers.has(name)) {
|
||||
throw new Error(`[XuqmSDK] User session handler "${name}" is already registered.`)
|
||||
}
|
||||
_userInfoHandlers.set(name, handler)
|
||||
return () => _userInfoHandlers.delete(name)
|
||||
}
|
||||
|
||||
export function setUserInfo(info: XuqmUserInfo | null): void {
|
||||
export async function _setUserInfo(info: XuqmUserInfo | null): Promise<void> {
|
||||
_userInfo = info
|
||||
_userId = info?.userId ?? null
|
||||
for (const handler of _userInfoHandlers) {
|
||||
try { handler(info) } catch { /* sub-SDK errors must not break the chain */ }
|
||||
const results = await Promise.allSettled(
|
||||
Array.from(_userInfoHandlers.entries(), async ([name, handler]) => {
|
||||
try {
|
||||
await handler(info)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`${name}: ${message}`)
|
||||
}
|
||||
}),
|
||||
)
|
||||
const failures = results
|
||||
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
.map(result => String(result.reason))
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`[XuqmSDK] Session propagation failed: ${failures.join('; ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,86 +0,0 @@
|
||||
/**
|
||||
* 内置配置文件解密。
|
||||
*
|
||||
* 支持格式:
|
||||
* XUQM-LICENSE-V1.{salt}.{iv}.{ciphertext}
|
||||
* XUQM-CONFIG-V1.{salt}.{iv}.{ciphertext}
|
||||
*
|
||||
* 使用 react-native-quick-crypto 的 SubtleCrypto(peer dependency)。
|
||||
*/
|
||||
|
||||
const MAGIC_LICENSE = 'XUQM-LICENSE-V1'
|
||||
const MAGIC_CONFIG = 'XUQM-CONFIG-V1'
|
||||
const PASSPHRASE_CONFIG = 'xuqm-config-file-v1.2026.internal'
|
||||
const PASSPHRASE_LICENSE = 'xuqm-license-file-v1.2026.internal'
|
||||
const PBKDF2_ITERATIONS = 120_000
|
||||
|
||||
function getPassphrase(magic: string): string {
|
||||
return magic === MAGIC_CONFIG ? PASSPHRASE_CONFIG : PASSPHRASE_LICENSE
|
||||
}
|
||||
|
||||
export interface DecryptedConfig {
|
||||
appKey: string
|
||||
appName?: string
|
||||
companyName?: string
|
||||
baseUrl?: string
|
||||
serverUrl?: string
|
||||
signingKey?: string
|
||||
issuedAt?: string
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
interface SubtleCryptoLike {
|
||||
importKey(format: string, keyData: BufferSource, algorithm: string | Record<string, unknown>, extractable: boolean, keyUsages: string[]): Promise<CryptoKey>
|
||||
deriveKey(algorithm: string | Record<string, unknown>, baseKey: CryptoKey, derivedKeyAlgorithm: string | Record<string, unknown>, extractable: boolean, keyUsages: string[]): Promise<CryptoKey>
|
||||
decrypt(algorithm: string | Record<string, unknown>, key: CryptoKey, data: BufferSource): Promise<ArrayBuffer>
|
||||
}
|
||||
|
||||
function getSubtle(): SubtleCryptoLike {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const qc = require('react-native-quick-crypto') as { subtle?: SubtleCryptoLike; default?: { subtle?: SubtleCryptoLike } }
|
||||
const subtle = qc.subtle ?? qc.default?.subtle
|
||||
if (!subtle) throw new Error('[XuqmSDK] react-native-quick-crypto not available')
|
||||
return subtle
|
||||
}
|
||||
|
||||
function base64UrlDecode(s: string): Uint8Array {
|
||||
const padded = s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4)
|
||||
const binary = atob(padded)
|
||||
return Uint8Array.from({ length: binary.length }, (_, i) => binary.charCodeAt(i))
|
||||
}
|
||||
|
||||
async function deriveKey(salt: Uint8Array, passphrase: string): Promise<CryptoKey> {
|
||||
const subtle = getSubtle()
|
||||
const passphraseKey = await subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(passphrase),
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveKey'],
|
||||
)
|
||||
return subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt: salt as unknown as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
||||
passphraseKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['decrypt'],
|
||||
)
|
||||
}
|
||||
|
||||
export async function decryptConfigFile(content: string): Promise<DecryptedConfig> {
|
||||
const parts = content.trim().split('.')
|
||||
const magic = parts[0]
|
||||
if (parts.length !== 4 || (magic !== MAGIC_CONFIG && magic !== MAGIC_LICENSE)) {
|
||||
throw new Error('[XuqmSDK] Invalid config/license file format')
|
||||
}
|
||||
const salt = base64UrlDecode(parts[1])
|
||||
const iv = base64UrlDecode(parts[2])
|
||||
const ciphertext = base64UrlDecode(parts[3])
|
||||
|
||||
const passphrase = getPassphrase(magic)
|
||||
const key = await deriveKey(salt, passphrase)
|
||||
const subtle = getSubtle()
|
||||
const plainBuffer = await subtle.decrypt({ name: 'AES-GCM', iv: iv as unknown as BufferSource }, key, ciphertext as unknown as BufferSource)
|
||||
const json = new TextDecoder().decode(plainBuffer)
|
||||
return JSON.parse(json) as DecryptedConfig
|
||||
}
|
||||
42
packages/common/src/crypto-types.d.ts
vendored
42
packages/common/src/crypto-types.d.ts
vendored
@ -1,42 +0,0 @@
|
||||
/**
|
||||
* Web Crypto API 类型声明(Hermes 引擎提供实现)。
|
||||
*
|
||||
* TypeScript 默认 lib 不包含这些类型,
|
||||
* 但 Hermes (React Native 0.71+) 在运行时提供 crypto.subtle。
|
||||
*/
|
||||
|
||||
interface SubtleCrypto {
|
||||
importKey(
|
||||
format: string,
|
||||
keyData: BufferSource,
|
||||
algorithm: AlgorithmIdentifier,
|
||||
extractable: boolean,
|
||||
keyUsages: string[],
|
||||
): Promise<CryptoKey>
|
||||
deriveKey(
|
||||
algorithm: AlgorithmIdentifier,
|
||||
baseKey: CryptoKey,
|
||||
derivedKeyType: AlgorithmIdentifier,
|
||||
extractable: boolean,
|
||||
keyUsages: string[],
|
||||
): Promise<CryptoKey>
|
||||
decrypt(
|
||||
algorithm: AlgorithmIdentifier,
|
||||
key: CryptoKey,
|
||||
data: BufferSource,
|
||||
): Promise<ArrayBuffer>
|
||||
}
|
||||
|
||||
interface CryptoKey {
|
||||
readonly type: string
|
||||
readonly extractable: boolean
|
||||
readonly algorithm: AlgorithmIdentifier
|
||||
readonly usages: string[]
|
||||
}
|
||||
|
||||
interface Crypto {
|
||||
readonly subtle: SubtleCrypto
|
||||
}
|
||||
|
||||
declare var crypto: Crypto
|
||||
declare function atob(data: string): string
|
||||
111
packages/common/src/crypto.ts
普通文件
111
packages/common/src/crypto.ts
普通文件
@ -0,0 +1,111 @@
|
||||
import { gcm } from '@noble/ciphers/aes.js'
|
||||
import { hmac } from '@noble/hashes/hmac.js'
|
||||
import { pbkdf2Async } from '@noble/hashes/pbkdf2.js'
|
||||
import { sha256 } from '@noble/hashes/sha2.js'
|
||||
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'
|
||||
|
||||
const MAGIC_CONFIG = 'XUQM-CONFIG-V1'
|
||||
const PASSPHRASES = {
|
||||
[MAGIC_CONFIG]: 'xuqm-config-file-v1.2026.internal',
|
||||
} as const
|
||||
const PBKDF2_ITERATIONS = 120_000
|
||||
const AES_KEY_BYTES = 32
|
||||
|
||||
export type XuqmEncryptedFileMagic = keyof typeof PASSPHRASES
|
||||
|
||||
export interface DecryptedConfig {
|
||||
appKey: string
|
||||
appName?: string
|
||||
companyName?: string
|
||||
baseUrl?: string
|
||||
serverUrl?: string
|
||||
signingKey?: string
|
||||
issuedAt?: string
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
function base64UrlDecode(value: string): Uint8Array {
|
||||
const padded =
|
||||
value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4)
|
||||
const binary = atob(padded)
|
||||
return Uint8Array.from({ length: binary.length }, (_, index) => binary.charCodeAt(index))
|
||||
}
|
||||
|
||||
function base64UrlEncode(value: Uint8Array): string {
|
||||
let binary = ''
|
||||
value.forEach(byte => {
|
||||
binary += String.fromCharCode(byte)
|
||||
})
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode UTF-8 without relying on the optional TextDecoder implementation in
|
||||
* the React Native runtime. Hermes versions and third-party polyfills have
|
||||
* returned invalid text for Uint8Array views in real applications.
|
||||
*/
|
||||
function utf8BytesToString(value: Uint8Array): string {
|
||||
let encoded = ''
|
||||
for (const byte of value) {
|
||||
encoded += `%${byte.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
return decodeURIComponent(encoded)
|
||||
}
|
||||
|
||||
function parseEncryptedFile(content: string): {
|
||||
magic: XuqmEncryptedFileMagic
|
||||
salt: Uint8Array
|
||||
iv: Uint8Array
|
||||
ciphertext: Uint8Array
|
||||
} {
|
||||
const parts = content.trim().split('.')
|
||||
const magic = parts[0] as XuqmEncryptedFileMagic
|
||||
if (parts.length !== 4 || !(magic in PASSPHRASES)) {
|
||||
throw new Error('[XuqmSDK] Invalid config file format')
|
||||
}
|
||||
|
||||
const salt = base64UrlDecode(parts[1])
|
||||
const iv = base64UrlDecode(parts[2])
|
||||
const ciphertext = base64UrlDecode(parts[3])
|
||||
if (salt.length === 0 || iv.length !== 12 || ciphertext.length <= 16) {
|
||||
throw new Error('[XuqmSDK] Invalid config cryptographic parameters')
|
||||
}
|
||||
|
||||
return { magic, salt, iv, ciphertext }
|
||||
}
|
||||
|
||||
export function hmacSha256Base64Url(secret: string, data: string): string {
|
||||
return base64UrlEncode(hmac(sha256, utf8ToBytes(secret), utf8ToBytes(data)))
|
||||
}
|
||||
|
||||
/** Canonical SHA-256 digest used by downloads, bundles, and package verification. */
|
||||
export function sha256Hex(value: string | Uint8Array): string {
|
||||
return bytesToHex(sha256(typeof value === 'string' ? utf8ToBytes(value) : value))
|
||||
}
|
||||
|
||||
export async function decryptXuqmFile<T extends object = DecryptedConfig>(
|
||||
content: string,
|
||||
): Promise<T> {
|
||||
const { magic, salt, iv, ciphertext } = parseEncryptedFile(content)
|
||||
const key = await pbkdf2Async(sha256, PASSPHRASES[magic], salt, {
|
||||
c: PBKDF2_ITERATIONS,
|
||||
dkLen: AES_KEY_BYTES,
|
||||
})
|
||||
|
||||
let plaintext: Uint8Array
|
||||
try {
|
||||
plaintext = gcm(key, iv).decrypt(ciphertext)
|
||||
} catch {
|
||||
throw new Error('[XuqmSDK] Config decryption failed')
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(utf8BytesToString(plaintext)) as T
|
||||
} catch {
|
||||
throw new Error('[XuqmSDK] Config content is not valid JSON')
|
||||
}
|
||||
}
|
||||
|
||||
export function decryptConfigFile(content: string): Promise<DecryptedConfig> {
|
||||
return decryptXuqmFile<DecryptedConfig>(content)
|
||||
}
|
||||
66
packages/common/src/date.ts
普通文件
66
packages/common/src/date.ts
普通文件
@ -0,0 +1,66 @@
|
||||
export type DateInput = Date | number | string | null | undefined
|
||||
|
||||
export type ExpirationStatus = 'missing' | 'valid' | 'expired'
|
||||
|
||||
/**
|
||||
* Converts a supported date value to epoch milliseconds.
|
||||
* Numeric values below 10^12 are treated as epoch seconds; larger values as milliseconds.
|
||||
*/
|
||||
export function toTimestamp(value: DateInput): number | null {
|
||||
if (value == null || value === '') return null
|
||||
if (value instanceof Date) {
|
||||
const timestamp = value.getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : null
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return null
|
||||
return Math.abs(value) < 1_000_000_000_000 ? value * 1000 : value
|
||||
}
|
||||
|
||||
const raw = value.trim()
|
||||
if (!raw) return null
|
||||
const numeric = Number(raw)
|
||||
if (Number.isFinite(numeric)) return toTimestamp(numeric)
|
||||
|
||||
// API responses commonly use `yyyy-MM-dd HH:mm:ss`; normalize it for Hermes/iOS.
|
||||
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(raw) ? raw.replace(' ', 'T') : raw
|
||||
const timestamp = Date.parse(normalized)
|
||||
return Number.isFinite(timestamp) ? timestamp : null
|
||||
}
|
||||
|
||||
/** Canonical expiry decision used by all packages. */
|
||||
export function expirationStatus(
|
||||
endTime: DateInput,
|
||||
now: DateInput = Date.now(),
|
||||
): ExpirationStatus {
|
||||
const end = toTimestamp(endTime)
|
||||
if (end == null) return 'missing'
|
||||
const reference = toTimestamp(now)
|
||||
if (reference == null) throw new Error('[XuqmSDK] Invalid reference time.')
|
||||
return end > reference ? 'valid' : 'expired'
|
||||
}
|
||||
|
||||
export function millisecondsUntil(target: DateInput, now: DateInput = Date.now()): number | null {
|
||||
const targetTimestamp = toTimestamp(target)
|
||||
const nowTimestamp = toTimestamp(now)
|
||||
if (targetTimestamp == null || nowTimestamp == null) return null
|
||||
return targetTimestamp - nowTimestamp
|
||||
}
|
||||
|
||||
export function formatDateTime(
|
||||
value: DateInput,
|
||||
options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
},
|
||||
locale = 'zh-CN',
|
||||
): string | null {
|
||||
const timestamp = toTimestamp(value)
|
||||
if (timestamp == null) return null
|
||||
return new Intl.DateTimeFormat(locale, options).format(new Date(timestamp))
|
||||
}
|
||||
@ -4,7 +4,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
const DEVICE_ID_KEY = '@xuqm:deviceId'
|
||||
|
||||
function uuid(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
||||
})
|
||||
@ -40,14 +40,14 @@ export interface DeviceInfo {
|
||||
}
|
||||
|
||||
const BRAND_MAP: Record<string, PushVendor> = {
|
||||
xiaomi: 'XIAOMI',
|
||||
redmi: 'XIAOMI',
|
||||
huawei: 'HUAWEI',
|
||||
honor: 'HONOR',
|
||||
oppo: 'OPPO',
|
||||
realme: 'OPPO',
|
||||
vivo: 'VIVO',
|
||||
iqoo: 'VIVO',
|
||||
xiaomi: 'XIAOMI',
|
||||
redmi: 'XIAOMI',
|
||||
huawei: 'HUAWEI',
|
||||
honor: 'HONOR',
|
||||
oppo: 'OPPO',
|
||||
realme: 'OPPO',
|
||||
vivo: 'VIVO',
|
||||
iqoo: 'VIVO',
|
||||
}
|
||||
|
||||
export function detectPushVendor(brand: string): PushVendor {
|
||||
|
||||
79
packages/common/src/download.ts
普通文件
79
packages/common/src/download.ts
普通文件
@ -0,0 +1,79 @@
|
||||
export interface DownloadProgress {
|
||||
bytesDownloaded: number
|
||||
totalBytes: number | null
|
||||
/** 0-100; null when the server does not provide Content-Length. */
|
||||
percent: number | null
|
||||
}
|
||||
|
||||
export interface DownloadOptions {
|
||||
headers?: Readonly<Record<string, string>>
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: DownloadProgress) => void
|
||||
}
|
||||
|
||||
export class DownloadError extends Error {
|
||||
readonly name = 'DownloadError'
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly url: string,
|
||||
public readonly status: number | null,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options)
|
||||
}
|
||||
}
|
||||
|
||||
function totalBytes(response: Response): number | null {
|
||||
const value = Number(response.headers.get('Content-Length'))
|
||||
return Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
export async function downloadBytes(
|
||||
url: string,
|
||||
options: DownloadOptions = {},
|
||||
): Promise<Uint8Array> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: options.headers,
|
||||
signal: options.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new DownloadError(`Unable to download ${url}`, url, null, { cause: error })
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new DownloadError(`Download failed with HTTP ${response.status}`, url, response.status)
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) return new Uint8Array(await response.arrayBuffer())
|
||||
|
||||
const expected = totalBytes(response)
|
||||
const chunks: Uint8Array[] = []
|
||||
let received = 0
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
received += value.byteLength
|
||||
options.onProgress?.({
|
||||
bytesDownloaded: received,
|
||||
totalBytes: expected,
|
||||
percent: expected === null ? null : Math.min(100, Math.round((received / expected) * 100)),
|
||||
})
|
||||
}
|
||||
|
||||
const result = new Uint8Array(received)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function downloadText(url: string, options: DownloadOptions = {}): Promise<string> {
|
||||
return new TextDecoder().decode(await downloadBytes(url, options))
|
||||
}
|
||||
163
packages/common/src/file.ts
普通文件
163
packages/common/src/file.ts
普通文件
@ -0,0 +1,163 @@
|
||||
import { Platform } from 'react-native'
|
||||
import { fileExtension, sanitizeFileName } from './fileName'
|
||||
|
||||
export {
|
||||
fileExtension,
|
||||
fileNameFromUrl,
|
||||
parseContentDispositionFileName,
|
||||
sanitizeFileName,
|
||||
} from './fileName'
|
||||
|
||||
export type FileConflictPolicy = 'rename' | 'overwrite'
|
||||
|
||||
export interface FileTransferProgress {
|
||||
bytesTransferred: number
|
||||
totalBytes: number | null
|
||||
percent: number | null
|
||||
}
|
||||
|
||||
export interface FileDownloadTask {
|
||||
cancel(): void
|
||||
result: Promise<{ path: string }>
|
||||
}
|
||||
|
||||
const MIME_BY_EXTENSION: Readonly<Record<string, string>> = {
|
||||
pdf: 'application/pdf',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
bmp: 'image/bmp',
|
||||
svg: 'image/svg+xml',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
zip: 'application/zip',
|
||||
rar: 'application/vnd.rar',
|
||||
txt: 'text/plain',
|
||||
csv: 'text/csv',
|
||||
json: 'application/json',
|
||||
mp4: 'video/mp4',
|
||||
mp3: 'audio/mpeg',
|
||||
}
|
||||
|
||||
function blobUtil(): typeof import('react-native-blob-util').default {
|
||||
// Lazy loading avoids touching the native module until a file operation is requested.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
return (
|
||||
require('react-native-blob-util') as {
|
||||
default: typeof import('react-native-blob-util').default
|
||||
}
|
||||
).default
|
||||
}
|
||||
|
||||
export function inferMimeType(pathOrName: string): string {
|
||||
return MIME_BY_EXTENSION[fileExtension(pathOrName)] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
export function getFileDirectories() {
|
||||
return blobUtil().fs.dirs
|
||||
}
|
||||
|
||||
export async function fileExists(path: string): Promise<boolean> {
|
||||
return blobUtil().fs.exists(path)
|
||||
}
|
||||
|
||||
export async function ensureDirectory(path: string): Promise<void> {
|
||||
if (!(await fileExists(path))) await blobUtil().fs.mkdir(path)
|
||||
}
|
||||
|
||||
export async function resolveAvailableFilePath(
|
||||
directory: string,
|
||||
fileName: string,
|
||||
conflict: FileConflictPolicy = 'rename',
|
||||
): Promise<string> {
|
||||
const safeName = sanitizeFileName(fileName)
|
||||
const initial = `${directory.replace(/\/$/, '')}/${safeName}`
|
||||
if (conflict === 'overwrite' || !(await fileExists(initial))) return initial
|
||||
|
||||
const dot = safeName.lastIndexOf('.')
|
||||
const base = dot > 0 ? safeName.slice(0, dot) : safeName
|
||||
const extension = dot > 0 ? safeName.slice(dot) : ''
|
||||
for (let index = 1; ; index += 1) {
|
||||
const candidate = `${directory.replace(/\/$/, '')}/${base}(${index})${extension}`
|
||||
if (!(await fileExists(candidate))) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeBase64File(path: string, base64: string): Promise<void> {
|
||||
await blobUtil().fs.writeFile(path, base64, 'base64')
|
||||
}
|
||||
|
||||
export async function readFileAsBase64(path: string): Promise<string> {
|
||||
return blobUtil().fs.readFile(path, 'base64')
|
||||
}
|
||||
|
||||
export async function deleteFile(path: string): Promise<void> {
|
||||
if (await fileExists(path)) await blobUtil().fs.unlink(path)
|
||||
}
|
||||
|
||||
export async function openLocalFile(
|
||||
path: string,
|
||||
mimeType = inferMimeType(path),
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (Platform.OS === 'android') {
|
||||
const result = await blobUtil().android.actionViewIntent(path, mimeType)
|
||||
return result !== false
|
||||
}
|
||||
await blobUtil().ios.openDocument(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadFileToPath(
|
||||
url: string,
|
||||
path: string,
|
||||
options: {
|
||||
headers?: Record<string, string>
|
||||
onProgress?: (progress: FileTransferProgress) => void
|
||||
} = {},
|
||||
): FileDownloadTask {
|
||||
const task = blobUtil().config({ path }).fetch('GET', url, options.headers)
|
||||
task.progress({ interval: 300 }, (received: number, total: number) => {
|
||||
const bytesTransferred = received
|
||||
const totalValue = total
|
||||
const totalBytes = Number.isFinite(totalValue) && totalValue > 0 ? totalValue : null
|
||||
options.onProgress?.({
|
||||
bytesTransferred,
|
||||
totalBytes,
|
||||
percent:
|
||||
totalBytes === null
|
||||
? null
|
||||
: Math.min(100, Math.round((bytesTransferred / totalBytes) * 100)),
|
||||
})
|
||||
})
|
||||
return {
|
||||
cancel: () => task.cancel(),
|
||||
result: task.then(response => ({ path: response.path() })),
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerAndroidDownloadedFile(options: {
|
||||
path: string
|
||||
title: string
|
||||
description?: string
|
||||
mimeType?: string
|
||||
showNotification?: boolean
|
||||
}): Promise<void> {
|
||||
if (Platform.OS !== 'android') return
|
||||
await blobUtil().android.addCompleteDownload({
|
||||
path: options.path,
|
||||
title: options.title,
|
||||
description: options.description ?? options.title,
|
||||
mime: options.mimeType ?? inferMimeType(options.path),
|
||||
showNotification: options.showNotification ?? true,
|
||||
})
|
||||
}
|
||||
43
packages/common/src/fileName.ts
普通文件
43
packages/common/src/fileName.ts
普通文件
@ -0,0 +1,43 @@
|
||||
export function fileExtension(pathOrName: string): string {
|
||||
const clean = pathOrName.split(/[?#]/, 1)[0] ?? ''
|
||||
const name = clean.split('/').pop() ?? clean
|
||||
const index = name.lastIndexOf('.')
|
||||
return index > 0 ? name.slice(index + 1).toLowerCase() : ''
|
||||
}
|
||||
|
||||
export function sanitizeFileName(name: string, fallback = 'file'): string {
|
||||
const sanitized = name
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
return sanitized || fallback
|
||||
}
|
||||
|
||||
/** Resolve an RFC 5987/6266 filename from a Content-Disposition header. */
|
||||
export function parseContentDispositionFileName(header: string): string | null {
|
||||
const encoded = header.match(/filename\*=(?:[^']*'[^']*')?([^;\s]+)/i)?.[1]
|
||||
if (encoded) {
|
||||
try {
|
||||
return sanitizeFileName(decodeURIComponent(encoded.replace(/['"]/g, '')))
|
||||
} catch {
|
||||
// Fall through to the non-encoded forms.
|
||||
}
|
||||
}
|
||||
|
||||
const quoted = header.match(/filename="([^"]+)"/i)?.[1]
|
||||
if (quoted?.trim()) return sanitizeFileName(quoted)
|
||||
|
||||
const plain = header.match(/filename=([^;\s"]+)/i)?.[1]
|
||||
return plain?.trim() ? sanitizeFileName(plain) : null
|
||||
}
|
||||
|
||||
export function fileNameFromUrl(url: string, fallback = 'download'): string {
|
||||
try {
|
||||
const pathname = new URL(url).pathname
|
||||
const candidate = pathname.split('/').filter(Boolean).at(-1)
|
||||
return candidate ? sanitizeFileName(decodeURIComponent(candidate), fallback) : fallback
|
||||
} catch {
|
||||
const candidate = url.split(/[?#]/, 1)[0]?.split('/').filter(Boolean).at(-1)
|
||||
return candidate ? sanitizeFileName(candidate, fallback) : fallback
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
|
||||
import { getConfig } from './config'
|
||||
import { getSigningKey } from './sdk'
|
||||
import { getOptionalConfig, getUserId } from './config'
|
||||
import { hmacSha256Base64Url } from './crypto'
|
||||
|
||||
const TOKEN_KEY = '@xuqm:token'
|
||||
let _baseUrl = DEFAULT_TENANT_PLATFORM_URL
|
||||
@ -16,62 +16,25 @@ export function configureHttp(options: { baseUrl?: string; debug?: boolean } = {
|
||||
}
|
||||
}
|
||||
|
||||
export async function _getToken(): Promise<string | null> {
|
||||
export async function _getAccessToken(): Promise<string | null> {
|
||||
return AsyncStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export async function _saveToken(token: string): Promise<void> {
|
||||
export async function _saveAccessToken(token: string): Promise<void> {
|
||||
return AsyncStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export async function _clearToken(): Promise<void> {
|
||||
export async function _clearAccessToken(): Promise<void> {
|
||||
return AsyncStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
function generateUUID(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
||||
})
|
||||
}
|
||||
let nonceSequence = 0
|
||||
|
||||
async function hmacSha256(secret: string, data: string): Promise<string> {
|
||||
// 使用 react-native-quick-crypto 或降级到纯 JS 实现
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const qc = require('react-native-quick-crypto') as { default?: { createHmac?: Function }; createHmac?: Function }
|
||||
const createHmac = qc.createHmac ?? qc.default?.createHmac
|
||||
if (createHmac) {
|
||||
const hmac = createHmac('sha256', secret)
|
||||
hmac.update(data)
|
||||
return hmac.digest('base64url')
|
||||
}
|
||||
} catch {
|
||||
// 降级到纯 JS 实现
|
||||
}
|
||||
// 纯 JS HMAC-SHA256 实现(用于没有 react-native-quick-crypto 的环境)
|
||||
// 注意:这是一个简化实现,生产环境建议使用 react-native-quick-crypto
|
||||
const encoder = new TextEncoder()
|
||||
const keyData = encoder.encode(secret)
|
||||
const dataBytes = encoder.encode(data)
|
||||
|
||||
// 使用 SubtleCrypto API(如果可用)
|
||||
if (typeof globalThis.crypto?.subtle !== 'undefined') {
|
||||
const key = await globalThis.crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
)
|
||||
const signature = await globalThis.crypto.subtle.sign('HMAC', key, dataBytes)
|
||||
return btoa(String.fromCharCode(...new Uint8Array(signature)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
}
|
||||
|
||||
throw new Error('[XuqmSDK] No HMAC-SHA256 implementation available')
|
||||
function generateRequestNonce(secret: string, appKey: string): string {
|
||||
nonceSequence = (nonceSequence + 1) % Number.MAX_SAFE_INTEGER
|
||||
const seed = `${Date.now()}\n${nonceSequence}\n${appKey}`
|
||||
const value = hmacSha256Base64Url(secret, seed)
|
||||
return `${value.slice(0, 8)}-${value.slice(8, 12)}-4${value.slice(13, 16)}-a${value.slice(17, 20)}-${value.slice(20, 32)}`
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(
|
||||
@ -83,9 +46,7 @@ export async function apiRequest<T>(
|
||||
skipAuth?: boolean
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
let url = path.startsWith('http://') || path.startsWith('https://')
|
||||
? path
|
||||
: `${_baseUrl}${path}`
|
||||
let url = path.startsWith('http://') || path.startsWith('https://') ? path : `${_baseUrl}${path}`
|
||||
if (options.params) {
|
||||
const qs = new URLSearchParams(options.params).toString()
|
||||
url += (url.includes('?') ? '&' : '?') + qs
|
||||
@ -97,31 +58,22 @@ export async function apiRequest<T>(
|
||||
}
|
||||
|
||||
if (!options.skipAuth) {
|
||||
const token = await _getToken()
|
||||
const token = await _getAccessToken()
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 注入签名头
|
||||
try {
|
||||
const signingKey = getSigningKey()
|
||||
if (signingKey) {
|
||||
const config = getConfig()
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||
const nonce = generateUUID()
|
||||
const userId = config.appKey // 使用 appKey 作为 userId 的默认值
|
||||
const payload = `${config.appKey}\n${userId}\n${timestamp}\n${nonce}`
|
||||
const signature = await hmacSha256(signingKey, payload)
|
||||
headers['X-App-Key'] = config.appKey
|
||||
headers['X-Timestamp'] = timestamp
|
||||
headers['X-Nonce'] = nonce
|
||||
headers['X-Signature'] = signature
|
||||
headers['X-User-Id'] = userId
|
||||
}
|
||||
} catch (e) {
|
||||
// 签名失败不应阻止请求
|
||||
if (_debugHttp) {
|
||||
console.debug('[XuqmSDK][HTTP] signature injection failed:', e)
|
||||
}
|
||||
const config = getOptionalConfig()
|
||||
const signingKey = config?.signingKey
|
||||
if (config && signingKey) {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||
const nonce = generateRequestNonce(signingKey, config.appKey)
|
||||
const userId = getUserId() ?? ''
|
||||
const payload = `${config.appKey}\n${userId}\n${timestamp}\n${nonce}`
|
||||
headers['X-App-Key'] = config.appKey
|
||||
headers['X-Timestamp'] = timestamp
|
||||
headers['X-Nonce'] = nonce
|
||||
headers['X-Signature'] = hmacSha256Base64Url(signingKey, payload)
|
||||
if (userId) headers['X-User-Id'] = userId
|
||||
}
|
||||
|
||||
if (_debugHttp) {
|
||||
|
||||
@ -1,22 +1,44 @@
|
||||
// 自动初始化(对齐 Android ContentProvider 模式)
|
||||
import './autoInit'
|
||||
|
||||
export { XuqmSDK, getSigningKey } from './sdk'
|
||||
export type { XuqmInitOptions, XuqmConfig, XuqmUserInfo } from './config'
|
||||
export {
|
||||
getConfig,
|
||||
isInitialized,
|
||||
setUserId,
|
||||
getUserId,
|
||||
setUserInfo,
|
||||
getUserInfo,
|
||||
_registerUserInfoHandler,
|
||||
} from './config'
|
||||
export { XuqmSDK } from './sdk'
|
||||
export type { XuqmInitOptions, XuqmConfig, XuqmLoginOptions, XuqmUserInfo } from './config'
|
||||
export { getConfig, isInitialized, getUserId, getUserInfo } from './config'
|
||||
export { awaitInitialization } from './sdk'
|
||||
export { apiRequest, configureHttp, _getToken, _saveToken, _clearToken } from './http'
|
||||
export { apiRequest, configureHttp } from './http'
|
||||
export { decryptConfigFile, decryptXuqmFile, hmacSha256Base64Url, sha256Hex } from './crypto'
|
||||
export type { DecryptedConfig, XuqmEncryptedFileMagic } from './crypto'
|
||||
export { DEFAULT_TENANT_PLATFORM_URL, DEFAULT_IM_WS_URL } from './constants'
|
||||
export { getDeviceId, getDeviceInfo, detectPushVendor } from './device'
|
||||
export type { DeviceInfo, PushVendor } from './device'
|
||||
export { expirationStatus, formatDateTime, millisecondsUntil, toTimestamp } from './date'
|
||||
export type { DateInput, ExpirationStatus } from './date'
|
||||
export {
|
||||
deleteFile,
|
||||
downloadFileToPath,
|
||||
ensureDirectory,
|
||||
fileExists,
|
||||
getFileDirectories,
|
||||
inferMimeType,
|
||||
openLocalFile,
|
||||
readFileAsBase64,
|
||||
registerAndroidDownloadedFile,
|
||||
resolveAvailableFilePath,
|
||||
writeBase64File,
|
||||
} from './file'
|
||||
export type { FileConflictPolicy, FileDownloadTask, FileTransferProgress } from './file'
|
||||
export {
|
||||
fileExtension,
|
||||
fileNameFromUrl,
|
||||
parseContentDispositionFileName,
|
||||
sanitizeFileName,
|
||||
} from './fileName'
|
||||
export { downloadBytes, downloadText, DownloadError } from './download'
|
||||
export type { DownloadOptions, DownloadProgress } from './download'
|
||||
export {
|
||||
compareVersions,
|
||||
compatibleMajorRange,
|
||||
isVersionUpgrade,
|
||||
normalizeVersion,
|
||||
satisfiesVersion,
|
||||
} from './version'
|
||||
export { ScaledImage } from './components/ScaledImage'
|
||||
export {
|
||||
XWebViewControl,
|
||||
|
||||
10
packages/common/src/internal.ts
普通文件
10
packages/common/src/internal.ts
普通文件
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Internal extension surface for official @xuqm packages.
|
||||
*
|
||||
* Application code must use XuqmSDK.login()/logout() and must not register
|
||||
* lifecycle handlers directly.
|
||||
*/
|
||||
import './autoInit'
|
||||
|
||||
export { _registerInitializationHandler, _registerUserInfoHandler } from './config'
|
||||
export type { XuqmInitializationHandler } from './config'
|
||||
@ -1,38 +1,85 @@
|
||||
import { initConfigFromRemote, isInitialized, type XuqmInitOptions, type XuqmUserInfo, setUserInfo as setCommonUserInfo, getUserInfo as getCommonUserInfo, getUserId as getCommonUserId } from './config'
|
||||
import {
|
||||
_notifyInitialized,
|
||||
_setUserInfo,
|
||||
getUserId as getCommonUserId,
|
||||
getUserInfo as getCommonUserInfo,
|
||||
initConfigFromRemote,
|
||||
isInitialized,
|
||||
type XuqmInitOptions,
|
||||
type XuqmLoginOptions,
|
||||
type XuqmUserInfo,
|
||||
} from './config'
|
||||
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
|
||||
import { configureHttp } from './http'
|
||||
import { decryptConfigFile } from './configCrypto'
|
||||
import { _clearAccessToken, _saveAccessToken, configureHttp } from './http'
|
||||
import { decryptConfigFile } from './crypto'
|
||||
|
||||
let _initPromise: Promise<void> | null = null
|
||||
let _initResolve: (() => void) | null = null
|
||||
let _initReject: ((e: unknown) => void) | null = null
|
||||
let _signingKey: string | undefined = undefined
|
||||
let _configFileInitPromise: Promise<void> | null = null
|
||||
let _initializationKey: string | null = null
|
||||
let _sessionTransition: Promise<void> = Promise.resolve()
|
||||
let _activeSessionKey: string | null = null
|
||||
|
||||
function ensureInitPromise(): Promise<void> {
|
||||
if (!_initPromise) {
|
||||
_initPromise = new Promise((resolve, reject) => {
|
||||
_initResolve = resolve
|
||||
_initReject = reject
|
||||
})
|
||||
function initializationKey(options: XuqmInitOptions): string {
|
||||
return `${options.appKey.trim()}\n${(options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')}`
|
||||
}
|
||||
|
||||
function startInitialization(options: XuqmInitOptions, signingKey?: string): Promise<void> {
|
||||
const nextKey = initializationKey(options)
|
||||
if (isInitialized()) {
|
||||
if (_initializationKey !== nextKey) {
|
||||
return Promise.reject(new Error('[XuqmSDK] Already initialized with a different config.'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (_initPromise) {
|
||||
if (_initializationKey !== nextKey) {
|
||||
return Promise.reject(
|
||||
new Error('[XuqmSDK] Initialization is already running with a different config.'),
|
||||
)
|
||||
}
|
||||
return _initPromise
|
||||
}
|
||||
|
||||
_initializationKey = nextKey
|
||||
_initPromise = (async () => {
|
||||
const baseUrl = options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL
|
||||
const configUrl = `${baseUrl.replace(/\/$/, '')}/api/sdk/config?appKey=${encodeURIComponent(options.appKey)}`
|
||||
const res = await fetch(configUrl)
|
||||
if (!res.ok) {
|
||||
throw new Error(`[XuqmSDK] Platform config request failed: ${res.status}`)
|
||||
}
|
||||
const json = (await res.json()) as Record<string, unknown>
|
||||
const remote = (json.data ?? json) as Record<string, unknown>
|
||||
initConfigFromRemote(
|
||||
options,
|
||||
{
|
||||
apiUrl: remote.apiUrl as string | undefined,
|
||||
imWsUrl: remote.imWsUrl as string | undefined,
|
||||
fileServiceUrl: remote.fileServiceUrl as string | undefined,
|
||||
imEnabled: remote.imEnabled as boolean | undefined,
|
||||
pushEnabled: remote.pushEnabled as boolean | undefined,
|
||||
bugCollectApiUrl: remote.bugCollectApiUrl as string | undefined,
|
||||
bugCollectEnabled: remote.bugCollectEnabled as boolean | undefined,
|
||||
},
|
||||
signingKey,
|
||||
)
|
||||
configureHttp({
|
||||
baseUrl: (remote.apiUrl as string | undefined) ?? baseUrl,
|
||||
debug: options.debug,
|
||||
})
|
||||
await _notifyInitialized()
|
||||
})().catch(error => {
|
||||
_initPromise = null
|
||||
_initializationKey = null
|
||||
throw error
|
||||
})
|
||||
return _initPromise
|
||||
}
|
||||
|
||||
function markInitialized(): void {
|
||||
if (_initResolve) {
|
||||
_initResolve()
|
||||
_initResolve = null
|
||||
_initReject = null
|
||||
}
|
||||
}
|
||||
|
||||
function markInitializationFailed(e: unknown): void {
|
||||
const reject = _initReject
|
||||
// Reset so a retry can call initialize() again
|
||||
_initPromise = null
|
||||
_initResolve = null
|
||||
_initReject = null
|
||||
reject?.(e)
|
||||
function enqueueSessionTransition(action: () => Promise<void>): Promise<void> {
|
||||
const next = _sessionTransition.catch(() => undefined).then(action)
|
||||
_sessionTransition = next
|
||||
return next
|
||||
}
|
||||
|
||||
export const XuqmSDK = {
|
||||
@ -47,63 +94,17 @@ export const XuqmSDK = {
|
||||
* @param options.debug 是否开启调试日志
|
||||
*/
|
||||
async initialize(options: XuqmInitOptions): Promise<void> {
|
||||
if (isInitialized()) return
|
||||
ensureInitPromise()
|
||||
const baseUrl = options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL
|
||||
const configUrl = `${baseUrl}/api/sdk/config?appKey=${options.appKey}`
|
||||
try {
|
||||
const res = await fetch(configUrl)
|
||||
if (!res.ok) throw new Error(`[XuqmSDK] Platform config request failed: ${res.status}`)
|
||||
const json = await res.json() as Record<string, unknown>
|
||||
const remote = (json.data ?? json) as Record<string, unknown>
|
||||
initConfigFromRemote(options, {
|
||||
apiUrl: remote.apiUrl as string | undefined,
|
||||
imWsUrl: remote.imWsUrl as string | undefined,
|
||||
fileServiceUrl: remote.fileServiceUrl as string | undefined,
|
||||
licenseUrl: remote.licenseUrl as string | undefined,
|
||||
imEnabled: remote.imEnabled as boolean | undefined,
|
||||
pushEnabled: remote.pushEnabled as boolean | undefined,
|
||||
licenseEnabled: remote.licenseEnabled as boolean | undefined,
|
||||
bugCollectApiUrl: remote.bugCollectApiUrl as string | undefined,
|
||||
bugCollectEnabled: remote.bugCollectEnabled as boolean | undefined,
|
||||
}, _signingKey)
|
||||
configureHttp({
|
||||
baseUrl: (remote.apiUrl as string | undefined) ?? baseUrl,
|
||||
debug: options.debug,
|
||||
})
|
||||
markInitialized()
|
||||
} catch (e) {
|
||||
markInitializationFailed(e)
|
||||
throw e
|
||||
if (!options.appKey.trim()) {
|
||||
throw new Error('[XuqmSDK] appKey is required.')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 方式 A(内部):从加密配置文件初始化 SDK。
|
||||
*
|
||||
* 宿主把 .xuqmconfig 文件内容通过 autoInit 模块传入,SDK 自动解密并调用 initialize()。
|
||||
* 不对外暴露(不在 index.ts 中 export)。
|
||||
*
|
||||
* @param encryptedContent 加密配置文件的完整内容(XUQM-CONFIG-V1 或 XUQM-LICENSE-V1 格式)
|
||||
*/
|
||||
async initWithConfigFile(encryptedContent: string, options?: { debug?: boolean }): Promise<void> {
|
||||
if (isInitialized()) return
|
||||
const file = await decryptConfigFile(encryptedContent)
|
||||
// 配置文件解密后包含 appKey 和 platformUrl(字段名 serverUrl 或 baseUrl)
|
||||
const platformUrl = file.serverUrl ?? file.baseUrl ?? undefined
|
||||
// 保存 signingKey 供后续请求签名使用
|
||||
if (file.signingKey) {
|
||||
_signingKey = file.signingKey
|
||||
}
|
||||
await this.initialize({ appKey: file.appKey, platformUrl, debug: options?.debug })
|
||||
await startInitialization(options)
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待初始化完成。在任意子 SDK 内部调用以确保 XuqmSDK 已就绪。
|
||||
*/
|
||||
async awaitInitialization(): Promise<void> {
|
||||
if (isInitialized()) return
|
||||
await ensureInitPromise()
|
||||
await awaitInitialization()
|
||||
},
|
||||
|
||||
getUserId(): string | null {
|
||||
@ -111,11 +112,32 @@ export const XuqmSDK = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户认证核心枢纽。登录成功后调用一次,所有子 SDK(Push、IM、License 等)自动同步状态。
|
||||
* 用户认证核心枢纽。登录成功后调用一次,所有已安装的扩展 SDK 自动同步状态。
|
||||
* 登出时传 null,触发所有子 SDK 登出。
|
||||
*/
|
||||
setUserInfo(info: XuqmUserInfo | null): void {
|
||||
setCommonUserInfo(info)
|
||||
async login(options: XuqmLoginOptions): Promise<void> {
|
||||
const userId = options.userId.trim()
|
||||
if (!userId) throw new Error('[XuqmSDK] userId is required.')
|
||||
const accessToken = options.accessToken?.trim() || undefined
|
||||
const normalized = { ...options, accessToken, userId }
|
||||
const nextSessionKey = JSON.stringify(normalized)
|
||||
await enqueueSessionTransition(async () => {
|
||||
await awaitInitialization()
|
||||
if (_activeSessionKey === nextSessionKey && getCommonUserInfo()) return
|
||||
if (accessToken) await _saveAccessToken(accessToken)
|
||||
else await _clearAccessToken()
|
||||
const { accessToken: _accessToken, ...userInfo } = normalized
|
||||
await _setUserInfo(userInfo)
|
||||
_activeSessionKey = nextSessionKey
|
||||
})
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await enqueueSessionTransition(async () => {
|
||||
await _clearAccessToken()
|
||||
if (getCommonUserInfo()) await _setUserInfo(null)
|
||||
_activeSessionKey = null
|
||||
})
|
||||
},
|
||||
|
||||
getUserInfo(): XuqmUserInfo | null {
|
||||
@ -125,9 +147,33 @@ export const XuqmSDK = {
|
||||
|
||||
export async function awaitInitialization(): Promise<void> {
|
||||
if (isInitialized()) return
|
||||
await ensureInitPromise()
|
||||
const pendingInitialization = _configFileInitPromise ?? _initPromise
|
||||
if (!pendingInitialization) {
|
||||
throw new Error(
|
||||
'[XuqmSDK] Automatic initialization did not start. Place config.xuqmconfig in src/assets/config and wrap Metro with withXuqmConfig(), or call XuqmSDK.initialize().',
|
||||
)
|
||||
}
|
||||
await pendingInitialization
|
||||
}
|
||||
|
||||
export function getSigningKey(): string | undefined {
|
||||
return _signingKey
|
||||
/** Internal entry used only by the Metro auto-init module. */
|
||||
export function _initializeFromConfigFile(
|
||||
encryptedContent: string,
|
||||
options?: { debug?: boolean },
|
||||
): Promise<void> {
|
||||
if (_configFileInitPromise) return _configFileInitPromise
|
||||
|
||||
_configFileInitPromise = (async () => {
|
||||
const file = await decryptConfigFile(encryptedContent)
|
||||
const platformUrl = file.serverUrl ?? file.baseUrl ?? undefined
|
||||
await startInitialization(
|
||||
{ appKey: file.appKey, platformUrl, debug: options?.debug },
|
||||
file.signingKey,
|
||||
)
|
||||
})().catch(error => {
|
||||
_configFileInitPromise = null
|
||||
throw error
|
||||
})
|
||||
|
||||
return _configFileInitPromise
|
||||
}
|
||||
|
||||
27
packages/common/src/version.ts
普通文件
27
packages/common/src/version.ts
普通文件
@ -0,0 +1,27 @@
|
||||
import semver from 'semver'
|
||||
|
||||
export function normalizeVersion(version: string): string {
|
||||
const normalized = semver.valid(version.trim())
|
||||
if (!normalized) throw new Error(`[XuqmSDK] Invalid semantic version: ${version}`)
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function compareVersions(left: string, right: string): number {
|
||||
return semver.compare(normalizeVersion(left), normalizeVersion(right))
|
||||
}
|
||||
|
||||
export function isVersionUpgrade(current: string, candidate: string): boolean {
|
||||
return compareVersions(candidate, current) > 0
|
||||
}
|
||||
|
||||
export function satisfiesVersion(version: string, range: string): boolean {
|
||||
return semver.satisfies(normalizeVersion(version), range.trim(), {
|
||||
includePrerelease: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function compatibleMajorRange(minimumVersion: string): string {
|
||||
const minimum = semver.parse(normalizeVersion(minimumVersion))
|
||||
if (!minimum) throw new Error(`[XuqmSDK] Invalid semantic version: ${minimumVersion}`)
|
||||
return `>=${minimum.version} <${minimum.major + 1}.0.0`
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { Alert, Platform, ToastAndroid } from 'react-native'
|
||||
import { getDeviceInfo } from '../device'
|
||||
import { getUserInfo } from '../config'
|
||||
import { _getToken } from '../http'
|
||||
import { _getAccessToken } from '../http'
|
||||
import type { XWebViewConfig } from './types'
|
||||
|
||||
export type XWebViewControllerAPI = {
|
||||
@ -123,7 +123,7 @@ true;`.trim()
|
||||
}
|
||||
|
||||
case 'xuqm.getToken': {
|
||||
const token = await _getToken()
|
||||
const token = await _getAccessToken()
|
||||
respond(ok(token))
|
||||
break
|
||||
}
|
||||
@ -173,7 +173,9 @@ true;`.trim()
|
||||
|
||||
case 'xuqm.scanQRCode': {
|
||||
if (!_scanQRCodeHandler) {
|
||||
respond(fail('scanQRCode not available — call setXWebViewScanQRCodeHandler() in host app'))
|
||||
respond(
|
||||
fail('scanQRCode not available — call setXWebViewScanQRCodeHandler() in host app'),
|
||||
)
|
||||
break
|
||||
}
|
||||
const result = await _scanQRCodeHandler()
|
||||
@ -197,10 +199,7 @@ true;`.trim()
|
||||
let _config: XWebViewConfig = {}
|
||||
let _controller: XWebViewControllerAPI | null = null
|
||||
|
||||
export function openXWebView(
|
||||
navigate: (pluginId: string) => void,
|
||||
config: XWebViewConfig,
|
||||
) {
|
||||
export function openXWebView(navigate: (pluginId: string) => void, config: XWebViewConfig) {
|
||||
_config = { ...config }
|
||||
navigate('xwebview')
|
||||
}
|
||||
@ -209,9 +208,7 @@ export function getXWebViewConfig(): XWebViewConfig {
|
||||
return _config
|
||||
}
|
||||
|
||||
export function setXWebViewController(
|
||||
controller: XWebViewControllerAPI | null,
|
||||
) {
|
||||
export function setXWebViewController(controller: XWebViewControllerAPI | null) {
|
||||
_controller = controller
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { decryptXuqmFile, hmacSha256Base64Url } from '../src/crypto'
|
||||
|
||||
const CONFIG_VECTOR =
|
||||
'XUQM-CONFIG-V1.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.vhdKh3AhEdeftXCdXp9KusVySkrBNAzmdaA747uf1tVWGFnAsJeCLe2mHKk0dxxez8SRTknIbk1UuRibnXY0Gbot1fmkR-jSN2ssMJO0_zQ8dUu8'
|
||||
|
||||
test('hmacSha256Base64Url matches the standard HMAC-SHA256 vector', () => {
|
||||
assert.equal(
|
||||
hmacSha256Base64Url('key', 'The quick brown fox jumps over the lazy dog'),
|
||||
'97yD9DBThCSxMpjmqm-xQ-9NWaFJRhdZl0edvC0aPNg',
|
||||
)
|
||||
})
|
||||
|
||||
test('decryptXuqmFile remains compatible with existing PBKDF2 + AES-256-GCM files', async () => {
|
||||
await assert.doesNotReject(async () => {
|
||||
const result = await decryptXuqmFile(CONFIG_VECTOR)
|
||||
assert.deepEqual(result, {
|
||||
appKey: 'test-app',
|
||||
appName: '兼容向量',
|
||||
signingKey: 'secret',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('decryptXuqmFile does not depend on a React Native TextDecoder polyfill', async () => {
|
||||
const original = globalThis.TextDecoder
|
||||
Object.defineProperty(globalThis, 'TextDecoder', {
|
||||
configurable: true,
|
||||
value: class BrokenTextDecoder {
|
||||
decode(): string {
|
||||
return '[object Uint8Array]'
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await decryptXuqmFile(CONFIG_VECTOR)
|
||||
assert.equal(result.appName, '兼容向量')
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'TextDecoder', {
|
||||
configurable: true,
|
||||
value: original,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('decryptXuqmFile rejects invalid authentication tags', async () => {
|
||||
await assert.rejects(() => decryptXuqmFile(`${CONFIG_VECTOR.slice(0, -1)}A`), /decryption failed/)
|
||||
})
|
||||
@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { expirationStatus, millisecondsUntil, toTimestamp } from '../src/date'
|
||||
|
||||
test('toTimestamp accepts seconds, milliseconds and API date strings', () => {
|
||||
assert.equal(toTimestamp(1_700_000_000), 1_700_000_000_000)
|
||||
assert.equal(toTimestamp(1_700_000_000_000), 1_700_000_000_000)
|
||||
assert.equal(toTimestamp('2026-07-17 12:30:00'), new Date('2026-07-17T12:30:00').getTime())
|
||||
assert.equal(toTimestamp('invalid'), null)
|
||||
})
|
||||
|
||||
test('expirationStatus uses one canonical strict end-time decision', () => {
|
||||
const now = 1_700_000_000_000
|
||||
assert.equal(expirationStatus(null, now), 'missing')
|
||||
assert.equal(expirationStatus(now + 1, now), 'valid')
|
||||
assert.equal(expirationStatus(now, now), 'expired')
|
||||
assert.equal(expirationStatus(now - 1, now), 'expired')
|
||||
})
|
||||
|
||||
test('millisecondsUntil keeps positive and negative durations', () => {
|
||||
assert.equal(millisecondsUntil(2_000, 1_000), 1_000_000)
|
||||
assert.equal(millisecondsUntil(1_000_000_000_000, 1_000_000_000_500), -500)
|
||||
})
|
||||
@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { fileNameFromUrl, parseContentDispositionFileName, sanitizeFileName } from '../src/fileName'
|
||||
|
||||
test('fileNameFromUrl handles encoded names, query strings, and fallbacks', () => {
|
||||
assert.equal(fileNameFromUrl('https://example.test/files/%E6%8A%A5%E5%91%8A.pdf?x=1'), '报告.pdf')
|
||||
assert.equal(fileNameFromUrl('https://example.test/'), 'download')
|
||||
assert.equal(fileNameFromUrl('not a url/file.txt#preview'), 'file.txt')
|
||||
})
|
||||
|
||||
test('parseContentDispositionFileName prefers RFC 5987 and sanitizes names', () => {
|
||||
assert.equal(
|
||||
parseContentDispositionFileName("attachment; filename*=UTF-8''%E6%8A%A5%E5%91%8A.pdf"),
|
||||
'报告.pdf',
|
||||
)
|
||||
assert.equal(parseContentDispositionFileName('attachment; filename="a:b.pdf"'), 'a_b.pdf')
|
||||
assert.equal(parseContentDispositionFileName('inline'), null)
|
||||
assert.equal(sanitizeFileName(' a / b.txt '), 'a _ b.txt')
|
||||
})
|
||||
@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
_notifyInitialized,
|
||||
_registerInitializationHandler,
|
||||
getConfig,
|
||||
initConfigFromRemote,
|
||||
} from '../src/config'
|
||||
|
||||
test('all extensions observe the same initialized config exactly once', async () => {
|
||||
const observed: string[] = []
|
||||
const unregister = _registerInitializationHandler('test-extension', config => {
|
||||
observed.push(`${config.appKey}:${config.apiUrl}`)
|
||||
})
|
||||
|
||||
initConfigFromRemote(
|
||||
{ appKey: 'shared-app' },
|
||||
{
|
||||
apiUrl: 'https://api.example.test',
|
||||
imApiUrl: 'https://im.example.test',
|
||||
},
|
||||
)
|
||||
await _notifyInitialized()
|
||||
|
||||
assert.equal(getConfig().apiUrl, 'https://api.example.test')
|
||||
assert.deepEqual(observed, ['shared-app:https://api.example.test'])
|
||||
unregister()
|
||||
})
|
||||
@ -0,0 +1,56 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
const { withXuqmConfig } = require('../metro')
|
||||
|
||||
test('withXuqmConfig schedules automatic initialization before the app entry', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
|
||||
const configDir = path.join(projectRoot, 'src/assets/config')
|
||||
const existingPrelude = path.join(projectRoot, 'existing-prelude.js')
|
||||
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(configDir, 'app.xuqmconfig'), 'XUQM-CONFIG-V1.test', 'utf8')
|
||||
|
||||
const config = withXuqmConfig({
|
||||
projectRoot,
|
||||
resolver: {},
|
||||
serializer: {
|
||||
getModulesRunBeforeMainModule: () => [existingPrelude],
|
||||
},
|
||||
})
|
||||
|
||||
const preludeModules = config.serializer.getModulesRunBeforeMainModule('index.js')
|
||||
assert.equal(preludeModules[0], existingPrelude)
|
||||
assert.match(preludeModules[1], /packages\/common\/src\/internal\.ts$/)
|
||||
|
||||
const virtualModule = config.resolver.resolveRequest(
|
||||
{ resolveRequest: () => assert.fail('fallback resolver should not run') },
|
||||
'@xuqm/autoinit-config',
|
||||
'android',
|
||||
)
|
||||
assert.equal(virtualModule.type, 'sourceFile')
|
||||
assert.match(fs.readFileSync(virtualModule.filePath, 'utf8'), /XUQM-CONFIG-V1\.test/)
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('withXuqmConfig leaves common-only hosts unchanged without a config file', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
|
||||
const original = {
|
||||
projectRoot,
|
||||
serializer: {
|
||||
getModulesRunBeforeMainModule: () => ['existing-prelude.js'],
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
assert.equal(withXuqmConfig(original), original)
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { _initializeFromConfigFile, awaitInitialization } from '../src/sdk'
|
||||
|
||||
const CONFIG_VECTOR =
|
||||
'XUQM-CONFIG-V1.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.vhdKh3AhEdeftXCdXp9KusVySkrBNAzmdaA747uf1tVWGFnAsJeCLe2mHKk0dxxez8SRTknIbk1UuRibnXY0Gbot1fmkR-jSN2ssMJO0_zQ8dUu8'
|
||||
|
||||
test('awaitInitialization observes config decryption already in progress', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let requestCount = 0
|
||||
globalThis.fetch = async () => {
|
||||
requestCount += 1
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
apiUrl: 'https://api.example.test',
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: { 'content-type': 'application/json' },
|
||||
status: 200,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const autoInitialization = _initializeFromConfigFile(CONFIG_VECTOR, {
|
||||
debug: true,
|
||||
})
|
||||
await assert.doesNotReject(() => awaitInitialization())
|
||||
await autoInitialization
|
||||
assert.equal(requestCount, 1)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
compareVersions,
|
||||
compatibleMajorRange,
|
||||
isVersionUpgrade,
|
||||
satisfiesVersion,
|
||||
} from '../src/version'
|
||||
|
||||
test('semantic version comparison never treats inequality as an upgrade', () => {
|
||||
assert.equal(isVersionUpgrade('1.0.0', '1.0.1'), true)
|
||||
assert.equal(isVersionUpgrade('1.0.1', '1.0.0'), false)
|
||||
assert.equal(compareVersions('1.0.0-alpha.2', '1.0.0-alpha.10'), -1)
|
||||
})
|
||||
|
||||
test('common compatibility is explicit and bounded by the next major', () => {
|
||||
const range = compatibleMajorRange('1.0.0')
|
||||
assert.equal(range, '>=1.0.0 <2.0.0')
|
||||
assert.equal(satisfiesVersion('1.0.2', range), true)
|
||||
assert.equal(satisfiesVersion('2.0.0', range), false)
|
||||
})
|
||||
@ -2,10 +2,6 @@
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-native",
|
||||
"baseUrl": "../../",
|
||||
"paths": {
|
||||
"@react-native-async-storage/async-storage": ["src/shims/async-storage.ts"]
|
||||
},
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
|
||||
@ -5,14 +5,14 @@ XuqmGroup RN SDK IM 模块。提供 WebSocket 实时通信、会话管理、消
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
yarn add @xuqm/rn-im @nozbe/watermelondb
|
||||
pnpm add @xuqm/rn-common @xuqm/rn-im @nozbe/watermelondb
|
||||
```
|
||||
|
||||
Peer dependencies:`@nozbe/watermelondb >= 0.27.0`,`react-native >= 0.76`
|
||||
|
||||
## 使用
|
||||
|
||||
**无需手动初始化。** `XuqmSDK.setUserInfo({ userId, userSig })` 被调用时,SDK 自动完成 IM 登录。
|
||||
IM 不提供独立初始化入口。宿主通过配置文件完成一次公共初始化,并在应用登录后只调用一次 `XuqmSDK.login({userId, userSig})`;IM 自动接收共享会话并连接。
|
||||
|
||||
```ts
|
||||
import { ImSDK } from '@xuqm/rn-im'
|
||||
@ -36,7 +36,7 @@ ImSDK.addListener({
|
||||
|
||||
| API | 说明 |
|
||||
|-----|------|
|
||||
| `ImSDK.login(userId, userSig)` | 手动登录(通常不需要直接调用) |
|
||||
| `ImSDK.login(userId, userSig)` | 底层连接入口;宿主业务不得直接调用 |
|
||||
| `ImSDK.refreshToken(userSig)` | 刷新 userSig(过期时调用) |
|
||||
| `ImSDK.disconnect()` | 断开连接 |
|
||||
| `ImSDK.reconnect()` | 重新连接 |
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xuqm/rn-im",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0-alpha.1",
|
||||
"description": "XuqmGroup RN SDK — IM module (single chat, group chat, 13 message types)",
|
||||
"license": "UNLICENSED",
|
||||
"main": "src/index.ts",
|
||||
@ -21,9 +21,10 @@
|
||||
"peerDependencies": {
|
||||
"@nozbe/watermelondb": ">=0.27.0",
|
||||
"react-native": ">=0.76.0",
|
||||
"@xuqm/rn-common": ">=0.2.2"
|
||||
"@xuqm/rn-common": "workspace:>=0.6.0-alpha.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
"@nozbe/watermelondb": "^0.28.0",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { _getToken, getConfig, getUserId } from '@xuqm/rn-common'
|
||||
import { getConfig, getUserId } from '@xuqm/rn-common'
|
||||
import type { ImEventListener, ImMessage, SendMessageParams } from './types'
|
||||
|
||||
interface StompFrame {
|
||||
@ -31,9 +31,6 @@ export class ImClient {
|
||||
this.activeWsUrl = this.wsUrl ?? getConfig().imWsUrl
|
||||
this.activeToken = this.token ?? null
|
||||
this.activeAppKey = this.appKey ?? getConfig().appKey
|
||||
if (!this.activeToken) {
|
||||
this.activeToken = await _getToken()
|
||||
}
|
||||
if (!this.activeWsUrl) {
|
||||
this.listeners.forEach(listener => listener.onError?.('IM websocket URL not configured'))
|
||||
return
|
||||
@ -175,19 +172,7 @@ export class ImClient {
|
||||
this.ws = new WebSocket(this.activeWsUrl)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
if (!this.activeToken) {
|
||||
void _getToken().then((token: string | null) => {
|
||||
this.activeToken = token
|
||||
if (token) {
|
||||
this.sendFrame('CONNECT', {
|
||||
'accept-version': '1.2',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'heart-beat': '10000,10000',
|
||||
})
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!this.activeToken) return
|
||||
this.sendFrame('CONNECT', {
|
||||
'accept-version': '1.2',
|
||||
Authorization: `Bearer ${this.activeToken}`,
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { apiRequest, _getToken, _saveToken, getConfig, getDeviceInfo, setUserId as setCommonUserId, getUserId as getCommonUserId, _registerUserInfoHandler } from '@xuqm/rn-common'
|
||||
import {
|
||||
apiRequest,
|
||||
getConfig,
|
||||
getDeviceInfo,
|
||||
getUserId as getCommonUserId,
|
||||
getUserInfo,
|
||||
} from '@xuqm/rn-common'
|
||||
import { _registerUserInfoHandler } from '@xuqm/rn-common/internal'
|
||||
import type { XuqmUserInfo } from '@xuqm/rn-common'
|
||||
import { ImClient } from './ImClient'
|
||||
import { ImDatabase } from './db/ImDatabase'
|
||||
@ -297,20 +304,14 @@ function resolveMimeTypeFromUri(uri: string, fallback: string): string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
export const ImSDK = {
|
||||
/**
|
||||
* Login to IM service with a pre-obtained userSig and open the WebSocket connection.
|
||||
*/
|
||||
async login(userId: string, userSig: string): Promise<void> {
|
||||
async function connectSession(userId: string, userSig: string): Promise<void> {
|
||||
const config = getConfig()
|
||||
if (client && _currentUserId === userId && _currentUserSig === userSig) {
|
||||
return
|
||||
}
|
||||
client?.disconnect()
|
||||
await _saveToken(userSig)
|
||||
_currentUserId = userId
|
||||
_currentUserSig = userSig
|
||||
setCommonUserId(userId)
|
||||
|
||||
ImDatabase.init(buildDbName(config.appKey, userId))
|
||||
|
||||
@ -321,13 +322,17 @@ export const ImSDK = {
|
||||
},
|
||||
})
|
||||
await client.connect()
|
||||
},
|
||||
}
|
||||
|
||||
export const ImSDK = {
|
||||
|
||||
async reconnect(): Promise<void> {
|
||||
const config = getConfig()
|
||||
const token = await _getToken()
|
||||
if (!token) throw new Error('[ImSDK] No active session — call login() first.')
|
||||
client = new ImClient(config.imWsUrl, token, config.appKey)
|
||||
const session = getUserInfo()
|
||||
if (!session?.userSig) {
|
||||
throw new Error('[ImSDK] No IM credential — call XuqmSDK.login() with userSig first.')
|
||||
}
|
||||
client = new ImClient(config.imWsUrl, session.userSig, config.appKey)
|
||||
await client.connect()
|
||||
},
|
||||
|
||||
@ -1493,34 +1498,20 @@ export const ImSDK = {
|
||||
client = null
|
||||
_currentUserId = null
|
||||
_currentUserSig = null
|
||||
setCommonUserId(null)
|
||||
},
|
||||
|
||||
/**
|
||||
* 刷新 IM userSig(过期时调用)。
|
||||
*/
|
||||
async refreshToken(userSig: string): Promise<void> {
|
||||
if (!_currentUserId) throw new Error('[ImSDK] Not logged in — call setUserInfo first.')
|
||||
await ImSDK.login(_currentUserId, userSig)
|
||||
},
|
||||
}
|
||||
|
||||
// ─── 订阅 XuqmSDK.setUserInfo ──────────────────────────────────────────────────
|
||||
// ─── 订阅 XuqmSDK.login/logout ─────────────────────────────────────────────────
|
||||
|
||||
_registerUserInfoHandler(async (info: XuqmUserInfo | null) => {
|
||||
_registerUserInfoHandler('rn-im', async (info: XuqmUserInfo | null) => {
|
||||
if (!info) {
|
||||
ImSDK.disconnect()
|
||||
return
|
||||
}
|
||||
// 仅当 IM 服务已开通且 userSig 存在时自动登录
|
||||
if (!info.userSig) return
|
||||
try {
|
||||
const { getConfig, isInitialized } = await import('@xuqm/rn-common')
|
||||
if (!isInitialized()) return
|
||||
const config = getConfig()
|
||||
if (!config.imEnabled) return
|
||||
} catch {
|
||||
const config = getConfig()
|
||||
if (!config.imEnabled || !info.userSig) {
|
||||
ImSDK.disconnect()
|
||||
return
|
||||
}
|
||||
await ImSDK.login(info.userId, info.userSig).catch(() => {})
|
||||
await connectSession(info.userId, info.userSig)
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { getConfig, _getToken } from '@xuqm/rn-common'
|
||||
import { getConfig, getUserInfo } from '@xuqm/rn-common'
|
||||
|
||||
export interface UploadResult {
|
||||
url: string
|
||||
@ -22,7 +22,7 @@ export async function uploadFile(
|
||||
thumbnailUri?: string,
|
||||
): Promise<UploadResult> {
|
||||
const config = getConfig()
|
||||
const token = await _getToken()
|
||||
const token = getUserInfo()?.userSig
|
||||
if (!token) {
|
||||
throw new Error('[uploadFile] No active session — call XuqmSDK.login() first.')
|
||||
}
|
||||
|
||||
9
packages/im/tsconfig.json
普通文件
9
packages/im/tsconfig.json
普通文件
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
# @xuqm/rn-license
|
||||
|
||||
XuqmGroup RN SDK 证书授权模块。提供设备 License 验证能力。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
yarn add @xuqm/rn-license react-native-quick-crypto
|
||||
```
|
||||
|
||||
Peer dependencies:`react-native >= 0.76`,`@react-native-async-storage/async-storage >= 1.21.0`,`react-native-quick-crypto >= 0.7.0`
|
||||
|
||||
## 使用
|
||||
|
||||
**无需独立初始化。** 内部自动调用 `awaitInitialization()` 等待 `XuqmSDK` 就绪。
|
||||
|
||||
```ts
|
||||
import { checkLicense, getStatus, clear } from '@xuqm/rn-license'
|
||||
|
||||
// 验证 License
|
||||
const result = await checkLicense()
|
||||
if (result.type === 'success') {
|
||||
console.log('License 通过:', result.reason)
|
||||
} else {
|
||||
console.error('License 失败:', result.message)
|
||||
}
|
||||
|
||||
// 获取状态
|
||||
const status = await getStatus() // 'ok' | 'denied' | 'unknown'
|
||||
|
||||
// 清除本地缓存(调试用)
|
||||
await clear()
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| API | 说明 |
|
||||
|-----|------|
|
||||
| `checkLicense(userInfo?)` | 验证设备 License |
|
||||
| `getStatus()` | 获取当前 License 状态 |
|
||||
| `getDeviceId()` | 获取设备 ID |
|
||||
| `clear()` | 清除本地 License 缓存 |
|
||||
|
||||
## 类型
|
||||
|
||||
```ts
|
||||
interface LicenseUserInfo {
|
||||
userId?: string
|
||||
userSig?: string
|
||||
}
|
||||
|
||||
type LicenseStatus = 'ok' | 'denied' | 'unknown'
|
||||
|
||||
interface LicenseResult {
|
||||
type: 'success' | 'denied' | 'error'
|
||||
reason?: string
|
||||
message?: string
|
||||
}
|
||||
```
|
||||
@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "@xuqm/rn-license",
|
||||
"version": "0.3.0",
|
||||
"description": "XuqmGroup RN SDK — License module (device registration & verification)",
|
||||
"license": "UNLICENSED",
|
||||
"main": "src/index.ts",
|
||||
"react-native": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {},
|
||||
"peerDependencies": {
|
||||
"react-native": ">=0.76.0",
|
||||
"@react-native-async-storage/async-storage": ">=1.21.0",
|
||||
"react-native-quick-crypto": ">=0.7.0",
|
||||
"@xuqm/rn-common": ">=0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@ -1,69 +0,0 @@
|
||||
import type { LicenseFile } from './models'
|
||||
|
||||
const MAGIC_LICENSE = 'XUQM-LICENSE-V1'
|
||||
const MAGIC_CONFIG = 'XUQM-CONFIG-V1'
|
||||
const SUPPORTED_MAGICS = [MAGIC_LICENSE, MAGIC_CONFIG]
|
||||
const PASSPHRASE = 'xuqm-license-file-v1.2026.internal'
|
||||
const PBKDF2_ITERATIONS = 120_000
|
||||
|
||||
// Accesses react-native-quick-crypto's SubtleCrypto implementation (peer dependency).
|
||||
// Avoids a top-level import so that tree-shaking works and missing dep throws at call-time.
|
||||
function getSubtle(): SubtleCrypto {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const qc = require('react-native-quick-crypto') as { subtle?: SubtleCrypto; default?: { subtle?: SubtleCrypto } }
|
||||
const subtle = qc.subtle ?? qc.default?.subtle
|
||||
if (!subtle) throw new Error('[XuqmLicense] react-native-quick-crypto not available')
|
||||
return subtle
|
||||
}
|
||||
|
||||
function base64UrlDecode(s: string): Uint8Array {
|
||||
const padded = s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4)
|
||||
// atob is available on Hermes (React Native 0.71+)
|
||||
const binary = atob(padded)
|
||||
return Uint8Array.from({ length: binary.length }, (_, i) => binary.charCodeAt(i))
|
||||
}
|
||||
|
||||
async function deriveKey(salt: Uint8Array): Promise<CryptoKey> {
|
||||
const subtle = getSubtle()
|
||||
const passphraseKey = await subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(PASSPHRASE),
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveKey'],
|
||||
)
|
||||
return subtle.deriveKey(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ name: 'PBKDF2', salt: salt as any, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
||||
passphraseKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['decrypt'],
|
||||
)
|
||||
}
|
||||
|
||||
// Decrypts license/config file content.
|
||||
// Supported formats:
|
||||
// XUQM-LICENSE-V1.{salt}.{iv}.{ciphertext}
|
||||
// XUQM-CONFIG-V1.{salt}.{iv}.{ciphertext}
|
||||
// Ciphertext includes the 16-byte GCM tag appended (same as Android JCE output).
|
||||
export async function decryptLicenseFile(content: string): Promise<LicenseFile> {
|
||||
const parts = content.trim().split('.')
|
||||
if (parts.length !== 4 || !SUPPORTED_MAGICS.includes(parts[0])) {
|
||||
throw new Error('[XuqmLicense] Invalid license/config file format')
|
||||
}
|
||||
const salt = base64UrlDecode(parts[1])
|
||||
const iv = base64UrlDecode(parts[2])
|
||||
const ciphertext = base64UrlDecode(parts[3])
|
||||
|
||||
const key = await deriveKey(salt)
|
||||
const subtle = getSubtle()
|
||||
// Web Crypto AES-GCM decrypt expects ciphertext with tag appended — matches Android JCE output
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const plainBuffer = await subtle.decrypt({ name: 'AES-GCM', iv } as any, key, ciphertext as any)
|
||||
const json = new TextDecoder().decode(plainBuffer)
|
||||
return JSON.parse(json) as LicenseFile
|
||||
}
|
||||
|
||||
/** Alias for decryptLicenseFile — makes intent clearer when reading .xuqmconfig files. */
|
||||
export const decryptConfigFile = decryptLicenseFile
|
||||
@ -1,2 +0,0 @@
|
||||
export { checkLicense, getStatus, getDeviceId, clear } from './license'
|
||||
export type { LicenseUserInfo, LicenseStatus, LicenseResult } from './models'
|
||||
@ -1,105 +0,0 @@
|
||||
import { Platform } from 'react-native'
|
||||
import { getDeviceId as getCommonDeviceId, getDeviceInfo, awaitInitialization, getConfig, getUserId, apiRequest } from '@xuqm/rn-common'
|
||||
import * as store from './store'
|
||||
import type { LicenseResult, LicenseStatus, LicenseUserInfo, RegisterRequest, RegisterResponse, VerifyRequest, VerifyResponse } from './models'
|
||||
|
||||
const CACHE_WINDOW_MS = 10 * 60 * 1000
|
||||
const STATUS_OK = 'ok'
|
||||
const STATUS_DENIED = 'denied'
|
||||
|
||||
let _cachedStatus: string | null = null
|
||||
let _cachedStatusTime = 0
|
||||
|
||||
export async function checkLicense(userInfo?: LicenseUserInfo): Promise<LicenseResult> {
|
||||
// 等待 XuqmSDK 初始化完成,使用公共配置
|
||||
await awaitInitialization()
|
||||
const config = getConfig()
|
||||
const { licenseUrl, appKey } = config
|
||||
const baseUrl = licenseUrl.endsWith('/') ? licenseUrl : licenseUrl + '/'
|
||||
|
||||
// 内存缓存
|
||||
if (_cachedStatus === STATUS_OK && Date.now() - _cachedStatusTime < CACHE_WINDOW_MS) {
|
||||
return { type: 'success', reason: 'Cached' }
|
||||
}
|
||||
// 持久化缓存
|
||||
const persistedStatus = await store.getStatus()
|
||||
const persistedTime = await store.getStatusTime()
|
||||
if (persistedStatus === STATUS_OK && Date.now() - persistedTime < CACHE_WINDOW_MS) {
|
||||
_cachedStatus = STATUS_OK
|
||||
_cachedStatusTime = persistedTime
|
||||
return { type: 'success', reason: 'Cached' }
|
||||
}
|
||||
|
||||
const deviceId = await _getOrCreateDeviceId()
|
||||
const deviceInfo = await getDeviceInfo()
|
||||
|
||||
try {
|
||||
const storedToken = await store.getToken()
|
||||
if (storedToken) {
|
||||
const verifyReq: VerifyRequest = { appKey, deviceId, token: storedToken, userInfo }
|
||||
const verifyResp = await _post<VerifyResponse>(`${baseUrl}api/license/verify`, verifyReq)
|
||||
if (verifyResp.valid) {
|
||||
await _persistStatus(STATUS_OK)
|
||||
return { type: 'success', reason: 'Verified' }
|
||||
}
|
||||
await store.setToken(null)
|
||||
}
|
||||
|
||||
const userId = getUserId()
|
||||
const regReq: RegisterRequest = {
|
||||
appKey,
|
||||
deviceId,
|
||||
deviceModel: deviceInfo.model,
|
||||
deviceVendor: Platform.OS === 'ios' ? 'Apple' : deviceInfo.brand,
|
||||
osVersion: `${Platform.OS === 'ios' ? 'iOS' : 'Android'} ${deviceInfo.osVersion}`,
|
||||
userInfo: userInfo ?? (userId ? { userId } : undefined),
|
||||
}
|
||||
const regResp = await _post<RegisterResponse>(`${baseUrl}api/license/register`, regReq)
|
||||
if (regResp.success && regResp.token) {
|
||||
await store.setToken(regResp.token)
|
||||
await _persistStatus(STATUS_OK)
|
||||
return { type: 'success', reason: 'Registered' }
|
||||
}
|
||||
await _persistStatus(STATUS_DENIED)
|
||||
return { type: 'error', message: regResp.message ?? 'Registration denied' }
|
||||
} catch (e) {
|
||||
if (persistedStatus === STATUS_OK) return { type: 'success', reason: 'Offline - cached ok' }
|
||||
return { type: 'error', message: e instanceof Error ? e.message : 'Network error' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStatus(): Promise<LicenseStatus> {
|
||||
const s = _cachedStatus ?? (await store.getStatus())
|
||||
if (s === STATUS_OK) return 'ok'
|
||||
if (s === STATUS_DENIED) return 'denied'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export async function getDeviceId(): Promise<string> {
|
||||
return _getOrCreateDeviceId()
|
||||
}
|
||||
|
||||
export async function clear(): Promise<void> {
|
||||
_cachedStatus = null
|
||||
_cachedStatusTime = 0
|
||||
await store.clearAll()
|
||||
}
|
||||
|
||||
async function _getOrCreateDeviceId(): Promise<string> {
|
||||
const existing = await store.getDeviceId()
|
||||
if (existing) return existing
|
||||
const id = await getCommonDeviceId()
|
||||
await store.setDeviceId(id)
|
||||
return id
|
||||
}
|
||||
|
||||
async function _persistStatus(status: string): Promise<void> {
|
||||
const now = Date.now()
|
||||
_cachedStatus = status
|
||||
_cachedStatusTime = now
|
||||
await Promise.all([store.setStatus(status), store.setStatusTime(now)])
|
||||
}
|
||||
|
||||
async function _post<T>(url: string, body: unknown): Promise<T> {
|
||||
return apiRequest<T>(url, { method: 'POST', body })
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
export interface LicenseFile {
|
||||
appKey: string
|
||||
appName?: string
|
||||
companyName?: string
|
||||
baseUrl?: string
|
||||
issuedAt?: string
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
export interface LicenseUserInfo {
|
||||
userId?: string
|
||||
name?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
appKey: string
|
||||
deviceId: string
|
||||
deviceName?: string
|
||||
deviceModel: string
|
||||
deviceVendor: string
|
||||
osVersion: string
|
||||
userInfo?: LicenseUserInfo
|
||||
}
|
||||
|
||||
export interface VerifyRequest {
|
||||
appKey: string
|
||||
deviceId: string
|
||||
token: string
|
||||
userInfo?: LicenseUserInfo
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
success: boolean
|
||||
token?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface VerifyResponse {
|
||||
valid: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type LicenseStatus = 'ok' | 'denied' | 'unknown'
|
||||
|
||||
export type LicenseResult =
|
||||
| { type: 'success'; reason: string }
|
||||
| { type: 'error'; message: string }
|
||||
@ -1,54 +0,0 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
const KEY_TOKEN = '@xuqm:license:token'
|
||||
const KEY_DEVICE_ID = '@xuqm:license:deviceId'
|
||||
const KEY_STATUS = '@xuqm:license:status'
|
||||
const KEY_STATUS_TIME = '@xuqm:license:statusTime'
|
||||
const KEY_APP_KEY = '@xuqm:license:appKey'
|
||||
|
||||
export async function getToken(): Promise<string | null> {
|
||||
return AsyncStorage.getItem(KEY_TOKEN)
|
||||
}
|
||||
|
||||
export async function setToken(token: string | null): Promise<void> {
|
||||
if (token == null) { await AsyncStorage.removeItem(KEY_TOKEN); return }
|
||||
await AsyncStorage.setItem(KEY_TOKEN, token)
|
||||
}
|
||||
|
||||
export async function getDeviceId(): Promise<string | null> {
|
||||
return AsyncStorage.getItem(KEY_DEVICE_ID)
|
||||
}
|
||||
|
||||
export async function setDeviceId(id: string): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY_DEVICE_ID, id)
|
||||
}
|
||||
|
||||
export async function getStatus(): Promise<string | null> {
|
||||
return AsyncStorage.getItem(KEY_STATUS)
|
||||
}
|
||||
|
||||
export async function setStatus(status: string | null): Promise<void> {
|
||||
if (status == null) { await AsyncStorage.removeItem(KEY_STATUS); return }
|
||||
await AsyncStorage.setItem(KEY_STATUS, status)
|
||||
}
|
||||
|
||||
export async function getStatusTime(): Promise<number> {
|
||||
const v = await AsyncStorage.getItem(KEY_STATUS_TIME)
|
||||
return v ? Number(v) : 0
|
||||
}
|
||||
|
||||
export async function setStatusTime(ms: number): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY_STATUS_TIME, String(ms))
|
||||
}
|
||||
|
||||
export async function getStoredAppKey(): Promise<string | null> {
|
||||
return AsyncStorage.getItem(KEY_APP_KEY)
|
||||
}
|
||||
|
||||
export async function setStoredAppKey(appKey: string): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY_APP_KEY, appKey)
|
||||
}
|
||||
|
||||
export async function clearAll(): Promise<void> {
|
||||
await AsyncStorage.multiRemove([KEY_TOKEN, KEY_DEVICE_ID, KEY_STATUS, KEY_STATUS_TIME, KEY_APP_KEY])
|
||||
}
|
||||
@ -5,14 +5,14 @@ XuqmGroup RN SDK 推送模块。自动检测设备厂商并完成推送 token
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
yarn add @xuqm/rn-push
|
||||
pnpm add @xuqm/rn-common @xuqm/rn-push
|
||||
```
|
||||
|
||||
Peer dependencies:`react-native >= 0.76`
|
||||
|
||||
## 使用
|
||||
|
||||
**无需手动初始化。** `XuqmSDK.setUserInfo(info)` 被调用时,SDK 自动完成:
|
||||
Push 不提供独立初始化或登录入口。宿主调用一次 `XuqmSDK.login()` 后,SDK 自动完成:
|
||||
1. 检测设备厂商(华为/小米/OPPO/vivo/荣耀/FCM/APNs)
|
||||
2. 从平台获取该厂商推送配置
|
||||
3. 调用厂商 SDK 完成设备注册
|
||||
@ -21,7 +21,7 @@ Peer dependencies:`react-native >= 0.76`
|
||||
```ts
|
||||
import { PushSDK } from '@xuqm/rn-push'
|
||||
|
||||
// 通常不需要手动调用,setUserInfo 会自动触发
|
||||
// 通常不需要手动调用,XuqmSDK.login() 会自动触发
|
||||
// 设置离线推送开关
|
||||
await PushSDK.setOfflinePushEnabled(false)
|
||||
|
||||
@ -31,7 +31,7 @@ await PushSDK.setQuietHours('22:00', '08:00')
|
||||
// 清除免打扰
|
||||
await PushSDK.clearQuietHours()
|
||||
|
||||
// 主动登出(通常不需要,setUserInfo(null) 自动触发)
|
||||
// 主动登出(通常不需要,XuqmSDK.logout() 自动触发)
|
||||
await PushSDK.logout()
|
||||
```
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ import com.xuqm.sdk.push.PushSDK
|
||||
* 设计要点(见 docs / [[rnsdk-prefer-pure-rn]]):
|
||||
* - SDK 初始化由 sdk-core 的 XuqmMergedProvider(ContentProvider) 在进程启动时自动完成(读 assets/config 下的 .xuqmconfig),
|
||||
* RN 侧无需调用 initialize。
|
||||
* - 用户态由唯一的 setUserInfo 驱动:JS XuqmSDK.setUserInfo() → 本模块 setUserInfo() → 原生 XuqmSDK.setUserInfo(),
|
||||
* - 用户态由 common 的统一登录态驱动:共享适配器 → 本模块 setUserInfo() → 原生 XuqmSDK.setUserInfo(),
|
||||
* 原生 notifyOptionalModules("onSdkLogin") 自动触发 PushSDK.onSdkLogin(),完成厂商检测/token/绑定。
|
||||
* - 不在 RN 侧做厂商检测/token/注册,避免与原生重复。
|
||||
*/
|
||||
@ -28,7 +28,7 @@ class XuqmPushModule(reactContext: ReactApplicationContext) :
|
||||
const val NAME = "XuqmPushModule"
|
||||
}
|
||||
|
||||
/** 登录/登出统一入口:转发到原生 XuqmSDK.setUserInfo,原生自动扇出到 push 等模块。 */
|
||||
/** common 内部登录/登出入口:转发到原生 XuqmSDK.setUserInfo,原生自动扇出到 push 等模块。 */
|
||||
@ReactMethod
|
||||
fun setUserInfo(info: ReadableMap?, promise: Promise) {
|
||||
try {
|
||||
|
||||
@ -6,7 +6,7 @@ import XuqmCoreSDK
|
||||
/**
|
||||
* push 桥接(iOS):薄封装原生 XuqmCoreSDK。
|
||||
*
|
||||
* 与 Android 一致的单一入口:JS XuqmSDK.setUserInfo() → 本模块 setUserInfo() →
|
||||
* 与 Android 一致的单一入口:common 共享登录态适配器 → 本模块 setUserInfo() →
|
||||
* XuqmSDK.shared.setUserInfo()/clearUserInfo()。APNs token 由 XuqmPushSDK 的
|
||||
* AppDelegate swizzle 自动转发到 XuqmSDK.registerDeviceToken,在 userId 就绪后完成绑定。
|
||||
*
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xuqm/rn-push",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0-alpha.1",
|
||||
"description": "XuqmGroup RN SDK — Push module (device token registration)",
|
||||
"license": "UNLICENSED",
|
||||
"main": "src/index.ts",
|
||||
@ -19,9 +19,9 @@
|
||||
"dependencies": {},
|
||||
"peerDependencies": {
|
||||
"react-native": ">=0.76.0",
|
||||
"@xuqm/rn-common": ">=0.2.2"
|
||||
"@xuqm/rn-common": "workspace:>=0.6.0-alpha.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { NativeModules } from 'react-native'
|
||||
import type { XuqmUserInfo } from '@xuqm/rn-common'
|
||||
|
||||
interface XuqmPushNative {
|
||||
/** 转发到原生 XuqmSDK.setUserInfo;原生自动完成厂商检测/token/绑定。传 null 登出。 */
|
||||
/** 由共享登录态适配器转发到原生会话入口;原生自动完成厂商检测、token 与绑定。传 null 登出。 */
|
||||
setUserInfo(info: Pick<XuqmUserInfo, 'userId' | 'userSig' | 'name' | 'phone'> | null): Promise<void>
|
||||
setOfflinePushEnabled(enabled: boolean): Promise<void>
|
||||
setQuietHours(start: string, end: string): Promise<void>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { _registerUserInfoHandler } from '@xuqm/rn-common'
|
||||
import { _registerUserInfoHandler } from '@xuqm/rn-common/internal'
|
||||
import type { PushVendor, XuqmUserInfo } from '@xuqm/rn-common'
|
||||
import {
|
||||
nativeSetUserInfo,
|
||||
@ -9,11 +9,11 @@ import {
|
||||
|
||||
export type { PushVendor }
|
||||
|
||||
// ─── 订阅 XuqmSDK.setUserInfo ──────────────────────────────────────────────────
|
||||
// 唯一入口:登录/登出都只转发到原生 XuqmSDK.setUserInfo,由原生 sdk-push 自动完成
|
||||
// ─── 订阅 XuqmSDK.login/logout ─────────────────────────────────────────────────
|
||||
// 唯一入口:公共会话变化转发到原生,原生自动完成厂商检测、token 获取与绑定。
|
||||
// 厂商检测 + token 获取 + 绑定到服务端(/api/push/register)。RN 侧不再做 vendor/token/register。
|
||||
_registerUserInfoHandler(async (info: XuqmUserInfo | null) => {
|
||||
await nativeSetUserInfo(info).catch(() => {})
|
||||
_registerUserInfoHandler('rn-push', async (info: XuqmUserInfo | null) => {
|
||||
await nativeSetUserInfo(info)
|
||||
})
|
||||
|
||||
// ─── PushSDK 公开 API ──────────────────────────────────────────────────────────
|
||||
@ -33,9 +33,4 @@ export const PushSDK = {
|
||||
async clearQuietHours(): Promise<void> {
|
||||
await nativeClearQuietHours()
|
||||
},
|
||||
|
||||
/** 登出推送(通常无需手动调用,XuqmSDK.setUserInfo(null) 会自动触发)。 */
|
||||
async logout(): Promise<void> {
|
||||
await nativeSetUserInfo(null)
|
||||
},
|
||||
}
|
||||
|
||||
8
packages/push/tsconfig.json
普通文件
8
packages/push/tsconfig.json
普通文件
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@ -1,142 +1,95 @@
|
||||
# @xuqm/rn-update
|
||||
|
||||
XuqmGroup RN SDK 更新模块。提供 App 整包更新检查和 RN 插件热更新能力。
|
||||
Android 整包更新与 RN 多 Bundle 插件运行时。它依赖 `@xuqm/rn-common` 的唯一配置和会话,不提供第二套初始化、登录、网络或文件实现。
|
||||
|
||||
## 安装
|
||||
当前发布范围为 Android。iOS 插件事务实现完成并通过验证前,不作为本包的已支持能力发布。
|
||||
|
||||
## 最小接入
|
||||
|
||||
```bash
|
||||
yarn add @xuqm/rn-update @react-native-async-storage/async-storage
|
||||
pnpm add @xuqm/rn-common @xuqm/rn-update \
|
||||
@react-native-async-storage/async-storage
|
||||
pnpm exec xuqm-rn init
|
||||
pnpm run xuqm:doctor
|
||||
```
|
||||
|
||||
Peer dependencies:`react-native >= 0.76`,`@react-native-async-storage/async-storage >= 1.21.0`
|
||||
`xuqm-rn init` 生成 schema v3 `xuqm.config.json` 并补充最少脚本。版本只有两条明确来源:
|
||||
|
||||
## 快速开始
|
||||
- 完整 App 版本来自宿主 `package.json.version`,医网信新包从 `8.0.0` 开始。
|
||||
- 插件默认版本来自 `xuqm.config.json.pluginVersion`,从 `1.0.0` 开始;仅独立发布的模块覆盖 `moduleVersion`。
|
||||
|
||||
app/buz 使用 `commonVersionRange` 声明 SemVer 兼容范围,并使用 `minNativeApiLevel` 声明最低原生能力。Bundle 只接受 SHA-256,不使用 MD5。
|
||||
|
||||
## 开发与打包
|
||||
|
||||
- `pnpm android`:安装 Debug 包、连接 Metro、支持热刷新。
|
||||
- `pnpm release:android`:重新构建并内嵌全部 startup/common/app/buz 后生成 Release AAB。
|
||||
- `pnpm release:android -- --apk`:生成包含同一批插件的 Release APK。
|
||||
- `pnpm publish:android`:上传插件产物;SDK npm 制品发布只能通过 Jenkins。
|
||||
|
||||
Android 宿主只应用一次 SDK 脚本:
|
||||
|
||||
```groovy
|
||||
apply from: file("../../node_modules/@xuqm/rn-update/android/xuqm-bundles.gradle")
|
||||
```
|
||||
|
||||
该脚本关闭 React Native 默认单体 Bundle,避免宿主同时维护两套启动产物。
|
||||
|
||||
## 运行时规则
|
||||
|
||||
```ts
|
||||
import { UpdateSDK } from '@xuqm/rn-update'
|
||||
import { UpdateSDK, XuqmRuntime } from '@xuqm/rn-update'
|
||||
|
||||
// 注册插件
|
||||
UpdateSDK.registerPlugins([
|
||||
{ moduleId: 'szyx' },
|
||||
{ moduleId: 'miniapp' },
|
||||
])
|
||||
|
||||
// 注入宿主 BundleRuntime
|
||||
UpdateSDK.setBundleCallbacks({
|
||||
writeBundle: writeBundleFile,
|
||||
reloadBundle: loadBundle,
|
||||
XuqmRuntime.configure({
|
||||
plugins: [
|
||||
{
|
||||
moduleId: 'common',
|
||||
type: 'common',
|
||||
appVersionRange: '>=8.0.0 <9.0.0',
|
||||
},
|
||||
{
|
||||
moduleId: 'app',
|
||||
type: 'app',
|
||||
appVersionRange: '>=8.0.0 <9.0.0',
|
||||
commonVersionRange: '>=1.0.0 <2.0.0',
|
||||
},
|
||||
{
|
||||
moduleId: 'prescription',
|
||||
type: 'buz',
|
||||
appVersionRange: '>=8.0.0 <9.0.0',
|
||||
commonVersionRange: '>=1.0.0 <2.0.0',
|
||||
},
|
||||
],
|
||||
context,
|
||||
loadBundle,
|
||||
})
|
||||
|
||||
// 检查 App 更新
|
||||
const info = await UpdateSDK.checkAppUpdate()
|
||||
if (info.needsUpdate) {
|
||||
await UpdateSDK.downloadAndInstallApk(info.downloadUrl)
|
||||
}
|
||||
|
||||
// 检查插件更新
|
||||
const pluginInfo = await UpdateSDK.checkPluginUpdate('szyx')
|
||||
if (pluginInfo.needsUpdate) {
|
||||
await UpdateSDK.updatePlugin('szyx', { onProgress: setProgress })
|
||||
}
|
||||
const startup = await XuqmRuntime.start()
|
||||
```
|
||||
|
||||
## API
|
||||
`start()` 的固定顺序是:准备未确认事务 → 检查完整 App → 没有整包更新时检查 app+common。返回 `kind: 'app'` 时由宿主显示整包更新 UI;返回 `kind: 'plugins'` 且 `requiresColdStart` 时重启后加载并确认。
|
||||
|
||||
### 插件注册
|
||||
|
||||
| API | 说明 |
|
||||
|-----|------|
|
||||
| `UpdateSDK.registerPlugins(plugins)` | 批量注册插件 |
|
||||
| `UpdateSDK.registerPlugin(meta)` | 单个注册(向后兼容) |
|
||||
| `UpdateSDK.setBundleCallbacks(callbacks)` | 注入宿主写入/重载能力 |
|
||||
| `UpdateSDK.getRegisteredPlugins()` | 获取已注册插件列表 |
|
||||
|
||||
### App 整包更新
|
||||
|
||||
| API | 说明 |
|
||||
|-----|------|
|
||||
| `UpdateSDK.checkAppUpdate(bypassIgnore?)` | 检查 App 更新(30 分钟缓存) |
|
||||
| `UpdateSDK.downloadApk(info, options?)` | 下载 APK,返回 ArrayBuffer(支持进度回调) |
|
||||
| `UpdateSDK.downloadAndInstallApk(url, options?)` | 下载 APK 并调起系统安装器(Android) |
|
||||
| `UpdateSDK.openStore(appStoreUrl?, marketUrl?)` | 跳转应用商店 |
|
||||
| `UpdateSDK.getAppVersionCode()` | 获取当前 versionCode |
|
||||
| `UpdateSDK.getAppVersionName()` | 获取当前 versionName |
|
||||
|
||||
### 插件热更新
|
||||
|
||||
| API | 说明 |
|
||||
|-----|------|
|
||||
| `UpdateSDK.checkPluginUpdate(moduleId)` | 检查插件更新(30 分钟缓存) |
|
||||
| `UpdateSDK.downloadPlugin(moduleId, info, options?)` | 下载插件 bundle,返回 JS 源码(支持进度回调) |
|
||||
| `UpdateSDK.updatePlugin(moduleId, options?)` | 一步完成:检查 → 下载 → 写文件 → 重载 |
|
||||
|
||||
### 类型定义
|
||||
进入 buz 前只检查当前入口及 common:
|
||||
|
||||
```ts
|
||||
interface PluginRegistration {
|
||||
moduleId: string
|
||||
}
|
||||
|
||||
interface AppUpdateInfo {
|
||||
needsUpdate: boolean
|
||||
versionName?: string
|
||||
versionCode?: number
|
||||
downloadUrl?: string
|
||||
changeLog?: string
|
||||
forceUpdate?: boolean
|
||||
appStoreUrl?: string
|
||||
marketUrl?: string
|
||||
apkHash?: string | null
|
||||
}
|
||||
|
||||
interface PluginUpdateInfo {
|
||||
needsUpdate: boolean
|
||||
latestVersion: string
|
||||
currentVersion: string
|
||||
downloadUrl: string
|
||||
md5: string
|
||||
minCommonVersion: string
|
||||
note: string
|
||||
forceUpdate?: boolean
|
||||
}
|
||||
|
||||
interface CachedRnBundle {
|
||||
moduleId: string
|
||||
version: string
|
||||
md5: string
|
||||
downloadedAt: string
|
||||
source: string
|
||||
}
|
||||
|
||||
type UpdateDownloadProgress = {
|
||||
bytesDownloaded: number
|
||||
totalBytes: number
|
||||
/** 0-100 */
|
||||
percent: number
|
||||
}
|
||||
```
|
||||
|
||||
### 下载进度回调
|
||||
|
||||
`downloadApk()` 和 `downloadPlugin()` 支持 `onProgress` 回调,使用 `fetch + ReadableStream` 实现流式进度计算:
|
||||
|
||||
```ts
|
||||
// 下载 APK 带进度
|
||||
const buffer = await UpdateSDK.downloadApk(info, {
|
||||
onProgress: ({ bytesDownloaded, totalBytes, percent }) => {
|
||||
console.log(`下载进度: ${percent}% (${bytesDownloaded}/${totalBytes})`)
|
||||
const plan = await UpdateSDK.checkAndInstallPlugin('prescription', {
|
||||
onProgress(moduleId, progress) {
|
||||
console.log(moduleId, progress.percent)
|
||||
},
|
||||
})
|
||||
|
||||
// 下载插件 bundle 带进度
|
||||
const source = await UpdateSDK.downloadPlugin('szyx', pluginInfo, {
|
||||
onProgress: ({ percent }) => setProgress(percent),
|
||||
})
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
SDK 会验证所有已安装模块在目标 common 下仍兼容,然后整组下载、校验、staging、激活、确认或回滚。Bundle 版本与事务状态只保存在原生 state;宿主不得另建 AsyncStorage 版本表,也不得逐层传递版本或路径状态。
|
||||
|
||||
- 版本号由 SDK 自动从本地 AsyncStorage 缓存读取,首次为 `0.0.0`
|
||||
- 更新检查结果缓存 30 分钟(TTL),避免频繁请求
|
||||
- `updatePlugin()` 内部自动完成:`checkPluginUpdate` → `fetch(downloadUrl)` → AsyncStorage 缓存 → `writeBundle` → `reloadBundle`
|
||||
- `silent: true` 时跳过 `reloadBundle`,下次启动生效
|
||||
需要先展示插件更新提示的项目,分别调用纯检查和安装 API:
|
||||
|
||||
```ts
|
||||
const plan = await UpdateSDK.checkPluginRelease('prescription')
|
||||
if (plan && (await showPluginUpdateDialog(plan))) {
|
||||
await UpdateSDK.installPluginRelease(plan)
|
||||
}
|
||||
```
|
||||
|
||||
检查不会下载或修改状态;安装前会重新比对检查时的全部本地版本,计划过期时抛出 `StaleReleaseSetError`,调用方重新检查即可。
|
||||
|
||||
服务端使用单次 `POST /api/v1/rn/release-set/check` 返回目标 app/buz 与 common 的依赖闭包,不能让客户端并发拼接多个独立更新结果。
|
||||
|
||||
@ -1,30 +1,26 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.1.0")
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: "com.android.library"
|
||||
|
||||
android {
|
||||
compileSdkVersion 34
|
||||
namespace "com.xuqm.update"
|
||||
compileSdk = 36
|
||||
namespace = "com.xuqm.update"
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 24
|
||||
targetSdkVersion 34
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven { url "https://nexus.xuqinmin.com/repository/android/" }
|
||||
mavenCentral()
|
||||
google()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "com.facebook.react:react-android"
|
||||
implementation("com.facebook.react:react-android")
|
||||
implementation("com.xuqm:sdk-update:2.0.0-SNAPSHOT")
|
||||
}
|
||||
|
||||
@ -0,0 +1,147 @@
|
||||
package com.xuqm.update;
|
||||
|
||||
import android.content.Intent;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.Promise;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
||||
import com.facebook.react.bridge.ReactMethod;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
import com.facebook.react.module.annotations.ReactModule;
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule;
|
||||
import com.xuqm.sdk.update.UpdateSDK;
|
||||
import com.xuqm.sdk.update.model.ApkInstallRequest;
|
||||
import com.xuqm.sdk.update.model.UpdateDownloadProgress;
|
||||
import com.xuqm.sdk.update.model.UpdateInstallCallback;
|
||||
import com.xuqm.sdk.update.model.UpdateInstallTask;
|
||||
import com.xuqm.sdk.update.model.UpdateProgressListener;
|
||||
|
||||
@ReactModule(name = XuqmAppUpdateModule.NAME)
|
||||
public final class XuqmAppUpdateModule extends ReactContextBaseJavaModule {
|
||||
|
||||
public static final String NAME = "XuqmAppUpdateModule";
|
||||
private static final String PROGRESS_EVENT = "XuqmAppUpdateProgress";
|
||||
|
||||
@Nullable
|
||||
private UpdateInstallTask activeTask;
|
||||
|
||||
public XuqmAppUpdateModule(ReactApplicationContext reactContext) {
|
||||
super(reactContext);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String getName() {
|
||||
return NAME;
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public synchronized void downloadAndInstall(
|
||||
String downloadUrl,
|
||||
int versionCode,
|
||||
@Nullable String sha256,
|
||||
int retryCount,
|
||||
Promise promise) {
|
||||
if (activeTask != null && activeTask.isActive()) {
|
||||
promise.reject("DOWNLOAD_FAILED", "An APK download is already in progress");
|
||||
return;
|
||||
}
|
||||
if (sha256 == null || sha256.trim().isEmpty()) {
|
||||
promise.reject("HASH_MISMATCH", "APK SHA-256 is required");
|
||||
return;
|
||||
}
|
||||
|
||||
ApkInstallRequest request = new ApkInstallRequest(
|
||||
downloadUrl,
|
||||
versionCode,
|
||||
sha256,
|
||||
Math.max(0, retryCount));
|
||||
activeTask = UpdateSDK.startDownloadAndInstall(
|
||||
getReactApplicationContext(),
|
||||
request,
|
||||
new UpdateProgressListener() {
|
||||
@Override
|
||||
public void onProgress(UpdateDownloadProgress progress) {
|
||||
emitProgress(progress);
|
||||
}
|
||||
},
|
||||
new UpdateInstallCallback() {
|
||||
@Override
|
||||
public void onSuccess() {
|
||||
clearActiveTask();
|
||||
promise.resolve(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@NonNull String code, @NonNull String message) {
|
||||
clearActiveTask();
|
||||
promise.reject(code, message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public synchronized void cancelDownload(Promise promise) {
|
||||
UpdateInstallTask task = activeTask;
|
||||
if (task == null || !task.isActive()) {
|
||||
promise.resolve(null);
|
||||
return;
|
||||
}
|
||||
task.cancel();
|
||||
promise.resolve(null);
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void openInstallPermissionSettings(Promise promise) {
|
||||
Intent intent = UpdateSDK.createInstallPermissionIntent(getReactApplicationContext());
|
||||
if (intent == null) {
|
||||
promise.resolve(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
getReactApplicationContext().startActivity(intent);
|
||||
promise.resolve(true);
|
||||
} catch (Exception error) {
|
||||
promise.reject("INSTALL_PERMISSION_REQUIRED", "Unable to open install permission settings", error);
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void addListener(String eventName) {
|
||||
// Required by NativeEventEmitter. The Android SDK owns the active transfer.
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void removeListeners(double count) {
|
||||
// Required by NativeEventEmitter.
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void invalidate() {
|
||||
if (activeTask != null) activeTask.cancel();
|
||||
activeTask = null;
|
||||
super.invalidate();
|
||||
}
|
||||
|
||||
private synchronized void clearActiveTask() {
|
||||
activeTask = null;
|
||||
}
|
||||
|
||||
private void emitProgress(UpdateDownloadProgress progress) {
|
||||
WritableMap payload = Arguments.createMap();
|
||||
payload.putDouble("bytesDownloaded", progress.getBytesDownloaded());
|
||||
Long totalBytes = progress.getTotalBytes();
|
||||
if (totalBytes == null) payload.putNull("totalBytes");
|
||||
else payload.putDouble("totalBytes", totalBytes);
|
||||
Integer percent = progress.getPercent();
|
||||
if (percent == null) payload.putNull("percent");
|
||||
else payload.putInt("percent", percent);
|
||||
getReactApplicationContext()
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
|
||||
.emit(PROGRESS_EVENT, payload);
|
||||
}
|
||||
}
|
||||
@ -1,37 +1,49 @@
|
||||
package com.xuqm.update;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
import android.system.ErrnoException;
|
||||
import android.system.Os;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.Promise;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
||||
import com.facebook.react.bridge.ReactMethod;
|
||||
import com.facebook.react.bridge.Promise;
|
||||
import com.facebook.react.bridge.ReadableArray;
|
||||
import com.facebook.react.bridge.ReadableMap;
|
||||
import com.facebook.react.bridge.WritableArray;
|
||||
import com.facebook.react.module.annotations.ReactModule;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* XuqmBundleModule — 原生 bundle 文件管理。
|
||||
*
|
||||
* 提供 bundle 文件的读取、写入、清单管理和热重载能力。
|
||||
* 宿主应用通过此模块实现插件化 bundle 加载。
|
||||
*
|
||||
* 注意:bundle 注入到运行中的 RN host 需要宿主侧配合实现
|
||||
* (RN 0.84 不暴露公开的 loadBundle API)。
|
||||
* 本模块提供文件管理能力,注入逻辑由宿主的 BundleRuntime 负责。
|
||||
*/
|
||||
public class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
/** Owns embedded recovery and transactional activation for every RN plugin bundle. */
|
||||
@ReactModule(name = XuqmBundleModule.NAME)
|
||||
public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
|
||||
private static final String TAG = "XuqmBundleModule";
|
||||
public static final String NAME = "XuqmBundleModule";
|
||||
private static final int NATIVE_API_LEVEL = 1;
|
||||
private static final String BUNDLE_DIR = "rn-bundles";
|
||||
private static final String EMBEDDED_DIR = "rn-bundles";
|
||||
private static final String ACTIVE_FILE = "active.bundle";
|
||||
private static final String STAGED_FILE = "staged.bundle";
|
||||
private static final String ROLLBACK_FILE = "rollback.bundle";
|
||||
private static final String STATE_FILE = "state.json";
|
||||
private static final String RELEASE_STATE_FILE = "release-state.json";
|
||||
|
||||
public XuqmBundleModule(ReactApplicationContext reactContext) {
|
||||
super(reactContext);
|
||||
@ -40,153 +52,585 @@ public class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
@NonNull
|
||||
@Override
|
||||
public String getName() {
|
||||
return "XuqmBundleModule";
|
||||
return NAME;
|
||||
}
|
||||
|
||||
// ── Bundle 文件操作 ──────────────────────────────────────────────────────
|
||||
@ReactMethod
|
||||
public void getNativeApiLevel(Promise promise) {
|
||||
promise.resolve(NATIVE_API_LEVEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定 bundle 文件是否存在。
|
||||
*/
|
||||
@ReactMethod
|
||||
public void bundleExists(String moduleId, Promise promise) {
|
||||
try {
|
||||
File file = getBundleFile(moduleId);
|
||||
promise.resolve(file.exists());
|
||||
} catch (Exception e) {
|
||||
promise.reject("BUNDLE_EXISTS_ERROR", e.getMessage(), e);
|
||||
promise.resolve(ensureEmbeddedBundle(moduleId).exists());
|
||||
} catch (Exception error) {
|
||||
promise.reject("BUNDLE_EXISTS_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 bundle 文件内容(JS 源码文本)。
|
||||
*/
|
||||
@ReactMethod
|
||||
public void readBundle(String moduleId, Promise promise) {
|
||||
try {
|
||||
File file = getBundleFile(moduleId);
|
||||
File file = ensureEmbeddedBundle(moduleId);
|
||||
if (!file.exists()) {
|
||||
promise.reject("BUNDLE_NOT_FOUND", "Bundle not found: " + moduleId);
|
||||
return;
|
||||
}
|
||||
byte[] bytes = readBytes(file);
|
||||
promise.resolve(new String(bytes, StandardCharsets.UTF_8));
|
||||
} catch (Exception e) {
|
||||
promise.reject("BUNDLE_READ_ERROR", e.getMessage(), e);
|
||||
promise.resolve(new String(readBytes(file), StandardCharsets.UTF_8));
|
||||
} catch (Exception error) {
|
||||
promise.reject("BUNDLE_READ_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 bundle 源码写入本地文件。
|
||||
*
|
||||
* @param moduleId 插件 ID
|
||||
* @param source JS 源码文本
|
||||
* @param md5 文件 MD5 校验值
|
||||
*/
|
||||
@ReactMethod
|
||||
public void writeBundle(String moduleId, String source, String md5, Promise promise) {
|
||||
try {
|
||||
File file = getBundleFile(moduleId);
|
||||
File dir = file.getParentFile();
|
||||
if (dir != null && !dir.exists()) {
|
||||
dir.mkdirs();
|
||||
}
|
||||
try (FileOutputStream fos = new FileOutputStream(file)) {
|
||||
fos.write(source.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
promise.resolve(true);
|
||||
} catch (Exception e) {
|
||||
promise.reject("BUNDLE_WRITE_ERROR", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定 bundle 文件。
|
||||
*/
|
||||
@ReactMethod
|
||||
public void deleteBundle(String moduleId, Promise promise) {
|
||||
try {
|
||||
File file = getBundleFile(moduleId);
|
||||
if (file.exists()) {
|
||||
file.delete();
|
||||
}
|
||||
deleteRecursively(getModuleDir(moduleId));
|
||||
promise.resolve(true);
|
||||
} catch (Exception e) {
|
||||
promise.reject("BUNDLE_DELETE_ERROR", e.getMessage(), e);
|
||||
} catch (Exception error) {
|
||||
promise.reject("BUNDLE_DELETE_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 bundle 文件路径。
|
||||
*/
|
||||
@ReactMethod
|
||||
public void getBundlePath(String moduleId, Promise promise) {
|
||||
try {
|
||||
File file = getBundleFile(moduleId);
|
||||
promise.resolve(file.getAbsolutePath());
|
||||
} catch (Exception e) {
|
||||
promise.reject("BUNDLE_PATH_ERROR", e.getMessage(), e);
|
||||
promise.resolve(ensureEmbeddedBundle(moduleId).getAbsolutePath());
|
||||
} catch (Exception error) {
|
||||
promise.reject("BUNDLE_PATH_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manifest 操作 ────────────────────────────────────────────────────────
|
||||
@ReactMethod
|
||||
public void getBundleState(String moduleId, Promise promise) {
|
||||
try {
|
||||
ensureEmbeddedBundle(moduleId);
|
||||
promise.resolve(readState(moduleId).toString());
|
||||
} catch (Exception error) {
|
||||
promise.reject("BUNDLE_STATE_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 manifest.json 内容。
|
||||
*/
|
||||
@ReactMethod
|
||||
public void getManifest(Promise promise) {
|
||||
try {
|
||||
File manifestFile = new File(getBundleDir(), "manifest.json");
|
||||
if (!manifestFile.exists()) {
|
||||
promise.resolve("{}");
|
||||
File manifest = new File(getBundleDir(), "manifest.json");
|
||||
if (manifest.exists()) {
|
||||
promise.resolve(new String(readBytes(manifest), StandardCharsets.UTF_8));
|
||||
return;
|
||||
}
|
||||
byte[] bytes = readBytes(manifestFile);
|
||||
promise.resolve(new String(bytes, StandardCharsets.UTF_8));
|
||||
} catch (Exception e) {
|
||||
promise.reject("MANIFEST_READ_ERROR", e.getMessage(), e);
|
||||
promise.resolve(readEmbeddedManifest().toString());
|
||||
} catch (Exception error) {
|
||||
promise.reject("MANIFEST_READ_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void writeManifest(String manifestJson, Promise promise) {
|
||||
try {
|
||||
JSONObject parsed = new JSONObject(manifestJson);
|
||||
atomicWrite(
|
||||
new File(getBundleDir(), "manifest.json"),
|
||||
parsed.toString().getBytes(StandardCharsets.UTF_8));
|
||||
promise.resolve(true);
|
||||
} catch (Exception error) {
|
||||
promise.reject("MANIFEST_WRITE_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stages every downloaded bundle before any active bundle is touched. */
|
||||
@ReactMethod
|
||||
public void stageReleaseSet(String releaseId, ReadableArray modules, Promise promise) {
|
||||
Set<String> stagedModules = new LinkedHashSet<>();
|
||||
try {
|
||||
validateReleaseId(releaseId);
|
||||
if (modules == null || modules.size() == 0) {
|
||||
throw new IllegalArgumentException("A release set must contain at least one module");
|
||||
}
|
||||
if (readReleaseStateOrNull() != null) {
|
||||
throw new IllegalStateException("Another release set is pending");
|
||||
}
|
||||
|
||||
JSONArray moduleIds = new JSONArray();
|
||||
for (int index = 0; index < modules.size(); index += 1) {
|
||||
ReadableMap module = modules.getMap(index);
|
||||
if (module == null) throw new IllegalArgumentException("Invalid release module at " + index);
|
||||
String moduleId = requiredString(module, "moduleId");
|
||||
String version = requiredString(module, "version");
|
||||
String source = requiredString(module, "source");
|
||||
String sha256 = requiredString(module, "sha256");
|
||||
validateModuleId(moduleId);
|
||||
verifyHash(source.getBytes(StandardCharsets.UTF_8), sha256);
|
||||
|
||||
File staged = new File(getModuleDir(moduleId), STAGED_FILE);
|
||||
atomicWrite(staged, source.getBytes(StandardCharsets.UTF_8));
|
||||
JSONObject state = readState(moduleId);
|
||||
state.put("moduleId", moduleId);
|
||||
state.put("pendingVersion", version);
|
||||
state.put("pendingHash", sha256.toLowerCase(Locale.ROOT));
|
||||
state.put("pendingType", requiredString(module, "type"));
|
||||
state.put("pendingAppVersionRange", requiredString(module, "appVersionRange"));
|
||||
copyOptionalString(module, "builtAgainstNativeBaselineId", state, "pendingBuiltAgainstNativeBaselineId");
|
||||
copyOptionalString(module, "commonVersionRange", state, "pendingCommonVersionRange");
|
||||
copyOptionalInt(module, "minNativeApiLevel", state, "pendingMinNativeApiLevel");
|
||||
state.put("releaseId", releaseId);
|
||||
state.put("status", "staged");
|
||||
writeState(moduleId, state);
|
||||
stagedModules.add(moduleId);
|
||||
moduleIds.put(moduleId);
|
||||
}
|
||||
|
||||
JSONObject release = new JSONObject();
|
||||
release.put("releaseId", releaseId);
|
||||
release.put("status", "staged");
|
||||
release.put("modules", moduleIds);
|
||||
release.put("activated", new JSONArray());
|
||||
writeReleaseState(release);
|
||||
promise.resolve(true);
|
||||
} catch (Exception error) {
|
||||
for (String moduleId : stagedModules) cleanupStaged(moduleId);
|
||||
deleteReleaseStateQuietly();
|
||||
promise.reject("RELEASE_STAGE_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Activates the staged set and records each move so interrupted activation can recover. */
|
||||
@ReactMethod
|
||||
public void activateReleaseSet(String releaseId, Promise promise) {
|
||||
try {
|
||||
JSONObject release = requireRelease(releaseId);
|
||||
release.put("status", "activating");
|
||||
writeReleaseState(release);
|
||||
JSONArray modules = release.getJSONArray("modules");
|
||||
JSONArray activated = release.getJSONArray("activated");
|
||||
for (int index = 0; index < modules.length(); index += 1) {
|
||||
String moduleId = modules.getString(index);
|
||||
activateModule(moduleId, releaseId);
|
||||
activated.put(moduleId);
|
||||
release.put("activated", activated);
|
||||
writeReleaseState(release);
|
||||
}
|
||||
release.put("status", "pending_confirmation");
|
||||
release.put("launchAttempts", 0);
|
||||
writeReleaseState(release);
|
||||
promise.resolve(true);
|
||||
} catch (Exception error) {
|
||||
rollbackPendingReleaseQuietly();
|
||||
promise.reject("RELEASE_ACTIVATE_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void confirmReleaseSet(String releaseId, Promise promise) {
|
||||
try {
|
||||
JSONObject release = requireRelease(releaseId);
|
||||
JSONArray modules = release.getJSONArray("modules");
|
||||
for (int index = 0; index < modules.length(); index += 1) {
|
||||
confirmModule(modules.getString(index));
|
||||
}
|
||||
deleteReleaseState();
|
||||
promise.resolve(true);
|
||||
} catch (Exception error) {
|
||||
promise.reject("RELEASE_CONFIRM_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void rollbackReleaseSet(String releaseId, Promise promise) {
|
||||
try {
|
||||
requireRelease(releaseId);
|
||||
rollbackPendingRelease();
|
||||
promise.resolve(true);
|
||||
} catch (Exception error) {
|
||||
promise.reject("RELEASE_ROLLBACK_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void getPendingRelease(Promise promise) {
|
||||
try {
|
||||
JSONObject release = readReleaseStateOrNull();
|
||||
promise.resolve(release == null ? null : release.toString());
|
||||
} catch (Exception error) {
|
||||
promise.reject("RELEASE_STATE_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 manifest.json。
|
||||
* Gives a newly activated set one process launch to confirm itself. If the next process
|
||||
* reaches this method while the same set is still pending, that launch failed and the whole
|
||||
* set is rolled back.
|
||||
*/
|
||||
@ReactMethod
|
||||
public void writeManifest(String manifestJson, Promise promise) {
|
||||
public void preparePendingRelease(Promise promise) {
|
||||
WritableArray recovered = Arguments.createArray();
|
||||
try {
|
||||
File manifestFile = new File(getBundleDir(), "manifest.json");
|
||||
try (FileOutputStream fos = new FileOutputStream(manifestFile)) {
|
||||
fos.write(manifestJson.getBytes(StandardCharsets.UTF_8));
|
||||
JSONObject release = readReleaseStateOrNull();
|
||||
if (release != null) {
|
||||
if ("pending_confirmation".equals(release.optString("status"))
|
||||
&& release.optInt("launchAttempts", 0) == 0) {
|
||||
release.put("launchAttempts", 1);
|
||||
writeReleaseState(release);
|
||||
} else {
|
||||
JSONArray modules = release.getJSONArray("modules");
|
||||
for (int index = 0; index < modules.length(); index += 1) {
|
||||
recovered.pushString(modules.getString(index));
|
||||
}
|
||||
rollbackPendingRelease();
|
||||
}
|
||||
}
|
||||
promise.resolve(true);
|
||||
} catch (Exception e) {
|
||||
promise.reject("MANIFEST_WRITE_ERROR", e.getMessage(), e);
|
||||
promise.resolve(recovered);
|
||||
} catch (Exception error) {
|
||||
promise.reject("RELEASE_RECOVERY_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 辅助方法 ─────────────────────────────────────────────────────────────
|
||||
@ReactMethod
|
||||
public void getLaunchBundlePath(String moduleId, Promise promise) {
|
||||
try {
|
||||
File active = ensureEmbeddedBundle(moduleId);
|
||||
if (!active.exists()) {
|
||||
promise.reject("BUNDLE_NOT_FOUND", "No bundle for " + moduleId);
|
||||
return;
|
||||
}
|
||||
JSONObject state = readState(moduleId);
|
||||
if ("pending_confirmation".equals(state.optString("status"))) {
|
||||
state.put("launchAttempts", state.optInt("launchAttempts", 0) + 1);
|
||||
writeState(moduleId, state);
|
||||
}
|
||||
promise.resolve(active.getAbsolutePath());
|
||||
} catch (Exception error) {
|
||||
promise.reject("BUNDLE_PATH_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
private void activateModule(String moduleId, String releaseId) throws Exception {
|
||||
File directory = getModuleDir(moduleId);
|
||||
File staged = new File(directory, STAGED_FILE);
|
||||
if (!staged.exists()) throw new IOException("No staged bundle for " + moduleId);
|
||||
JSONObject state = readState(moduleId);
|
||||
if (!releaseId.equals(state.optString("releaseId"))) {
|
||||
throw new IOException("Staged bundle does not belong to release " + releaseId);
|
||||
}
|
||||
|
||||
File active = new File(directory, ACTIVE_FILE);
|
||||
File rollback = new File(directory, ROLLBACK_FILE);
|
||||
boolean hadActive = active.exists();
|
||||
deleteIfExists(rollback);
|
||||
if (hadActive && !active.renameTo(rollback)) {
|
||||
throw new IOException("Unable to preserve active bundle for " + moduleId);
|
||||
}
|
||||
if (!staged.renameTo(active)) {
|
||||
if (rollback.exists()) rollback.renameTo(active);
|
||||
throw new IOException("Unable to activate staged bundle for " + moduleId);
|
||||
}
|
||||
|
||||
if (hadActive) copyActiveToRollbackMetadata(state);
|
||||
else clearRollbackMetadata(state);
|
||||
state.put("status", "pending_confirmation");
|
||||
state.put("launchAttempts", 0);
|
||||
writeState(moduleId, state);
|
||||
}
|
||||
|
||||
private void confirmModule(String moduleId) throws Exception {
|
||||
JSONObject state = readState(moduleId);
|
||||
movePendingToActiveMetadata(state);
|
||||
clearPendingMetadata(state);
|
||||
clearRollbackMetadata(state);
|
||||
state.remove("releaseId");
|
||||
state.put("status", "active");
|
||||
state.put("launchAttempts", 0);
|
||||
deleteIfExists(new File(getModuleDir(moduleId), ROLLBACK_FILE));
|
||||
writeState(moduleId, state);
|
||||
}
|
||||
|
||||
private File ensureEmbeddedBundle(String moduleId) throws Exception {
|
||||
validateModuleId(moduleId);
|
||||
File active = new File(getModuleDir(moduleId), ACTIVE_FILE);
|
||||
if (active.exists()) return active;
|
||||
|
||||
JSONObject manifest = readEmbeddedManifest();
|
||||
JSONObject modules = manifest.optJSONObject("modules");
|
||||
JSONObject entry = modules == null ? null : modules.optJSONObject(moduleId);
|
||||
if (entry == null) return active;
|
||||
String bundleFile = entry.optString("bundleFile", moduleId + ".android.bundle");
|
||||
byte[] bytes = readAsset(EMBEDDED_DIR + "/" + bundleFile);
|
||||
atomicWrite(active, bytes);
|
||||
|
||||
JSONObject state = new JSONObject();
|
||||
state.put("moduleId", moduleId);
|
||||
state.put("status", "embedded");
|
||||
state.put("activeVersion", entry.optString("moduleVersion", "0.0.0"));
|
||||
state.put("activeType", entry.optString("type", "buz"));
|
||||
copyJsonValue(entry, "commonVersionRange", state, "activeCommonVersionRange");
|
||||
copyJsonValue(entry, "appVersionRange", state, "activeAppVersionRange");
|
||||
copyJsonValue(entry, "builtAgainstNativeBaselineId", state, "activeBuiltAgainstNativeBaselineId");
|
||||
copyJsonValue(entry, "minNativeApiLevel", state, "activeMinNativeApiLevel");
|
||||
writeState(moduleId, state);
|
||||
return active;
|
||||
}
|
||||
|
||||
private JSONObject readEmbeddedManifest() throws Exception {
|
||||
try {
|
||||
return new JSONObject(new String(readAsset(EMBEDDED_DIR + "/manifest.json"), StandardCharsets.UTF_8));
|
||||
} catch (IOException missing) {
|
||||
return new JSONObject();
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject requireRelease(String releaseId) throws Exception {
|
||||
validateReleaseId(releaseId);
|
||||
JSONObject release = readReleaseStateOrNull();
|
||||
if (release == null || !releaseId.equals(release.optString("releaseId"))) {
|
||||
throw new IllegalStateException("Release set is not pending: " + releaseId);
|
||||
}
|
||||
return release;
|
||||
}
|
||||
|
||||
private void rollbackPendingRelease() throws Exception {
|
||||
JSONObject release = readReleaseStateOrNull();
|
||||
if (release == null) return;
|
||||
JSONArray modules = release.getJSONArray("modules");
|
||||
Exception first = null;
|
||||
for (int index = modules.length() - 1; index >= 0; index -= 1) {
|
||||
try {
|
||||
rollbackModule(modules.getString(index));
|
||||
} catch (Exception error) {
|
||||
if (first == null) first = error;
|
||||
}
|
||||
}
|
||||
if (first != null) throw first;
|
||||
deleteReleaseState();
|
||||
}
|
||||
|
||||
private void rollbackModule(String moduleId) throws Exception {
|
||||
File directory = getModuleDir(moduleId);
|
||||
File active = new File(directory, ACTIVE_FILE);
|
||||
File rollback = new File(directory, ROLLBACK_FILE);
|
||||
JSONObject state = readState(moduleId);
|
||||
if (rollback.exists()) {
|
||||
deleteIfExists(active);
|
||||
if (!rollback.renameTo(active)) throw new IOException("Unable to restore " + moduleId);
|
||||
moveRollbackToActiveMetadata(state);
|
||||
} else if ("pending_confirmation".equals(state.optString("status"))) {
|
||||
deleteIfExists(active);
|
||||
clearActiveMetadata(state);
|
||||
}
|
||||
deleteIfExists(new File(directory, STAGED_FILE));
|
||||
clearPendingMetadata(state);
|
||||
clearRollbackMetadata(state);
|
||||
state.remove("releaseId");
|
||||
state.put("status", active.exists() ? "active" : "embedded");
|
||||
state.put("launchAttempts", 0);
|
||||
writeState(moduleId, state);
|
||||
}
|
||||
|
||||
private void cleanupStaged(String moduleId) {
|
||||
try {
|
||||
deleteIfExists(new File(getModuleDir(moduleId), STAGED_FILE));
|
||||
JSONObject state = readState(moduleId);
|
||||
clearPendingMetadata(state);
|
||||
state.remove("releaseId");
|
||||
state.put("status", new File(getModuleDir(moduleId), ACTIVE_FILE).exists() ? "active" : "embedded");
|
||||
writeState(moduleId, state);
|
||||
} catch (Exception ignored) {
|
||||
// The original staging failure is more useful to the caller.
|
||||
}
|
||||
}
|
||||
|
||||
private File getBundleDir() {
|
||||
Context context = getReactApplicationContext();
|
||||
File dir = new File(context.getFilesDir(), BUNDLE_DIR);
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs();
|
||||
File directory = new File(context.getFilesDir(), BUNDLE_DIR);
|
||||
if (!directory.exists() && !directory.mkdirs()) {
|
||||
throw new IllegalStateException("Unable to create bundle directory");
|
||||
}
|
||||
return dir;
|
||||
return directory;
|
||||
}
|
||||
|
||||
private File getBundleFile(String moduleId) {
|
||||
String platform = "android";
|
||||
String filename = moduleId + "." + platform + ".bundle";
|
||||
return new File(getBundleDir(), filename);
|
||||
private File getModuleDir(String moduleId) {
|
||||
validateModuleId(moduleId);
|
||||
File directory = new File(getBundleDir(), moduleId);
|
||||
if (!directory.exists() && !directory.mkdirs()) {
|
||||
throw new IllegalStateException("Unable to create bundle directory for " + moduleId);
|
||||
}
|
||||
return directory;
|
||||
}
|
||||
|
||||
private JSONObject readState(String moduleId) throws Exception {
|
||||
File file = new File(getModuleDir(moduleId), STATE_FILE);
|
||||
return file.exists()
|
||||
? new JSONObject(new String(readBytes(file), StandardCharsets.UTF_8))
|
||||
: new JSONObject().put("moduleId", moduleId).put("status", "embedded");
|
||||
}
|
||||
|
||||
private void writeState(String moduleId, JSONObject state) throws Exception {
|
||||
atomicWrite(new File(getModuleDir(moduleId), STATE_FILE), state.toString().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private JSONObject readReleaseStateOrNull() throws Exception {
|
||||
File file = new File(getBundleDir(), RELEASE_STATE_FILE);
|
||||
return file.exists() ? new JSONObject(new String(readBytes(file), StandardCharsets.UTF_8)) : null;
|
||||
}
|
||||
|
||||
private void writeReleaseState(JSONObject state) throws Exception {
|
||||
atomicWrite(new File(getBundleDir(), RELEASE_STATE_FILE), state.toString().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private void deleteReleaseState() throws Exception {
|
||||
deleteIfExists(new File(getBundleDir(), RELEASE_STATE_FILE));
|
||||
}
|
||||
|
||||
private void rollbackPendingReleaseQuietly() {
|
||||
try { rollbackPendingRelease(); } catch (Exception ignored) { }
|
||||
}
|
||||
|
||||
private void deleteReleaseStateQuietly() {
|
||||
try { deleteReleaseState(); } catch (Exception ignored) { }
|
||||
}
|
||||
|
||||
private String requiredString(ReadableMap map, String key) {
|
||||
if (!map.hasKey(key) || map.isNull(key)) throw new IllegalArgumentException(key + " is required");
|
||||
String value = map.getString(key);
|
||||
if (value == null || value.trim().isEmpty()) throw new IllegalArgumentException(key + " is required");
|
||||
return value;
|
||||
}
|
||||
|
||||
private void copyOptionalString(ReadableMap from, String sourceKey, JSONObject to, String targetKey) throws Exception {
|
||||
if (from.hasKey(sourceKey) && !from.isNull(sourceKey)) to.put(targetKey, from.getString(sourceKey));
|
||||
else to.remove(targetKey);
|
||||
}
|
||||
|
||||
private void copyOptionalInt(ReadableMap from, String sourceKey, JSONObject to, String targetKey) throws Exception {
|
||||
if (from.hasKey(sourceKey) && !from.isNull(sourceKey)) to.put(targetKey, from.getInt(sourceKey));
|
||||
else to.remove(targetKey);
|
||||
}
|
||||
|
||||
private void copyJsonValue(JSONObject from, String sourceKey, JSONObject to, String targetKey) throws Exception {
|
||||
if (from.has(sourceKey) && !from.isNull(sourceKey)) to.put(targetKey, from.get(sourceKey));
|
||||
}
|
||||
|
||||
private void copyActiveToRollbackMetadata(JSONObject state) throws Exception {
|
||||
copyJsonValue(state, "activeVersion", state, "rollbackVersion");
|
||||
copyJsonValue(state, "activeHash", state, "rollbackHash");
|
||||
copyJsonValue(state, "activeType", state, "rollbackType");
|
||||
copyJsonValue(state, "activeAppVersionRange", state, "rollbackAppVersionRange");
|
||||
copyJsonValue(state, "activeBuiltAgainstNativeBaselineId", state, "rollbackBuiltAgainstNativeBaselineId");
|
||||
copyJsonValue(state, "activeCommonVersionRange", state, "rollbackCommonVersionRange");
|
||||
copyJsonValue(state, "activeMinNativeApiLevel", state, "rollbackMinNativeApiLevel");
|
||||
}
|
||||
|
||||
private void movePendingToActiveMetadata(JSONObject state) throws Exception {
|
||||
moveJsonValue(state, "pendingVersion", "activeVersion");
|
||||
moveJsonValue(state, "pendingHash", "activeHash");
|
||||
moveJsonValue(state, "pendingType", "activeType");
|
||||
moveJsonValue(state, "pendingAppVersionRange", "activeAppVersionRange");
|
||||
moveJsonValue(state, "pendingBuiltAgainstNativeBaselineId", "activeBuiltAgainstNativeBaselineId");
|
||||
moveJsonValue(state, "pendingCommonVersionRange", "activeCommonVersionRange");
|
||||
moveJsonValue(state, "pendingMinNativeApiLevel", "activeMinNativeApiLevel");
|
||||
}
|
||||
|
||||
private void moveRollbackToActiveMetadata(JSONObject state) throws Exception {
|
||||
moveJsonValue(state, "rollbackVersion", "activeVersion");
|
||||
moveJsonValue(state, "rollbackHash", "activeHash");
|
||||
moveJsonValue(state, "rollbackType", "activeType");
|
||||
moveJsonValue(state, "rollbackAppVersionRange", "activeAppVersionRange");
|
||||
moveJsonValue(state, "rollbackBuiltAgainstNativeBaselineId", "activeBuiltAgainstNativeBaselineId");
|
||||
moveJsonValue(state, "rollbackCommonVersionRange", "activeCommonVersionRange");
|
||||
moveJsonValue(state, "rollbackMinNativeApiLevel", "activeMinNativeApiLevel");
|
||||
}
|
||||
|
||||
private void moveJsonValue(JSONObject state, String source, String target) throws Exception {
|
||||
if (state.has(source) && !state.isNull(source)) state.put(target, state.get(source));
|
||||
else state.remove(target);
|
||||
}
|
||||
|
||||
private void clearActiveMetadata(JSONObject state) {
|
||||
for (String key : new String[]{"activeVersion", "activeHash", "activeType", "activeAppVersionRange", "activeBuiltAgainstNativeBaselineId", "activeCommonVersionRange", "activeMinNativeApiLevel"}) state.remove(key);
|
||||
}
|
||||
|
||||
private void clearPendingMetadata(JSONObject state) {
|
||||
for (String key : new String[]{"pendingVersion", "pendingHash", "pendingType", "pendingAppVersionRange", "pendingBuiltAgainstNativeBaselineId", "pendingCommonVersionRange", "pendingMinNativeApiLevel"}) state.remove(key);
|
||||
}
|
||||
|
||||
private void clearRollbackMetadata(JSONObject state) {
|
||||
for (String key : new String[]{"rollbackVersion", "rollbackHash", "rollbackType", "rollbackAppVersionRange", "rollbackBuiltAgainstNativeBaselineId", "rollbackCommonVersionRange", "rollbackMinNativeApiLevel"}) state.remove(key);
|
||||
}
|
||||
|
||||
private void validateModuleId(String moduleId) {
|
||||
if (moduleId == null || !moduleId.matches("^[a-z][a-z0-9-]*$")) {
|
||||
throw new IllegalArgumentException("Invalid moduleId: " + moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateReleaseId(String releaseId) {
|
||||
if (releaseId == null || releaseId.trim().isEmpty() || releaseId.length() > 200) {
|
||||
throw new IllegalArgumentException("Invalid releaseId");
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyHash(byte[] bytes, String expected) throws Exception {
|
||||
if (expected == null || !expected.matches("(?i)^[a-f0-9]{64}$")) {
|
||||
throw new IOException("A valid SHA-256 is required");
|
||||
}
|
||||
String actual = digest("SHA-256", bytes);
|
||||
if (!actual.equalsIgnoreCase(expected)) {
|
||||
throw new IOException("Bundle SHA-256 mismatch. Expected " + expected + " but got " + actual);
|
||||
}
|
||||
}
|
||||
|
||||
private String digest(String algorithm, byte[] bytes) throws Exception {
|
||||
byte[] result = MessageDigest.getInstance(algorithm).digest(bytes);
|
||||
StringBuilder output = new StringBuilder(result.length * 2);
|
||||
for (byte value : result) output.append(String.format(Locale.ROOT, "%02x", value));
|
||||
return output.toString();
|
||||
}
|
||||
|
||||
private byte[] readAsset(String path) throws IOException {
|
||||
try (InputStream input = getReactApplicationContext().getAssets().open(path);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count);
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] readBytes(File file) throws IOException {
|
||||
try (FileInputStream fis = new FileInputStream(file)) {
|
||||
byte[] bytes = new byte[(int) file.length()];
|
||||
fis.read(bytes);
|
||||
return bytes;
|
||||
try (FileInputStream input = new FileInputStream(file);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream((int) file.length())) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count);
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private void atomicWrite(File target, byte[] bytes) throws IOException {
|
||||
File parent = target.getParentFile();
|
||||
if (parent != null && !parent.exists() && !parent.mkdirs()) throw new IOException("Unable to create " + parent);
|
||||
File temporary = new File(target.getAbsolutePath() + ".tmp");
|
||||
try (FileOutputStream output = new FileOutputStream(temporary)) {
|
||||
output.write(bytes);
|
||||
output.flush();
|
||||
output.getFD().sync();
|
||||
}
|
||||
try {
|
||||
Os.rename(temporary.getAbsolutePath(), target.getAbsolutePath());
|
||||
} catch (ErrnoException error) {
|
||||
deleteIfExists(temporary);
|
||||
throw new IOException("Unable to atomically replace " + target, error);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteIfExists(File file) throws IOException {
|
||||
if (file.exists() && !file.delete()) throw new IOException("Unable to delete " + file);
|
||||
}
|
||||
|
||||
private void deleteRecursively(File file) throws IOException {
|
||||
if (!file.exists()) return;
|
||||
File[] children = file.listFiles();
|
||||
if (children != null) for (File child : children) deleteRecursively(child);
|
||||
deleteIfExists(file);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,26 +1,61 @@
|
||||
package com.xuqm.update;
|
||||
|
||||
import com.facebook.react.ReactPackage;
|
||||
import com.facebook.react.BaseReactPackage;
|
||||
import com.facebook.react.bridge.NativeModule;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.uimanager.ViewManager;
|
||||
import com.facebook.react.module.model.ReactModuleInfo;
|
||||
import com.facebook.react.module.model.ReactModuleInfoProvider;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class XuqmUpdatePackage implements ReactPackage {
|
||||
public class XuqmUpdatePackage extends BaseReactPackage {
|
||||
|
||||
@Override
|
||||
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
|
||||
return Arrays.asList(
|
||||
new XuqmVersionModule(reactContext),
|
||||
new XuqmBundleModule(reactContext)
|
||||
public NativeModule getModule(String name, ReactApplicationContext reactContext) {
|
||||
if (XuqmVersionModule.NAME.equals(name)) {
|
||||
return new XuqmVersionModule(reactContext);
|
||||
}
|
||||
if (XuqmBundleModule.NAME.equals(name)) {
|
||||
return new XuqmBundleModule(reactContext);
|
||||
}
|
||||
if (XuqmAppUpdateModule.NAME.equals(name)) {
|
||||
return new XuqmAppUpdateModule(reactContext);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReactModuleInfoProvider getReactModuleInfoProvider() {
|
||||
return () -> {
|
||||
Map<String, ReactModuleInfo> modules = new HashMap<>();
|
||||
modules.put(
|
||||
XuqmVersionModule.NAME,
|
||||
moduleInfo(XuqmVersionModule.NAME, XuqmVersionModule.class)
|
||||
);
|
||||
modules.put(
|
||||
XuqmBundleModule.NAME,
|
||||
moduleInfo(XuqmBundleModule.NAME, XuqmBundleModule.class)
|
||||
);
|
||||
modules.put(
|
||||
XuqmAppUpdateModule.NAME,
|
||||
moduleInfo(XuqmAppUpdateModule.NAME, XuqmAppUpdateModule.class)
|
||||
);
|
||||
return modules;
|
||||
};
|
||||
}
|
||||
|
||||
private ReactModuleInfo moduleInfo(
|
||||
String name,
|
||||
Class<? extends NativeModule> moduleClass
|
||||
) {
|
||||
return new ReactModuleInfo(
|
||||
name,
|
||||
moduleClass.getName(),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,16 +5,20 @@ import android.content.pm.PackageManager;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
||||
import com.facebook.react.bridge.ReactMethod;
|
||||
import com.facebook.react.module.annotations.ReactModule;
|
||||
|
||||
@ReactModule(name = XuqmVersionModule.NAME)
|
||||
public class XuqmVersionModule extends ReactContextBaseJavaModule {
|
||||
|
||||
public static final String NAME = "XuqmVersionModule";
|
||||
|
||||
public XuqmVersionModule(ReactApplicationContext ctx) {
|
||||
super(ctx);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "XuqmVersionModule";
|
||||
return NAME;
|
||||
}
|
||||
|
||||
@ReactMethod(isBlockingSynchronousMethod = true)
|
||||
|
||||
@ -0,0 +1,118 @@
|
||||
import javax.inject.Inject
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
import org.gradle.api.file.RegularFileProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.InputFile
|
||||
import org.gradle.api.tasks.Internal
|
||||
import org.gradle.api.tasks.OutputDirectory
|
||||
import org.gradle.api.tasks.PathSensitive
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.gradle.process.ExecOperations
|
||||
import org.gradle.work.DisableCachingByDefault
|
||||
|
||||
/**
|
||||
* Generates every configured RN plugin bundle before Android packages assets.
|
||||
*
|
||||
* Apply once from the host application module:
|
||||
* apply from: file("../../node_modules/@xuqm/rn-update/android/xuqm-bundles.gradle")
|
||||
*/
|
||||
|
||||
@DisableCachingByDefault(because = "Metro maintains its own transform cache")
|
||||
abstract class GenerateXuqmBundlesTask extends DefaultTask {
|
||||
@Internal
|
||||
abstract DirectoryProperty getProjectRootDirectory()
|
||||
|
||||
@InputFile
|
||||
@PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract RegularFileProperty getCliFile()
|
||||
|
||||
@InputFile
|
||||
@PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract RegularFileProperty getConfigFile()
|
||||
|
||||
@InputFile
|
||||
@PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract RegularFileProperty getPackageFile()
|
||||
|
||||
@Input
|
||||
abstract Property<String> getNodeExecutable()
|
||||
|
||||
@Input
|
||||
abstract Property<String> getAppVersion()
|
||||
|
||||
@OutputDirectory
|
||||
abstract DirectoryProperty getOutputDirectory()
|
||||
|
||||
@Inject
|
||||
abstract ExecOperations getExecOperations()
|
||||
|
||||
@TaskAction
|
||||
void generate() {
|
||||
def embeddedBundles = outputDirectory.get().dir("rn-bundles").asFile
|
||||
execOperations.exec {
|
||||
workingDir(projectRootDirectory.get().asFile)
|
||||
commandLine(
|
||||
nodeExecutable.get(),
|
||||
cliFile.get().asFile.absolutePath,
|
||||
"embed",
|
||||
"android",
|
||||
"--output",
|
||||
embeddedBundles.absolutePath,
|
||||
"--app-version",
|
||||
appVersion.get(),
|
||||
)
|
||||
}.assertNormalExitValue()
|
||||
}
|
||||
}
|
||||
|
||||
if (!plugins.hasPlugin("com.android.application")) {
|
||||
throw new GradleException("xuqm-bundles.gradle must be applied to an Android application module")
|
||||
}
|
||||
|
||||
def xuqmProjectRoot = rootProject.projectDir.parentFile
|
||||
def xuqmUseMetro = providers.gradleProperty("USE_METRO")
|
||||
.orElse(providers.environmentVariable("USE_METRO"))
|
||||
.map { it.toBoolean() }
|
||||
.orElse(false)
|
||||
|
||||
def prepareXuqmBundles = tasks.register(
|
||||
"prepareXuqmEmbeddedBundles",
|
||||
GenerateXuqmBundlesTask,
|
||||
) {
|
||||
group = "xuqm"
|
||||
description = "Builds and embeds all configured Xuqm RN plugin bundles."
|
||||
projectRootDirectory.set(xuqmProjectRoot)
|
||||
cliFile.set(new File(xuqmProjectRoot, "node_modules/@xuqm/rn-update/scripts/xuqm-rn.mjs"))
|
||||
configFile.set(new File(xuqmProjectRoot, "xuqm.config.json"))
|
||||
packageFile.set(new File(xuqmProjectRoot, "package.json"))
|
||||
nodeExecutable.set(System.getenv("NODE_BINARY") ?: "node")
|
||||
appVersion.set(providers.provider {
|
||||
def version = android.defaultConfig.versionName
|
||||
if (version == null || version.toString().trim().isEmpty()) {
|
||||
throw new GradleException("Android defaultConfig.versionName is required")
|
||||
}
|
||||
version.toString()
|
||||
})
|
||||
outputDirectory.set(layout.buildDirectory.dir("generated/xuqm/assets"))
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
androidComponents.onVariants(androidComponents.selector().all()) { variant ->
|
||||
if (variant.buildType == "release" || !xuqmUseMetro.get()) {
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(
|
||||
prepareXuqmBundles,
|
||||
{ task -> task.outputDirectory },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.configureEach { task ->
|
||||
if (task.name ==~ /^createBundle.*ReleaseJsAndAssets$/) {
|
||||
// Xuqm hosts load startup/common/app/plugin bundles explicitly. A second
|
||||
// monolithic index.android.bundle is both redundant and unsafe.
|
||||
task.enabled = false
|
||||
}
|
||||
}
|
||||
@ -1,28 +1,42 @@
|
||||
{
|
||||
"name": "@xuqm/rn-update",
|
||||
"version": "0.4.0",
|
||||
"description": "XuqmGroup RN SDK — Update module (App update, RN plugin hot-update)",
|
||||
"version": "0.5.0-alpha.10",
|
||||
"description": "XuqmGroup RN SDK — Android app update and transactional RN plugin runtime",
|
||||
"license": "UNLICENSED",
|
||||
"main": "src/index.ts",
|
||||
"react-native": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"files": [
|
||||
"src"
|
||||
"src",
|
||||
"android",
|
||||
"scripts",
|
||||
"templates",
|
||||
"react-native.config.js",
|
||||
"README.md"
|
||||
],
|
||||
"bin": {
|
||||
"xuqm-rn": "scripts/xuqm-rn.mjs"
|
||||
},
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
"test": "node --test scripts/*.test.mjs && node --import tsx --test tests/*.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"pack:check": "node scripts/verify-package.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": "7.8.5"
|
||||
},
|
||||
"dependencies": {},
|
||||
"peerDependencies": {
|
||||
"react-native": ">=0.76.0",
|
||||
"@react-native-async-storage/async-storage": ">=1.21.0",
|
||||
"@xuqm/rn-common": ">=0.2.2"
|
||||
"@xuqm/rn-common": "workspace:>=0.6.0-alpha.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
"@types/semver": "7.7.1",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,427 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* create-plugin.mjs — UpdateSDK 插件脚手架工具
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/create-plugin.mjs
|
||||
* npx @xuqm/rn-update create-plugin
|
||||
*
|
||||
* 功能:
|
||||
* 1. 交互式输入插件参数(moduleId / title / subtitle / accentColor)
|
||||
* 2. 校验 moduleId 唯一性
|
||||
* 3. 自动生成完整插件骨架(bundle.ts / Screen / plugin.json)
|
||||
* 4. 自动注册到宿主(pluginCatalog / debugPlugins / build scripts / metro config)
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import readline from 'readline';
|
||||
|
||||
// ─── 交互工具 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function createRL() {
|
||||
return readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
}
|
||||
|
||||
function ask(rl, question) {
|
||||
return new Promise(resolve => {
|
||||
rl.question(question, answer => resolve(answer.trim()));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 项目路径检测 ────────────────────────────────────────────────────────────
|
||||
|
||||
function findProjectRoot() {
|
||||
let dir = process.cwd();
|
||||
while (dir !== path.dirname(dir)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
console.error('❌ 未找到 package.json,请在 RN 项目根目录下运行此脚本。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ─── 唯一性校验 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getExistingPluginIds(root) {
|
||||
const ids = new Set();
|
||||
|
||||
// 从 pluginCatalog.ts 读取
|
||||
const catalogPath = path.join(root, 'src/app/pluginCatalog.ts');
|
||||
if (fs.existsSync(catalogPath)) {
|
||||
const content = fs.readFileSync(catalogPath, 'utf-8');
|
||||
const matches = content.matchAll(/id:\s*'([^']+)'/g);
|
||||
for (const m of matches) ids.add(m[1]);
|
||||
}
|
||||
|
||||
// 从 plugins 目录读取
|
||||
const pluginsDir = path.join(root, 'src/plugins');
|
||||
if (fs.existsSync(pluginsDir)) {
|
||||
for (const entry of fs.readdirSync(pluginsDir)) {
|
||||
const full = path.join(pluginsDir, entry);
|
||||
if (fs.statSync(full).isDirectory()) ids.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ─── 文件生成 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function pascalCase(str) {
|
||||
return str
|
||||
.replace(/[-_]+(\w)/g, (_, c) => c.toUpperCase())
|
||||
.replace(/^(\w)/, (_, c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function generateBundleTs(moduleId, title, subtitle, accentColor) {
|
||||
const screenName = `${pascalCase(moduleId)}Screen`;
|
||||
return `import { UpdateSDK } from '@xuqm/rn-update';
|
||||
import { registerPluginFromBridge } from '@plugins/runtimeBridge';
|
||||
import { ${screenName} } from '@${moduleId}/${screenName}';
|
||||
|
||||
// 自动注册插件版本(bundle 加载时执行)
|
||||
UpdateSDK.registerPlugin({ moduleId: '${moduleId}', version: '1.0.0' });
|
||||
|
||||
// 注册 UI 组件到宿主运行时
|
||||
registerPluginFromBridge({
|
||||
id: '${moduleId}',
|
||||
title: '${title}',
|
||||
subtitle: '${subtitle}',
|
||||
accentColor: '${accentColor}',
|
||||
Component: ${screenName},
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
function generateScreenTs(moduleId) {
|
||||
const screenName = `${pascalCase(moduleId)}Screen`;
|
||||
return `import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
export function ${screenName}() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>${moduleId}</Text>
|
||||
<Text style={styles.subtitle}>TODO: 实现${moduleId}功能</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F5F7FA' },
|
||||
title: { fontSize: 24, fontWeight: '700', color: '#17171A' },
|
||||
subtitle: { fontSize: 14, color: '#666666', marginTop: 8 },
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
function generatePluginJson(moduleId) {
|
||||
return JSON.stringify({ moduleId, version: '1.0.0' }, null, 2) + '\n';
|
||||
}
|
||||
|
||||
// ─── 宿主文件更新 ────────────────────────────────────────────────────────────
|
||||
|
||||
function updatePluginCatalog(root, moduleId, title, accentColor) {
|
||||
const filePath = path.join(root, 'src/app/pluginCatalog.ts');
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn('⚠️ src/app/pluginCatalog.ts 不存在,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
// 检查是否已存在
|
||||
if (content.includes(`id: '${moduleId}'`)) {
|
||||
console.log(` ℹ️ pluginCatalog 已包含 ${moduleId},跳过`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newEntry = ` {
|
||||
id: '${moduleId}',
|
||||
title: '${title}',
|
||||
summary: '${title}业务模块',
|
||||
description: '点击后异步加载 ${moduleId} bundle。',
|
||||
accentColor: '${accentColor}',
|
||||
},`;
|
||||
|
||||
// 在最后一个 ] 之前插入
|
||||
const lastBracket = content.lastIndexOf('];');
|
||||
if (lastBracket === -1) {
|
||||
console.warn('⚠️ pluginCatalog.ts 格式异常,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
content =
|
||||
content.slice(0, lastBracket) + newEntry + '\n' + content.slice(lastBracket);
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
function updateDebugPlugins(root, moduleId) {
|
||||
const filePath = path.join(root, 'src/bootstrap/debugPlugins.ts');
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn('⚠️ src/bootstrap/debugPlugins.ts 不存在,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
if (content.includes(`'${moduleId}'`)) {
|
||||
console.log(` ℹ️ debugPlugins 已包含 ${moduleId},跳过`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newLoader = `
|
||||
registerDebugPluginLoader('${moduleId}', async () => {
|
||||
require('../plugins/${moduleId}/bundle');
|
||||
});`;
|
||||
|
||||
content = content.trimEnd() + newLoader + '\n';
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
function updatePackageJson(root, moduleId) {
|
||||
const filePath = path.join(root, 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
|
||||
const androidKey = `build:android:${moduleId}`;
|
||||
const iosKey = `build:ios:${moduleId}`;
|
||||
|
||||
if (pkg.scripts[androidKey]) {
|
||||
console.log(` ℹ️ package.json 已包含 ${androidKey},跳过`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加构建脚本
|
||||
pkg.scripts[androidKey] =
|
||||
`mkdir -p bundle/android/${moduleId} && react-native bundle --platform android --dev false --entry-file ./src/plugins/${moduleId}/bundle.ts --bundle-output ./bundle/android/${moduleId}/${moduleId}.android.bundle --assets-dest ./bundle/android/${moduleId} --config metro.split.config.js --reset-cache`;
|
||||
pkg.scripts[iosKey] =
|
||||
`mkdir -p bundle/ios/${moduleId} && react-native bundle --platform ios --dev false --entry-file ./src/plugins/${moduleId}/bundle.ts --bundle-output ./bundle/ios/${moduleId}/${moduleId}.ios.bundle --assets-dest ./bundle/ios/${moduleId} --config metro.split.config.js --reset-cache`;
|
||||
|
||||
// 更新 plugins 聚合脚本
|
||||
for (const platform of ['android', 'ios']) {
|
||||
const key = `build:${platform}:plugins`;
|
||||
if (pkg.scripts[key] && !pkg.scripts[key].includes(`${platform}:${moduleId}`)) {
|
||||
pkg.scripts[key] = pkg.scripts[key] + ` && yarn build:${platform}:${moduleId}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 embedded 脚本
|
||||
for (const platform of ['android', 'ios']) {
|
||||
const key = `build:${platform}:embedded`;
|
||||
if (pkg.scripts[key] && !pkg.scripts[key].includes(`${platform}:${moduleId}`)) {
|
||||
// 在 prepare-embedded-bundles 之前插入
|
||||
pkg.scripts[key] = pkg.scripts[key].replace(
|
||||
/ && node \.\/scripts\/prepare/,
|
||||
` && yarn build:${platform}:${moduleId} && node ./scripts/prepare`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
function updateMetroConfig(root, moduleId) {
|
||||
const filePath = path.join(root, 'metro.split.config.js');
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn('⚠️ metro.split.config.js 不存在,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
if (content.includes(`'${moduleId}'`)) {
|
||||
console.log(` ℹ️ metro.split.config 已包含 ${moduleId},跳过`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算 offset:buz1=11M, buz2=12M, buzN=(10+N)M
|
||||
const num = parseInt(moduleId.replace(/\D/g, ''), 10);
|
||||
const offset = Number.isFinite(num) ? (10 + num) * 1_000_000 : 30_000_000;
|
||||
|
||||
// 在 inferOffset 函数中添加新插件的分支
|
||||
const insertPoint = content.lastIndexOf('return MODULE_OFFSETS');
|
||||
if (insertPoint === -1) {
|
||||
console.warn('⚠️ metro.split.config.js 格式异常,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
const newBranch = ` if (modulePath.includes(\`\${path.sep}${moduleId}\${path.sep}\`)) {
|
||||
return ${offset};
|
||||
}
|
||||
|
||||
`;
|
||||
content = content.slice(0, insertPoint) + newBranch + content.slice(insertPoint);
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
function updateBabelAlias(root, moduleId) {
|
||||
const filePath = path.join(root, 'babel.config.js');
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
|
||||
let content = fs.readFileSync(filePath, 'utf-8');
|
||||
if (content.includes(`'@${moduleId}'`)) {
|
||||
console.log(` ℹ️ babel alias 已包含 @${moduleId},跳过`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 在 alias 对象最后一个条目后添加
|
||||
content = content.replace(
|
||||
/('@utils':\s*'[^']+',?\s*)}/,
|
||||
`$1'@${moduleId}': './src/plugins/${moduleId}',\n }}`,
|
||||
);
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
function updateTsconfig(root, moduleId) {
|
||||
const filePath = path.join(root, 'tsconfig.json');
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
|
||||
let content = fs.readFileSync(filePath, 'utf-8');
|
||||
if (content.includes(`"@${moduleId}/*"`)) {
|
||||
console.log(` ℹ️ tsconfig 已包含 @${moduleId},跳过`);
|
||||
return;
|
||||
}
|
||||
|
||||
content = content.replace(
|
||||
/("@utils\/\*":\s*\["src\/utils\/\*"\],?)/,
|
||||
`$1\n "@${moduleId}/*": ["src/plugins/${moduleId}/*"]`,
|
||||
);
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
// ─── 主流程 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function printUsage() {
|
||||
console.log('');
|
||||
console.log('用法:');
|
||||
console.log(' 交互模式: node scripts/create-plugin.mjs');
|
||||
console.log(' 命令行: node scripts/create-plugin.mjs <moduleId> [title] [subtitle] [accentColor]');
|
||||
console.log('');
|
||||
console.log('示例:');
|
||||
console.log(' node scripts/create-plugin.mjs buz4 "IM 消息" "即时通讯业务组件" "#E74C3C"');
|
||||
console.log(' node scripts/create-plugin.mjs buz5');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('');
|
||||
console.log('╔══════════════════════════════════════════╗');
|
||||
console.log('║ XuqmGroup UpdateSDK — 插件脚手架工具 ║');
|
||||
console.log('╚══════════════════════════════════════════╝');
|
||||
|
||||
const root = findProjectRoot();
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// ── 解析参数(支持 CLI 和交互两种模式)──
|
||||
let moduleId, title, subtitle, accentColor;
|
||||
|
||||
if (args.length > 0) {
|
||||
// CLI 模式
|
||||
[moduleId, title, subtitle, accentColor] = args;
|
||||
} else {
|
||||
// 交互模式
|
||||
const rl = createRL();
|
||||
try {
|
||||
moduleId = await ask(rl, '\n插件 ID(如 buz4): ');
|
||||
title = await ask(rl, '插件标题(如 IM 消息): ');
|
||||
subtitle = await ask(rl, '插件副标题(如 即时通讯业务组件): ');
|
||||
accentColor = await ask(rl, '主题色(如 #0E84FA): ');
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 校验 ──
|
||||
if (!moduleId || !/^[a-z][a-z0-9]*$/.test(moduleId)) {
|
||||
console.error('\n❌ moduleId 必须以小写字母开头,只包含小写字母和数字');
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
title = title || moduleId;
|
||||
subtitle = subtitle || `${title}业务组件`;
|
||||
accentColor = accentColor || '#0E84FA';
|
||||
|
||||
// 唯一性校验
|
||||
const existing = getExistingPluginIds(root);
|
||||
if (existing.has(moduleId)) {
|
||||
console.error(`\n❌ moduleId "${moduleId}" 已存在,请使用其他 ID`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`📦 创建插件: ${moduleId}`);
|
||||
console.log(` 标题: ${title}`);
|
||||
console.log(` 副标题: ${subtitle}`);
|
||||
console.log(` 主题色: ${accentColor}`);
|
||||
console.log('');
|
||||
|
||||
// ── 创建目录 ──
|
||||
const pluginDir = path.join(root, 'src/plugins', moduleId);
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
|
||||
// ── 生成文件 ──
|
||||
const files = [
|
||||
{
|
||||
path: path.join(pluginDir, 'bundle.ts'),
|
||||
content: generateBundleTs(moduleId, title, subtitle, accentColor),
|
||||
desc: 'bundle 入口(自动注册 UpdateSDK + pluginRuntime)',
|
||||
},
|
||||
{
|
||||
path: path.join(pluginDir, `${pascalCase(moduleId)}Screen.tsx`),
|
||||
content: generateScreenTs(moduleId),
|
||||
desc: 'Screen 骨架',
|
||||
},
|
||||
{
|
||||
path: path.join(pluginDir, 'plugin.json'),
|
||||
content: generatePluginJson(moduleId),
|
||||
desc: '插件元数据',
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
fs.writeFileSync(file.path, file.content, 'utf-8');
|
||||
console.log(` ✅ ${path.relative(root, file.path)} — ${file.desc}`);
|
||||
}
|
||||
|
||||
// ── 更新宿主配置 ──
|
||||
console.log('');
|
||||
console.log('🔗 更新宿主配置:');
|
||||
|
||||
updatePluginCatalog(root, moduleId, title, accentColor);
|
||||
console.log(' ✅ pluginCatalog.ts');
|
||||
|
||||
updateDebugPlugins(root, moduleId);
|
||||
console.log(' ✅ debugPlugins.ts');
|
||||
|
||||
updatePackageJson(root, moduleId);
|
||||
console.log(' ✅ package.json(构建脚本)');
|
||||
|
||||
updateMetroConfig(root, moduleId);
|
||||
console.log(' ✅ metro.split.config.js');
|
||||
|
||||
updateBabelAlias(root, moduleId);
|
||||
console.log(' ✅ babel.config.js');
|
||||
|
||||
updateTsconfig(root, moduleId);
|
||||
console.log(' ✅ tsconfig.json');
|
||||
|
||||
// ── 完成 ──
|
||||
console.log('');
|
||||
console.log('═══════════════════════════════════════════');
|
||||
console.log(`✅ 插件 ${moduleId} 创建完成!`);
|
||||
console.log('');
|
||||
console.log('下一步:');
|
||||
console.log(` 1. 编辑 src/plugins/${moduleId}/${pascalCase(moduleId)}Screen.tsx 实现业务 UI`);
|
||||
console.log(` 2. 在 src/plugins/${moduleId}/ 下创建 services/、pages/ 等目录`);
|
||||
console.log(` 3. yarn validate 确认无错误`);
|
||||
console.log(` 4. yarn build:android:${moduleId} 构建 Android bundle`);
|
||||
console.log('═══════════════════════════════════════════');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
main();
|
||||
@ -0,0 +1,78 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const NATIVE_EXTENSIONS = new Set([
|
||||
'.c',
|
||||
'.cc',
|
||||
'.cmake',
|
||||
'.cpp',
|
||||
'.gradle',
|
||||
'.h',
|
||||
'.java',
|
||||
'.kts',
|
||||
'.kt',
|
||||
'.properties',
|
||||
'.toml',
|
||||
'.xml',
|
||||
])
|
||||
const IGNORED_DIRECTORIES = new Set(['.cxx', '.gradle', 'build', 'generated'])
|
||||
const IGNORED_FILES = new Set(['local.properties'])
|
||||
|
||||
function walkNativeFiles(directory, root = directory) {
|
||||
if (!existsSync(directory)) return []
|
||||
const files = []
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) continue
|
||||
const absolute = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) files.push(...walkNativeFiles(absolute, root))
|
||||
else if (
|
||||
!IGNORED_FILES.has(entry.name) &&
|
||||
(NATIVE_EXTENSIONS.has(path.extname(entry.name)) || entry.name === 'gradlew')
|
||||
) {
|
||||
files.push({ absolute, relative: path.relative(root, absolute).split(path.sep).join('/') })
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function packageJson(directory) {
|
||||
const file = path.join(directory, 'package.json')
|
||||
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null
|
||||
}
|
||||
|
||||
function nativeDependencyVersions(root, hostPackage) {
|
||||
const dependencies = { ...hostPackage.dependencies, ...hostPackage.devDependencies }
|
||||
const native = []
|
||||
for (const name of Object.keys(dependencies).sort()) {
|
||||
const directory = path.join(root, 'node_modules', ...name.split('/'))
|
||||
const manifest = packageJson(directory)
|
||||
if (!manifest) continue
|
||||
const hasNativeCode =
|
||||
existsSync(path.join(directory, 'android')) ||
|
||||
existsSync(path.join(directory, 'react-native.config.js')) ||
|
||||
manifest.codegenConfig !== undefined
|
||||
if (hasNativeCode || name === 'react-native') {
|
||||
native.push(`${name}@${manifest.version ?? dependencies[name]}`)
|
||||
}
|
||||
}
|
||||
return native
|
||||
}
|
||||
|
||||
/** Deterministic identity of code and dependencies that can only change through a full APK. */
|
||||
export function computeNativeBaseline(root = process.cwd()) {
|
||||
const hostPackage = packageJson(root)
|
||||
if (!hostPackage) throw new Error('package.json is missing')
|
||||
const hash = createHash('sha256')
|
||||
for (const dependency of nativeDependencyVersions(root, hostPackage)) {
|
||||
hash.update(`dependency\0${dependency}\0`)
|
||||
}
|
||||
for (const file of walkNativeFiles(path.join(root, 'android')).sort((a, b) =>
|
||||
a.relative.localeCompare(b.relative),
|
||||
)) {
|
||||
hash.update(`file\0${file.relative}\0`)
|
||||
hash.update(readFileSync(file.absolute))
|
||||
hash.update('\0')
|
||||
}
|
||||
return `android-sha256:${hash.digest('hex')}`
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { computeNativeBaseline } from './native-baseline.mjs'
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-native-baseline-'))
|
||||
mkdirSync(path.join(root, 'android', 'app', 'src', 'main', 'java'), { recursive: true })
|
||||
mkdirSync(path.join(root, 'src'), { recursive: true })
|
||||
writeFileSync(path.join(root, 'package.json'), JSON.stringify({ dependencies: {} }))
|
||||
writeFileSync(path.join(root, 'android', 'settings.gradle'), 'rootProject.name = "fixture"')
|
||||
writeFileSync(path.join(root, 'src', 'business.ts'), 'export const value = 1')
|
||||
return root
|
||||
}
|
||||
|
||||
test('native source changes the baseline while ordinary JS does not', () => {
|
||||
const root = fixture()
|
||||
try {
|
||||
const initial = computeNativeBaseline(root)
|
||||
writeFileSync(path.join(root, 'src', 'business.ts'), 'export const value = 2')
|
||||
assert.equal(computeNativeBaseline(root), initial)
|
||||
writeFileSync(path.join(root, 'android', 'app', 'src', 'main', 'java', 'Main.kt'), 'class Main')
|
||||
assert.notEqual(computeNativeBaseline(root), initial)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const cwd = new URL('..', import.meta.url).pathname
|
||||
const result = JSON.parse(
|
||||
execFileSync('npm', ['pack', '--json', '--ignore-scripts'], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, npm_config_cache: path.join(tmpdir(), 'xuqm-rn-npm-cache') },
|
||||
}),
|
||||
)[0]
|
||||
const required = [
|
||||
'android/src/main/AndroidManifest.xml',
|
||||
'android/src/main/java/com/xuqm/update/XuqmBundleModule.java',
|
||||
'android/src/main/java/com/xuqm/update/XuqmAppUpdateModule.java',
|
||||
'android/build.gradle',
|
||||
'android/xuqm-bundles.gradle',
|
||||
'scripts/xuqm-rn.mjs',
|
||||
'scripts/xuqm-config.mjs',
|
||||
'scripts/xuqm_release.mjs',
|
||||
'src/UpdateSDK.ts',
|
||||
'src/NativeBundle.ts',
|
||||
'src/NativeAppUpdate.ts',
|
||||
'src/releaseSet.ts',
|
||||
'react-native.config.js',
|
||||
]
|
||||
const files = new Set(result.files.map(file => file.path))
|
||||
const missing = required.filter(file => !files.has(file))
|
||||
rmSync(path.join(cwd, result.filename), { force: true })
|
||||
if (missing.length) throw new Error(`Published package is missing:\n${missing.join('\n')}`)
|
||||
const pkg = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'))
|
||||
if (!pkg.bin?.['xuqm-rn']) throw new Error('package.json bin.xuqm-rn is missing')
|
||||
if (files.has('src/md5.ts') || [...files].some(file => file.startsWith('ios/'))) {
|
||||
throw new Error('Android-only package must not publish legacy MD5 or unverified iOS code')
|
||||
}
|
||||
console.log(`Package content verified (${result.files.length} files).`)
|
||||
@ -0,0 +1,159 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import semver from 'semver'
|
||||
|
||||
export const CONFIG_FILE_NAME = 'xuqm.config.json'
|
||||
export const SUPPORTED_PLATFORMS = new Set(['android', 'ios'])
|
||||
export const SUPPORTED_MODULE_TYPES = new Set(['startup', 'common', 'app', 'buz'])
|
||||
|
||||
export function readConfig(root = process.cwd()) {
|
||||
const configFile = path.join(root, CONFIG_FILE_NAME)
|
||||
if (!existsSync(configFile)) {
|
||||
throw new Error(`missing ${CONFIG_FILE_NAME}; run \`xuqm-rn init\` first`)
|
||||
}
|
||||
let config
|
||||
try {
|
||||
config = JSON.parse(readFileSync(configFile, 'utf8'))
|
||||
} catch (error) {
|
||||
throw new Error(`invalid ${CONFIG_FILE_NAME}: ${error.message}`)
|
||||
}
|
||||
if (config.schemaVersion !== 3) {
|
||||
throw new Error(`${CONFIG_FILE_NAME} schemaVersion must be 3`)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
export function validateConfig(config, root = process.cwd()) {
|
||||
const errors = []
|
||||
if (!config.appId) errors.push('appId is required')
|
||||
if (!config.mainModuleName) errors.push('mainModuleName is required')
|
||||
if (typeof config.pluginVersion !== 'string' || !semver.valid(config.pluginVersion)) {
|
||||
errors.push('pluginVersion must be SemVer')
|
||||
}
|
||||
if (typeof config.appVersionRange !== 'string' || !semver.validRange(config.appVersionRange)) {
|
||||
errors.push('appVersionRange must be a valid SemVer range')
|
||||
}
|
||||
if (!Array.isArray(config.modules) || config.modules.length === 0) {
|
||||
errors.push('modules must contain at least one module')
|
||||
return errors
|
||||
}
|
||||
|
||||
const ids = new Set()
|
||||
for (const module of config.modules) {
|
||||
if (!/^[a-z][a-z0-9-]*$/.test(module.id ?? '')) {
|
||||
errors.push(`invalid module id: ${module.id ?? '<missing>'}`)
|
||||
}
|
||||
if (ids.has(module.id)) errors.push(`duplicate module id: ${module.id}`)
|
||||
ids.add(module.id)
|
||||
if (!SUPPORTED_MODULE_TYPES.has(module.type)) {
|
||||
errors.push(`invalid module type for ${module.id}: ${module.type ?? '<missing>'}`)
|
||||
}
|
||||
if (!module.entry || !existsSync(path.resolve(root, module.entry))) {
|
||||
errors.push(`module entry not found for ${module.id}: ${module.entry ?? '<missing>'}`)
|
||||
}
|
||||
if (module.moduleVersion !== undefined && typeof module.moduleVersion !== 'string') {
|
||||
errors.push(`moduleVersion must be a string for ${module.id}`)
|
||||
} else if (module.moduleVersion !== undefined && !semver.valid(module.moduleVersion)) {
|
||||
errors.push(`moduleVersion must be SemVer for ${module.id}`)
|
||||
}
|
||||
if (['app', 'buz'].includes(module.type) && module.commonVersionRange !== undefined) {
|
||||
if (
|
||||
typeof module.commonVersionRange !== 'string' ||
|
||||
!semver.validRange(module.commonVersionRange)
|
||||
) {
|
||||
errors.push(`commonVersionRange must be a valid SemVer range for ${module.id}`)
|
||||
}
|
||||
}
|
||||
if (
|
||||
module.minNativeApiLevel !== undefined &&
|
||||
(!Number.isInteger(module.minNativeApiLevel) || module.minNativeApiLevel < 1)
|
||||
) {
|
||||
errors.push(`minNativeApiLevel must be a positive integer for ${module.id}`)
|
||||
}
|
||||
if (module.metroConfig && !existsSync(path.resolve(root, module.metroConfig))) {
|
||||
errors.push(`Metro config not found for ${module.id}: ${module.metroConfig}`)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
export function readPackageVersion(root = process.cwd()) {
|
||||
const packageFile = path.join(root, 'package.json')
|
||||
if (!existsSync(packageFile)) throw new Error('package.json is missing')
|
||||
const pkg = JSON.parse(readFileSync(packageFile, 'utf8'))
|
||||
if (typeof pkg.version !== 'string' || pkg.version.trim() === '') {
|
||||
throw new Error('package.json version must be a non-empty string')
|
||||
}
|
||||
return pkg.version.trim()
|
||||
}
|
||||
|
||||
export function compatibleAppRange(appVersion) {
|
||||
const parsed = semver.parse(appVersion)
|
||||
if (!parsed) throw new Error(`app version must be SemVer: ${appVersion}`)
|
||||
return `>=${parsed.version} <${parsed.major + 1}.0.0`
|
||||
}
|
||||
|
||||
export function resolveVersionedModules(config, root = process.cwd(), appVersionOverride) {
|
||||
const appVersion = appVersionOverride?.trim() || readPackageVersion(root)
|
||||
const pluginVersion = semver.valid(config.pluginVersion)
|
||||
if (!pluginVersion) throw new Error('pluginVersion must be SemVer')
|
||||
const common = config.modules.find(module => module.type === 'common')
|
||||
const commonVersion = common?.moduleVersion ?? pluginVersion
|
||||
const commonSemVer = semver.parse(commonVersion)
|
||||
if (!commonSemVer) throw new Error(`common module version must be SemVer: ${commonVersion}`)
|
||||
const defaultCommonRange = `>=${commonSemVer.version} <${commonSemVer.major + 1}.0.0`
|
||||
return {
|
||||
appVersion,
|
||||
modules: config.modules.map(module => ({
|
||||
...module,
|
||||
moduleVersion: module.moduleVersion ?? pluginVersion,
|
||||
appVersionRange: module.appVersionRange ?? config.appVersionRange,
|
||||
...(module.commonVersionRange === undefined && ['app', 'buz'].includes(module.type)
|
||||
? { commonVersionRange: defaultCommonRange }
|
||||
: {}),
|
||||
minNativeApiLevel: module.minNativeApiLevel ?? 1,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function selectModules(config, moduleIds = []) {
|
||||
if (moduleIds.length === 0) return config.modules
|
||||
const requested = new Set(moduleIds)
|
||||
const selected = config.modules.filter(module => requested.has(module.id))
|
||||
const found = new Set(selected.map(module => module.id))
|
||||
const missing = [...requested].filter(id => !found.has(id))
|
||||
if (missing.length) throw new Error(`unknown module(s): ${missing.join(', ')}`)
|
||||
return selected
|
||||
}
|
||||
|
||||
export function bundleOutputDirectory(root, config, platform, moduleId) {
|
||||
return path.resolve(root, config.outputDir ?? './bundle', platform, moduleId)
|
||||
}
|
||||
|
||||
export function bundleFile(root, config, platform, moduleId) {
|
||||
return path.join(
|
||||
bundleOutputDirectory(root, config, platform, moduleId),
|
||||
`${moduleId}.${platform}.bundle`,
|
||||
)
|
||||
}
|
||||
|
||||
export function parseModuleOptions(args) {
|
||||
const moduleIds = []
|
||||
const remaining = []
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
if (args[index] === '--module') {
|
||||
const value = args[index + 1]
|
||||
if (!value) throw new Error('--module requires a module id')
|
||||
moduleIds.push(
|
||||
...value
|
||||
.split(',')
|
||||
.map(id => id.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
index += 1
|
||||
} else {
|
||||
remaining.push(args[index])
|
||||
}
|
||||
}
|
||||
return { moduleIds: [...new Set(moduleIds)], remaining }
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { resolveVersionedModules, validateConfig } from './xuqm-config.mjs'
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-config-'))
|
||||
writeFileSync(path.join(root, 'package.json'), JSON.stringify({ version: '7.2.14' }))
|
||||
writeFileSync(path.join(root, 'common.ts'), '')
|
||||
writeFileSync(path.join(root, 'app.ts'), '')
|
||||
return root
|
||||
}
|
||||
|
||||
test('plugins start at the configured plugin version independently from the app version', () => {
|
||||
const root = fixture()
|
||||
try {
|
||||
const config = {
|
||||
appId: 'example',
|
||||
mainModuleName: 'Example',
|
||||
pluginVersion: '1.0.0',
|
||||
appVersionRange: '>=7.2.14 <8.0.0',
|
||||
modules: [
|
||||
{ id: 'common', type: 'common', entry: './common.ts' },
|
||||
{ id: 'app', type: 'app', entry: './app.ts' },
|
||||
],
|
||||
}
|
||||
assert.deepEqual(validateConfig(config, root), [])
|
||||
const resolved = resolveVersionedModules(config, root)
|
||||
assert.equal(resolved.appVersion, '7.2.14')
|
||||
assert.equal(resolved.modules[0].moduleVersion, '1.0.0')
|
||||
assert.equal(resolved.modules[1].moduleVersion, '1.0.0')
|
||||
assert.equal(resolved.modules[1].commonVersionRange, '>=1.0.0 <2.0.0')
|
||||
assert.equal(resolved.modules[1].minNativeApiLevel, 1)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('an explicitly independently released module keeps its own version', () => {
|
||||
const root = fixture()
|
||||
try {
|
||||
const resolved = resolveVersionedModules(
|
||||
{
|
||||
pluginVersion: '1.0.0',
|
||||
appVersionRange: '>=8.0.0 <9.0.0',
|
||||
modules: [
|
||||
{ id: 'common', type: 'common', moduleVersion: '3.0.0' },
|
||||
{ id: 'feature', type: 'buz', moduleVersion: '4.1.0' },
|
||||
],
|
||||
},
|
||||
root,
|
||||
'8.0.0',
|
||||
)
|
||||
assert.equal(resolved.appVersion, '8.0.0')
|
||||
assert.equal(resolved.modules[0].moduleVersion, '3.0.0')
|
||||
assert.equal(resolved.modules[1].moduleVersion, '4.1.0')
|
||||
assert.equal(resolved.modules[1].commonVersionRange, '>=3.0.0 <4.0.0')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
某些文件未显示,因为此 diff 中更改的文件太多 显示更多
正在加载...
在新工单中引用
屏蔽一个用户