2026-04-24 16:16:31 +08:00
|
|
|
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
|
|
|
|
import { Linking, Platform } from 'react-native'
|
2026-05-08 12:00:34 +08:00
|
|
|
|
import { apiRequest, getConfig, getUserId } from '@xuqm/rn-common'
|
2026-04-24 16:16:31 +08:00
|
|
|
|
import { getAppVersionCode, getAppVersionName, _devSetAppVersion } from './NativeVersion'
|
2026-05-22 17:57:01 +08:00
|
|
|
|
import { awaitInitialization } from '@xuqm/rn-common'
|
2026-04-24 16:16:31 +08:00
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** 插件注册元数据 */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
export interface PluginMeta {
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** 插件唯一标识,如 'buz1'、'buz2' */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
moduleId: string
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** 当前 bundle 版本号 */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
version: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* App 整包更新信息。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 对齐 Android SDK 的 UpdateInfo 模型。
|
|
|
|
|
|
* SDK 只返回数据,UI 由 app 层自行处理。
|
|
|
|
|
|
*/
|
2026-04-24 16:16:31 +08:00
|
|
|
|
export interface AppUpdateInfo {
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** 是否需要更新 */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
needsUpdate: boolean
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** 最新版本名,如 '2.1.0' */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
versionName?: string
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** 最新版本号(整数) */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
versionCode?: number
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** APK/安装包直接下载地址(Android 有此字段时优先下载) */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
downloadUrl?: string
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** 更新日志 */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
changeLog?: string
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** 是否强制更新(true 时不允许跳过) */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
forceUpdate?: boolean
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** iOS App Store 地址 */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
appStoreUrl?: string
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** Android 应用商店地址(华为/小米/OPPO 等) */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
marketUrl?: string
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** 服务端要求登录后才能检查(true 时 needsUpdate 通常为 false) */
|
|
|
|
|
|
requiresLogin?: boolean
|
|
|
|
|
|
/** SDK 内部标记:APK 是否已下载到本地(仅 Android) */
|
|
|
|
|
|
alreadyDownloaded?: boolean
|
|
|
|
|
|
/** APK 文件 SHA-256 校验值 */
|
|
|
|
|
|
apkHash?: string | null
|
2026-04-24 16:16:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 插件(RN Bundle)更新信息。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 插件唯一标识 = appKey + platform + moduleId。
|
|
|
|
|
|
* - appKey:应用标识(来自配置文件)
|
|
|
|
|
|
* - platform:ANDROID / IOS
|
|
|
|
|
|
* - moduleId:插件 ID(如 'buz1')
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface PluginUpdateInfo {
|
|
|
|
|
|
/** 是否需要更新 */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
needsUpdate: boolean
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** 最新版本号 */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
latestVersion: string
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** bundle 下载地址 */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
downloadUrl: string
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** bundle 文件 MD5 */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
md5: string
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** 要求的最低 common bundle 版本 */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
minCommonVersion: string
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** 更新说明 */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
note: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** 已缓存的 bundle 元数据 */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
export interface CachedRnBundle {
|
|
|
|
|
|
moduleId: string
|
|
|
|
|
|
version: string
|
|
|
|
|
|
md5: string
|
|
|
|
|
|
downloadedAt: string
|
|
|
|
|
|
source: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
// ─── Internal ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
|
const _pluginRegistry = new Map<string, PluginMeta>()
|
|
|
|
|
|
|
|
|
|
|
|
function bundleCacheKey(moduleId: string) {
|
|
|
|
|
|
return `@xuqm:bundle:${moduleId}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
// ─── UpdateSDK ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
|
export const UpdateSDK = {
|
2026-06-15 01:44:20 +08:00
|
|
|
|
|
|
|
|
|
|
// ── 插件注册 ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
|
/**
|
2026-06-15 01:44:20 +08:00
|
|
|
|
* 注册插件元数据。在插件 bundle 入口文件顶部调用。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 插件唯一标识 = moduleId(如 'buz1')。
|
|
|
|
|
|
* 完整定位 = appKey(配置文件)+ platform(自动检测)+ moduleId。
|
2026-04-24 16:16:31 +08:00
|
|
|
|
*
|
|
|
|
|
|
* @example
|
2026-06-15 01:44:20 +08:00
|
|
|
|
* // src/plugins/buz1/bundle.ts
|
|
|
|
|
|
* UpdateSDK.registerPlugin({ moduleId: 'buz1', version: '1.0.0' })
|
2026-04-24 16:16:31 +08:00
|
|
|
|
*/
|
|
|
|
|
|
registerPlugin(meta: PluginMeta): void {
|
|
|
|
|
|
_pluginRegistry.set(meta.moduleId, meta)
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-15 01:44:20 +08:00
|
|
|
|
* 获取已注册的插件版本号。
|
2026-04-24 16:16:31 +08:00
|
|
|
|
*/
|
2026-06-15 01:44:20 +08:00
|
|
|
|
getRegisteredPluginVersion(moduleId: string): string | undefined {
|
|
|
|
|
|
return _pluginRegistry.get(moduleId)?.version
|
2026-04-24 16:16:31 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-15 01:44:20 +08:00
|
|
|
|
* 获取所有已注册的插件列表。
|
2026-04-24 16:16:31 +08:00
|
|
|
|
*/
|
2026-06-15 01:44:20 +08:00
|
|
|
|
getRegisteredPlugins(): PluginMeta[] {
|
|
|
|
|
|
return Array.from(_pluginRegistry.values())
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// ── App 整包更新 ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 检查 App 整包更新。
|
|
|
|
|
|
*
|
|
|
|
|
|
* SDK 自动检测平台(iOS/Android)并传给服务端。
|
|
|
|
|
|
* 服务端根据平台返回对应的版本信息和下载/商店地址。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 对齐 Android SDK UpdateSDK.checkAppUpdate():
|
|
|
|
|
|
* - 返回完整更新信息,app 层自行决定 UI
|
|
|
|
|
|
* - Android:有 downloadUrl 时下载 APK;无 downloadUrl 时用 marketUrl 跳转商店
|
|
|
|
|
|
* - iOS:用 appStoreUrl 跳转 App Store
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param bypassIgnore false(默认)= 静默检查,跳过用户已忽略的版本
|
|
|
|
|
|
* true = 用户主动检查,不跳过
|
|
|
|
|
|
*/
|
|
|
|
|
|
async checkAppUpdate(bypassIgnore?: boolean): Promise<AppUpdateInfo> {
|
2026-05-22 17:57:01 +08:00
|
|
|
|
await awaitInitialization()
|
2026-04-24 16:16:31 +08:00
|
|
|
|
const config = getConfig()
|
|
|
|
|
|
const currentVersionCode = getAppVersionCode()
|
2026-05-08 12:00:34 +08:00
|
|
|
|
const userId = getUserId()?.trim()
|
|
|
|
|
|
const params: Record<string, string> = {
|
|
|
|
|
|
appKey: config.appKey,
|
|
|
|
|
|
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
|
|
|
|
|
|
currentVersionCode: String(currentVersionCode),
|
|
|
|
|
|
}
|
2026-06-15 01:44:20 +08:00
|
|
|
|
if (userId) params.userId = userId
|
|
|
|
|
|
if (bypassIgnore) params.bypassIgnore = 'true'
|
|
|
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
|
const result = await apiRequest<AppUpdateInfo>('/api/v1/updates/app/check', {
|
|
|
|
|
|
skipAuth: true,
|
2026-05-08 12:00:34 +08:00
|
|
|
|
params,
|
2026-04-24 16:16:31 +08:00
|
|
|
|
})
|
|
|
|
|
|
return { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) }
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 打开应用商店。
|
|
|
|
|
|
* iOS 使用 appStoreUrl,Android 使用 marketUrl。
|
|
|
|
|
|
*/
|
2026-04-24 16:16:31 +08:00
|
|
|
|
async openStore(appStoreUrl?: string, marketUrl?: string): Promise<void> {
|
|
|
|
|
|
const url = Platform.OS === 'ios' ? appStoreUrl : marketUrl
|
|
|
|
|
|
if (url) await Linking.openURL(url)
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
// ── 插件更新 ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
|
/**
|
2026-06-15 01:44:20 +08:00
|
|
|
|
* 检查指定插件的更新。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 插件唯一标识 = appKey + platform + moduleId。
|
|
|
|
|
|
* 插件必须已通过 registerPlugin() 注册。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param moduleId 插件 ID(如 'buz1')
|
|
|
|
|
|
* @returns 更新信息,包含 latestVersion / downloadUrl / md5 等
|
|
|
|
|
|
* @throws 插件未注册时抛出错误
|
2026-04-24 16:16:31 +08:00
|
|
|
|
*/
|
2026-06-15 01:44:20 +08:00
|
|
|
|
async checkPluginUpdate(moduleId: string): Promise<PluginUpdateInfo> {
|
|
|
|
|
|
await awaitInitialization()
|
2026-04-24 16:16:31 +08:00
|
|
|
|
const config = getConfig()
|
|
|
|
|
|
const meta = _pluginRegistry.get(moduleId)
|
|
|
|
|
|
if (!meta) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`[UpdateSDK] Plugin "${moduleId}" not registered. ` +
|
|
|
|
|
|
'Call UpdateSDK.registerPlugin({ moduleId, version }) at bundle load time.',
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-06-15 01:44:20 +08:00
|
|
|
|
const result = await apiRequest<PluginUpdateInfo>('/api/v1/rn/update/check', {
|
2026-04-24 16:16:31 +08:00
|
|
|
|
skipAuth: true,
|
|
|
|
|
|
params: {
|
2026-05-07 19:39:41 +08:00
|
|
|
|
appKey: config.appKey,
|
2026-04-24 16:16:31 +08:00
|
|
|
|
moduleId,
|
|
|
|
|
|
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
|
|
|
|
|
|
currentVersion: meta.version,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
return { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) ?? result.downloadUrl }
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 下载插件 bundle 源码文本。
|
2026-06-15 02:36:11 +08:00
|
|
|
|
*
|
|
|
|
|
|
* @param downloadUrl 下载地址
|
|
|
|
|
|
* @param onProgress 下载进度回调 (0~1)
|
2026-06-15 01:44:20 +08:00
|
|
|
|
*/
|
2026-06-15 02:36:11 +08:00
|
|
|
|
async downloadPluginBundle(downloadUrl: string, onProgress?: (progress: number) => void): Promise<string> {
|
2026-04-24 16:16:31 +08:00
|
|
|
|
const response = await fetch(downloadUrl)
|
|
|
|
|
|
if (!response.ok) throw new Error(`[UpdateSDK] Bundle download failed: ${response.status}`)
|
2026-06-15 02:36:11 +08:00
|
|
|
|
|
|
|
|
|
|
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(chunk => decoder.decode(chunk, { stream: true })).join('') + decoder.decode()
|
2026-04-24 16:16:31 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 缓存插件 bundle 到本地(AsyncStorage)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async cachePluginBundle(moduleId: string, version: string, md5: string, source: string): Promise<CachedRnBundle> {
|
2026-04-24 16:16:31 +08:00
|
|
|
|
const payload: CachedRnBundle = {
|
|
|
|
|
|
moduleId, version, md5, source,
|
|
|
|
|
|
downloadedAt: new Date().toISOString(),
|
|
|
|
|
|
}
|
|
|
|
|
|
await AsyncStorage.setItem(bundleCacheKey(moduleId), JSON.stringify(payload))
|
|
|
|
|
|
return payload
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 读取已缓存的插件 bundle。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async getCachedPluginBundle(moduleId: string): Promise<CachedRnBundle | null> {
|
2026-04-24 16:16:31 +08:00
|
|
|
|
const raw = await AsyncStorage.getItem(bundleCacheKey(moduleId))
|
|
|
|
|
|
return raw ? (JSON.parse(raw) as CachedRnBundle) : null
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 检查并下载插件更新(一步完成)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 如果有更新,下载 bundle 并缓存到本地。
|
|
|
|
|
|
* 调用方需要在下次启动时通过 reloadPlugin() 加载新版本。
|
|
|
|
|
|
*
|
2026-06-15 02:36:11 +08:00
|
|
|
|
* @param moduleId 插件 ID
|
|
|
|
|
|
* @param onProgress 下载进度回调 (0~1)
|
2026-06-15 01:44:20 +08:00
|
|
|
|
* @returns 如果有更新,返回缓存的 bundle 信息;否则返回 null
|
|
|
|
|
|
*/
|
2026-06-15 02:36:11 +08:00
|
|
|
|
async checkAndCachePlugin(moduleId: string, onProgress?: (progress: number) => void): Promise<CachedRnBundle | null> {
|
2026-06-15 01:44:20 +08:00
|
|
|
|
const info = await this.checkPluginUpdate(moduleId)
|
|
|
|
|
|
if (!info.needsUpdate) return null
|
|
|
|
|
|
|
2026-06-15 02:36:11 +08:00
|
|
|
|
const source = await this.downloadPluginBundle(info.downloadUrl, onProgress)
|
2026-06-15 01:44:20 +08:00
|
|
|
|
return this.cachePluginBundle(moduleId, info.latestVersion, info.md5, source)
|
2026-04-24 16:16:31 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
// ── 版本信息 ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** 当前 App versionCode(原生读取) */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
getAppVersionCode,
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/** 当前 App versionName(原生读取) */
|
2026-04-24 16:16:31 +08:00
|
|
|
|
getAppVersionName,
|
2026-06-15 01:44:20 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 开发环境手动设置版本号(仅调试用,生产环境不要调用)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
_devSetAppVersion(versionCode: number, versionName?: string): void {
|
|
|
|
|
|
_devSetAppVersion(versionCode, versionName)
|
|
|
|
|
|
},
|
2026-04-24 16:16:31 +08:00
|
|
|
|
}
|