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() // ─── 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(key: string): Promise { 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(key: string, data: T): Promise { 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) | null = null let _reloadBundleCallback: ((moduleId: string) => Promise) | null = null function bundleCacheKey(moduleId: string) { return `@xuqm:bundle:${moduleId}` } async function _getCachedVersion(moduleId: string): Promise { 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 { 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 { 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 reloadBundle: (moduleId: string) => Promise }): 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 { // Return cached result if within TTL (cache is skipped when bypassIgnore is set) if (!bypassIgnore) { const cached = await _readUpdateCache(UPDATE_APP_CACHE_KEY) if (cached) return cached } const config = getConfig() const params: Record = { 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('/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 { 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 { 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 { 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 { 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 { 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(cacheKey) if (cached) return cached const config = getConfig() const currentVersion = await _getCachedVersion(moduleId) const userId = getUserId()?.trim() const params: Record = { appKey: config.appKey, moduleId, platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS', currentVersion, } if (userId) params.userId = userId const result = await apiRequest('/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 { 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) }, }