XuqmGroup-RNSDK/packages/update/src/UpdateSDK.ts
XuqmGroup 16750b0421 feat: rn-update 进度回调 + rn-xwebview JSBridge + rn-log v0.1.0 新建
Agent 3 — rn-update:
- downloadPlugin/downloadApk 新增 onProgress 进度回调
- checkAppUpdate/checkPluginUpdate 版本缓存(30分钟 TTL)
- 新增 UpdateDownloadProgress 类型导出

Agent 3 — rn-xwebview:
- XWebViewBridge 补全标准 JSBridge handler
- getDeviceInfo/getToken/getUserInfo/openNativePage/closeWebView/showToast

Agent 4 — rn-log v0.1.0:
- XLog 主入口:event/captureError/warn/info/startCapture
- LogQueue:AsyncStorage 本地队列 + 批量上报
- ErrorCapture:JS global error + unhandledRejection
- FunnelTracker:漏斗分析
- fingerprint:SHA-256 指纹去重
- HttpInterceptor:rn-common HTTP 错误自动上报
- NativeLogReporter:TurboModule spec
2026-06-16 12:10:28 +08:00

462 行
16 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

import AsyncStorage from '@react-native-async-storage/async-storage'
import { Linking, Platform } from 'react-native'
import { apiRequest, getConfig, getUserId, _registerUserInfoHandler } from '@xuqm/rn-common'
import { getAppVersionCode, getAppVersionName, _devSetAppVersion } from './NativeVersion'
// ─── Types (public) ────────────────────────────────────────────────────────────
/** 插件注册(版本号由 SDK 自动获取,不需要 App 传入) */
export interface PluginRegistration {
moduleId: string
}
/** @deprecated 使用 PluginRegistration移除 version 字段) */
export interface PluginMeta extends PluginRegistration {
version?: string
}
export interface AppUpdateInfo {
needsUpdate: boolean
versionName?: string
versionCode?: number
downloadUrl?: string
changeLog?: string
forceUpdate?: boolean
appStoreUrl?: string
marketUrl?: string
requiresLogin?: boolean
alreadyDownloaded?: boolean
apkHash?: string | null
}
export interface PluginUpdateInfo {
needsUpdate: boolean
latestVersion: string
currentVersion: string
downloadUrl: string
md5: string
minCommonVersion: string
note: string
forceUpdate?: boolean
changeLog?: string
}
export type UpdateDownloadProgress = {
bytesDownloaded: number
totalBytes: number
/** 0-100 */
percent: number
}
export type { CachedRnBundle }
interface CachedRnBundle {
moduleId: string
version: string
md5: string
downloadedAt: string
source: string
}
// ─── Internal state ────────────────────────────────────────────────────────────
const _pluginRegistry = new Set<string>()
// ─── Update check cache ────────────────────────────────────────────────────────
const UPDATE_APP_CACHE_KEY = 'xuqm_update_app_cache'
const UPDATE_PLUGIN_CACHE_KEY_PREFIX = 'xuqm_update_plugin_cache_'
const UPDATE_CACHE_TTL_MS = 30 * 60 * 1000 // 30 minutes
async function _readUpdateCache<T>(key: string): Promise<T | null> {
try {
const raw = await AsyncStorage.getItem(key)
if (!raw) return null
const cached = JSON.parse(raw) as { ts: number; data: T }
if (Date.now() - cached.ts < UPDATE_CACHE_TTL_MS) return cached.data
return null
} catch {
return null
}
}
async function _writeUpdateCache<T>(key: string, data: T): Promise<void> {
try {
await AsyncStorage.setItem(key, JSON.stringify({ ts: Date.now(), data }))
} catch {
// cache write failure is non-fatal
}
}
let _writeBundleCallback: ((moduleId: string, source: string) => Promise<void>) | null = null
let _reloadBundleCallback: ((moduleId: string) => Promise<void>) | null = null
function bundleCacheKey(moduleId: string) {
return `@xuqm:bundle:${moduleId}`
}
async function _getCachedVersion(moduleId: string): Promise<string> {
const raw = await AsyncStorage.getItem(bundleCacheKey(moduleId)).catch(() => null)
if (!raw) return '0.0.0'
const cached = JSON.parse(raw) as CachedRnBundle
return cached.version ?? '0.0.0'
}
function normalizeDownloadUrl(rawUrl?: string): string | undefined {
if (!rawUrl) return rawUrl
if (rawUrl.includes('/api/v1/updates/api/v1/rn/files/')) {
return rawUrl.replace('/api/v1/updates/api/v1/rn/files/', '/api/v1/rn/files/')
}
if (rawUrl.includes('/files/apk/')) {
try {
const url = new URL(rawUrl)
if (url.pathname.startsWith('/files/apk/')) {
return `${url.origin}/api/v1/updates${url.pathname}${url.search}`
}
} catch {}
}
return rawUrl
}
async function _downloadText(url: string, onProgress?: (p: number) => void): Promise<string> {
const response = await fetch(url)
if (!response.ok) throw new Error(`[UpdateSDK] Download failed: ${response.status}`)
if (!onProgress) return response.text()
const contentLength = Number(response.headers.get('Content-Length') ?? '0')
const reader = response.body?.getReader()
if (!reader) return response.text()
const chunks: Uint8Array[] = []
let received = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.length
if (contentLength > 0) onProgress(received / contentLength)
}
const decoder = new TextDecoder()
return chunks.map(c => decoder.decode(c, { stream: true })).join('') + decoder.decode()
}
async function _downloadBinary(url: string, onProgress?: (p: number) => void): Promise<ArrayBuffer> {
const response = await fetch(url)
if (!response.ok) throw new Error(`[UpdateSDK] Download failed: ${response.status}`)
if (!onProgress) return response.arrayBuffer()
const contentLength = Number(response.headers.get('Content-Length') ?? '0')
const reader = response.body?.getReader()
if (!reader) return response.arrayBuffer()
const chunks: Uint8Array[] = []
let received = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.length
if (contentLength > 0) onProgress(received / contentLength)
}
const totalLength = chunks.reduce((acc, c) => acc + c.length, 0)
const combined = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
combined.set(chunk, offset)
offset += chunk.length
}
return combined.buffer
}
// ─── 订阅 setUserInfouserId 用于定向更新) ──────────────────────────────────
_registerUserInfoHandler(() => {
// updatePlugin 检查更新时会实时调用 getUserId(),此处无需额外操作
})
// ─── UpdateSDK ────────────────────────────────────────────────────────────────
export const UpdateSDK = {
// ── 宿主注入(插件化写入/重载能力)──────────────────────────────────────────
/**
* 注入 bundle 写入和重载回调。
* 宿主在初始化时调用(如 src/core/updater.ts,注入 BundleRuntime 能力。
*
* @example
* UpdateSDK.setBundleCallbacks({
* writeBundle: writeBundleFile,
* reloadBundle: loadBundle,
* })
*/
setBundleCallbacks(callbacks: {
writeBundle: (moduleId: string, source: string) => Promise<void>
reloadBundle: (moduleId: string) => Promise<void>
}): void {
_writeBundleCallback = callbacks.writeBundle
_reloadBundleCallback = callbacks.reloadBundle
},
// ── 插件注册 ──────────────────────────────────────────────────────────────
/**
* 批量注册插件。版本号由 SDK 自动从本地缓存获取,不需要 App 传入。
*
* @example
* UpdateSDK.registerPlugins([{ moduleId: 'buz1' }, { moduleId: 'buz2' }])
*/
registerPlugins(plugins: PluginRegistration[]): void {
for (const p of plugins) {
_pluginRegistry.add(p.moduleId)
}
},
/**
* 注册单个插件向后兼容;version 字段已废弃,SDK 自动获取)。
*/
registerPlugin(meta: PluginRegistration): void {
_pluginRegistry.add(meta.moduleId)
},
getRegisteredPlugins(): string[] {
return Array.from(_pluginRegistry)
},
// ── App 整包更新 ──────────────────────────────────────────────────────────
async checkAppUpdate(bypassIgnore?: boolean): Promise<AppUpdateInfo> {
// Return cached result if within TTL (cache is skipped when bypassIgnore is set)
if (!bypassIgnore) {
const cached = await _readUpdateCache<AppUpdateInfo>(UPDATE_APP_CACHE_KEY)
if (cached) return cached
}
const config = getConfig()
const params: Record<string, string> = {
appKey: config.appKey,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
currentVersionCode: String(getAppVersionCode()),
}
const userId = getUserId()?.trim()
if (userId) params.userId = userId
if (bypassIgnore) params.bypassIgnore = 'true'
const result = await apiRequest<AppUpdateInfo>('/api/v1/updates/app/check', {
skipAuth: true,
params,
})
const normalized = { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) }
await _writeUpdateCache(UPDATE_APP_CACHE_KEY, normalized)
return normalized
},
async openStore(appStoreUrl?: string, marketUrl?: string): Promise<void> {
const url = Platform.OS === 'ios' ? appStoreUrl : marketUrl
if (url) await Linking.openURL(url)
},
/**
* 下载 APK 文件,返回 ArrayBuffer。
* 支持进度回调UpdateDownloadProgress,向下兼容options 可选)。
*/
async downloadApk(
updateInfo: AppUpdateInfo,
options?: {
onProgress?: (progress: UpdateDownloadProgress) => void
},
): Promise<ArrayBuffer> {
const url = updateInfo.downloadUrl
if (!url) throw new Error('[UpdateSDK] downloadApk: no downloadUrl in updateInfo')
if (!options?.onProgress) {
return _downloadBinary(url)
}
// Streaming download with detailed progress
const response = await fetch(url)
if (!response.ok) throw new Error(`[UpdateSDK] downloadApk failed: ${response.status}`)
const contentLength = Number(response.headers.get('Content-Length') ?? '0')
const reader = response.body?.getReader()
if (!reader) return response.arrayBuffer()
const chunks: Uint8Array[] = []
let received = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.length
options.onProgress({
bytesDownloaded: received,
totalBytes: contentLength,
percent: contentLength > 0 ? Math.min(100, Math.round((received / contentLength) * 100)) : 0,
})
}
const totalLength = chunks.reduce((acc, c) => acc + c.length, 0)
const combined = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
combined.set(chunk, offset)
offset += chunk.length
}
return combined.buffer
},
/**
* 下载插件 bundle,返回 bundle 文本内容JS 源码)。
* 支持进度回调UpdateDownloadProgress,向下兼容options 可选)。
*/
async downloadPlugin(
moduleId: string,
updateInfo: PluginUpdateInfo,
options?: {
onProgress?: (progress: UpdateDownloadProgress) => void
},
): Promise<string> {
if (!updateInfo.downloadUrl) {
throw new Error(`[UpdateSDK] downloadPlugin(${moduleId}): no downloadUrl in updateInfo`)
}
if (!options?.onProgress) {
return _downloadText(updateInfo.downloadUrl)
}
// Streaming download with detailed progress
const response = await fetch(updateInfo.downloadUrl)
if (!response.ok) throw new Error(`[UpdateSDK] downloadPlugin failed: ${response.status}`)
const contentLength = Number(response.headers.get('Content-Length') ?? '0')
const reader = response.body?.getReader()
if (!reader) return response.text()
const chunks: Uint8Array[] = []
let received = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.length
options.onProgress({
bytesDownloaded: received,
totalBytes: contentLength,
percent: contentLength > 0 ? Math.min(100, Math.round((received / contentLength) * 100)) : 0,
})
}
const decoder = new TextDecoder()
return chunks.map(c => decoder.decode(c, { stream: true })).join('') + decoder.decode()
},
/**
* Android APK 直接下载并调起系统安装器。
*/
async downloadAndInstallApk(
downloadUrl: string,
options?: {
onProgress?: (progress: number) => void
sha256?: string
},
): Promise<void> {
if (Platform.OS !== 'android') throw new Error('[UpdateSDK] APK install is Android-only.')
// NativeApkInstall 由宿主提供TurboModule,若未注入则 fallback 到 Linking
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { XuqmApkInstall } = require('./NativeApkInstall') as { XuqmApkInstall: { installApk(url: string, sha256?: string | null): Promise<void> } }
await XuqmApkInstall.installApk(downloadUrl, options?.sha256 ?? null)
} catch {
// Native module not available — fallback to Linking
await Linking.openURL(downloadUrl)
}
},
// ── 插件Bundle热更新 ──────────────────────────────────────────────────
/**
* 检查指定插件的更新。
*
* 版本号由 SDK 自动从本地 AsyncStorage 缓存读取(首次为 0.0.0)。
* 插件必须先通过 registerPlugin / registerPlugins 注册。
*/
async checkPluginUpdate(moduleId: string): Promise<PluginUpdateInfo> {
if (!_pluginRegistry.has(moduleId)) {
throw new Error(
`[UpdateSDK] Plugin "${moduleId}" not registered. ` +
'Call UpdateSDK.registerPlugins([{ moduleId }]) first.',
)
}
// Return cached result if within TTL
const cacheKey = `${UPDATE_PLUGIN_CACHE_KEY_PREFIX}${moduleId}`
const cached = await _readUpdateCache<PluginUpdateInfo>(cacheKey)
if (cached) return cached
const config = getConfig()
const currentVersion = await _getCachedVersion(moduleId)
const userId = getUserId()?.trim()
const params: Record<string, string> = {
appKey: config.appKey,
moduleId,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
currentVersion,
}
if (userId) params.userId = userId
const result = await apiRequest<PluginUpdateInfo>('/api/v1/rn/update/check', {
skipAuth: true,
params,
})
const normalized = {
...result,
currentVersion,
downloadUrl: normalizeDownloadUrl(result.downloadUrl) ?? result.downloadUrl,
}
await _writeUpdateCache(cacheKey, normalized)
return normalized
},
/**
* 执行插件更新(一步完成)。
*
* SDK 内部自动:检查版本 → 下载 bundle → 写入文件系统 → 触发重载silent = false 时)。
*
* 需先调用 setBundleCallbacks 注入写入/重载能力。
*
* @param silent true = 静默更新,下次启动生效;false默认= 立即重载
*/
async updatePlugin(
moduleId: string,
options?: {
onProgress?: (progress: number) => void
silent?: boolean
},
): Promise<void> {
const info = await UpdateSDK.checkPluginUpdate(moduleId)
if (!info.needsUpdate) return
const source = await _downloadText(info.downloadUrl, options?.onProgress)
// 写入 AsyncStorage 缓存(版本记录)
const cachePayload: CachedRnBundle = {
moduleId,
version: info.latestVersion,
md5: info.md5,
source,
downloadedAt: new Date().toISOString(),
}
await AsyncStorage.setItem(bundleCacheKey(moduleId), JSON.stringify(cachePayload))
// 写入宿主文件系统(需注入 setBundleCallbacks
if (_writeBundleCallback) {
await _writeBundleCallback(moduleId, source)
}
// 触发重载(非静默模式)
if (!options?.silent && _reloadBundleCallback) {
await _reloadBundleCallback(moduleId)
}
},
// ── 版本信息 ──────────────────────────────────────────────────────────────
getAppVersionCode,
getAppVersionName,
_devSetAppVersion(versionCode: number, versionName?: string): void {
_devSetAppVersion(versionCode, versionName)
},
}