468 行
16 KiB
TypeScript
468 行
16 KiB
TypeScript
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'
|
||
import { md5 } from './md5'
|
||
|
||
// ─── 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
|
||
}
|
||
|
||
// ─── 订阅 setUserInfo(userId 用于定向更新) ──────────────────────────────────
|
||
|
||
_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 直接下载并调起系统安装器。
|
||
*
|
||
* 当前实现使用 Linking.openURL 打开下载链接,由浏览器完成下载和安装引导。
|
||
* 如需应用内下载 + 静默安装,宿主可集成 react-native-blob-util 并自行实现。
|
||
*/
|
||
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.')
|
||
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)
|
||
|
||
// MD5 校验
|
||
if (info.md5) {
|
||
const actual = md5(source)
|
||
if (actual !== info.md5) {
|
||
throw new Error(
|
||
`[UpdateSDK] Bundle MD5 mismatch for "${moduleId}": expected ${info.md5}, got ${actual}`,
|
||
)
|
||
}
|
||
}
|
||
|
||
// 写入 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)
|
||
},
|
||
}
|