XuqmGroup-RNSDK/packages/update/src/UpdateSDK.ts

302 行
10 KiB
TypeScript

import AsyncStorage from '@react-native-async-storage/async-storage'
import { Linking, Platform } from 'react-native'
import { apiRequest, getConfig, getUserId } from '@xuqm/rn-common'
import { getAppVersionCode, getAppVersionName, _devSetAppVersion } from './NativeVersion'
import { awaitInitialization } from '@xuqm/rn-common'
// ─── Types ────────────────────────────────────────────────────────────────────
/** 插件注册元数据 */
export interface PluginMeta {
/** 插件唯一标识,如 'buz1'、'buz2' */
moduleId: string
/** 当前 bundle 版本号 */
version: string
}
/**
* App
*
* Android SDK UpdateInfo
* SDK UI app
*/
export interface AppUpdateInfo {
/** 是否需要更新 */
needsUpdate: boolean
/** 最新版本名,如 '2.1.0' */
versionName?: string
/** 最新版本号(整数) */
versionCode?: number
/** APK/安装包直接下载地址Android 有此字段时优先下载) */
downloadUrl?: string
/** 更新日志 */
changeLog?: string
/** 是否强制更新true 时不允许跳过) */
forceUpdate?: boolean
/** iOS App Store 地址 */
appStoreUrl?: string
/** Android 应用商店地址(华为/小米/OPPO 等) */
marketUrl?: string
/** 服务端要求登录后才能检查true 时 needsUpdate 通常为 false */
requiresLogin?: boolean
/** SDK 内部标记APK 是否已下载到本地(仅 Android */
alreadyDownloaded?: boolean
/** APK 文件 SHA-256 校验值 */
apkHash?: string | null
}
/**
* RN Bundle
*
* = appKey + platform + moduleId
* - appKey
* - platformANDROID / IOS
* - moduleId ID 'buz1'
*/
export interface PluginUpdateInfo {
/** 是否需要更新 */
needsUpdate: boolean
/** 最新版本号 */
latestVersion: string
/** bundle 下载地址 */
downloadUrl: string
/** bundle 文件 MD5 */
md5: string
/** 要求的最低 common bundle 版本 */
minCommonVersion: string
/** 更新说明 */
note: string
}
/** 已缓存的 bundle 元数据 */
export interface CachedRnBundle {
moduleId: string
version: string
md5: string
downloadedAt: string
source: string
}
// ─── Internal ─────────────────────────────────────────────────────────────────
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
}
// ─── UpdateSDK ────────────────────────────────────────────────────────────────
export const UpdateSDK = {
// ── 插件注册 ──────────────────────────────────────────────────────────────
/**
* bundle
*
* = moduleId 'buz1'
* = appKey+ platform+ moduleId
*
* @example
* // src/plugins/buz1/bundle.ts
* UpdateSDK.registerPlugin({ moduleId: 'buz1', version: '1.0.0' })
*/
registerPlugin(meta: PluginMeta): void {
_pluginRegistry.set(meta.moduleId, meta)
},
/**
*
*/
getRegisteredPluginVersion(moduleId: string): string | undefined {
return _pluginRegistry.get(moduleId)?.version
},
/**
*
*/
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> {
await awaitInitialization()
const config = getConfig()
const currentVersionCode = getAppVersionCode()
const userId = getUserId()?.trim()
const params: Record<string, string> = {
appKey: config.appKey,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
currentVersionCode: String(currentVersionCode),
}
if (userId) params.userId = userId
if (bypassIgnore) params.bypassIgnore = 'true'
const result = await apiRequest<AppUpdateInfo>('/api/v1/updates/app/check', {
skipAuth: true,
params,
})
return { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) }
},
/**
*
* iOS 使 appStoreUrlAndroid 使 marketUrl
*/
async openStore(appStoreUrl?: string, marketUrl?: string): Promise<void> {
const url = Platform.OS === 'ios' ? appStoreUrl : marketUrl
if (url) await Linking.openURL(url)
},
// ── 插件更新 ──────────────────────────────────────────────────────────────
/**
*
*
* = appKey + platform + moduleId
* registerPlugin()
*
* @param moduleId ID 'buz1'
* @returns latestVersion / downloadUrl / md5
* @throws
*/
async checkPluginUpdate(moduleId: string): Promise<PluginUpdateInfo> {
await awaitInitialization()
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.',
)
}
const result = await apiRequest<PluginUpdateInfo>('/api/v1/rn/update/check', {
skipAuth: true,
params: {
2026-05-07 19:39:41 +08:00
appKey: config.appKey,
moduleId,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
currentVersion: meta.version,
},
})
return { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) ?? result.downloadUrl }
},
/**
* bundle
*
* @param downloadUrl
* @param onProgress (0~1)
*/
async downloadPluginBundle(downloadUrl: string, onProgress?: (progress: number) => void): Promise<string> {
const response = await fetch(downloadUrl)
if (!response.ok) throw new Error(`[UpdateSDK] Bundle 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(chunk => decoder.decode(chunk, { stream: true })).join('') + decoder.decode()
},
/**
* bundle AsyncStorage
*/
async cachePluginBundle(moduleId: string, version: string, md5: string, source: string): Promise<CachedRnBundle> {
const payload: CachedRnBundle = {
moduleId, version, md5, source,
downloadedAt: new Date().toISOString(),
}
await AsyncStorage.setItem(bundleCacheKey(moduleId), JSON.stringify(payload))
return payload
},
/**
* bundle
*/
async getCachedPluginBundle(moduleId: string): Promise<CachedRnBundle | null> {
const raw = await AsyncStorage.getItem(bundleCacheKey(moduleId))
return raw ? (JSON.parse(raw) as CachedRnBundle) : null
},
/**
*
*
* bundle
* reloadPlugin()
*
* @param moduleId ID
* @param onProgress (0~1)
* @returns bundle null
*/
async checkAndCachePlugin(moduleId: string, onProgress?: (progress: number) => void): Promise<CachedRnBundle | null> {
const info = await this.checkPluginUpdate(moduleId)
if (!info.needsUpdate) return null
const source = await this.downloadPluginBundle(info.downloadUrl, onProgress)
return this.cachePluginBundle(moduleId, info.latestVersion, info.md5, source)
},
// ── 版本信息 ──────────────────────────────────────────────────────────────
/** 当前 App versionCode原生读取 */
getAppVersionCode,
/** 当前 App versionName原生读取 */
getAppVersionName,
/**
*
*/
_devSetAppVersion(versionCode: number, versionName?: string): void {
_devSetAppVersion(versionCode, versionName)
},
}